From ace37e532a35d290ddcd39d8cd20ffc412fe1e30 Mon Sep 17 00:00:00 2001 From: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc Date: Tue, 7 Jul 2026 18:16:58 -0400 Subject: [PATCH 1/8] desktop(moderation): queue triage domain logic (U2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure, hook-free transforms over the NIP-98 /moderation/* read contract: report/action/ban type mirrors of the relay's report_json/action_json/ ban_json (bridge.rs), plus severity ordering (illegal top → other bottom), grouping by kind-qualified target, and prior-actions correlation from the audit log. This is the deterministic core the U2 queue card renders; it carries no dependency on the shared features/moderation hooks (Dawn's lane), so it lands independently. Reporter identity is modeled as mod-only and is never surfaced author-side. 11 unit tests cover severity ranks, target grouping/collapse, group sort (severity then recency), in-group recency, event/pubkey prior-action correlation, the blob no-correlation case, and open-status filtering. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- .../settings/lib/moderationQueue.test.mjs | 176 ++++++++++++++++ .../features/settings/lib/moderationQueue.ts | 192 ++++++++++++++++++ 2 files changed, 368 insertions(+) create mode 100644 desktop/src/features/settings/lib/moderationQueue.test.mjs create mode 100644 desktop/src/features/settings/lib/moderationQueue.ts diff --git a/desktop/src/features/settings/lib/moderationQueue.test.mjs b/desktop/src/features/settings/lib/moderationQueue.test.mjs new file mode 100644 index 0000000000..043a15c5f0 --- /dev/null +++ b/desktop/src/features/settings/lib/moderationQueue.test.mjs @@ -0,0 +1,176 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + buildModerationQueue, + isOpenReport, + reportSeverity, + targetKey, +} from "./moderationQueue.ts"; + +function report(overrides = {}) { + return { + id: overrides.id ?? "r1", + reportEventId: overrides.reportEventId ?? "e".repeat(64), + reporterPubkey: overrides.reporterPubkey ?? "a".repeat(64), + targetKind: overrides.targetKind ?? "event", + target: overrides.target ?? "t".repeat(64), + channelId: overrides.channelId ?? null, + reportType: overrides.reportType ?? "spam", + note: overrides.note ?? null, + status: overrides.status ?? "open", + resolvedBy: overrides.resolvedBy ?? null, + resolvedAt: overrides.resolvedAt ?? null, + actionId: overrides.actionId ?? null, + createdAt: overrides.createdAt ?? "2026-07-07T00:00:00.000Z", + }; +} + +function action(overrides = {}) { + return { + id: overrides.id ?? "a1", + actorPubkey: overrides.actorPubkey ?? "b".repeat(64), + action: overrides.action ?? "timeout", + targetPubkey: overrides.targetPubkey ?? null, + targetEventId: overrides.targetEventId ?? null, + channelId: overrides.channelId ?? null, + reasonCode: overrides.reasonCode ?? null, + publicReason: overrides.publicReason ?? null, + privateReason: overrides.privateReason ?? null, + matchedPrincipal: overrides.matchedPrincipal ?? null, + createdAt: overrides.createdAt ?? "2026-07-06T00:00:00.000Z", + }; +} + +test("reportSeverity: illegal outranks all; other is lowest", () => { + assert.ok(reportSeverity("illegal") > reportSeverity("malware")); + assert.ok(reportSeverity("malware") > reportSeverity("spam")); + assert.ok(reportSeverity("spam") > reportSeverity("profanity")); + assert.ok(reportSeverity("profanity") > reportSeverity("other")); + assert.equal(reportSeverity("other"), 0); +}); + +test("targetKey is kind-qualified so event/pubkey with same hex don't collide", () => { + const hex = "c".repeat(64); + assert.notEqual( + targetKey(report({ targetKind: "event", target: hex })), + targetKey(report({ targetKind: "pubkey", target: hex })), + ); +}); + +test("buildModerationQueue collapses reports about the same target into one group", () => { + const t = "d".repeat(64); + const groups = buildModerationQueue([ + report({ id: "r1", target: t, reporterPubkey: "1".repeat(64) }), + report({ id: "r2", target: t, reporterPubkey: "2".repeat(64) }), + ]); + assert.equal(groups.length, 1); + assert.equal(groups[0].reports.length, 2); +}); + +test("group maxSeverity is the highest among its reports", () => { + const t = "d".repeat(64); + const [group] = buildModerationQueue([ + report({ id: "r1", target: t, reportType: "spam" }), + report({ id: "r2", target: t, reportType: "illegal" }), + ]); + assert.equal(group.maxSeverity, reportSeverity("illegal")); +}); + +test("groups sort by severity desc, then most-recent report desc", () => { + const groups = buildModerationQueue([ + report({ + id: "low", + target: "1".repeat(64), + reportType: "profanity", + createdAt: "2026-07-07T09:00:00.000Z", + }), + report({ + id: "high", + target: "2".repeat(64), + reportType: "illegal", + createdAt: "2026-07-07T01:00:00.000Z", + }), + report({ + id: "midNew", + target: "3".repeat(64), + reportType: "spam", + createdAt: "2026-07-07T10:00:00.000Z", + }), + report({ + id: "midOld", + target: "4".repeat(64), + reportType: "spam", + createdAt: "2026-07-07T02:00:00.000Z", + }), + ]); + assert.deepEqual( + groups.map((g) => g.reports[0].id), + ["high", "midNew", "midOld", "low"], + ); +}); + +test("reports within a group are newest-first; latestCreatedAt reflects that", () => { + const t = "d".repeat(64); + const [group] = buildModerationQueue([ + report({ id: "old", target: t, createdAt: "2026-07-01T00:00:00.000Z" }), + report({ id: "new", target: t, createdAt: "2026-07-05T00:00:00.000Z" }), + ]); + assert.equal(group.reports[0].id, "new"); + assert.equal(group.latestCreatedAt, "2026-07-05T00:00:00.000Z"); +}); + +test("prior actions correlate to event-targeted groups via targetEventId", () => { + const eventId = "e".repeat(64); + const [group] = buildModerationQueue( + [report({ targetKind: "event", target: eventId })], + [ + action({ + id: "match", + targetEventId: eventId, + createdAt: "2026-07-02T00:00:00.000Z", + }), + action({ + id: "matchNewer", + targetEventId: eventId, + createdAt: "2026-07-04T00:00:00.000Z", + }), + action({ id: "other", targetEventId: "f".repeat(64) }), + ], + ); + assert.deepEqual( + group.priorActions.map((a) => a.id), + ["matchNewer", "match"], + ); +}); + +test("prior actions correlate to pubkey-targeted groups via targetPubkey", () => { + const pk = "9".repeat(64); + const [group] = buildModerationQueue( + [report({ targetKind: "pubkey", target: pk })], + [action({ id: "ban", action: "ban", targetPubkey: pk })], + ); + assert.deepEqual( + group.priorActions.map((a) => a.id), + ["ban"], + ); +}); + +test("blob-targeted groups surface no prior-actions correlation (audit has no blob key)", () => { + const sha = "7".repeat(64); + const [group] = buildModerationQueue( + [report({ targetKind: "blob", target: sha })], + [action({ targetEventId: sha }), action({ targetPubkey: sha })], + ); + assert.equal(group.priorActions.length, 0); +}); + +test("isOpenReport is true only for open status", () => { + assert.equal(isOpenReport(report({ status: "open" })), true); + assert.equal(isOpenReport(report({ status: "resolved" })), false); + assert.equal(isOpenReport(report({ status: "escalated" })), false); +}); + +test("empty input yields empty queue", () => { + assert.deepEqual(buildModerationQueue([]), []); +}); diff --git a/desktop/src/features/settings/lib/moderationQueue.ts b/desktop/src/features/settings/lib/moderationQueue.ts new file mode 100644 index 0000000000..d90d46945c --- /dev/null +++ b/desktop/src/features/settings/lib/moderationQueue.ts @@ -0,0 +1,192 @@ +// Domain logic for the community-moderation admin queue (U2 admin surface). +// +// Pure, hook-free transforms over the NIP-98 `/moderation/*` read contract so +// they can be unit-tested without a relay. The wire shapes below mirror the +// authoritative JSON emitted by the relay's `report_json` / `action_json` / +// `ban_json` (crates/buzz-relay/src/api/bridge.rs) — field names are pinned to +// that source. The queue view consumes these via the shared +// `features/moderation` hooks (Dawn's lane); this module owns only the +// triage math: severity ordering, grouping by target, and prior-actions +// correlation. +// +// Privacy invariant (locked, Tyler 2026-07-07): `reporterPubkey` is visible in +// this admin queue but MUST NEVER reach any surface the reported author can +// see. Nothing here is rendered author-side. + +/** NIP-56 report categories accepted at ingest (relay `report.rs::REPORT_TYPES`). */ +export type ReportType = + | "illegal" + | "nudity" + | "malware" + | "spam" + | "impersonation" + | "profanity" + | "other"; + +/** Discriminant for what a report points at (`report_json.target_kind`). */ +export type ReportTargetKind = "event" | "pubkey" | "blob"; + +/** + * Report lifecycle status (DB CHECK on `moderation_reports.status`). `open` is + * the default and the only actionable state; `escalated` routes out of + * community discretion into the platform-safety lane. + */ +export type ReportStatus = "open" | "resolved" | "dismissed" | "escalated"; + +/** Queue row: one accepted kind:1984 report (`/moderation/reports`). */ +export type ModerationReport = { + id: string; + reportEventId: string; + /** Reporter identity — mod-only, never author-visible. */ + reporterPubkey: string; + targetKind: ReportTargetKind; + /** Hex event id / pubkey / blob sha, per `targetKind`. */ + target: string; + channelId: string | null; + reportType: ReportType; + /** Reporter-supplied context; mod-only. */ + note: string | null; + status: ReportStatus; + resolvedBy: string | null; + resolvedAt: string | null; + actionId: string | null; + createdAt: string; +}; + +/** Audit row: one accepted moderation action (`/moderation/audit`). */ +export type ModerationAction = { + id: string; + actorPubkey: string; + action: string; + targetPubkey: string | null; + targetEventId: string | null; + channelId: string | null; + reasonCode: string | null; + /** Sanitized, tombstone-safe. */ + publicReason: string | null; + /** Mod-only; never leaves the audit surface. */ + privateReason: string | null; + matchedPrincipal: string | null; + createdAt: string; +}; + +/** + * Severity rank per report category — higher acts first. `illegal` tops the + * queue because it routes to the platform-safety escalation lane, not + * community discretion (Eva's two-layer model). The rest descend by typical + * community harm. `other` sinks to the bottom as the catch-all. + */ +const SEVERITY_RANK: Record = { + illegal: 6, + malware: 5, + impersonation: 4, + nudity: 3, + spam: 2, + profanity: 1, + other: 0, +}; + +export function reportSeverity(reportType: ReportType): number { + return SEVERITY_RANK[reportType] ?? SEVERITY_RANK.other; +} + +/** + * Stable identity for the *thing* a report targets, so multiple reports about + * the same message/user/blob collapse into one queue group. Kind-qualified to + * keep an event id and a (hypothetical) identical pubkey hex from colliding. + */ +export function targetKey(report: ModerationReport): string { + return `${report.targetKind}:${report.target}`; +} + +export type ModerationQueueGroup = { + targetKey: string; + targetKind: ReportTargetKind; + target: string; + /** Reports about this target, newest first. */ + reports: ModerationReport[]; + /** Highest severity among the group's reports — drives group ordering. */ + maxSeverity: number; + /** Most recent report timestamp in the group (ISO), for tie-breaks. */ + latestCreatedAt: string; + /** Prior accepted actions already taken against this target (newest first). */ + priorActions: ModerationAction[]; +}; + +/** Newest-first ISO timestamp comparator (descending). */ +function byCreatedAtDesc( + a: { createdAt: string }, + b: { createdAt: string }, +): number { + return b.createdAt.localeCompare(a.createdAt); +} + +/** + * Does an audit row concern the same target as a queue group? Reports point at + * events, pubkeys, or blobs; audit rows carry `targetPubkey` / `targetEventId` + * (blobs are not separately keyed in the audit shape, so blob groups surface no + * prior-actions correlation — by design, not omission). + */ +function actionMatchesTarget( + action: ModerationAction, + targetKind: ReportTargetKind, + target: string, +): boolean { + if (targetKind === "event") return action.targetEventId === target; + if (targetKind === "pubkey") return action.targetPubkey === target; + return false; +} + +/** + * Build the triaged queue: reports grouped by target, each group carrying its + * max severity, prior actions, and reports newest-first; groups sorted by + * severity desc, then most-recent-report desc. `actions` is the audit log used + * to attach prior-actions context (pass `[]` when unavailable). + */ +export function buildModerationQueue( + reports: readonly ModerationReport[], + actions: readonly ModerationAction[] = [], +): ModerationQueueGroup[] { + const groups = new Map(); + + for (const report of reports) { + const key = targetKey(report); + const existing = groups.get(key); + if (existing) { + existing.reports.push(report); + existing.maxSeverity = Math.max( + existing.maxSeverity, + reportSeverity(report.reportType), + ); + } else { + groups.set(key, { + targetKey: key, + targetKind: report.targetKind, + target: report.target, + reports: [report], + maxSeverity: reportSeverity(report.reportType), + latestCreatedAt: report.createdAt, + priorActions: [], + }); + } + } + + for (const group of groups.values()) { + group.reports.sort(byCreatedAtDesc); + group.latestCreatedAt = + group.reports[0]?.createdAt ?? group.latestCreatedAt; + group.priorActions = actions + .filter((a) => actionMatchesTarget(a, group.targetKind, group.target)) + .sort(byCreatedAtDesc); + } + + return [...groups.values()].sort((a, b) => { + if (b.maxSeverity !== a.maxSeverity) return b.maxSeverity - a.maxSeverity; + return b.latestCreatedAt.localeCompare(a.latestCreatedAt); + }); +} + +/** Reports still awaiting a decision (`status === "open"`). */ +export function isOpenReport(report: ModerationReport): boolean { + return report.status === "open"; +} From f5fbafac37779ce52e0eae33bd9e05b8abed1190 Mon Sep 17 00:00:00 2001 From: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc Date: Tue, 7 Jul 2026 18:18:37 -0400 Subject: [PATCH 2/8] desktop(moderation): queue presentation helpers (U2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reportTypeLabel, severityTier (critical/high/normal for badge styling, decoupled from the numeric sort rank), and groupTopReportType — the display derivations the queue card renders. 3 added tests (14 total green). Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- .../settings/lib/moderationQueue.test.mjs | 38 ++++++++++++++++ .../features/settings/lib/moderationQueue.ts | 45 +++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/desktop/src/features/settings/lib/moderationQueue.test.mjs b/desktop/src/features/settings/lib/moderationQueue.test.mjs index 043a15c5f0..86aaa2af18 100644 --- a/desktop/src/features/settings/lib/moderationQueue.test.mjs +++ b/desktop/src/features/settings/lib/moderationQueue.test.mjs @@ -3,8 +3,11 @@ import { test } from "node:test"; import { buildModerationQueue, + groupTopReportType, isOpenReport, reportSeverity, + reportTypeLabel, + severityTier, targetKey, } from "./moderationQueue.ts"; @@ -174,3 +177,38 @@ test("isOpenReport is true only for open status", () => { test("empty input yields empty queue", () => { assert.deepEqual(buildModerationQueue([]), []); }); + +test("reportTypeLabel covers every category", () => { + for (const t of [ + "illegal", + "nudity", + "malware", + "spam", + "impersonation", + "profanity", + "other", + ]) { + assert.equal(typeof reportTypeLabel(t), "string"); + assert.ok(reportTypeLabel(t).length > 0); + } +}); + +test("severityTier: illegal=critical, malware/impersonation=high, rest=normal", () => { + assert.equal(severityTier("illegal"), "critical"); + assert.equal(severityTier("malware"), "high"); + assert.equal(severityTier("impersonation"), "high"); + assert.equal(severityTier("spam"), "normal"); + assert.equal(severityTier("nudity"), "normal"); + assert.equal(severityTier("profanity"), "normal"); + assert.equal(severityTier("other"), "normal"); +}); + +test("groupTopReportType returns the most severe type in a group", () => { + const t = "d".repeat(64); + const [group] = buildModerationQueue([ + report({ id: "r1", target: t, reportType: "spam" }), + report({ id: "r2", target: t, reportType: "impersonation" }), + report({ id: "r3", target: t, reportType: "profanity" }), + ]); + assert.equal(groupTopReportType(group), "impersonation"); +}); diff --git a/desktop/src/features/settings/lib/moderationQueue.ts b/desktop/src/features/settings/lib/moderationQueue.ts index d90d46945c..5c811d6bd5 100644 --- a/desktop/src/features/settings/lib/moderationQueue.ts +++ b/desktop/src/features/settings/lib/moderationQueue.ts @@ -190,3 +190,48 @@ export function buildModerationQueue( export function isOpenReport(report: ModerationReport): boolean { return report.status === "open"; } + +/** Human label for a NIP-56 report category. */ +export function reportTypeLabel(reportType: ReportType): string { + switch (reportType) { + case "illegal": + return "Illegal content"; + case "nudity": + return "Nudity"; + case "malware": + return "Malware"; + case "spam": + return "Spam"; + case "impersonation": + return "Impersonation"; + case "profanity": + return "Profanity"; + case "other": + return "Other"; + } +} + +/** + * Coarse severity tier for badge styling. `illegal` is `critical` (escalation + * lane); malware/impersonation are `high`; the rest are `normal`. Kept separate + * from the numeric `reportSeverity` rank so the visual tiers can be tuned + * without perturbing sort order. + */ +export type SeverityTier = "critical" | "high" | "normal"; + +export function severityTier(reportType: ReportType): SeverityTier { + if (reportType === "illegal") return "critical"; + if (reportType === "malware" || reportType === "impersonation") return "high"; + return "normal"; +} + +/** The most severe report type in a group (drives the group's badge). */ +export function groupTopReportType(group: ModerationQueueGroup): ReportType { + let top = group.reports[0]?.reportType ?? "other"; + for (const report of group.reports) { + if (reportSeverity(report.reportType) > reportSeverity(top)) { + top = report.reportType; + } + } + return top; +} From 97d19615b6c5fda8fb807b2e3281359350453bf5 Mon Sep 17 00:00:00 2001 From: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc Date: Tue, 7 Jul 2026 18:33:38 -0400 Subject: [PATCH 3/8] desktop(moderation): moderation queue card + settings section (U2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin-facing moderation queue in community settings. Mod-authz gated via useMyRelayMembershipQuery (owner/admin only, mirroring the relay's 403 on /moderation/*); members see an access notice, never a doomed fetch. Consumes the triage core in lib/moderationQueue.ts: reports grouped by kind-qualified target, severity-sorted (illegal top → other bottom), each group showing per-report reporter identity (mod-only), notes, and a prior-actions warning correlated from the audit log. One-click resolutions via useResolveReportMutation, with statusForAction encoding the relay's dismiss↔dismissed / else↔resolved pairing so an invalid combo can't be sent. Boundary normalizers (toQueueReport/toQueueAction) map the shared hook rows to the triage core's precise string-literal unions; String() on id/timestamp fields is idempotent — correct against today's wire (Uuid/DateTime → JSON strings) and after the shared types tighten to strings. Registered "moderation" in SettingsPanels (union + values + descriptor + render case), placed after Relay Access as a community-admin surface. typecheck + lint clean; 2046 tests green. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- .../settings/ui/ModerationQueueCard.tsx | 416 ++++++++++++++++++ .../features/settings/ui/SettingsPanels.tsx | 11 + 2 files changed, 427 insertions(+) create mode 100644 desktop/src/features/settings/ui/ModerationQueueCard.tsx diff --git a/desktop/src/features/settings/ui/ModerationQueueCard.tsx b/desktop/src/features/settings/ui/ModerationQueueCard.tsx new file mode 100644 index 0000000000..79166f01f5 --- /dev/null +++ b/desktop/src/features/settings/ui/ModerationQueueCard.tsx @@ -0,0 +1,416 @@ +import { AlertTriangle, ChevronDown, ShieldAlert } from "lucide-react"; +import { useMemo } from "react"; +import { toast } from "sonner"; + +import { + useModerationAuditQuery, + useModerationReportsQuery, + useResolveReportMutation, + type ModerationAction as HookModerationAction, + type ModerationReport as HookModerationReport, + type ResolutionAction, +} from "@/features/moderation/hooks"; +import { useMyRelayMembershipQuery } from "@/features/relay-members/hooks"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { + buildModerationQueue, + groupTopReportType, + reportTypeLabel, + severityTier, + type ModerationAction, + type ModerationQueueGroup, + type ModerationReport, + type ReportTargetKind, + type ReportType, + type SeverityTier, +} from "@/features/settings/lib/moderationQueue"; +import { cn } from "@/shared/lib/cn"; +import { truncatePubkey } from "@/shared/lib/pubkey"; +import { Button } from "@/shared/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; +import { SettingsSectionHeader } from "./SettingsSectionHeader"; + +// The queue is mod-only: only relay owners/admins may read /moderation/* (the +// relay returns 403 otherwise). Mirror that gate client-side so members never +// see the panel attempt a doomed fetch. + +// --- Boundary normalizers ------------------------------------------------- +// +// The shared hooks expose the wire rows; this card's triage math lives in +// `lib/moderationQueue.ts` with precise string-literal unions and ISO-string +// timestamps. These map hook rows → triage rows. `String(...)` on id/timestamp +// fields is idempotent: the relay emits them as strings today (Uuid / +// DateTime → JSON strings, see api/bridge.rs report_json), so this is a +// no-op cast that also stays correct if the shared types tighten to strings. + +function toQueueReport(r: HookModerationReport): ModerationReport { + return { + id: String(r.id), + reportEventId: r.reportEventId, + reporterPubkey: r.reporterPubkey, + targetKind: r.targetKind as ReportTargetKind, + target: r.target, + channelId: r.channelId, + reportType: r.reportType as ReportType, + note: r.note, + status: r.status as ModerationReport["status"], + resolvedBy: r.resolvedBy, + resolvedAt: r.resolvedAt == null ? null : String(r.resolvedAt), + actionId: r.actionId, + createdAt: String(r.createdAt), + }; +} + +function toQueueAction(a: HookModerationAction): ModerationAction { + return { + id: String(a.id), + actorPubkey: a.actorPubkey, + action: a.action, + targetPubkey: a.targetPubkey, + targetEventId: a.targetEventId, + channelId: a.channelId, + reasonCode: a.reasonCode, + publicReason: a.publicReason, + privateReason: a.privateReason, + matchedPrincipal: a.matchedPrincipal, + createdAt: String(a.createdAt), + }; +} + +// --- Resolution vocabulary ------------------------------------------------ +// +// The relay pairs `dismiss` with status `dismissed` and every other action +// with `resolved` (moderation_commands.rs: `(action == "dismiss") == +// (status == "dismissed")`). Encode that pairing here so the UI can never +// submit an invalid combination. +function statusForAction(action: ResolutionAction): "resolved" | "dismissed" { + return action === "dismiss" ? "dismissed" : "resolved"; +} + +const RESOLUTION_OPTIONS: { + action: ResolutionAction; + label: string; + description: string; +}[] = [ + { + action: "delete", + label: "Delete content", + description: "Remove the reported content and resolve.", + }, + { + action: "kick", + label: "Kick author", + description: "Remove the author from the community.", + }, + { + action: "ban", + label: "Ban author", + description: "Block the author from the community.", + }, + { + action: "timeout", + label: "Time out author", + description: "Temporarily mute the author.", + }, + { + action: "escalate", + label: "Escalate", + description: "Route to the platform-safety lane.", + }, + { + action: "dismiss", + label: "Dismiss", + description: "No violation — close without action.", + }, +]; + +function formatTimestamp(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return iso; + return date.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +const SEVERITY_BADGE: Record = { + critical: "bg-destructive/15 text-destructive", + high: "bg-amber-500/15 text-amber-600 dark:text-amber-400", + normal: "bg-muted text-muted-foreground", +}; + +function targetLabel(group: ModerationQueueGroup): string { + const short = truncatePubkey(group.target); + switch (group.targetKind) { + case "event": + return `Message ${short}`; + case "pubkey": + return `Member ${short}`; + case "blob": + return `Attachment ${short}`; + } +} + +function ReporterLine({ + report, + displayName, +}: { + report: ModerationReport; + displayName?: string | null; +}) { + const who = displayName?.trim() || truncatePubkey(report.reporterPubkey); + return ( +
+
+ + {reportTypeLabel(report.reportType)} + + + reported by {who} · {formatTimestamp(report.createdAt)} + +
+ {report.note ? ( +

{report.note}

+ ) : null} +
+ ); +} + +function ResolveMenu({ + disabled, + onResolve, +}: { + disabled: boolean; + onResolve: (action: ResolutionAction) => void; +}) { + return ( + + + + + + Resolution + + {RESOLUTION_OPTIONS.map((option) => ( + onResolve(option.action)} + > +
+ {option.label} + + {option.description} + +
+
+ ))} +
+
+ ); +} + +function QueueGroupCard({ + group, + reporterNames, + onResolve, + disabled, +}: { + group: ModerationQueueGroup; + reporterNames: Record; + onResolve: (group: ModerationQueueGroup, action: ResolutionAction) => void; + disabled: boolean; +}) { + const topType = groupTopReportType(group); + const tier = severityTier(topType); + return ( +
+
+
+
+ + {tier === "critical" ? ( + + ) : null} + {reportTypeLabel(topType)} + + + {targetLabel(group)} + + + · {group.reports.length}{" "} + {group.reports.length === 1 ? "report" : "reports"} + +
+
+
+ onResolve(group, action)} + /> +
+
+ +
+ {group.reports.map((report) => ( + + ))} +
+ + {group.priorActions.length > 0 ? ( +
+ + + {group.priorActions.length} prior action + {group.priorActions.length === 1 ? "" : "s"} against this target + {" — "} + {group.priorActions + .slice(0, 3) + .map((a) => a.action) + .join(", ")} + +
+ ) : null} +
+ ); +} + +export function ModerationQueueCard() { + const membershipQuery = useMyRelayMembershipQuery(); + const role = membershipQuery.data?.role; + const isModerator = role === "owner" || role === "admin"; + + const reportsQuery = useModerationReportsQuery( + { status: "open" }, + isModerator, + ); + const auditQuery = useModerationAuditQuery(undefined, isModerator); + const resolveMutation = useResolveReportMutation(); + + const groups = useMemo(() => { + const reports = (reportsQuery.data ?? []).map(toQueueReport); + const actions = (auditQuery.data ?? []).map(toQueueAction); + return buildModerationQueue(reports, actions); + }, [reportsQuery.data, auditQuery.data]); + + const reporterPubkeys = useMemo( + () => + groups.flatMap((group) => + group.reports.map((report) => report.reporterPubkey), + ), + [groups], + ); + const reporterProfiles = useUsersBatchQuery(reporterPubkeys, { + enabled: isModerator && reporterPubkeys.length > 0, + }); + const reporterNames = useMemo(() => { + const map: Record = {}; + const profiles = reporterProfiles.data?.profiles ?? {}; + for (const [pubkey, summary] of Object.entries(profiles)) { + map[pubkey.toLowerCase()] = summary?.displayName ?? null; + } + return map; + }, [reporterProfiles.data]); + + async function handleResolve( + group: ModerationQueueGroup, + action: ResolutionAction, + ) { + const status = statusForAction(action); + // Resolve every open report about this target with the chosen disposition. + const openReports = group.reports.filter( + (report) => report.status === "open", + ); + try { + await Promise.all( + openReports.map((report) => + resolveMutation.mutateAsync({ + reportEventId: report.reportEventId, + status, + action, + }), + ), + ); + toast.success( + status === "dismissed" ? "Report dismissed" : "Report resolved", + ); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to resolve the report", + ); + } + } + + return ( +
+ + + {!isModerator ? ( + membershipQuery.isLoading ? ( +

Checking access…

+ ) : ( +

+ The moderation queue is available to community moderators only. +

+ ) + ) : reportsQuery.error instanceof Error ? ( +

+ {reportsQuery.error.message} +

+ ) : reportsQuery.isLoading ? ( +

Loading reports…

+ ) : groups.length === 0 ? ( +

+ No open reports. The queue is clear. +

+ ) : ( +
+ {groups.map((group) => ( + + ))} +
+ )} +
+ ); +} diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 4104d91050..719910b825 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -12,6 +12,7 @@ import { LockKeyhole, MonitorCog, Moon, + ShieldAlert, Smartphone, Smile, Stethoscope, @@ -56,6 +57,7 @@ import { ExperimentalFeaturesCard } from "./ExperimentalFeaturesCard"; import { KeyboardShortcutsCard } from "./KeyboardShortcutsCard"; import { MeshComputeSettingsCard } from "@/features/mesh-compute/ui/MeshComputeSettingsCard"; import { MobilePairingCard } from "./MobilePairingCard"; +import { ModerationQueueCard } from "./ModerationQueueCard"; import { NotificationSettingsCard } from "./NotificationSettingsCard"; import { PreventSleepSettingsCard } from "./PreventSleepSettingsCard"; import { ProfileSettingsCard } from "./ProfileSettingsCard"; @@ -72,6 +74,7 @@ export type SettingsSection = | "appearance" | "shortcuts" | "relay-members" + | "moderation" | "custom-emoji" | "local-archive" | "mobile" @@ -90,6 +93,7 @@ const SETTINGS_SECTION_VALUES: readonly SettingsSection[] = [ "appearance", "shortcuts", "relay-members", + "moderation", "custom-emoji", "local-archive", "mobile", @@ -175,6 +179,11 @@ export const settingsSections: SettingsSectionDescriptor[] = [ label: "Relay Access", icon: LockKeyhole, }, + { + value: "moderation", + label: "Moderation", + icon: ShieldAlert, + }, { value: "custom-emoji", label: "Custom Emoji", @@ -639,6 +648,8 @@ export function renderSettingsSection( return ; case "relay-members": return ; + case "moderation": + return ; case "custom-emoji": return ; case "local-archive": From c6251ef6e6a2b64db9cb9555b18be8a8ae4d6355 Mon Sep 17 00:00:00 2001 From: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc Date: Tue, 7 Jul 2026 18:35:59 -0400 Subject: [PATCH 4/8] desktop(moderation): audit-log tab in the moderation section (U2) Split the moderation section into Queue and Audit-log tabs. The audit tab reads useModerationAuditQuery (newest-first, mod-gated) and renders each action with its actor (display-name-resolved), target, and public reason. Queue body extracted to a self-contained QueueTab so each tab owns its own queries; the role gate stays at the ModerationQueueCard orchestrator. typecheck + lint clean. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- .../settings/ui/ModerationQueueCard.tsx | 182 +++++++++++++++--- 1 file changed, 150 insertions(+), 32 deletions(-) diff --git a/desktop/src/features/settings/ui/ModerationQueueCard.tsx b/desktop/src/features/settings/ui/ModerationQueueCard.tsx index 79166f01f5..96074cc91a 100644 --- a/desktop/src/features/settings/ui/ModerationQueueCard.tsx +++ b/desktop/src/features/settings/ui/ModerationQueueCard.tsx @@ -35,6 +35,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs"; import { SettingsSectionHeader } from "./SettingsSectionHeader"; // The queue is mod-only: only relay owners/admins may read /moderation/* (the @@ -304,16 +305,9 @@ function QueueGroupCard({ ); } -export function ModerationQueueCard() { - const membershipQuery = useMyRelayMembershipQuery(); - const role = membershipQuery.data?.role; - const isModerator = role === "owner" || role === "admin"; - - const reportsQuery = useModerationReportsQuery( - { status: "open" }, - isModerator, - ); - const auditQuery = useModerationAuditQuery(undefined, isModerator); +function QueueTab() { + const reportsQuery = useModerationReportsQuery({ status: "open" }); + const auditQuery = useModerationAuditQuery(); const resolveMutation = useResolveReportMutation(); const groups = useMemo(() => { @@ -330,7 +324,7 @@ export function ModerationQueueCard() { [groups], ); const reporterProfiles = useUsersBatchQuery(reporterPubkeys, { - enabled: isModerator && reporterPubkeys.length > 0, + enabled: reporterPubkeys.length > 0, }); const reporterNames = useMemo(() => { const map: Record = {}; @@ -370,6 +364,135 @@ export function ModerationQueueCard() { } } + if (reportsQuery.error instanceof Error) { + return ( +

+ {reportsQuery.error.message} +

+ ); + } + if (reportsQuery.isLoading) { + return

Loading reports…

; + } + if (groups.length === 0) { + return ( +

+ No open reports. The queue is clear. +

+ ); + } + return ( +
+ {groups.map((group) => ( + + ))} +
+ ); +} + +function AuditRow({ + action, + actorName, +}: { + action: ModerationAction; + actorName?: string | null; +}) { + const who = actorName?.trim() || truncatePubkey(action.actorPubkey); + const targetShort = action.targetPubkey + ? truncatePubkey(action.targetPubkey) + : action.targetEventId + ? truncatePubkey(action.targetEventId) + : null; + return ( +
+
+ + {action.action.replace(/_/g, " ")} + + {targetShort ? ( + + → {targetShort} + + ) : null} + + by {who} · {formatTimestamp(action.createdAt)} + +
+ {action.publicReason ? ( +

{action.publicReason}

+ ) : null} +
+ ); +} + +function AuditTab() { + const auditQuery = useModerationAuditQuery(); + + const actions = useMemo( + () => (auditQuery.data ?? []).map(toQueueAction), + [auditQuery.data], + ); + + const actorPubkeys = useMemo( + () => actions.map((action) => action.actorPubkey), + [actions], + ); + const actorProfiles = useUsersBatchQuery(actorPubkeys, { + enabled: actorPubkeys.length > 0, + }); + const actorNames = useMemo(() => { + const map: Record = {}; + const profiles = actorProfiles.data?.profiles ?? {}; + for (const [pubkey, summary] of Object.entries(profiles)) { + map[pubkey.toLowerCase()] = summary?.displayName ?? null; + } + return map; + }, [actorProfiles.data]); + + if (auditQuery.error instanceof Error) { + return ( +

+ {auditQuery.error.message} +

+ ); + } + if (auditQuery.isLoading) { + return

Loading audit log…

; + } + if (actions.length === 0) { + return ( +

+ No moderation actions yet. +

+ ); + } + return ( +
+ {actions.map((action) => ( + + ))} +
+ ); +} + +export function ModerationQueueCard() { + const membershipQuery = useMyRelayMembershipQuery(); + const role = membershipQuery.data?.role; + const isModerator = role === "owner" || role === "admin"; + return (
) - ) : reportsQuery.error instanceof Error ? ( -

- {reportsQuery.error.message} -

- ) : reportsQuery.isLoading ? ( -

Loading reports…

- ) : groups.length === 0 ? ( -

- No open reports. The queue is clear. -

) : ( -
- {groups.map((group) => ( - - ))} -
+ + + + Queue + + + Audit log + + + + + + + + + )}
); From 4a6d9e8cbedca92eb11074810562e5d2c7e433d0 Mon Sep 17 00:00:00 2001 From: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc Date: Tue, 7 Jul 2026 18:45:59 -0400 Subject: [PATCH 5/8] desktop(moderation): ban/timeout controls in MembersSidebar (U2) Add mod-gated per-member moderation actions to the members sidebar: Ban/Lift ban and Time out (1h/24h/7d presets)/Lift timeout, driven by useModerationRestrictionsQuery state and wired to the ban/unban/timeout/ untimeout mutations with toast feedback. Actions are relay-role-gated (owner/admin via useMyRelayMembershipQuery), independent of per-channel role, and never offered for bots, self, or the community owner. A parseTimestampMs helper reads mutedUntil tolerantly across both number (legacy unix-secs) and string (ISO) forms, failing closed to null so a bad value never renders a phantom timeout state. The moderation wiring lives in a new useMembersSidebarModeration hook, mirroring the sibling useMembersSidebarActions convention. To keep the sidebar under the 1000-line file gate, the self-contained EditRespondToDialog is also extracted to its own module (no behavior change). Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- .../channels/ui/EditRespondToDialog.tsx | 96 +++++++++++++ .../features/channels/ui/MembersSidebar.tsx | 115 ++++------------ .../channels/ui/MembersSidebarMemberCard.tsx | 119 +++++++++++++++- .../ui/useMembersSidebarModeration.ts | 129 ++++++++++++++++++ 4 files changed, 370 insertions(+), 89 deletions(-) create mode 100644 desktop/src/features/channels/ui/EditRespondToDialog.tsx create mode 100644 desktop/src/features/channels/ui/useMembersSidebarModeration.ts diff --git a/desktop/src/features/channels/ui/EditRespondToDialog.tsx b/desktop/src/features/channels/ui/EditRespondToDialog.tsx new file mode 100644 index 0000000000..11a2dcd09d --- /dev/null +++ b/desktop/src/features/channels/ui/EditRespondToDialog.tsx @@ -0,0 +1,96 @@ +import * as React from "react"; + +import { useUpdateManagedAgentMutation } from "@/features/agents/hooks"; +import { CreateAgentRespondToField } from "@/features/agents/ui/RespondToField"; +import type { ManagedAgent, RespondToMode } from "@/shared/api/types"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; + +export function EditRespondToDialog({ + agent, + currentPubkey, + onOpenChange, + open, +}: { + agent: ManagedAgent | null; + currentPubkey?: string; + onOpenChange: (open: boolean) => void; + open: boolean; +}) { + const updateMutation = useUpdateManagedAgentMutation(); + const [respondTo, setRespondTo] = React.useState("owner-only"); + const [respondToAllowlist, setRespondToAllowlist] = React.useState( + [], + ); + + React.useEffect(() => { + if (agent) { + setRespondTo(agent.respondTo); + setRespondToAllowlist([...agent.respondToAllowlist]); + } + }, [agent]); + + const respondToValid = + respondTo !== "allowlist" || respondToAllowlist.length > 0; + + async function handleSave() { + if (!agent) return; + await updateMutation.mutateAsync({ + pubkey: agent.pubkey, + respondTo, + respondToAllowlist: + respondTo === "allowlist" ? respondToAllowlist : undefined, + }); + onOpenChange(false); + } + + return ( + + + + Edit respond-to + + Choose who {agent?.name ?? "this agent"} responds to. + + + + {updateMutation.error instanceof Error ? ( +

+ {updateMutation.error.message} +

+ ) : null} +
+ + +
+
+
+ ); +} diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index b1b55417c7..77f99cca7f 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -6,13 +6,11 @@ import { useChannelMembersQuery, useChannelsQuery, } from "@/features/channels/hooks"; -import { useUpdateManagedAgentMutation } from "@/features/agents/hooks"; import { coalesceAgentAutocompleteCandidates, getMentionableAgentPubkeys, getSharedChannelIds, } from "@/features/agents/lib/agentAutocompleteEligibility"; -import { CreateAgentRespondToField } from "@/features/agents/ui/RespondToField"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import { useClassifiedMembers } from "@/features/channels/lib/useClassifiedMembers"; import { formatMemberName } from "@/features/channels/lib/memberUtils"; @@ -32,7 +30,6 @@ import type { Channel, ChannelMember, ManagedAgent, - RespondToMode, UserSearchResult, } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; @@ -40,7 +37,6 @@ import { Dialog, DialogClose, DialogContent, - DialogDescription, DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; @@ -54,10 +50,13 @@ import { MODAL_SEARCH_SHELL_CLASS, } from "@/shared/ui/modalSearchStyles"; import { MembersSidebarMemberCard } from "./MembersSidebarMemberCard"; +import { EditRespondToDialog } from "./EditRespondToDialog"; import { useMembersSidebarActions } from "./useMembersSidebarActions"; +import { useMembersSidebarModeration } from "./useMembersSidebarModeration"; const MEMBER_ADD_RESULT_LIMIT = 50; const MEMBER_ROW_INSET_DIVIDER_CLASS = "after:pointer-events-none after:absolute after:bottom-0 after:left-[3.75rem] after:right-0 after:h-px after:bg-border/60 after:content-[''] last:after:hidden"; + function formatAddCandidateName(user: UserSearchResult) { return ( user.displayName?.trim() || @@ -437,6 +436,17 @@ export function MembersSidebar({ const canManageMembers = selfMember?.role === "owner" || selfMember?.role === "admin"; + + const { + canModerate, + isModerationPending, + moderationStateByPubkey, + onBan, + onUnban, + onTimeout, + onUntimeout, + } = useMembersSidebarModeration(open); + const isArchived = channel?.archivedAt !== null && channel?.archivedAt !== undefined; const managedAgentByPubkey = React.useMemo( @@ -563,8 +573,13 @@ export function MembersSidebar({
{ void changeRoleMutation.mutateAsync({ pubkey: m.pubkey, role }); }} @@ -586,6 +605,9 @@ export function MembersSidebar({ }} onOpenProfile={handleOpenProfile} onRemoveMember={handleRemoveMember} + onTimeout={onTimeout} + onUnban={onUnban} + onUntimeout={onUntimeout} onViewActivity={ onViewActivity ? (pubkey: string) => { @@ -897,86 +919,3 @@ function AddMemberSearchResultRow({
); } - -function EditRespondToDialog({ - agent, - currentPubkey, - onOpenChange, - open, -}: { - agent: ManagedAgent | null; - currentPubkey?: string; - onOpenChange: (open: boolean) => void; - open: boolean; -}) { - const updateMutation = useUpdateManagedAgentMutation(); - const [respondTo, setRespondTo] = React.useState("owner-only"); - const [respondToAllowlist, setRespondToAllowlist] = React.useState( - [], - ); - - React.useEffect(() => { - if (agent) { - setRespondTo(agent.respondTo); - setRespondToAllowlist([...agent.respondToAllowlist]); - } - }, [agent]); - - const respondToValid = - respondTo !== "allowlist" || respondToAllowlist.length > 0; - - async function handleSave() { - if (!agent) return; - await updateMutation.mutateAsync({ - pubkey: agent.pubkey, - respondTo, - respondToAllowlist: - respondTo === "allowlist" ? respondToAllowlist : undefined, - }); - onOpenChange(false); - } - - return ( - - - - Edit respond-to - - Choose who {agent?.name ?? "this agent"} responds to. - - - - {updateMutation.error instanceof Error ? ( -

- {updateMutation.error.message} -

- ) : null} -
- - -
-
-
- ); -} diff --git a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx index 6fea3abe64..8234517432 100644 --- a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx +++ b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx @@ -1,11 +1,15 @@ import { Activity, + Ban, Bot, + CircleSlash, + Clock, Ellipsis, Pencil, Play, RotateCcw, Shield, + ShieldCheck, Square, Trash2, } from "lucide-react"; @@ -36,6 +40,7 @@ import { type MembersSidebarMemberCardProps = { canChangeRole: boolean; + canModerate: boolean; canRemoveMember: boolean; isActionPending: boolean; isArchived: boolean; @@ -44,17 +49,35 @@ type MembersSidebarMemberCardProps = { memberAvatarLabel: string; memberIsBot: boolean; memberLabel: string; + moderationState?: MemberModerationState; + onBan: (member: ChannelMember) => void; onChangeRole: (member: ChannelMember, role: string) => void; onEditRespondTo?: (agent: ManagedAgent) => void; onManagedAgentAction: (agent: ManagedAgent) => void; onOpenProfile?: (pubkey: string) => void; onRemoveMember: (member: ChannelMember) => void; + onTimeout: (member: ChannelMember, expiresAtSecs: number) => void; + onUnban: (member: ChannelMember) => void; + onUntimeout: (member: ChannelMember) => void; onViewActivity?: (pubkey: string) => void; presenceStatus?: PresenceStatus | null; profileAvatarUrl?: string | null; viewerIsOwner: boolean; }; +/** Whether a member is currently banned / timed out (from the restriction read). */ +export type MemberModerationState = { + banned: boolean; + timedOut: boolean; +}; + +/** Timeout durations offered in the member menu, in seconds. */ +const TIMEOUT_PRESETS: { 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 }, +]; + const MEMBER_ROW_INSET_DIVIDER_CLASS = "after:pointer-events-none after:absolute after:bottom-0 after:left-[3.75rem] after:right-0 after:h-px after:bg-border/60 after:content-[''] last:after:hidden"; @@ -96,6 +119,7 @@ function formatManagedAgentStatus(agent: ManagedAgent) { export function MembersSidebarMemberCard({ canChangeRole, + canModerate, canRemoveMember, isActionPending, isArchived, @@ -104,11 +128,16 @@ export function MembersSidebarMemberCard({ memberAvatarLabel, memberIsBot, memberLabel, + moderationState, + onBan, onChangeRole, onEditRespondTo, onManagedAgentAction, onOpenProfile, onRemoveMember, + onTimeout, + onUnban, + onUntimeout, onViewActivity, presenceStatus, profileAvatarUrl, @@ -120,9 +149,13 @@ export function MembersSidebarMemberCard({ memberIsBot && (viewerIsOwner || managedAgent?.backend.type === "local") && Boolean(onViewActivity); + // Community ban/timeout applies to people, never bots, and never the community + // owner (whom no moderator can restrict). + const canModerateMember = + canModerate && !memberIsBot && member.role !== "owner"; const hasActions = memberIsBot ? Boolean(managedAgent) || canRemoveMember || canViewActivity - : canRemoveMember || canChangeRole; + : canRemoveMember || canChangeRole || canModerateMember; const memberIdentity = (
@@ -215,16 +248,22 @@ export function MembersSidebarMemberCard({ {hasActions ? ( ) : null} @@ -236,33 +275,47 @@ const PEOPLE_ROLES = ["admin", "member", "guest"] as const; function MemberActionsMenu({ canChangeRole, + canModerateMember, canRemoveMember, canViewActivity, disabled, managedAgent, member, memberIsBot, + moderationState, + onBan, onChangeRole, onEditRespondTo, onManagedAgentAction, onRemoveMember, + onTimeout, + onUnban, + onUntimeout, onViewActivity, }: { canChangeRole: boolean; + canModerateMember: boolean; canRemoveMember: boolean; canViewActivity: boolean; disabled: boolean; managedAgent?: ManagedAgent; member: ChannelMember; memberIsBot: boolean; + moderationState?: MemberModerationState; + onBan: (member: ChannelMember) => void; onChangeRole: (member: ChannelMember, role: string) => void; onEditRespondTo?: (agent: ManagedAgent) => void; onManagedAgentAction: (agent: ManagedAgent) => void; onRemoveMember: (member: ChannelMember) => void; + onTimeout: (member: ChannelMember, expiresAtSecs: number) => void; + onUnban: (member: ChannelMember) => void; + onUntimeout: (member: ChannelMember) => void; onViewActivity?: (pubkey: string) => void; }) { const showChangeRole = canChangeRole && !memberIsBot && member.role !== "owner"; + const isBanned = moderationState?.banned ?? false; + const isTimedOut = moderationState?.timedOut ?? false; return ( @@ -353,6 +406,70 @@ function MemberActionsMenu({ ) : null} + {canModerateMember ? ( + <> + {canRemoveMember || showChangeRole ? ( + + ) : null} + {isTimedOut ? ( + onUntimeout(member)} + > + + Lift timeout + + ) : ( + + + + Time out + + + {TIMEOUT_PRESETS.map((preset) => ( + + onTimeout( + member, + Math.floor(Date.now() / 1000) + preset.seconds, + ) + } + > + {preset.label} + + ))} + + + )} + {isBanned ? ( + onUnban(member)} + > + + Lift ban + + ) : ( + onBan(member)} + > + + Ban from community + + )} + + ) : null} ); diff --git a/desktop/src/features/channels/ui/useMembersSidebarModeration.ts b/desktop/src/features/channels/ui/useMembersSidebarModeration.ts new file mode 100644 index 0000000000..63a4811324 --- /dev/null +++ b/desktop/src/features/channels/ui/useMembersSidebarModeration.ts @@ -0,0 +1,129 @@ +import * as React from "react"; +import { toast } from "sonner"; + +import { + useBanMemberMutation, + useModerationRestrictionsQuery, + useTimeoutMemberMutation, + useUnbanMemberMutation, + useUntimeoutMemberMutation, +} from "@/features/moderation/hooks"; +import { useMyRelayMembershipQuery } from "@/features/relay-members/hooks"; +import type { ChannelMember } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +import type { MemberModerationState } from "./MembersSidebarMemberCard"; + +/** + * Coerce a restriction timestamp to epoch milliseconds. The wire emits + * DateTime as an RFC3339 string, but the shared type still tolerates the + * legacy `number` (unix seconds) shape, so handle both: strings parse as ISO, + * numbers are treated as unix seconds. Returns null for absent/unparseable + * values (fails closed — no phantom "timed out" state). + */ +function parseTimestampMs(value: string | number | null): number | null { + if (value == null) return null; + if (typeof value === "number") return value * 1000; + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; +} + +/** + * Owns community ban/timeout wiring for the members sidebar. Gated by relay + * role (owner/admin), independent of the per-channel role — the relay rejects + * the command events otherwise. Restrictions are only fetched while the sidebar + * is open and the caller can moderate. + */ +export function useMembersSidebarModeration(open: boolean) { + const relayMembershipQuery = useMyRelayMembershipQuery(); + const relayRole = relayMembershipQuery.data?.role; + const canModerate = relayRole === "owner" || relayRole === "admin"; + const restrictionsQuery = useModerationRestrictionsQuery(open && canModerate); + const banMutation = useBanMemberMutation(); + const unbanMutation = useUnbanMemberMutation(); + const timeoutMutation = useTimeoutMemberMutation(); + const untimeoutMutation = useUntimeoutMemberMutation(); + const isModerationPending = + banMutation.isPending || + unbanMutation.isPending || + timeoutMutation.isPending || + untimeoutMutation.isPending; + + const moderationStateByPubkey = React.useMemo(() => { + const nowMs = Date.now(); + const map = new Map(); + for (const restriction of restrictionsQuery.data ?? []) { + const mutedUntilMs = parseTimestampMs(restriction.mutedUntil); + const timedOut = mutedUntilMs != null && mutedUntilMs > nowMs; + map.set(normalizePubkey(restriction.pubkey), { + banned: restriction.banned, + timedOut, + }); + } + return map; + }, [restrictionsQuery.data]); + + const runModerationAction = React.useCallback( + async (action: () => Promise, success: string) => { + try { + await action(); + toast.success(success); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Moderation action failed", + ); + } + }, + [], + ); + + const onBan = React.useCallback( + (member: ChannelMember) => + void runModerationAction( + () => banMutation.mutateAsync({ pubkey: member.pubkey }), + "Member banned", + ), + [banMutation, runModerationAction], + ); + + const onUnban = React.useCallback( + (member: ChannelMember) => + void runModerationAction( + () => unbanMutation.mutateAsync(member.pubkey), + "Ban lifted", + ), + [unbanMutation, runModerationAction], + ); + + const onTimeout = React.useCallback( + (member: ChannelMember, expiresAtSecs: number) => + void runModerationAction( + () => + timeoutMutation.mutateAsync({ + pubkey: member.pubkey, + expiresAt: expiresAtSecs, + }), + "Member timed out", + ), + [timeoutMutation, runModerationAction], + ); + + const onUntimeout = React.useCallback( + (member: ChannelMember) => + void runModerationAction( + () => untimeoutMutation.mutateAsync(member.pubkey), + "Timeout lifted", + ), + [untimeoutMutation, runModerationAction], + ); + + return { + canModerate, + isModerationPending, + moderationStateByPubkey, + onBan, + onUnban, + onTimeout, + onUntimeout, + }; +} From e4be1e5f8c3d701a9931b51ea7db55c22d354fd6 Mon Sep 17 00:00:00 2001 From: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc Date: Tue, 7 Jul 2026 18:50:36 -0400 Subject: [PATCH 6/8] desktop(moderation): adopt shared moderation row types post-flip (U2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dawn's type-flip landed on the integration branch, so the shared `@/shared/api/moderation` row types now carry string ids/timestamps. Drop the duplicate row-shape mirrors in the triage module and reuse the shared types directly: - `ModerationReport` becomes `Omit & { reportType: ReportType; status: ReportStatus }` — the edge-narrowing unions the triage math dispatches on are kept, the rest of the shape is single-sourced. `targetKind` already matches upstream. - `ModerationAction` is now a straight alias of the shared type (the math treats `action` opaquely), so `toQueueAction` was a pure identity map — removed, audit rows flow through untouched (via a stable EMPTY_ACTIONS reference so the audit memos don't churn). - `toQueueReport` keeps only the two narrowing casts; the `String(...)` no-ops are gone now that the shared ids/timestamps are already strings. No behavior change. Typecheck/lint/2054 tests green. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- .../features/settings/lib/moderationQueue.ts | 62 +++++++------------ .../settings/ui/ModerationQueueCard.tsx | 53 ++++------------ 2 files changed, 36 insertions(+), 79 deletions(-) diff --git a/desktop/src/features/settings/lib/moderationQueue.ts b/desktop/src/features/settings/lib/moderationQueue.ts index 5c811d6bd5..0e24e112ab 100644 --- a/desktop/src/features/settings/lib/moderationQueue.ts +++ b/desktop/src/features/settings/lib/moderationQueue.ts @@ -1,18 +1,23 @@ // Domain logic for the community-moderation admin queue (U2 admin surface). // // Pure, hook-free transforms over the NIP-98 `/moderation/*` read contract so -// they can be unit-tested without a relay. The wire shapes below mirror the -// authoritative JSON emitted by the relay's `report_json` / `action_json` / -// `ban_json` (crates/buzz-relay/src/api/bridge.rs) — field names are pinned to -// that source. The queue view consumes these via the shared -// `features/moderation` hooks (Dawn's lane); this module owns only the +// they can be unit-tested without a relay. The authoritative wire row shapes +// live in `@/shared/api/moderation` (Dawn's lane); this module owns only the // triage math: severity ordering, grouping by target, and prior-actions -// correlation. +// correlation. It reuses those row types directly, narrowing just the two +// fields the triage math dispatches on (`reportType`, `status`) to the precise +// unions below — the shared types keep them as `string` so the wire can carry +// values the client doesn't yet model. // // Privacy invariant (locked, Tyler 2026-07-07): `reporterPubkey` is visible in // this admin queue but MUST NEVER reach any surface the reported author can // see. Nothing here is rendered author-side. +import type { + ModerationAction as ApiModerationAction, + ModerationReport as ApiModerationReport, +} from "@/shared/api/moderation"; + /** NIP-56 report categories accepted at ingest (relay `report.rs::REPORT_TYPES`). */ export type ReportType = | "illegal" @@ -33,42 +38,23 @@ export type ReportTargetKind = "event" | "pubkey" | "blob"; */ export type ReportStatus = "open" | "resolved" | "dismissed" | "escalated"; -/** Queue row: one accepted kind:1984 report (`/moderation/reports`). */ -export type ModerationReport = { - id: string; - reportEventId: string; - /** Reporter identity — mod-only, never author-visible. */ - reporterPubkey: string; - targetKind: ReportTargetKind; - /** Hex event id / pubkey / blob sha, per `targetKind`. */ - target: string; - channelId: string | null; +/** Queue row: one accepted kind:1984 report (`/moderation/reports`). + * + * The shared `ApiModerationReport` shape verbatim, with `reportType` and + * `status` narrowed to the client-modeled unions the triage math dispatches + * on. `targetKind` is already the exact union upstream, so it passes through. + */ +export type ModerationReport = Omit< + ApiModerationReport, + "reportType" | "status" +> & { reportType: ReportType; - /** Reporter-supplied context; mod-only. */ - note: string | null; status: ReportStatus; - resolvedBy: string | null; - resolvedAt: string | null; - actionId: string | null; - createdAt: string; }; -/** Audit row: one accepted moderation action (`/moderation/audit`). */ -export type ModerationAction = { - id: string; - actorPubkey: string; - action: string; - targetPubkey: string | null; - targetEventId: string | null; - channelId: string | null; - reasonCode: string | null; - /** Sanitized, tombstone-safe. */ - publicReason: string | null; - /** Mod-only; never leaves the audit surface. */ - privateReason: string | null; - matchedPrincipal: string | null; - createdAt: string; -}; +/** Audit row: one accepted moderation action (`/moderation/audit`). The shared + * shape needs no narrowing here — the triage math treats `action` opaquely. */ +export type ModerationAction = ApiModerationAction; /** * Severity rank per report category — higher acts first. `illegal` tops the diff --git a/desktop/src/features/settings/ui/ModerationQueueCard.tsx b/desktop/src/features/settings/ui/ModerationQueueCard.tsx index 96074cc91a..d4510b84ac 100644 --- a/desktop/src/features/settings/ui/ModerationQueueCard.tsx +++ b/desktop/src/features/settings/ui/ModerationQueueCard.tsx @@ -6,7 +6,6 @@ import { useModerationAuditQuery, useModerationReportsQuery, useResolveReportMutation, - type ModerationAction as HookModerationAction, type ModerationReport as HookModerationReport, type ResolutionAction, } from "@/features/moderation/hooks"; @@ -20,7 +19,7 @@ import { type ModerationAction, type ModerationQueueGroup, type ModerationReport, - type ReportTargetKind, + type ReportStatus, type ReportType, type SeverityTier, } from "@/features/settings/lib/moderationQueue"; @@ -42,48 +41,24 @@ import { SettingsSectionHeader } from "./SettingsSectionHeader"; // relay returns 403 otherwise). Mirror that gate client-side so members never // see the panel attempt a doomed fetch. -// --- Boundary normalizers ------------------------------------------------- +// --- Boundary normalizer -------------------------------------------------- // // The shared hooks expose the wire rows; this card's triage math lives in -// `lib/moderationQueue.ts` with precise string-literal unions and ISO-string -// timestamps. These map hook rows → triage rows. `String(...)` on id/timestamp -// fields is idempotent: the relay emits them as strings today (Uuid / -// DateTime → JSON strings, see api/bridge.rs report_json), so this is a -// no-op cast that also stays correct if the shared types tighten to strings. +// `lib/moderationQueue.ts`, which reuses the shared row shapes but narrows +// `reportType`/`status` to precise unions. Report rows need that narrowing cast +// at the boundary; audit rows are structurally identical (ModerationAction = +// the shared shape), so they flow through untouched. function toQueueReport(r: HookModerationReport): ModerationReport { return { - id: String(r.id), - reportEventId: r.reportEventId, - reporterPubkey: r.reporterPubkey, - targetKind: r.targetKind as ReportTargetKind, - target: r.target, - channelId: r.channelId, + ...r, reportType: r.reportType as ReportType, - note: r.note, - status: r.status as ModerationReport["status"], - resolvedBy: r.resolvedBy, - resolvedAt: r.resolvedAt == null ? null : String(r.resolvedAt), - actionId: r.actionId, - createdAt: String(r.createdAt), + status: r.status as ReportStatus, }; } -function toQueueAction(a: HookModerationAction): ModerationAction { - return { - id: String(a.id), - actorPubkey: a.actorPubkey, - action: a.action, - targetPubkey: a.targetPubkey, - targetEventId: a.targetEventId, - channelId: a.channelId, - reasonCode: a.reasonCode, - publicReason: a.publicReason, - privateReason: a.privateReason, - matchedPrincipal: a.matchedPrincipal, - createdAt: String(a.createdAt), - }; -} +/** Stable empty-array reference so audit-derived memos don't churn on refetch. */ +const EMPTY_ACTIONS: readonly ModerationAction[] = []; // --- Resolution vocabulary ------------------------------------------------ // @@ -312,8 +287,7 @@ function QueueTab() { const groups = useMemo(() => { const reports = (reportsQuery.data ?? []).map(toQueueReport); - const actions = (auditQuery.data ?? []).map(toQueueAction); - return buildModerationQueue(reports, actions); + return buildModerationQueue(reports, auditQuery.data ?? []); }, [reportsQuery.data, auditQuery.data]); const reporterPubkeys = useMemo( @@ -437,10 +411,7 @@ function AuditRow({ function AuditTab() { const auditQuery = useModerationAuditQuery(); - const actions = useMemo( - () => (auditQuery.data ?? []).map(toQueueAction), - [auditQuery.data], - ); + const actions = auditQuery.data ?? EMPTY_ACTIONS; const actorPubkeys = useMemo( () => actions.map((action) => action.actorPubkey), From 647d481f3949248e3f9936aa5293e1dbdbb2b703 Mon Sep 17 00:00:00 2001 From: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc Date: Tue, 7 Jul 2026 18:56:39 -0400 Subject: [PATCH 7/8] desktop(moderation): per-message admin action cluster (U2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add mod-only actions against a message's author to the message more-actions menu: Time out author (1h/24h/7d) / Lift timeout, Kick from channel, and Ban author / Lift ban. Self-contained in MessageModerationMenuItems (wires its own hooks, no props threaded from the message row), mirroring ReportMessageDialog. The cluster renders only when the viewer is a relay owner/admin, the row is a real delivered message with a known author, and that author is not the viewer. Actions target `signerPubkey` — the raw signer, never the display-author (which `p`/`actor` tags can override) — per the security note on TimelineMessage. Live ban/timeout state comes from useModerationRestrictionsQuery so the menu offers the inverse (lift) action when a restriction is active. Extract the shared `isTimedOut` / `parseRestrictionTimestampMs` helper to features/moderation/lib/restrictionState.ts (8 unit tests) and route both this cluster and useMembersSidebarModeration through it, removing the two duplicated `mutedUntil` parsers. Notes / open seams: - Relay authz is authoritative: an admin action the relay refuses (e.g. against the community owner) surfaces as an error toast, so no separate owner-exclusion gate is added here. - Bots are moderatable from a message (unlike the sidebar, which offers agent-specific controls) — a bot author posting bad content is a legitimate ban target. - No "delete-with-reason" action: the enriched-tombstone reason path only exists via report resolution (9044), which needs an existing report; there is no direct moderator-delete-with-reason command. Plain admin delete (9005) remains the existing "Delete message". Raised with Eva. Typecheck/lint/2062 tests green. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- .../ui/useMembersSidebarModeration.ts | 19 +- .../features/messages/ui/MessageActionBar.tsx | 8 + .../moderation/lib/restrictionState.test.mjs | 41 ++++ .../moderation/lib/restrictionState.ts | 41 ++++ .../ui/MessageModerationMenuItems.tsx | 204 ++++++++++++++++++ 5 files changed, 296 insertions(+), 17 deletions(-) create mode 100644 desktop/src/features/moderation/lib/restrictionState.test.mjs create mode 100644 desktop/src/features/moderation/lib/restrictionState.ts create mode 100644 desktop/src/features/moderation/ui/MessageModerationMenuItems.tsx diff --git a/desktop/src/features/channels/ui/useMembersSidebarModeration.ts b/desktop/src/features/channels/ui/useMembersSidebarModeration.ts index 63a4811324..9a546d4836 100644 --- a/desktop/src/features/channels/ui/useMembersSidebarModeration.ts +++ b/desktop/src/features/channels/ui/useMembersSidebarModeration.ts @@ -9,25 +9,12 @@ import { useUntimeoutMemberMutation, } from "@/features/moderation/hooks"; import { useMyRelayMembershipQuery } from "@/features/relay-members/hooks"; +import { isTimedOut } from "@/features/moderation/lib/restrictionState"; import type { ChannelMember } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; import type { MemberModerationState } from "./MembersSidebarMemberCard"; -/** - * Coerce a restriction timestamp to epoch milliseconds. The wire emits - * DateTime as an RFC3339 string, but the shared type still tolerates the - * legacy `number` (unix seconds) shape, so handle both: strings parse as ISO, - * numbers are treated as unix seconds. Returns null for absent/unparseable - * values (fails closed — no phantom "timed out" state). - */ -function parseTimestampMs(value: string | number | null): number | null { - if (value == null) return null; - if (typeof value === "number") return value * 1000; - const parsed = Date.parse(value); - return Number.isNaN(parsed) ? null : parsed; -} - /** * Owns community ban/timeout wiring for the members sidebar. Gated by relay * role (owner/admin), independent of the per-channel role — the relay rejects @@ -53,11 +40,9 @@ export function useMembersSidebarModeration(open: boolean) { const nowMs = Date.now(); const map = new Map(); for (const restriction of restrictionsQuery.data ?? []) { - const mutedUntilMs = parseTimestampMs(restriction.mutedUntil); - const timedOut = mutedUntilMs != null && mutedUntilMs > nowMs; map.set(normalizePubkey(restriction.pubkey), { banned: restriction.banned, - timedOut, + timedOut: isTimedOut(restriction.mutedUntil, nowMs), }); } return map; diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index 0cb4c6814b..5e487d1d59 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -20,6 +20,7 @@ import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker"; import { useCustomEmoji } from "@/features/custom-emoji/hooks"; import { getThreadReference } from "@/features/messages/lib/threading"; import { ReportMessageDialog } from "@/features/moderation/ui/ReportMessageDialog"; +import { MessageModerationMenuItems } from "@/features/moderation/ui/MessageModerationMenuItems"; import type { TimelineMessage, TimelineReaction, @@ -265,6 +266,13 @@ function MoreActionsMenu({ Delete message ) : null} + + {canReport ? ( + + ) : null} diff --git a/desktop/src/features/moderation/lib/restrictionState.test.mjs b/desktop/src/features/moderation/lib/restrictionState.test.mjs new file mode 100644 index 0000000000..1c22dd66a7 --- /dev/null +++ b/desktop/src/features/moderation/lib/restrictionState.test.mjs @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isTimedOut, parseRestrictionTimestampMs } from "./restrictionState.ts"; + +test("parses an RFC3339 string to epoch ms", () => { + assert.equal( + parseRestrictionTimestampMs("2026-07-07T22:00:00Z"), + Date.parse("2026-07-07T22:00:00Z"), + ); +}); + +test("treats a legacy number as unix seconds", () => { + assert.equal(parseRestrictionTimestampMs(1751920000), 1751920000 * 1000); +}); + +test("returns null for a null value", () => { + assert.equal(parseRestrictionTimestampMs(null), null); +}); + +test("returns null for an unparseable string (fails closed)", () => { + assert.equal(parseRestrictionTimestampMs("not-a-date"), null); +}); + +test("isTimedOut is true for a future muted-until", () => { + const now = 1_000_000_000_000; + assert.equal(isTimedOut(new Date(now + 60_000).toISOString(), now), true); +}); + +test("isTimedOut is false for a past muted-until", () => { + const now = 1_000_000_000_000; + assert.equal(isTimedOut(new Date(now - 60_000).toISOString(), now), false); +}); + +test("isTimedOut is false for an absent muted-until (fail closed to not-timed-out)", () => { + assert.equal(isTimedOut(null), false); +}); + +test("isTimedOut is false for an unparseable muted-until", () => { + assert.equal(isTimedOut("garbage"), false); +}); diff --git a/desktop/src/features/moderation/lib/restrictionState.ts b/desktop/src/features/moderation/lib/restrictionState.ts new file mode 100644 index 0000000000..9f6fa82cca --- /dev/null +++ b/desktop/src/features/moderation/lib/restrictionState.ts @@ -0,0 +1,41 @@ +/** + * Derive a member's live moderation state from a `CommunityRestriction` row. + * + * Shared by the two admin surfaces that gate on it (the members sidebar and the + * per-message action cluster) so the ban/timeout reads can't drift. + */ + +/** Whether a member is currently banned and/or timed out. */ +export type MemberRestrictionState = { + banned: boolean; + timedOut: boolean; +}; + +/** + * Coerce a restriction timestamp to epoch milliseconds. The wire emits + * `DateTime` as an RFC3339 string, but the shared type still tolerates the + * legacy `number` (unix seconds) shape, so handle both: strings parse as ISO, + * numbers are treated as unix seconds. Returns `null` for absent or unparseable + * values — fails closed, so a bad value never renders a phantom timeout. + */ +export function parseRestrictionTimestampMs( + value: string | number | null, +): number | null { + if (value == null) return null; + if (typeof value === "number") return value * 1000; + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; +} + +/** + * True when a `mutedUntil` value is still in the future relative to `nowMs`. + * An absent or unparseable value is *not* an active timeout (fail closed to + * "not timed out" so the UI doesn't strand a member who has no live mute). + */ +export function isTimedOut( + mutedUntil: string | number | null, + nowMs: number = Date.now(), +): boolean { + const ms = parseRestrictionTimestampMs(mutedUntil); + return ms != null && ms > nowMs; +} diff --git a/desktop/src/features/moderation/ui/MessageModerationMenuItems.tsx b/desktop/src/features/moderation/ui/MessageModerationMenuItems.tsx new file mode 100644 index 0000000000..5681035e74 --- /dev/null +++ b/desktop/src/features/moderation/ui/MessageModerationMenuItems.tsx @@ -0,0 +1,204 @@ +import { Ban, CircleSlash, Clock, ShieldCheck, UserMinus } from "lucide-react"; +import * as React from "react"; +import { toast } from "sonner"; + +import { useRemoveChannelMemberMutation } from "@/features/channels/hooks"; +import { + useBanMemberMutation, + useModerationRestrictionsQuery, + useTimeoutMemberMutation, + useUnbanMemberMutation, + useUntimeoutMemberMutation, +} from "@/features/moderation/hooks"; +import { useMyRelayMembershipQuery } from "@/features/relay-members/hooks"; +import type { TimelineMessage } from "@/features/messages/types"; +import { isTimedOut } from "@/features/moderation/lib/restrictionState"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from "@/shared/ui/dropdown-menu"; + +const TIMEOUT_PRESETS: { 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 }, +]; + +/** + * Mod-only per-message actions against the message *author*: time out, ban, and + * kick from the current channel. Self-contained (wires its own hooks, no props + * threaded from the message row), mirroring ReportMessageDialog. + * + * Renders nothing unless the viewer is a relay owner/admin, the message has a + * real signer, and that signer is not the viewer. Actions target + * `signerPubkey` — the raw signer, never the display-author (which `p`/`actor` + * tags can override) — per the security note on TimelineMessage. + */ +export function MessageModerationMenuItems({ + channelId, + message, +}: { + channelId?: string | null; + message: TimelineMessage; +}) { + const relayMembershipQuery = useMyRelayMembershipQuery(); + const relayRole = relayMembershipQuery.data?.role; + const canModerate = relayRole === "owner" || relayRole === "admin"; + + const identityQuery = useIdentityQuery(); + const targetPubkey = message.signerPubkey ?? message.pubkey ?? null; + const isSelf = + targetPubkey != null && + identityQuery.data?.pubkey != null && + normalizePubkey(targetPubkey) === + normalizePubkey(identityQuery.data.pubkey); + + const enabled = canModerate && targetPubkey != null && !isSelf; + + const restrictionsQuery = useModerationRestrictionsQuery(enabled); + const banMutation = useBanMemberMutation(); + const unbanMutation = useUnbanMemberMutation(); + const timeoutMutation = useTimeoutMemberMutation(); + const untimeoutMutation = useUntimeoutMemberMutation(); + const removeMutation = useRemoveChannelMemberMutation(channelId ?? null); + const isPending = + banMutation.isPending || + unbanMutation.isPending || + timeoutMutation.isPending || + untimeoutMutation.isPending || + removeMutation.isPending; + + const restriction = React.useMemo(() => { + if (targetPubkey == null) return null; + const key = normalizePubkey(targetPubkey); + return ( + restrictionsQuery.data?.find((r) => normalizePubkey(r.pubkey) === key) ?? + null + ); + }, [restrictionsQuery.data, targetPubkey]); + + const isBanned = restriction?.banned ?? false; + const timedOut = isTimedOut(restriction?.mutedUntil ?? null); + + const run = React.useCallback( + async (action: () => Promise, success: string) => { + try { + await action(); + toast.success(success); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Moderation action failed", + ); + } + }, + [], + ); + + if (!enabled || targetPubkey == null) return null; + + return ( + <> + + {timedOut ? ( + + void run( + () => untimeoutMutation.mutateAsync(targetPubkey), + "Timeout lifted", + ) + } + > + + Lift timeout + + ) : ( + + + + Time out author + + + {TIMEOUT_PRESETS.map((preset) => ( + + void run( + () => + timeoutMutation.mutateAsync({ + pubkey: targetPubkey, + expiresAt: + Math.floor(Date.now() / 1000) + preset.seconds, + }), + "Author timed out", + ) + } + > + {preset.label} + + ))} + + + )} + + {channelId ? ( + + void run( + () => removeMutation.mutateAsync(targetPubkey), + "Author removed from channel", + ) + } + > + + Kick from channel + + ) : null} + + {isBanned ? ( + + void run( + () => unbanMutation.mutateAsync(targetPubkey), + "Ban lifted", + ) + } + > + + Lift ban + + ) : ( + + void run( + () => banMutation.mutateAsync({ pubkey: targetPubkey }), + "Author banned", + ) + } + > + + Ban author from community + + )} + + ); +} From ccc6bfd4bfbdec5369cba4bc6ab9193f7fd03b25 Mon Sep 17 00:00:00 2001 From: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc Date: Tue, 7 Jul 2026 19:56:11 -0400 Subject: [PATCH 8/8] desktop(moderation): enforce queue resolutions + fail-closed signer (U2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Eva's #1615 review (both items in the U2 lane). Item 1 (blocker) — one-click queue resolutions now actually enforce. A 9044 resolve only records the moderator's decision and DMs the reporter "reviewed and acted on"; the relay leaves the real enforcement to the client (moderation_commands.rs handle_resolve). So "Ban author" used to resolve + DM but never ban. On resolve, compose the paired enforcement event first — delete->9005, ban->9040, kick->9001 — and only send the 9044 once it lands. Ordering is enforce-FIRST, resolve-second (Eva's override): if enforcement fails we must not send the "acted on" DM or leave an orphan resolve:* decision row, so on failure we toast and leave the report open (retryable). Enforce-first is safe for delete — 9044 looks up only the report row + open status, never the target event. Author resolution: ban/kick act on the author's pubkey. A pubkey-target report carries it as the target; an event-target report carries only the event id (the reporter's `p` tag is dropped at ingest), so we read the reported event and take its signer `pubkey` — signer truth, never a `p`/`actor` override, same invariant as the per-message cluster. Per-target-kind menu gating (resolvableActions, 6 unit tests): offer only the resolutions whose inputs exist. delete/kick are channel-scoped so only event-target reports get them; a pubkey report gets ban/escalate/ dismiss; a blob report gets escalate/dismiss. Surface channelId onto the queue group (from the report row) to drive this. An action you can't complete is never a button. timeout is dropped from one-click this pass — the resolve flow collects no duration and a duration-less timeout would be a lie. It wires back in with Dawn's shared TimeoutDurationSubmenu as a follow-up. Item 2 (hygiene) — MessageModerationMenuItems targets `signerPubkey` only; drop the `?? message.pubkey` fallback that reintroduced the exact tag-override risk the component's own security comment forbids. Fail closed: a message without a signer renders no moderation menu. Typecheck/lint/queue tests (20) green. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- .../ui/MessageModerationMenuItems.tsx | 5 +- .../settings/lib/moderationQueue.test.mjs | 43 +++++++++ .../features/settings/lib/moderationQueue.ts | 41 +++++++++ .../settings/ui/ModerationQueueCard.tsx | 87 ++++++++++++++++++- 4 files changed, 172 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/moderation/ui/MessageModerationMenuItems.tsx b/desktop/src/features/moderation/ui/MessageModerationMenuItems.tsx index 5681035e74..6a60b8f8e7 100644 --- a/desktop/src/features/moderation/ui/MessageModerationMenuItems.tsx +++ b/desktop/src/features/moderation/ui/MessageModerationMenuItems.tsx @@ -51,7 +51,10 @@ export function MessageModerationMenuItems({ const canModerate = relayRole === "owner" || relayRole === "admin"; const identityQuery = useIdentityQuery(); - const targetPubkey = message.signerPubkey ?? message.pubkey ?? null; + // Fail closed: moderate only the raw signer, never the display `pubkey` + // (which `p`/`actor` tags can override — see the security note above). A + // message without a signer is not moderatable here; render nothing. + const targetPubkey = message.signerPubkey ?? null; const isSelf = targetPubkey != null && identityQuery.data?.pubkey != null && diff --git a/desktop/src/features/settings/lib/moderationQueue.test.mjs b/desktop/src/features/settings/lib/moderationQueue.test.mjs index 86aaa2af18..85ae8a2b91 100644 --- a/desktop/src/features/settings/lib/moderationQueue.test.mjs +++ b/desktop/src/features/settings/lib/moderationQueue.test.mjs @@ -7,6 +7,7 @@ import { isOpenReport, reportSeverity, reportTypeLabel, + resolvableActions, severityTier, targetKey, } from "./moderationQueue.ts"; @@ -212,3 +213,45 @@ test("groupTopReportType returns the most severe type in a group", () => { ]); assert.equal(groupTopReportType(group), "impersonation"); }); + +test("resolvableActions: event target with a channel offers the full enforceable set", () => { + const actions = resolvableActions("event", true); + assert.deepEqual(actions, ["delete", "ban", "kick", "escalate", "dismiss"]); +}); + +test("resolvableActions: event target without a channel drops the channel-scoped enforcements", () => { + // Defensive: an event report should always carry a channel, but if it + // doesn't, delete (9005) and kick (9001) have nowhere to land. + const actions = resolvableActions("event", false); + assert.deepEqual(actions, ["ban", "escalate", "dismiss"]); +}); + +test("resolvableActions: pubkey target offers ban but never delete or kick", () => { + // A pubkey report is not tied to a channel and points at no event, so the + // channel-scoped delete/kick are structurally impossible. + const actions = resolvableActions("pubkey", false); + assert.deepEqual(actions, ["ban", "escalate", "dismiss"]); + assert.ok(!actions.includes("delete")); + assert.ok(!actions.includes("kick")); +}); + +test("resolvableActions: blob target offers only decision-only resolutions", () => { + const actions = resolvableActions("blob", false); + assert.deepEqual(actions, ["escalate", "dismiss"]); +}); + +test("resolvableActions: timeout is never offered from one-click yet", () => { + for (const kind of ["event", "pubkey", "blob"]) { + for (const hasChannel of [true, false]) { + assert.ok(!resolvableActions(kind, hasChannel).includes("timeout")); + } + } +}); + +test("buildModerationQueue carries channelId from the report onto the group", () => { + const t = "d".repeat(64); + const [group] = buildModerationQueue([ + report({ target: t, targetKind: "event", channelId: "chan-1" }), + ]); + assert.equal(group.channelId, "chan-1"); +}); diff --git a/desktop/src/features/settings/lib/moderationQueue.ts b/desktop/src/features/settings/lib/moderationQueue.ts index 0e24e112ab..9ef377e457 100644 --- a/desktop/src/features/settings/lib/moderationQueue.ts +++ b/desktop/src/features/settings/lib/moderationQueue.ts @@ -16,6 +16,7 @@ import type { ModerationAction as ApiModerationAction, ModerationReport as ApiModerationReport, + ResolutionAction, } from "@/shared/api/moderation"; /** NIP-56 report categories accepted at ingest (relay `report.rs::REPORT_TYPES`). */ @@ -89,6 +90,13 @@ export type ModerationQueueGroup = { targetKey: string; targetKind: ReportTargetKind; target: string; + /** + * Channel the target lives in, if any. An event target lives in exactly one + * channel (all reports about it agree), so we take it from the first report; + * pubkey/blob targets are not channel-scoped and carry `null`. Drives which + * channel-scoped enforcements (delete/kick) are offerable. + */ + channelId: string | null; /** Reports about this target, newest first. */ reports: ModerationReport[]; /** Highest severity among the group's reports — drives group ordering. */ @@ -149,6 +157,7 @@ export function buildModerationQueue( targetKey: key, targetKind: report.targetKind, target: report.target, + channelId: report.channelId, reports: [report], maxSeverity: reportSeverity(report.reportType), latestCreatedAt: report.createdAt, @@ -221,3 +230,35 @@ export function groupTopReportType(group: ModerationQueueGroup): ReportType { } return top; } + +/** + * Which one-click resolutions can actually be *enforced* for a given target. + * + * A 9044 resolve only records the decision + DMs the reporter; the client must + * compose the paired enforcement event (delete→9005, ban→9040, kick→9001). + * Some pairings are structurally impossible, so we never offer them as buttons + * (Eva's ruling: an action you can't complete shouldn't be clickable): + * + * - `delete` (9005) needs an event id + channel — only event-target reports. + * - `kick` (9001) is channel-scoped — needs both an author and a channel, so + * only event-target reports (a pubkey report is not tied to a channel). + * - `ban` (9040) needs only the author pubkey — event reports resolve it from + * the reported event's signer; pubkey reports carry it as the target. + * - `escalate` / `dismiss` are decision-only and always available. + * + * `timeout` is intentionally excluded until the resolve flow can collect a + * duration (a duration-less timeout would be a lie); it wires back in with the + * duration picker as a follow-up. + */ +export function resolvableActions( + targetKind: ReportTargetKind, + hasChannel: boolean, +): ResolutionAction[] { + const actions: ResolutionAction[] = []; + if (targetKind === "event" && hasChannel) actions.push("delete"); + // ban needs only the author; event reports look it up from the signer. + if (targetKind === "event" || targetKind === "pubkey") actions.push("ban"); + if (targetKind === "event" && hasChannel) actions.push("kick"); + actions.push("escalate", "dismiss"); + return actions; +} diff --git a/desktop/src/features/settings/ui/ModerationQueueCard.tsx b/desktop/src/features/settings/ui/ModerationQueueCard.tsx index d4510b84ac..95cb5e0905 100644 --- a/desktop/src/features/settings/ui/ModerationQueueCard.tsx +++ b/desktop/src/features/settings/ui/ModerationQueueCard.tsx @@ -6,15 +6,22 @@ import { useModerationAuditQuery, useModerationReportsQuery, useResolveReportMutation, + useBanMemberMutation, type ModerationReport as HookModerationReport, type ResolutionAction, } from "@/features/moderation/hooks"; import { useMyRelayMembershipQuery } from "@/features/relay-members/hooks"; import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { + deleteMessage, + getEventById, + removeChannelMember, +} from "@/shared/api/tauri"; import { buildModerationQueue, groupTopReportType, reportTypeLabel, + resolvableActions, severityTier, type ModerationAction, type ModerationQueueGroup, @@ -70,6 +77,66 @@ function statusForAction(action: ResolutionAction): "resolved" | "dismissed" { return action === "dismiss" ? "dismissed" : "resolved"; } +/** + * Resolve the author (signer) pubkey a member-directed enforcement acts on. + * For a pubkey-target report that IS the target; for an event-target report the + * report row carries only the event id (the reporter's `p` author tag is + * dropped at ingest), so we read the reported event and take its signer — the + * stored `pubkey` is signer truth, never a `p`/`actor` override. Throws if the + * event can't be resolved (e.g. already deleted) so the caller aborts before + * touching the 9044. + */ +async function resolveTargetAuthor( + group: ModerationQueueGroup, +): Promise { + if (group.targetKind === "pubkey") return group.target; + const event = await getEventById(group.target); + if (!event?.pubkey) { + throw new Error("Could not resolve the message author."); + } + return event.pubkey; +} + +/** + * Compose the enforcement event paired with a resolution, BEFORE the 9044. + * + * A 9044 resolve records the decision and DMs the reporter "reviewed and acted + * on" — so it must not fire until the action actually happened. Enforce first; + * on success the caller sends the 9044. On failure this throws and the caller + * leaves the report open (no false DM, no orphan decision row). `escalate` and + * `dismiss` carry no enforcement — they are pure 9044 decisions. + */ +async function enforceResolution( + group: ModerationQueueGroup, + action: ResolutionAction, + ban: (input: { pubkey: string; reason?: string }) => Promise, +): Promise { + switch (action) { + case "delete": + // Gated to event targets with a channel (resolvableActions). + if (group.channelId == null) throw new Error("Report has no channel."); + await deleteMessage(group.channelId, group.target); + return; + case "ban": + await ban({ pubkey: await resolveTargetAuthor(group) }); + return; + case "kick": + // Gated to event targets with a channel (resolvableActions). + if (group.channelId == null) throw new Error("Report has no channel."); + await removeChannelMember( + group.channelId, + await resolveTargetAuthor(group), + ); + return; + case "escalate": + case "dismiss": + return; + case "timeout": + // Dropped from one-click until the resolve flow collects a duration. + throw new Error("Timeout is not available from the queue yet."); + } +} + const RESOLUTION_OPTIONS: { action: ResolutionAction; label: string; @@ -162,12 +229,17 @@ function ReporterLine({ } function ResolveMenu({ + allowed, disabled, onResolve, }: { + allowed: readonly ResolutionAction[]; disabled: boolean; onResolve: (action: ResolutionAction) => void; }) { + const options = RESOLUTION_OPTIONS.filter((option) => + allowed.includes(option.action), + ); return ( @@ -184,7 +256,7 @@ function ResolveMenu({ Resolution - {RESOLUTION_OPTIONS.map((option) => ( + {options.map((option) => (
onResolve(group, action)} /> @@ -284,6 +360,7 @@ function QueueTab() { const reportsQuery = useModerationReportsQuery({ status: "open" }); const auditQuery = useModerationAuditQuery(); const resolveMutation = useResolveReportMutation(); + const banMutation = useBanMemberMutation(); const groups = useMemo(() => { const reports = (reportsQuery.data ?? []).map(toQueueReport); @@ -314,11 +391,15 @@ function QueueTab() { action: ResolutionAction, ) { const status = statusForAction(action); - // Resolve every open report about this target with the chosen disposition. const openReports = group.reports.filter( (report) => report.status === "open", ); try { + // Enforce FIRST. The 9044 resolve DMs the reporter "reviewed and acted + // on" — if enforcement fails we must not send that lie, and we leave the + // report open (retryable, no orphan decision row). Only after the paired + // 9040/9005/9001 lands do we resolve every open report about this target. + await enforceResolution(group, action, banMutation.mutateAsync); await Promise.all( openReports.map((report) => resolveMutation.mutateAsync({ @@ -359,7 +440,7 @@ function QueueTab() {
{groups.map((group) => (