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..9a546d4836 --- /dev/null +++ b/desktop/src/features/channels/ui/useMembersSidebarModeration.ts @@ -0,0 +1,114 @@ +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 { isTimedOut } from "@/features/moderation/lib/restrictionState"; +import type { ChannelMember } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +import type { MemberModerationState } from "./MembersSidebarMemberCard"; + +/** + * 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 ?? []) { + map.set(normalizePubkey(restriction.pubkey), { + banned: restriction.banned, + timedOut: isTimedOut(restriction.mutedUntil, nowMs), + }); + } + 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, + }; +} 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..6a60b8f8e7 --- /dev/null +++ b/desktop/src/features/moderation/ui/MessageModerationMenuItems.tsx @@ -0,0 +1,207 @@ +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(); + // 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 && + 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 + + )} + + ); +} 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..85ae8a2b91 --- /dev/null +++ b/desktop/src/features/settings/lib/moderationQueue.test.mjs @@ -0,0 +1,257 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + buildModerationQueue, + groupTopReportType, + isOpenReport, + reportSeverity, + reportTypeLabel, + resolvableActions, + severityTier, + 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([]), []); +}); + +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"); +}); + +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 new file mode 100644 index 0000000000..9ef377e457 --- /dev/null +++ b/desktop/src/features/settings/lib/moderationQueue.ts @@ -0,0 +1,264 @@ +// 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 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. 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, + ResolutionAction, +} from "@/shared/api/moderation"; + +/** 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`). + * + * 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; + status: ReportStatus; +}; + +/** 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 + * 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; + /** + * 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. */ + 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, + channelId: report.channelId, + 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"; +} + +/** 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; +} + +/** + * 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 new file mode 100644 index 0000000000..95cb5e0905 --- /dev/null +++ b/desktop/src/features/settings/ui/ModerationQueueCard.tsx @@ -0,0 +1,586 @@ +import { AlertTriangle, ChevronDown, ShieldAlert } from "lucide-react"; +import { useMemo } from "react"; +import { toast } from "sonner"; + +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, + type ModerationReport, + type ReportStatus, + 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 { 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 +// relay returns 403 otherwise). Mirror that gate client-side so members never +// see the panel attempt a doomed fetch. + +// --- Boundary normalizer -------------------------------------------------- +// +// The shared hooks expose the wire rows; this card's triage math lives in +// `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 { + ...r, + reportType: r.reportType as ReportType, + status: r.status as ReportStatus, + }; +} + +/** Stable empty-array reference so audit-derived memos don't churn on refetch. */ +const EMPTY_ACTIONS: readonly ModerationAction[] = []; + +// --- 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"; +} + +/** + * 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; + 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({ + allowed, + disabled, + onResolve, +}: { + allowed: readonly ResolutionAction[]; + disabled: boolean; + onResolve: (action: ResolutionAction) => void; +}) { + const options = RESOLUTION_OPTIONS.filter((option) => + allowed.includes(option.action), + ); + return ( + + + + + + 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} +
+ ); +} + +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); + return buildModerationQueue(reports, auditQuery.data ?? []); + }, [reportsQuery.data, auditQuery.data]); + + const reporterPubkeys = useMemo( + () => + groups.flatMap((group) => + group.reports.map((report) => report.reporterPubkey), + ), + [groups], + ); + const reporterProfiles = useUsersBatchQuery(reporterPubkeys, { + enabled: 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); + 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({ + 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", + ); + } + } + + 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 = auditQuery.data ?? EMPTY_ACTIONS; + + 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 ( +
+ + + {!isModerator ? ( + membershipQuery.isLoading ? ( +

Checking access…

+ ) : ( +

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

+ ) + ) : ( + + + + Queue + + + Audit log + + + + + + + + + + )} +
+ ); +} 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":