diff --git a/desktop/src-tauri/src/commands/identity_archive.rs b/desktop/src-tauri/src/commands/identity_archive.rs index a40e14ded..d15ee82ab 100644 --- a/desktop/src-tauri/src/commands/identity_archive.rs +++ b/desktop/src-tauri/src/commands/identity_archive.rs @@ -227,7 +227,7 @@ struct RelayInformationDocument { self_: Option, } -async fn fetch_relay_self(state: &AppState) -> Result, String> { +pub(crate) async fn fetch_relay_self(state: &AppState) -> Result, String> { let relay_url = relay_ws_url_with_override(state); let http_url = relay_http_base_url(&relay_url); let response = state @@ -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, String> { + fetch_relay_self(&state).await +} + // ── Tests ─────────────────────────────────────────────────────────────────── #[cfg(test)] diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 341c99d7b..ee7af6b50 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -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, diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 4ed14bb00..8dc157d06 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -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, @@ -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(); @@ -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} /> diff --git a/desktop/src/features/moderation/hooks.ts b/desktop/src/features/moderation/hooks.ts index f196f3252..d5c8ed24e 100644 --- a/desktop/src/features/moderation/hooks.ts +++ b/desktop/src/features/moderation/hooks.ts @@ -1,5 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { getRelaySelf } from "@/features/moderation/lib/relaySelf"; import { banMember, type CommunityRestriction, @@ -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) --- diff --git a/desktop/src/features/moderation/lib/moderationDm.test.mjs b/desktop/src/features/moderation/lib/moderationDm.test.mjs new file mode 100644 index 000000000..c1ee97ae3 --- /dev/null +++ b/desktop/src/features/moderation/lib/moderationDm.test.mjs @@ -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); +}); diff --git a/desktop/src/features/moderation/lib/moderationDm.ts b/desktop/src/features/moderation/lib/moderationDm.ts new file mode 100644 index 000000000..3165cd52c --- /dev/null +++ b/desktop/src/features/moderation/lib/moderationDm.ts @@ -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 | 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; +} diff --git a/desktop/src/features/moderation/lib/relaySelf.ts b/desktop/src/features/moderation/lib/relaySelf.ts new file mode 100644 index 000000000..fbe5d6503 --- /dev/null +++ b/desktop/src/features/moderation/lib/relaySelf.ts @@ -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 { + return invokeTauri("get_relay_self"); +} diff --git a/desktop/src/features/moderation/lib/timeout.test.mjs b/desktop/src/features/moderation/lib/timeout.test.mjs index cebc543d5..de8727ce8 100644 --- a/desktop/src/features/moderation/lib/timeout.test.mjs +++ b/desktop/src/features/moderation/lib/timeout.test.mjs @@ -5,6 +5,8 @@ import { formatTimeoutRemaining, isTimeoutActive, parseTimeoutRejection, + timeoutExpiresAt, + TIMEOUT_PRESETS, } from "./timeout.ts"; test("parses a well-formed timeout rejection to epoch ms", () => { @@ -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], + ); +}); diff --git a/desktop/src/features/moderation/lib/timeout.ts b/desktop/src/features/moderation/lib/timeout.ts index 9199184f3..a7b729da3 100644 --- a/desktop/src/features/moderation/lib/timeout.ts +++ b/desktop/src/features/moderation/lib/timeout.ts @@ -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 diff --git a/desktop/src/features/moderation/ui/TimeoutDurationSubmenu.tsx b/desktop/src/features/moderation/ui/TimeoutDurationSubmenu.tsx new file mode 100644 index 000000000..87405d703 --- /dev/null +++ b/desktop/src/features/moderation/ui/TimeoutDurationSubmenu.tsx @@ -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 ( + + + + {label} + + + {TIMEOUT_PRESETS.map((preset) => ( + onSelect(timeoutExpiresAt(preset.seconds))} + > + {preset.label} + + ))} + + + ); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 953b3de40..b189c2e12 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -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` / @@ -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;