diff --git a/desktop/src-tauri/src/commands/identity_archive.rs b/desktop/src-tauri/src/commands/identity_archive.rs index a40e14ded..d15ee82ab 100644 --- a/desktop/src-tauri/src/commands/identity_archive.rs +++ b/desktop/src-tauri/src/commands/identity_archive.rs @@ -227,7 +227,7 @@ struct RelayInformationDocument { self_: Option, } -async fn fetch_relay_self(state: &AppState) -> Result, String> { +pub(crate) async fn fetch_relay_self(state: &AppState) -> Result, String> { let relay_url = relay_ws_url_with_override(state); let http_url = relay_http_base_url(&relay_url); let response = state @@ -318,6 +318,18 @@ pub async fn list_archived_identities( }) } +/// Read the active relay's NIP-11 `self` pubkey (its own signing key, hex). +/// +/// A public, unauthenticated document read reused by the moderation UI to tell +/// whether a DM peer is the relay identity (a moderation DM). Fails open: an +/// unreachable relay, a document without `self`, or a malformed value all +/// return `None`, and callers must treat that as "not the relay" — the disable +/// is an affordance, not enforcement, so a false negative is the safe failure. +#[tauri::command] +pub async fn get_relay_self(state: State<'_, AppState>) -> Result, String> { + fetch_relay_self(&state).await +} + // ── Tests ─────────────────────────────────────────────────────────────────── #[cfg(test)] diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 341c99d7b..ee7af6b50 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -518,6 +518,7 @@ pub fn run() { archive_identity, unarchive_identity, list_archived_identities, + get_relay_self, resolve_oa_owner, list_relay_agents, list_managed_agents, diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 6740359d6..8dc157d06 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -3,6 +3,10 @@ import { Bot, Hash, LogIn, Plus, Sparkles, UserPlus } from "lucide-react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; +import { ComposerTimeoutBanner } from "@/features/moderation/ui/ComposerTimeoutBanner"; +import { useTimeoutState } from "@/features/moderation/lib/timeoutStore"; +import { isModerationDm } from "@/features/moderation/lib/moderationDm"; +import { useRelaySelfQuery } from "@/features/moderation/hooks"; import { DropZoneOverlay } from "@/features/messages/ui/ComposerAttachments"; import { MessageThreadPanel, @@ -269,10 +273,24 @@ export const ChannelPane = React.memo(function ChannelPane({ return true; }, [findLastOwnEditable, onEdit, threadHeadMessage, threadMessages]); + const timeoutState = useTimeoutState(); + + // A moderation DM (1:1 with the relay identity) is read-only for the member; + // only DMs pay for the NIP-11 `self` lookup. Fails open: no `relaySelf` → + // ordinary DM, composer enabled. + const relaySelfQuery = useRelaySelfQuery(activeChannel?.channelType === "dm"); + const isModerationDmChannel = isModerationDm( + activeChannel ?? null, + currentPubkey, + relaySelfQuery.data, + ); + const isComposerDisabled = !activeChannel?.isMember || activeChannel.archivedAt !== null || activeChannel.channelType === "forum" || + timeoutState.active || + isModerationDmChannel || isSending; const knownAgentPubkeys = React.useMemo(() => { const pubkeys = new Set(); @@ -676,7 +694,11 @@ export const ChannelPane = React.memo(function ChannelPane({ ref={composerWrapperRef} >
- {isActiveWelcomeChannel ? ( + {timeoutState.active ? ( + + ) : isActiveWelcomeChannel ? ( ) : null} diff --git a/desktop/src/features/channels/ui/EditRespondToDialog.tsx b/desktop/src/features/channels/ui/EditRespondToDialog.tsx new file mode 100644 index 000000000..11a2dcd09 --- /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 b1b55417c..77f99cca7 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 6fea3abe6..823451743 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 000000000..9a546d483 --- /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/hooks.ts b/desktop/src/features/messages/hooks.ts index 341cdacf8..31a96439d 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -19,6 +19,10 @@ import { resolveReplyRootId, } from "@/features/messages/lib/threading"; import { splitOutgoingTags } from "@/features/messages/lib/imetaMediaMarkdown"; +import { + clearTimeoutState, + recordTimeoutFromRejection, +} from "@/features/moderation/lib/timeoutStore"; import { relayClient } from "@/shared/api/relayClient"; import { customEmojiQueryKey } from "@/features/custom-emoji/hooks"; import { channelsQueryKey } from "@/features/channels/hooks"; @@ -637,7 +641,11 @@ export function useSendMessageMutation( queryKey, }; }, - onError: (_error, _variables, context) => { + onError: (error, _variables, context) => { + // A community timeout surfaces here as the relay's `OK false` reason. + // Record it so the composer can show the timeout chip and block further + // sends until it expires; other errors fall through to the caller. + recordTimeoutFromRejection(error?.message); if (!context) { return; } @@ -649,6 +657,9 @@ export function useSendMessageMutation( ); }, onSuccess: (message, _variables, context) => { + // An accepted send proves the write-block is lifted; clear any recorded + // timeout so the chip and disable state fall away immediately. + clearTimeoutState(); if (!context) { return; } diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index d483ab221..5e487d1d5 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -5,6 +5,7 @@ import { Copy, CornerUpLeft, EllipsisVertical, + Flag, Link2, MailCheck, MailOpen, @@ -18,6 +19,8 @@ import { buildMessageLink } from "@/features/messages/lib/messageLink"; 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, @@ -89,6 +92,7 @@ function MoreActionsMenu({ isUnread?: boolean; }) { const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false); + const [isReportDialogOpen, setIsReportDialogOpen] = React.useState(false); // Set true the moment the user picks "Edit message". The // `onCloseAutoFocus` handler on `DropdownMenuContent` reads it to // suppress Radix's default focus-restoration (which would yank focus @@ -102,6 +106,14 @@ function MoreActionsMenu({ const hasCopyActions = !message.pending && message.kind !== KIND_HUDDLE_STARTED; + // A report needs a real, delivered event to target and a known author to + // name in the NIP-56 `p` tag. Pending sends and system huddle rows have + // neither, so the entry is hidden for them. + const canReport = + !message.pending && + message.kind !== KIND_HUDDLE_STARTED && + Boolean(message.pubkey); + return ( <> @@ -228,20 +240,38 @@ function MoreActionsMenu({ ) : null} + {canReport || onDelete ? : null} + + {canReport ? ( + { + setIsReportDialogOpen(true); + }} + > + + Report message + + ) : null} + {onDelete ? ( - <> - - { - setIsDeleteDialogOpen(true); - }} - > - - Delete message - - + { + setIsDeleteDialogOpen(true); + }} + > + + Delete message + + ) : null} + + {canReport ? ( + ) : null} @@ -277,6 +307,15 @@ function MoreActionsMenu({ ) : null} + + {canReport ? ( + + ) : null} ); } diff --git a/desktop/src/features/messages/ui/SystemMessageRow.tsx b/desktop/src/features/messages/ui/SystemMessageRow.tsx index df4dafe45..ffdff4436 100644 --- a/desktop/src/features/messages/ui/SystemMessageRow.tsx +++ b/desktop/src/features/messages/ui/SystemMessageRow.tsx @@ -33,6 +33,12 @@ type SystemMessagePayload = { target?: string; topic?: string; purpose?: string; + // Moderation tombstone fields (kind:40099 "message_deleted"). All optional and + // moderator-authored — present when a moderator removed the message, absent for + // a plain member self-delete. Reporter identity/evidence never appears here. + public_reason?: string; + reason_code?: string; + action_id?: string; }; type SystemMessageDescription = { @@ -326,6 +332,21 @@ function describeSystemEvent( title: actorName, action: "unarchived this channel", }; + case "message_deleted": { + // Room-facing tombstone. When a moderator removed the message, the relay + // stamps a sanitized public_reason; a plain self-delete carries none. The + // content and the reporter are never disclosed here. + if (payload.public_reason) { + return { + title: "Removed by community moderators", + action: payload.public_reason, + }; + } + return { + title: actorName, + action: "removed a message", + }; + } default: return null; } diff --git a/desktop/src/features/moderation/hooks.ts b/desktop/src/features/moderation/hooks.ts new file mode 100644 index 000000000..d5c8ed24e --- /dev/null +++ b/desktop/src/features/moderation/hooks.ts @@ -0,0 +1,170 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { getRelaySelf } from "@/features/moderation/lib/relaySelf"; +import { + banMember, + type CommunityRestriction, + listAuditActions, + listReports, + listRestrictions, + type ModerationAction, + type ModerationReport, + type ReportType, + type ResolutionAction, + type ResolutionStatus, + resolveReport, + submitReport, + timeoutMember, + unbanMember, + untimeoutMember, +} from "@/shared/api/moderation"; + +export const moderationReportsQueryKey = ["moderationReports"] as const; +export const moderationAuditQueryKey = ["moderationAudit"] as const; +export const moderationRestrictionsQueryKey = [ + "moderationRestrictions", +] as const; +export const relaySelfQueryKey = ["relaySelf"] as const; + +/** + * The active relay's NIP-11 `self` pubkey (hex), or `null` when it advertises + * none / is unreachable. Used to recognize a moderation DM. Workspace-scoped + * and effectively static for a session, so it is cached indefinitely; a `null` + * result is a valid answer (fail open), not an error to retry into. + */ +export function useRelaySelfQuery(enabled = true) { + return useQuery({ + enabled, + queryKey: relaySelfQueryKey, + queryFn: getRelaySelf, + staleTime: Number.POSITIVE_INFINITY, + }); +} + +// --- Reads (mod-authz gated; consumed by the U2 queue/audit surfaces) --- + +export function useModerationReportsQuery( + options?: { status?: string; limit?: number }, + enabled = true, +) { + return useQuery({ + enabled, + queryKey: [ + ...moderationReportsQueryKey, + options?.status ?? null, + options?.limit ?? null, + ], + queryFn: () => listReports(options), + staleTime: 15_000, + }); +} + +export function useModerationAuditQuery(limit?: number, enabled = true) { + return useQuery({ + enabled, + queryKey: [...moderationAuditQueryKey, limit ?? null], + queryFn: () => listAuditActions(limit), + staleTime: 15_000, + }); +} + +export function useModerationRestrictionsQuery(enabled = true) { + return useQuery({ + enabled, + queryKey: moderationRestrictionsQueryKey, + queryFn: listRestrictions, + staleTime: 15_000, + }); +} + +// --- Writes --- +// +// Moderation writes are relay-validated command events whose effects surface in +// the queue/audit/restricted reads after processing, so mutations invalidate the +// affected read queries on success rather than fabricating optimistic rows. + +function useInvalidateModerationReads() { + const queryClient = useQueryClient(); + return () => + Promise.all([ + queryClient.invalidateQueries({ queryKey: moderationReportsQueryKey }), + queryClient.invalidateQueries({ queryKey: moderationAuditQueryKey }), + queryClient.invalidateQueries({ + queryKey: moderationRestrictionsQueryKey, + }), + ]); +} + +/** Submit a NIP-56 report. Does not touch the mod-gated read caches. */ +export function useSubmitReportMutation() { + return useMutation({ + mutationFn: (input: { + authorPubkey: string; + eventId: string; + reportType: ReportType; + note?: string; + }) => submitReport(input), + }); +} + +export function useBanMemberMutation() { + const invalidate = useInvalidateModerationReads(); + return useMutation({ + mutationFn: (input: { + pubkey: string; + expiresAt?: number; + reason?: string; + }) => banMember(input), + onSuccess: invalidate, + }); +} + +export function useUnbanMemberMutation() { + const invalidate = useInvalidateModerationReads(); + return useMutation({ + mutationFn: (pubkey: string) => unbanMember(pubkey), + onSuccess: invalidate, + }); +} + +export function useTimeoutMemberMutation() { + const invalidate = useInvalidateModerationReads(); + return useMutation({ + mutationFn: (input: { + pubkey: string; + expiresAt: number; + reason?: string; + }) => timeoutMember(input), + onSuccess: invalidate, + }); +} + +export function useUntimeoutMemberMutation() { + const invalidate = useInvalidateModerationReads(); + return useMutation({ + mutationFn: (pubkey: string) => untimeoutMember(pubkey), + onSuccess: invalidate, + }); +} + +export function useResolveReportMutation() { + const invalidate = useInvalidateModerationReads(); + return useMutation({ + mutationFn: (input: { + reportEventId: string; + status: ResolutionStatus; + action: ResolutionAction; + reason?: string; + }) => resolveReport(input), + onSuccess: invalidate, + }); +} + +export type { + CommunityRestriction, + ModerationAction, + ModerationReport, + ReportType, + ResolutionAction, + ResolutionStatus, +}; diff --git a/desktop/src/features/moderation/lib/moderationDm.test.mjs b/desktop/src/features/moderation/lib/moderationDm.test.mjs new file mode 100644 index 000000000..c1ee97ae3 --- /dev/null +++ b/desktop/src/features/moderation/lib/moderationDm.test.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isModerationDm } from "./moderationDm.ts"; + +const RELAY = "a".repeat(64); +const ME = "b".repeat(64); +const OTHER = "c".repeat(64); + +const dm = (participantPubkeys) => ({ channelType: "dm", participantPubkeys }); + +test("moderation DM: 1:1 with the relay self is detected", () => { + assert.equal(isModerationDm(dm([ME, RELAY]), ME, RELAY), true); +}); + +test("moderation DM: case-insensitive on both self and participants", () => { + assert.equal(isModerationDm(dm([ME, RELAY.toUpperCase()]), ME, RELAY), true); +}); + +test("ordinary DM with another member is not a moderation DM", () => { + assert.equal(isModerationDm(dm([ME, OTHER]), ME, RELAY), false); +}); + +test("group DM including the relay is not a moderation DM", () => { + assert.equal(isModerationDm(dm([ME, RELAY, OTHER]), ME, RELAY), false); +}); + +test("non-DM channels are never moderation DMs", () => { + assert.equal( + isModerationDm( + { channelType: "stream", participantPubkeys: [ME, RELAY] }, + ME, + RELAY, + ), + false, + ); +}); + +test("fails open when relay self is null/undefined", () => { + assert.equal(isModerationDm(dm([ME, RELAY]), ME, null), false); + assert.equal(isModerationDm(dm([ME, RELAY]), ME, undefined), false); +}); + +test("null channel is not a moderation DM", () => { + assert.equal(isModerationDm(null, ME, RELAY), false); +}); diff --git a/desktop/src/features/moderation/lib/moderationDm.ts b/desktop/src/features/moderation/lib/moderationDm.ts new file mode 100644 index 000000000..3165cd52c --- /dev/null +++ b/desktop/src/features/moderation/lib/moderationDm.ts @@ -0,0 +1,31 @@ +import type { Channel } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +/** + * A moderation DM is the 1:1 direct message between a member and the relay's + * own identity — the channel moderators use to explain an action. The member + * must not be able to reply into it, so the composer is disabled on this + * channel alone (never on ordinary DMs). + * + * Identification is client-side and best-effort: the relay identity is the + * NIP-11 `self` pubkey (see {@link getRelaySelf}), and a moderation DM is a DM + * whose only other participant is that pubkey. It is an affordance, not + * enforcement — the relay decides what a write does — so this fails open: a + * missing `relaySelf` (relay unreachable, no `self` advertised) yields `false`, + * leaving the composer enabled. + */ +export function isModerationDm( + channel: Pick | null, + currentPubkey: string | undefined, + relaySelf: string | null | undefined, +): boolean { + if (channel?.channelType !== "dm" || !relaySelf) { + return false; + } + const self = normalizePubkey(relaySelf); + const me = currentPubkey ? normalizePubkey(currentPubkey) : null; + const others = channel.participantPubkeys + .map(normalizePubkey) + .filter((pubkey) => pubkey !== me); + return others.length === 1 && others[0] === self; +} diff --git a/desktop/src/features/moderation/lib/relaySelf.ts b/desktop/src/features/moderation/lib/relaySelf.ts new file mode 100644 index 000000000..fbe5d6503 --- /dev/null +++ b/desktop/src/features/moderation/lib/relaySelf.ts @@ -0,0 +1,12 @@ +import { invokeTauri } from "@/shared/api/tauri"; + +/** + * Read the active relay's NIP-11 `self` pubkey (its own signing key, hex), or + * `null` when the relay advertises none / is unreachable / serves a malformed + * document. Used by the moderation UI to recognize a DM with the relay identity + * (a moderation DM). Fails open by contract: a `null` result must be treated as + * "not the relay", never as an error. + */ +export function getRelaySelf(): Promise { + return invokeTauri("get_relay_self"); +} diff --git a/desktop/src/features/moderation/lib/restrictionState.test.mjs b/desktop/src/features/moderation/lib/restrictionState.test.mjs new file mode 100644 index 000000000..1c22dd66a --- /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 000000000..9f6fa82cc --- /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/lib/timeout.test.mjs b/desktop/src/features/moderation/lib/timeout.test.mjs new file mode 100644 index 000000000..de8727ce8 --- /dev/null +++ b/desktop/src/features/moderation/lib/timeout.test.mjs @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + formatTimeoutRemaining, + isTimeoutActive, + parseTimeoutRejection, + timeoutExpiresAt, + TIMEOUT_PRESETS, +} from "./timeout.ts"; + +test("parses a well-formed timeout rejection to epoch ms", () => { + const result = parseTimeoutRejection( + "restricted: you are timed out until 1751920000", + ); + assert.deepEqual(result, { expiresAtMs: 1751920000 * 1000 }); +}); + +test("tolerates surrounding whitespace", () => { + const result = parseTimeoutRejection( + " restricted: you are timed out until 1751920000 ", + ); + assert.deepEqual(result, { expiresAtMs: 1751920000 * 1000 }); +}); + +test("returns null for a non-timeout rejection", () => { + assert.equal( + parseTimeoutRejection("blocked: you are banned from this community"), + null, + ); + assert.equal(parseTimeoutRejection("restricted: not a channel member"), null); + assert.equal(parseTimeoutRejection(""), null); + assert.equal(parseTimeoutRejection(null), null); + assert.equal(parseTimeoutRejection(undefined), null); +}); + +test("timeout with unparseable timestamp still signals timed-out", () => { + assert.deepEqual( + parseTimeoutRejection("restricted: you are timed out until soon"), + { expiresAtMs: null }, + ); + assert.deepEqual( + parseTimeoutRejection("restricted: you are timed out until "), + { expiresAtMs: null }, + ); + assert.deepEqual( + parseTimeoutRejection("restricted: you are timed out until -5"), + { expiresAtMs: null }, + ); +}); + +test("isTimeoutActive: future expiry active, past expiry inactive", () => { + const now = 1_000_000_000_000; + assert.equal(isTimeoutActive(now + 5000, now), true); + assert.equal(isTimeoutActive(now - 5000, now), false); +}); + +test("isTimeoutActive: unknown expiry fails closed (active)", () => { + assert.equal(isTimeoutActive(null, 1_000_000_000_000), true); +}); + +test("formatTimeoutRemaining: hours, minutes, seconds tiers", () => { + const now = 1_000_000_000_000; + assert.equal( + formatTimeoutRemaining(now + (2 * 3600 + 5 * 60) * 1000, now), + "2h 5m", + ); + assert.equal( + formatTimeoutRemaining(now + (3 * 60 + 20) * 1000, now), + "3m 20s", + ); + assert.equal(formatTimeoutRemaining(now + 12 * 1000, now), "12s"); +}); + +test("formatTimeoutRemaining: null when unknown or elapsed", () => { + const now = 1_000_000_000_000; + assert.equal(formatTimeoutRemaining(null, now), null); + assert.equal(formatTimeoutRemaining(now - 1000, now), null); + assert.equal(formatTimeoutRemaining(now, now), null); +}); + +test("timeoutExpiresAt: absolute expiry is now (seconds) + preset seconds", () => { + const nowMs = 1_000_000_000_000; + assert.equal(timeoutExpiresAt(3600, nowMs), 1_000_000_000 + 3600); + // Floors sub-second now before adding, so the result is a whole second. + assert.equal(timeoutExpiresAt(60, nowMs + 999), 1_000_000_000 + 60); +}); + +test("TIMEOUT_PRESETS: the shared 1h/24h/7d set", () => { + assert.deepEqual( + TIMEOUT_PRESETS.map((preset) => preset.seconds), + [60 * 60, 24 * 60 * 60, 7 * 24 * 60 * 60], + ); +}); diff --git a/desktop/src/features/moderation/lib/timeout.ts b/desktop/src/features/moderation/lib/timeout.ts new file mode 100644 index 000000000..a7b729da3 --- /dev/null +++ b/desktop/src/features/moderation/lib/timeout.ts @@ -0,0 +1,120 @@ +/** + * Reactive detection of a community timeout from a send rejection. + * + * The relay refuses writes from a timed-out member with an `OK false` message + * of the exact form (ingest.rs, load-bearing parse contract): + * + * restricted: you are timed out until + * + * There is no proactive self-restriction read in v1, so the composer learns it + * is timed out only by attempting a send and inspecting the rejection. This is + * the Option-A (reactive) ruling. + */ + +const TIMEOUT_PREFIX = "restricted: you are timed out until"; + +/** + * The community-timeout durations offered wherever a moderator picks one — the + * per-message author cluster (U2) and the report-queue timeout resolution. + * Kept here as the single source of truth so the two surfaces can never drift. + */ +export const TIMEOUT_PRESETS: ReadonlyArray<{ + label: string; + seconds: number; +}> = [ + { label: "1 hour", seconds: 60 * 60 }, + { label: "24 hours", seconds: 24 * 60 * 60 }, + { label: "7 days", seconds: 7 * 24 * 60 * 60 }, +]; + +/** + * Convert a preset duration into the absolute expiry (epoch **seconds**) the + * timeout command (`useTimeoutMemberMutation`) expects — `now + seconds`. The + * relay stamps its own authoritative expiry; this is the client's request. + */ +export function timeoutExpiresAt( + seconds: number, + nowMs: number = Date.now(), +): number { + return Math.floor(nowMs / 1000) + seconds; +} + +export type TimeoutRejection = { + /** + * Timeout expiry in epoch milliseconds, or `null` when the relay's message + * carried an unparseable timestamp. A `null` expiry still means "timed out" — + * the caller shows the chip without a countdown rather than pretending the + * member can send. + */ + expiresAtMs: number | null; +}; + +/** + * Parse a relay send-rejection message. Returns a {@link TimeoutRejection} when + * the message is a timeout refusal, or `null` for any other rejection (which + * the caller surfaces through its normal error path, untouched). + * + * Defensive by contract: the prefix match is what identifies a timeout; the + * timestamp is best-effort. A malformed or out-of-range trailing value yields + * `expiresAtMs: null`, never a throw and never a false negative on the prefix. + */ +export function parseTimeoutRejection( + message: string | null | undefined, +): TimeoutRejection | null { + if (!message) { + return null; + } + const trimmed = message.trim(); + if (!trimmed.startsWith(TIMEOUT_PREFIX)) { + return null; + } + const rest = trimmed.slice(TIMEOUT_PREFIX.length).trim(); + const seconds = Number.parseInt(rest, 10); + if (!Number.isSafeInteger(seconds) || seconds <= 0) { + return { expiresAtMs: null }; + } + return { expiresAtMs: seconds * 1000 }; +} + +/** + * True when a known timeout expiry is still in the future relative to `nowMs`. + * A `null` expiry (unknown) is treated as still-active — fail closed, since the + * member was demonstrably timed out at their last send attempt. + */ +export function isTimeoutActive( + expiresAtMs: number | null, + nowMs: number = Date.now(), +): boolean { + if (expiresAtMs === null) { + return true; + } + return expiresAtMs > nowMs; +} + +/** + * Format the time left until `expiresAtMs` as a short human string for the + * composer chip: `"2h 5m"`, `"3m 20s"`, `"12s"`. Returns `null` when there is + * no countdown to show — either the expiry is unknown or already elapsed. + */ +export function formatTimeoutRemaining( + expiresAtMs: number | null, + nowMs: number = Date.now(), +): string | null { + if (expiresAtMs === null) { + return null; + } + const totalSeconds = Math.ceil((expiresAtMs - nowMs) / 1000); + if (totalSeconds <= 0) { + return null; + } + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + if (minutes > 0) { + return `${minutes}m ${seconds}s`; + } + return `${seconds}s`; +} diff --git a/desktop/src/features/moderation/lib/timeoutStore.ts b/desktop/src/features/moderation/lib/timeoutStore.ts new file mode 100644 index 000000000..2b7affe8f --- /dev/null +++ b/desktop/src/features/moderation/lib/timeoutStore.ts @@ -0,0 +1,121 @@ +import * as React from "react"; + +import { + isTimeoutActive, + parseTimeoutRejection, +} from "@/features/moderation/lib/timeout"; + +/** + * Process-wide store for the member's current community timeout, learned + * reactively from send rejections (there is no proactive read in v1). A single + * value suffices: the desktop app is bound to one community per relay + * connection, and a timeout blocks every channel's writes at the auth seam. + * + * Kept as a tiny external store rather than React Query cache because the value + * is written from a mutation's error path and read by the composer with a live + * countdown — an imperative setter plus `useSyncExternalStore` is the smallest + * primitive that fits both. + */ + +let expiresAtMs: number | null = null; +// `true` once a timeout is recorded, until it expires or a send is accepted. +// Distinguishes "not timed out" (null + inactive) from "timed out, unknown +// expiry" (active with a null `expiresAtMs`). +let active = false; +const listeners = new Set<() => void>(); + +// Cached snapshot so `useSyncExternalStore` gets a referentially stable value +// between changes (returning a fresh object each read would loop forever). +let snapshot: TimeoutState = { active: false, expiresAtMs: null }; + +function emit() { + snapshot = { active, expiresAtMs }; + for (const listener of listeners) { + listener(); + } +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** + * Inspect a relay send-rejection message. If it is a timeout refusal, record + * the timeout and return `true` so the caller can suppress its generic error + * surface. Any other rejection returns `false` and is left untouched. + */ +export function recordTimeoutFromRejection( + message: string | null | undefined, +): boolean { + const rejection = parseTimeoutRejection(message); + if (!rejection) { + return false; + } + expiresAtMs = rejection.expiresAtMs; + active = true; + emit(); + return true; +} + +/** Clear the timeout — called when a send is accepted (the block is lifted). */ +export function clearTimeoutState(): void { + if (!active && expiresAtMs === null) { + return; + } + expiresAtMs = null; + active = false; + emit(); +} + +export type TimeoutState = { + /** True while the member is timed out (write-blocked). */ + active: boolean; + /** Expiry in epoch ms, or null when the relay gave no parseable timestamp. */ + expiresAtMs: number | null; +}; + +const INACTIVE: TimeoutState = { active: false, expiresAtMs: null }; + +function currentState(state: TimeoutState, nowMs: number): TimeoutState { + if (!state.active) { + return INACTIVE; + } + if (!isTimeoutActive(state.expiresAtMs, nowMs)) { + // A known expiry has passed; collapse to inactive so the next read is + // clean even before a send re-probes the block. + return INACTIVE; + } + return state; +} + +/** + * Subscribe to the timeout state. Re-renders on record/clear and, while a + * known-expiry timeout is active, ticks once a second so a countdown UI stays + * live and auto-clears exactly at expiry. + */ +export function useTimeoutState(): TimeoutState { + const [nowMs, setNowMs] = React.useState(() => Date.now()); + const state = React.useSyncExternalStore( + subscribe, + () => snapshot, + () => INACTIVE, + ); + + React.useEffect(() => { + if (!state.active || state.expiresAtMs === null) { + return; + } + setNowMs(Date.now()); + const id = window.setInterval(() => { + setNowMs(Date.now()); + }, 1000); + return () => { + window.clearInterval(id); + }; + }, [state.active, state.expiresAtMs]); + + return currentState(state, nowMs); +} diff --git a/desktop/src/features/moderation/ui/ComposerTimeoutBanner.tsx b/desktop/src/features/moderation/ui/ComposerTimeoutBanner.tsx new file mode 100644 index 000000000..a60918409 --- /dev/null +++ b/desktop/src/features/moderation/ui/ComposerTimeoutBanner.tsx @@ -0,0 +1,34 @@ +import { Clock } from "lucide-react"; + +import { formatTimeoutRemaining } from "@/features/moderation/lib/timeout"; + +/** + * A banner docked to the top edge of the composer while the member is timed + * out by community moderators. Shows a live countdown when the expiry is known; + * otherwise states the block without a timer (the relay gave no timestamp). + * + * Purely presentational — the timeout state and its per-second tick are owned + * by {@link useTimeoutState}; this only formats what it is handed. + */ +export function ComposerTimeoutBanner({ + expiresAtMs, +}: { + /** Timeout expiry in epoch ms, or null when the relay gave no timestamp. */ + expiresAtMs: number | null; +}) { + const remaining = formatTimeoutRemaining(expiresAtMs); + + return ( +
+ + + {remaining + ? `You're timed out by community moderators — ${remaining} left.` + : "You're timed out by community moderators."} + +
+ ); +} diff --git a/desktop/src/features/moderation/ui/MessageModerationMenuItems.tsx b/desktop/src/features/moderation/ui/MessageModerationMenuItems.tsx new file mode 100644 index 000000000..6a60b8f8e --- /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/moderation/ui/ReportMessageDialog.tsx b/desktop/src/features/moderation/ui/ReportMessageDialog.tsx new file mode 100644 index 000000000..1cbc71c90 --- /dev/null +++ b/desktop/src/features/moderation/ui/ReportMessageDialog.tsx @@ -0,0 +1,139 @@ +import { Flag } from "lucide-react"; +import * as React from "react"; +import { toast } from "sonner"; + +import { useSubmitReportMutation } from "@/features/moderation/hooks"; +import type { ReportType } from "@/features/moderation/hooks"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Textarea } from "@/shared/ui/textarea"; + +/** NIP-56 report categories, in the order shown to the reporter. `other` is + * last so it reads as the fallback rather than a first-class choice. */ +const REPORT_CATEGORIES: { value: ReportType; label: string }[] = [ + { value: "spam", label: "Spam" }, + { value: "profanity", label: "Profanity or hate speech" }, + { value: "nudity", label: "Nudity or sexual content" }, + { value: "impersonation", label: "Impersonation" }, + { value: "malware", label: "Malware or scam" }, + { value: "illegal", label: "Illegal content" }, + { value: "other", label: "Other" }, +]; + +export function ReportMessageDialog({ + open, + onOpenChange, + authorPubkey, + eventId, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Display author of the reported message (the `p` tag target). */ + authorPubkey: string; + /** Reported message event id (the `e` tag target). */ + eventId: string; +}) { + const submitReport = useSubmitReportMutation(); + const [category, setCategory] = React.useState(null); + const [note, setNote] = React.useState(""); + + // Reset the form each time the dialog opens so a prior report's selection + // never leaks into the next one. + React.useEffect(() => { + if (open) { + setCategory(null); + setNote(""); + } + }, [open]); + + const submit = () => { + if (!category || submitReport.isPending) return; + submitReport.mutate( + { + authorPubkey, + eventId, + reportType: category, + note: note.trim() || undefined, + }, + { + onSuccess: () => { + toast.success("Report submitted to community moderators"); + onOpenChange(false); + }, + onError: () => toast.error("Failed to submit report"), + }, + ); + }; + + return ( + + + + + + Report message + + + Reports go to this community's moderators for review. The author is + not notified of who reported them. + + + +
+ {REPORT_CATEGORIES.map((item) => ( + + ))} +
+ +
+ +