Skip to content

desktop(moderation): U2 admin surface — queue, audit, member + per-message controls#1615

Merged
tlongwell-block merged 8 commits into
eva/moderation-uifrom
sami/mod-u2-admin
Jul 8, 2026
Merged

desktop(moderation): U2 admin surface — queue, audit, member + per-message controls#1615
tlongwell-block merged 8 commits into
eva/moderation-uifrom
sami/mod-u2-admin

Conversation

@tlongwell-block

Copy link
Copy Markdown
Collaborator

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:

  • Moderation queue + audit — a mod-only "Moderation" settings section (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).
  • MembersSidebar controls — mod-gated Ban / Lift ban and Time out (1h/24h/7d) / Lift timeout per member, driven by live restriction state.
  • Per-message admin cluster — the same actions against a message's author (Time out / Kick from channel / Ban), plus lift variants, in the message more-actions menu.

All actions are relay-role-gated (owner/admin via useMyRelayMembershipQuery), independent of per-channel role, and target the raw signerPubkey — never the display-author. They wire through Dawn's features/moderation hooks; no new relay surface.

Base

Stacked on eva/moderation-ui — please merge that first (or review this against that base). Rebased onto tip 6ebe3b91; duplicate row-type mirrors dropped in favor of the shared @/shared/api/moderation types (edge-narrowing unions kept for the triage math).

Notes / open seams

  • No "delete-with-reason" action. The enriched-tombstone reason path only exists via report resolution (9044), which requires an existing report — there is no direct moderator-delete-with-reason command. Plain admin delete (9005) remains the existing "Delete message". Flagged to Eva.
  • Relay authz is authoritative: an action the relay refuses (e.g. against the community owner) surfaces as an error toast rather than a client-side owner-exclusion gate.

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

npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc and others added 8 commits July 7, 2026 18:47
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>
@tlongwell-block tlongwell-block merged commit 49141b0 into eva/moderation-ui Jul 8, 2026
25 of 43 checks passed
@tlongwell-block tlongwell-block deleted the sami/mod-u2-admin branch July 8, 2026 17:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant