From c0d27ad943aa4cdb1f5ae2eb9cc89288fa7312cf Mon Sep 17 00:00:00 2001 From: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 Date: Tue, 7 Jul 2026 18:49:11 -0400 Subject: [PATCH 1/2] Disable the composer in a moderation DM (member surface) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A moderation DM is the 1:1 thread between a member and the relay's own identity, where moderators explain an action. The member must not be able to reply into it, so the composer is read-only there — and only there, never on ordinary DMs. The relay identity is the NIP-11 `self` pubkey. A private helper for this already existed in commands/identity_archive.rs (fetch_relay_self: unauthenticated NIP-11 GET, 64-hex validation, fail-open); rather than add a second copy, promote it to pub(crate) and expose it through one thin `get_relay_self` command, so the archive read and the moderation read share a single source of truth. - commands/identity_archive.rs: fetch_relay_self → pub(crate); add the get_relay_self command (+ register in lib.rs). - features/moderation/lib/relaySelf.ts: getRelaySelf() invoke wrapper; hooks.ts: useRelaySelfQuery (workspace-scoped, cached indefinitely, null is a valid fail-open answer). - features/moderation/lib/moderationDm.ts (+ tests): isModerationDm — pure predicate, true only for a DM whose sole other participant is the relay self. Case-insensitive; fails open on a null/absent self. - ChannelPane: query self only for DM channels, fold isModerationDm into the shared isComposerDisabled (thread panel inherits), and show a read-only placeholder. The moderator's message stays visible; only the reply is blocked. Affordance, not enforcement: the relay is authoritative over writes, so a false negative (composer stays enabled on a real moderation DM) is the safe failure — the relay rejects the write anyway. A false positive (disabling a normal DM) is the worse outcome, so every uncertain path resolves to "enabled". Validated: tsc --noEmit clean, biome clean, moderation tests 15/15 green, src-tauri cargo check clean. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- .../src/commands/identity_archive.rs | 14 +++++- desktop/src-tauri/src/lib.rs | 1 + .../src/features/channels/ui/ChannelPane.tsx | 35 ++++++++++---- desktop/src/features/moderation/hooks.ts | 17 +++++++ .../moderation/lib/moderationDm.test.mjs | 46 +++++++++++++++++++ .../features/moderation/lib/moderationDm.ts | 31 +++++++++++++ .../src/features/moderation/lib/relaySelf.ts | 12 +++++ desktop/src/testing/e2eBridge.ts | 6 +++ 8 files changed, 151 insertions(+), 11 deletions(-) create mode 100644 desktop/src/features/moderation/lib/moderationDm.test.mjs create mode 100644 desktop/src/features/moderation/lib/moderationDm.ts create mode 100644 desktop/src/features/moderation/lib/relaySelf.ts diff --git a/desktop/src-tauri/src/commands/identity_archive.rs b/desktop/src-tauri/src/commands/identity_archive.rs index a40e14dedb..d15ee82abc 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 341c99d7b1..ee7af6b502 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 4ed14bb007..8dc157d067 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 f196f32528..d5c8ed24ef 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 0000000000..c1ee97ae3f --- /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 0000000000..3165cd52c8 --- /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 0000000000..fbe5d6503b --- /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/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 953b3de40c..b189c2e12a 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; From d554db87ccfd397b0aeb027d37e5730b03c2d53b Mon Sep 17 00:00:00 2001 From: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 Date: Tue, 7 Jul 2026 19:52:07 -0400 Subject: [PATCH 2/2] Add a shared timeout-duration submenu for moderation surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The report-queue timeout resolution (U2) needs the same 1h/24h/7d duration choice the per-message author cluster already offers, and the two must never drift. Provide the shared piece from the timeout lib that owns the parse/format side. - lib/timeout.ts: TIMEOUT_PRESETS (the single 1h/24h/7d source of truth) and timeoutExpiresAt(seconds) → now + seconds in epoch seconds, the shape useTimeoutMemberMutation expects. (+ tests.) - ui/TimeoutDurationSubmenu.tsx: a presentational DropdownMenuSub of those presets. Duration-only — it knows nothing about the target or which command fires — so it drops into both the per-message cluster and the queue's timeout resolution unchanged, calling onSelect with the absolute expiry. No consumer on this branch yet; U2 wires it into the resolve menu's timeout branch. Validated: tsc --noEmit clean, biome clean, moderation tests 17/17 green. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- .../features/moderation/lib/timeout.test.mjs | 16 +++++ .../src/features/moderation/lib/timeout.ts | 26 +++++++++ .../moderation/ui/TimeoutDurationSubmenu.tsx | 58 +++++++++++++++++++ 3 files changed, 100 insertions(+) create mode 100644 desktop/src/features/moderation/ui/TimeoutDurationSubmenu.tsx diff --git a/desktop/src/features/moderation/lib/timeout.test.mjs b/desktop/src/features/moderation/lib/timeout.test.mjs index cebc543d52..de8727ce89 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 9199184f3d..a7b729da31 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 0000000000..87405d7036 --- /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} + + ))} + + + ); +}