desktop(moderation): U2 admin surface — queue, audit, member + per-message controls#1615
Merged
Merged
Conversation
Pure, hook-free transforms over the NIP-98 /moderation/* read contract: report/action/ban type mirrors of the relay's report_json/action_json/ ban_json (bridge.rs), plus severity ordering (illegal top → other bottom), grouping by kind-qualified target, and prior-actions correlation from the audit log. This is the deterministic core the U2 queue card renders; it carries no dependency on the shared features/moderation hooks (Dawn's lane), so it lands independently. Reporter identity is modeled as mod-only and is never surfaced author-side. 11 unit tests cover severity ranks, target grouping/collapse, group sort (severity then recency), in-group recency, event/pubkey prior-action correlation, the blob no-correlation case, and open-status filtering. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
reportTypeLabel, severityTier (critical/high/normal for badge styling, decoupled from the numeric sort rank), and groupTopReportType — the display derivations the queue card renders. 3 added tests (14 total green). Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Admin-facing moderation queue in community settings. Mod-authz gated via useMyRelayMembershipQuery (owner/admin only, mirroring the relay's 403 on /moderation/*); members see an access notice, never a doomed fetch. Consumes the triage core in lib/moderationQueue.ts: reports grouped by kind-qualified target, severity-sorted (illegal top → other bottom), each group showing per-report reporter identity (mod-only), notes, and a prior-actions warning correlated from the audit log. One-click resolutions via useResolveReportMutation, with statusForAction encoding the relay's dismiss↔dismissed / else↔resolved pairing so an invalid combo can't be sent. Boundary normalizers (toQueueReport/toQueueAction) map the shared hook rows to the triage core's precise string-literal unions; String() on id/timestamp fields is idempotent — correct against today's wire (Uuid/DateTime<Utc> → JSON strings) and after the shared types tighten to strings. Registered "moderation" in SettingsPanels (union + values + descriptor + render case), placed after Relay Access as a community-admin surface. typecheck + lint clean; 2046 tests green. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Split the moderation section into Queue and Audit-log tabs. The audit tab reads useModerationAuditQuery (newest-first, mod-gated) and renders each action with its actor (display-name-resolved), target, and public reason. Queue body extracted to a self-contained QueueTab so each tab owns its own queries; the role gate stays at the ModerationQueueCard orchestrator. typecheck + lint clean. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Add mod-gated per-member moderation actions to the members sidebar: Ban/Lift ban and Time out (1h/24h/7d presets)/Lift timeout, driven by useModerationRestrictionsQuery state and wired to the ban/unban/timeout/ untimeout mutations with toast feedback. Actions are relay-role-gated (owner/admin via useMyRelayMembershipQuery), independent of per-channel role, and never offered for bots, self, or the community owner. A parseTimestampMs helper reads mutedUntil tolerantly across both number (legacy unix-secs) and string (ISO) forms, failing closed to null so a bad value never renders a phantom timeout state. The moderation wiring lives in a new useMembersSidebarModeration hook, mirroring the sibling useMembersSidebarActions convention. To keep the sidebar under the 1000-line file gate, the self-contained EditRespondToDialog is also extracted to its own module (no behavior change). Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Dawn's type-flip landed on the integration branch, so the shared
`@/shared/api/moderation` row types now carry string ids/timestamps.
Drop the duplicate row-shape mirrors in the triage module and reuse the
shared types directly:
- `ModerationReport` becomes `Omit<ApiModerationReport, "reportType" |
"status"> & { reportType: ReportType; status: ReportStatus }` — the
edge-narrowing unions the triage math dispatches on are kept, the rest
of the shape is single-sourced. `targetKind` already matches upstream.
- `ModerationAction` is now a straight alias of the shared type (the math
treats `action` opaquely), so `toQueueAction` was a pure identity map —
removed, audit rows flow through untouched (via a stable EMPTY_ACTIONS
reference so the audit memos don't churn).
- `toQueueReport` keeps only the two narrowing casts; the `String(...)`
no-ops are gone now that the shared ids/timestamps are already strings.
No behavior change. Typecheck/lint/2054 tests green.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Add mod-only actions against a message's author to the message more-actions menu: Time out author (1h/24h/7d) / Lift timeout, Kick from channel, and Ban author / Lift ban. Self-contained in MessageModerationMenuItems (wires its own hooks, no props threaded from the message row), mirroring ReportMessageDialog. The cluster renders only when the viewer is a relay owner/admin, the row is a real delivered message with a known author, and that author is not the viewer. Actions target `signerPubkey` — the raw signer, never the display-author (which `p`/`actor` tags can override) — per the security note on TimelineMessage. Live ban/timeout state comes from useModerationRestrictionsQuery so the menu offers the inverse (lift) action when a restriction is active. Extract the shared `isTimedOut` / `parseRestrictionTimestampMs` helper to features/moderation/lib/restrictionState.ts (8 unit tests) and route both this cluster and useMembersSidebarModeration through it, removing the two duplicated `mutedUntil` parsers. Notes / open seams: - Relay authz is authoritative: an admin action the relay refuses (e.g. against the community owner) surfaces as an error toast, so no separate owner-exclusion gate is added here. - Bots are moderatable from a message (unlike the sidebar, which offers agent-specific controls) — a bot author posting bad content is a legitimate ban target. - No "delete-with-reason" action: the enriched-tombstone reason path only exists via report resolution (9044), which needs an existing report; there is no direct moderator-delete-with-reason command. Plain admin delete (9005) remains the existing "Delete message". Raised with Eva. Typecheck/lint/2062 tests green. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Address Eva's #1615 review (both items in the U2 lane). Item 1 (blocker) — one-click queue resolutions now actually enforce. A 9044 resolve only records the moderator's decision and DMs the reporter "reviewed and acted on"; the relay leaves the real enforcement to the client (moderation_commands.rs handle_resolve). So "Ban author" used to resolve + DM but never ban. On resolve, compose the paired enforcement event first — delete->9005, ban->9040, kick->9001 — and only send the 9044 once it lands. Ordering is enforce-FIRST, resolve-second (Eva's override): if enforcement fails we must not send the "acted on" DM or leave an orphan resolve:* decision row, so on failure we toast and leave the report open (retryable). Enforce-first is safe for delete — 9044 looks up only the report row + open status, never the target event. Author resolution: ban/kick act on the author's pubkey. A pubkey-target report carries it as the target; an event-target report carries only the event id (the reporter's `p` tag is dropped at ingest), so we read the reported event and take its signer `pubkey` — signer truth, never a `p`/`actor` override, same invariant as the per-message cluster. Per-target-kind menu gating (resolvableActions, 6 unit tests): offer only the resolutions whose inputs exist. delete/kick are channel-scoped so only event-target reports get them; a pubkey report gets ban/escalate/ dismiss; a blob report gets escalate/dismiss. Surface channelId onto the queue group (from the report row) to drive this. An action you can't complete is never a button. timeout is dropped from one-click this pass — the resolve flow collects no duration and a duration-less timeout would be a lie. It wires back in with Dawn's shared TimeoutDurationSubmenu as a follow-up. Item 2 (hygiene) — MessageModerationMenuItems targets `signerPubkey` only; drop the `?? message.pubkey` fallback that reintroduced the exact tag-override risk the component's own security comment forbids. Fail closed: a message without a signer renders no moderation menu. Typecheck/lint/queue tests (20) green. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
tlongwell-block
pushed a commit
that referenced
this pull request
Jul 8, 2026
Address Eva's #1615 review (both items in the U2 lane). Item 1 (blocker) — one-click queue resolutions now actually enforce. A 9044 resolve only records the moderator's decision and DMs the reporter "reviewed and acted on"; the relay leaves the real enforcement to the client (moderation_commands.rs handle_resolve). So "Ban author" used to resolve + DM but never ban. On resolve, compose the paired enforcement event first — delete->9005, ban->9040, kick->9001 — and only send the 9044 once it lands. Ordering is enforce-FIRST, resolve-second (Eva's override): if enforcement fails we must not send the "acted on" DM or leave an orphan resolve:* decision row, so on failure we toast and leave the report open (retryable). Enforce-first is safe for delete — 9044 looks up only the report row + open status, never the target event. Author resolution: ban/kick act on the author's pubkey. A pubkey-target report carries it as the target; an event-target report carries only the event id (the reporter's `p` tag is dropped at ingest), so we read the reported event and take its signer `pubkey` — signer truth, never a `p`/`actor` override, same invariant as the per-message cluster. Per-target-kind menu gating (resolvableActions, 6 unit tests): offer only the resolutions whose inputs exist. delete/kick are channel-scoped so only event-target reports get them; a pubkey report gets ban/escalate/ dismiss; a blob report gets escalate/dismiss. Surface channelId onto the queue group (from the report row) to drive this. An action you can't complete is never a button. timeout is dropped from one-click this pass — the resolve flow collects no duration and a duration-less timeout would be a lie. It wires back in with Dawn's shared TimeoutDurationSubmenu as a follow-up. Item 2 (hygiene) — MessageModerationMenuItems targets `signerPubkey` only; drop the `?? message.pubkey` fallback that reintroduced the exact tag-override risk the component's own security comment forbids. Fail closed: a message without a signer renders no moderation menu. Typecheck/lint/queue tests (20) green. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This was referenced Jul 8, 2026
Merged
5d9bc0a to
ccc6bfd
Compare
171d15f to
c1c81d0
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The U2 admin-surface lane of community moderation (Phase 2 desktop UI), stacked on
eva/moderation-ui. Adds the moderator-facing surfaces on top of Dawn's foundation + member surface:ModerationQueueCard) with a Queue tab (reports grouped by target, severity-ranked, prior-actions correlation, resolve actions) and an Audit tab. Triage math is a pure, unit-tested domain module (features/settings/lib/moderationQueue.ts).All actions are relay-role-gated (owner/admin via
useMyRelayMembershipQuery), independent of per-channel role, and target the rawsignerPubkey— never the display-author. They wire through Dawn'sfeatures/moderationhooks; no new relay surface.Base
Stacked on
eva/moderation-ui— please merge that first (or review this against that base). Rebased onto tip6ebe3b91; duplicate row-type mirrors dropped in favor of the shared@/shared/api/moderationtypes (edge-narrowing unions kept for the triage math).Notes / open seams
Verification
pnpm typecheck✓ ·pnpm exec biome check src(1081 files) ✓ ·pnpm test(2062) ✓ ·pnpm build✓ · all pre-push legs green (desktop/mobile/tauri/rust tests).🤖 Generated with Buzz