Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion desktop/src-tauri/src/commands/identity_archive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ struct RelayInformationDocument {
self_: Option<String>,
}

async fn fetch_relay_self(state: &AppState) -> Result<Option<String>, String> {
pub(crate) async fn fetch_relay_self(state: &AppState) -> Result<Option<String>, String> {
let relay_url = relay_ws_url_with_override(state);
let http_url = relay_http_base_url(&relay_url);
let response = state
Expand Down Expand Up @@ -318,6 +318,18 @@ pub async fn list_archived_identities(
})
}

/// Read the active relay's NIP-11 `self` pubkey (its own signing key, hex).
///
/// A public, unauthenticated document read reused by the moderation UI to tell
/// whether a DM peer is the relay identity (a moderation DM). Fails open: an
/// unreachable relay, a document without `self`, or a malformed value all
/// return `None`, and callers must treat that as "not the relay" — the disable
/// is an affordance, not enforcement, so a false negative is the safe failure.
#[tauri::command]
pub async fn get_relay_self(state: State<'_, AppState>) -> Result<Option<String>, String> {
fetch_relay_self(&state).await
}

// ── Tests ───────────────────────────────────────────────────────────────────

#[cfg(test)]
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,7 @@ pub fn run() {
archive_identity,
unarchive_identity,
list_archived_identities,
get_relay_self,
resolve_oa_owner,
list_relay_agents,
list_managed_agents,
Expand Down
35 changes: 25 additions & 10 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { useMediaUpload } from "@/features/messages/lib/useMediaUpload";
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
import { ComposerTimeoutBanner } from "@/features/moderation/ui/ComposerTimeoutBanner";
import { useTimeoutState } from "@/features/moderation/lib/timeoutStore";
import { isModerationDm } from "@/features/moderation/lib/moderationDm";
import { useRelaySelfQuery } from "@/features/moderation/hooks";
import { DropZoneOverlay } from "@/features/messages/ui/ComposerAttachments";
import {
MessageThreadPanel,
Expand Down Expand Up @@ -273,11 +275,22 @@ export const ChannelPane = React.memo(function ChannelPane({

const timeoutState = useTimeoutState();

// A moderation DM (1:1 with the relay identity) is read-only for the member;
// only DMs pay for the NIP-11 `self` lookup. Fails open: no `relaySelf` →
// ordinary DM, composer enabled.
const relaySelfQuery = useRelaySelfQuery(activeChannel?.channelType === "dm");
const isModerationDmChannel = isModerationDm(
activeChannel ?? null,
currentPubkey,
relaySelfQuery.data,
);

const isComposerDisabled =
!activeChannel?.isMember ||
activeChannel.archivedAt !== null ||
activeChannel.channelType === "forum" ||
timeoutState.active ||
isModerationDmChannel ||
isSending;
const knownAgentPubkeys = React.useMemo(() => {
const pubkeys = new Set<string>();
Expand Down Expand Up @@ -707,16 +720,18 @@ export const ChannelPane = React.memo(function ChannelPane({
placeholder={
timeoutState.active
? "You're timed out by community moderators."
: activeChannel?.archivedAt
? "Archived channels are read-only."
: activeChannel?.channelType === "forum"
? "Forum posting is not wired in this pass."
: activeChannel
? activeChannel.channelType === "dm" &&
directMessageIntro
? `Message ${directMessageIntro.displayName}`
: `Message #${activeChannel.name}`
: "Select a channel"
: isModerationDmChannel
? "This channel is read-only."
: activeChannel?.archivedAt
? "Archived channels are read-only."
: activeChannel?.channelType === "forum"
? "Forum posting is not wired in this pass."
: activeChannel
? activeChannel.channelType === "dm" &&
directMessageIntro
? `Message ${directMessageIntro.displayName}`
: `Message #${activeChannel.name}`
: "Select a channel"
}
showTopBorder={false}
/>
Expand Down
17 changes: 17 additions & 0 deletions desktop/src/features/moderation/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";

import { getRelaySelf } from "@/features/moderation/lib/relaySelf";
import {
banMember,
type CommunityRestriction,
Expand All @@ -23,6 +24,22 @@ export const moderationAuditQueryKey = ["moderationAudit"] as const;
export const moderationRestrictionsQueryKey = [
"moderationRestrictions",
] as const;
export const relaySelfQueryKey = ["relaySelf"] as const;

/**
* The active relay's NIP-11 `self` pubkey (hex), or `null` when it advertises
* none / is unreachable. Used to recognize a moderation DM. Workspace-scoped
* and effectively static for a session, so it is cached indefinitely; a `null`
* result is a valid answer (fail open), not an error to retry into.
*/
export function useRelaySelfQuery(enabled = true) {
return useQuery({
enabled,
queryKey: relaySelfQueryKey,
queryFn: getRelaySelf,
staleTime: Number.POSITIVE_INFINITY,
});
}

// --- Reads (mod-authz gated; consumed by the U2 queue/audit surfaces) ---

Expand Down
46 changes: 46 additions & 0 deletions desktop/src/features/moderation/lib/moderationDm.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import test from "node:test";

import { isModerationDm } from "./moderationDm.ts";

const RELAY = "a".repeat(64);
const ME = "b".repeat(64);
const OTHER = "c".repeat(64);

const dm = (participantPubkeys) => ({ channelType: "dm", participantPubkeys });

test("moderation DM: 1:1 with the relay self is detected", () => {
assert.equal(isModerationDm(dm([ME, RELAY]), ME, RELAY), true);
});

test("moderation DM: case-insensitive on both self and participants", () => {
assert.equal(isModerationDm(dm([ME, RELAY.toUpperCase()]), ME, RELAY), true);
});

test("ordinary DM with another member is not a moderation DM", () => {
assert.equal(isModerationDm(dm([ME, OTHER]), ME, RELAY), false);
});

test("group DM including the relay is not a moderation DM", () => {
assert.equal(isModerationDm(dm([ME, RELAY, OTHER]), ME, RELAY), false);
});

test("non-DM channels are never moderation DMs", () => {
assert.equal(
isModerationDm(
{ channelType: "stream", participantPubkeys: [ME, RELAY] },
ME,
RELAY,
),
false,
);
});

test("fails open when relay self is null/undefined", () => {
assert.equal(isModerationDm(dm([ME, RELAY]), ME, null), false);
assert.equal(isModerationDm(dm([ME, RELAY]), ME, undefined), false);
});

test("null channel is not a moderation DM", () => {
assert.equal(isModerationDm(null, ME, RELAY), false);
});
31 changes: 31 additions & 0 deletions desktop/src/features/moderation/lib/moderationDm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Channel } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";

/**
* A moderation DM is the 1:1 direct message between a member and the relay's
* own identity — the channel moderators use to explain an action. The member
* must not be able to reply into it, so the composer is disabled on this
* channel alone (never on ordinary DMs).
*
* Identification is client-side and best-effort: the relay identity is the
* NIP-11 `self` pubkey (see {@link getRelaySelf}), and a moderation DM is a DM
* whose only other participant is that pubkey. It is an affordance, not
* enforcement — the relay decides what a write does — so this fails open: a
* missing `relaySelf` (relay unreachable, no `self` advertised) yields `false`,
* leaving the composer enabled.
*/
export function isModerationDm(
channel: Pick<Channel, "channelType" | "participantPubkeys"> | null,
currentPubkey: string | undefined,
relaySelf: string | null | undefined,
): boolean {
if (channel?.channelType !== "dm" || !relaySelf) {
return false;
}
const self = normalizePubkey(relaySelf);
const me = currentPubkey ? normalizePubkey(currentPubkey) : null;
const others = channel.participantPubkeys
.map(normalizePubkey)
.filter((pubkey) => pubkey !== me);
return others.length === 1 && others[0] === self;
}
12 changes: 12 additions & 0 deletions desktop/src/features/moderation/lib/relaySelf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { invokeTauri } from "@/shared/api/tauri";

/**
* Read the active relay's NIP-11 `self` pubkey (its own signing key, hex), or
* `null` when the relay advertises none / is unreachable / serves a malformed
* document. Used by the moderation UI to recognize a DM with the relay identity
* (a moderation DM). Fails open by contract: a `null` result must be treated as
* "not the relay", never as an error.
*/
export function getRelaySelf(): Promise<string | null> {
return invokeTauri<string | null>("get_relay_self");
}
16 changes: 16 additions & 0 deletions desktop/src/features/moderation/lib/timeout.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
formatTimeoutRemaining,
isTimeoutActive,
parseTimeoutRejection,
timeoutExpiresAt,
TIMEOUT_PRESETS,
} from "./timeout.ts";

test("parses a well-formed timeout rejection to epoch ms", () => {
Expand Down Expand Up @@ -76,3 +78,17 @@ test("formatTimeoutRemaining: null when unknown or elapsed", () => {
assert.equal(formatTimeoutRemaining(now - 1000, now), null);
assert.equal(formatTimeoutRemaining(now, now), null);
});

test("timeoutExpiresAt: absolute expiry is now (seconds) + preset seconds", () => {
const nowMs = 1_000_000_000_000;
assert.equal(timeoutExpiresAt(3600, nowMs), 1_000_000_000 + 3600);
// Floors sub-second now before adding, so the result is a whole second.
assert.equal(timeoutExpiresAt(60, nowMs + 999), 1_000_000_000 + 60);
});

test("TIMEOUT_PRESETS: the shared 1h/24h/7d set", () => {
assert.deepEqual(
TIMEOUT_PRESETS.map((preset) => preset.seconds),
[60 * 60, 24 * 60 * 60, 7 * 24 * 60 * 60],
);
});
26 changes: 26 additions & 0 deletions desktop/src/features/moderation/lib/timeout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,32 @@

const TIMEOUT_PREFIX = "restricted: you are timed out until";

/**
* The community-timeout durations offered wherever a moderator picks one — the
* per-message author cluster (U2) and the report-queue timeout resolution.
* Kept here as the single source of truth so the two surfaces can never drift.
*/
export const TIMEOUT_PRESETS: ReadonlyArray<{
label: string;
seconds: number;
}> = [
{ label: "1 hour", seconds: 60 * 60 },
{ label: "24 hours", seconds: 24 * 60 * 60 },
{ label: "7 days", seconds: 7 * 24 * 60 * 60 },
];

/**
* Convert a preset duration into the absolute expiry (epoch **seconds**) the
* timeout command (`useTimeoutMemberMutation`) expects — `now + seconds`. The
* relay stamps its own authoritative expiry; this is the client's request.
*/
export function timeoutExpiresAt(
seconds: number,
nowMs: number = Date.now(),
): number {
return Math.floor(nowMs / 1000) + seconds;
}

export type TimeoutRejection = {
/**
* Timeout expiry in epoch milliseconds, or `null` when the relay's message
Expand Down
58 changes: 58 additions & 0 deletions desktop/src/features/moderation/ui/TimeoutDurationSubmenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Clock } from "lucide-react";

import {
TIMEOUT_PRESETS,
timeoutExpiresAt,
} from "@/features/moderation/lib/timeout";
import {
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from "@/shared/ui/dropdown-menu";

/**
* A dropdown submenu of community-timeout durations. Each item resolves the
* chosen preset to an absolute expiry (epoch seconds) and hands it to
* `onSelect` — the caller runs the timeout command with it. Presentational
* and duration-only: it knows nothing about who is being timed out or which
* command fires, so both the per-message author cluster and the report-queue
* timeout resolution can share it and stay on one preset list.
*/
export function TimeoutDurationSubmenu({
label = "Time out",
disabled = false,
testIdPrefix,
onSelect,
}: {
/** Sub-trigger label; defaults to "Time out". */
label?: string;
disabled?: boolean;
/** Prefix for each preset item's `data-testid` (e.g. `moderation-timeout`). */
testIdPrefix?: string;
/** Called with the absolute expiry in epoch seconds for the chosen preset. */
onSelect: (expiresAt: number) => void;
}) {
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={disabled}>
<Clock className="h-4 w-4" />
{label}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{TIMEOUT_PRESETS.map((preset) => (
<DropdownMenuItem
data-testid={
testIdPrefix ? `${testIdPrefix}-${preset.seconds}` : undefined
}
disabled={disabled}
key={preset.seconds}
onClick={() => onSelect(timeoutExpiresAt(preset.seconds))}
>
{preset.label}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
6 changes: 6 additions & 0 deletions desktop/src/testing/e2eBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@ type E2eConfig = {
// - `resolve_oa_owner` (oaOwnerIsMe)
// - `resetMockRelayMembers` (relayRole)
archivedIdentities?: string[];
// Relay's NIP-11 `self` pubkey (hex) for `get_relay_self`. A DM whose peer
// equals this is treated as a moderation DM (composer disabled). Absent →
// fail open (no mod-DM detection), matching the Rust command's contract.
relaySelf?: string | null;
oaOwnerIsMe?: boolean;
relayRole?: "owner" | "admin" | "member" | null;
// Descriptors returned by the mocked `pick_and_upload_media` /
Expand Down Expand Up @@ -8855,6 +8859,8 @@ export function maybeInstallE2eTauriMocks() {
const archived = activeConfig?.mock?.archivedIdentities ?? [];
return { archived };
}
case "get_relay_self":
return activeConfig?.mock?.relaySelf ?? null;
case "archive_identity":
case "unarchive_identity":
// The spec only verifies UI state, not the submitted request shape;
Expand Down
Loading