Skip to content

Community moderation UI: integration branch (data layer + member surface base)#1617

Merged
tlongwell-block merged 32 commits into
mainfrom
eva/moderation-ui
Jul 8, 2026
Merged

Community moderation UI: integration branch (data layer + member surface base)#1617
tlongwell-block merged 32 commits into
mainfrom
eva/moderation-ui

Conversation

@tlongwell-block

Copy link
Copy Markdown
Collaborator

Community Moderation — desktop UI integration branch

Integration branch for the Phase-2 desktop UI, stacked on the Phase-1 relay work (#1616). Carries the shared moderation data layer (shared/api/moderation + feature hooks), the member surface foundation (report modal, tombstone rendering, timeout parser + composer chip), and the merge of the Phase-1 tip (cb32adde).

Stack: #1616 (relay Phase 1) → this PR → #1614 (U1 member surface) → #1615 (U2 admin surface). Merge bottom-up.

Reviews: #1614 approved (Eva, re-affirmed at cfc415c2); #1615 approved (Eva, at 5d9bc0ae) — details in #buzz-moderation thread cc031bd4.

npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d and others added 30 commits July 7, 2026 11:37
…eams

Community moderation build (design: docs/moderation/PLAN.md, decisions
locked by Tyler 2026-07-07). This commit pins the cross-lane contract:

- migrations/0006_moderation.sql: moderation_reports (NIP-56 queue),
  community_bans (ban + muted_until timeout), moderation_actions (audit).
  All tenant-scoped, community_id-leading keys per the isolation lints.
- buzz-core kinds: KIND_REPORT (1984), moderation commands 9040-9044.
- buzz-db::moderation: persistence contract (signatures + row types).
- buzz-relay handler seams: moderation_authz (capability helper),
  report (1984 ingest), moderation_commands (9040-9044 dispatch),
  moderation_notices (relay-signed DM notices).

Bodies are todo!() stubs owned by implementation lanes; signatures are
the interface. Reports are signals, never triggers (NIP-56); targets
resolve under the requesting TenantContext only.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
The live-Postgres migration test hardcoded [1, 2, 3] and had rotted
(migrations 4 and 5 already exist on main; it only passes when skipped).
Derive the expected list from the embedded MIGRATOR so it stays honest
as additive migrations land — including 0006_moderation.

Verified against a fresh local Postgres: all 6 versions apply, and the
three moderation tables materialize with community-id-leading keys.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Wren's contract review of 67373d9 (two blockers + pins):

- 0006_moderation.sql: CHECK-enforce exactly-one-target-class per report
  row (target_kind authoritative, matching column only); 32-byte length
  checks on every pubkey/event-id/sha256 column; matched_principal
  restricted to self|owner; same-community FKs for channel provenance
  (reports+actions -> channels) and resolution provenance
  (reports.action_id -> moderation_actions).
- buzz-core: KIND_REPORT + 9040-9044 registered in ALL_KINDS (duplicate
  detection now covers them); is_moderation_command_kind() as the one
  canonical route check; compile-time asserts (u16 fit, not ephemeral).
- moderation_commands.rs docs: pinned 9040-9044 routing (community-global
  direct commands, is_global_only_kind, fresh timestamp, never stored,
  no channel-scoped tokens) and the exact tag vocabulary per kind so the
  CLI and relay seams cannot diverge.

Verified on live Postgres: migration applies; a two-target insert is
rejected by moderation_reports_check.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
A stray h tag must never channel-scope a moderation command; the
9040-series follows the NIP-43 9030-series model (executed, never
stored, community-global). Closes Wren's contract-review mismatch:
the note claimed this was landed at d02c9be but only the kind
constants were.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
The moderation_commands.rs module doc still carried the pre-review
shape (report = row uuid, status escalated, resolution/public_reason
tags). Pinned vocabulary (thread event 86f46207): report = 1984 event
id hex, status resolved|dismissed, required action tag, optional
reason -> moderation_actions.public_reason. kind.rs doc for 9044
updated to match. No code changes.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Implements the L1 moderation persistence lane: moderation_reports/community_bans/moderation_actions DB layer, Db wrapper seams, restriction_state enforcement read, report lookup by event id, and ignored Postgres-backed invariant tests (tenant fence, expired-ban+timeout, re-ingest idempotency, double-resolve).

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
…tion seams

Phase-1 community moderation enforcement lane (L4). Consumes L1's
`Db::moderation_restriction_state` seam at three points:

- Auth seam (handlers/auth.rs): reject AUTH from a banned member with
  `OK false "blocked: you are banned from this community"` and immediately
  close the socket (decision 4). The reason frame is routed over the
  control channel and the send loop drains it ahead of the Close, so the
  client learns why before the socket drops. Includes a NIP-OA owner
  cascade — an agent whose crypto-proven owner is banned is refused too.
  A DB error fails closed but denies with `error: internal ...` rather
  than falsely claiming a ban.

- Ingest write-block (handlers/ingest.rs): a timed-out member's EVENTs
  are refused with `restricted: you are timed out until <ts>` until the
  timeout expires. A ban is also re-checked here: an already-authenticated
  connection never re-auths, so if the live-disconnect fan-out is missed
  (fire-and-forget publish, broadcast lag, subscriber reconnect window),
  this write-path gate is the durable backstop that stops a banned member
  writing indefinitely. Moderation-command and relay-admin kinds are
  exempt so the tools that lift a restriction are never disarmed. Fails
  closed. The gate checks the authoring pubkey only; the NIP-OA cascade is
  structural at the auth seam for bans, and timeout's owner cascade is a
  documented Phase-1 asymmetry (IngestAuth carries no auth tag).

- Live disconnect (state.rs): `ConnectionManager::disconnect_pubkey`
  closes every live socket for a pubkey *within the banning community*,
  delivering the close reason over the control channel. The community
  filter is the tenant fence: one pod holds sockets for many communities,
  so a ban in A must never kill the same member's session in B.

Cross-pod fan-out (buzz-pubsub/src/conn_control.rs): a new Redis pub/sub
channel carrying `ConnControl::DisconnectPubkey`, parallel to
cache_invalidation rather than folded into it — a disconnect is an
imperative, non-idempotent action, not a pure cache-key drop. main.rs
spawns the subscriber and a consumer that applies inbound commands via
disconnect_pubkey, passing the scoped community so the fence holds
cross-pod.

Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…pState

Adds `AppState::disconnect_pubkey_clusterwide`, the single entry point for
live ban enforcement: it closes this pod's sockets for the banned pubkey
(fenced to the community) and spawns the cross-pod `publish_conn_control`
fan-out so every other pod's subscriber closes its sockets too.

Closes the wire-up gap Quinn found: #1594 shipped both halves as separate
primitives (`conn_manager.disconnect_pubkey` + `PubSubManager::
publish_conn_control`) but nothing called the publish half, so a live ban
was pod-local-only — violating decision 4's "immediately, everywhere,
including live sessions". Rather than have the ban handler (L6) remember to
call both, pairing them on AppState (mirroring `spawn_cache_invalidation`)
makes "do half the job" unrepresentable: callers get cluster-wide
enforcement from one call and can't silently drop the fan-out.

The banning pod re-receives its own publish and no-ops (local sockets
already closed) — intentional, documented, no origin-suppression. The
return count is pod-local only; remote closes are async and unreported.
Fire-and-forget publish with the DB ban row as the durable backstop.

No dedicated unit test: pure composition of two already-tested primitives
(the fenced pod-local disconnect has three tests incl. the tenant-fence
case; publish + serde are covered in conn_control.rs). A full AppState +
live-Redis harness for a 15-line pairing is disproportionate.

Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…live disconnect (#1594)

* commit '47630276':
  moderation(L4): pair live-ban disconnect with cross-pod fan-out on AppState
  moderation(L4): enforce bans/timeouts at the auth, ingest, and connection seams

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Phase-1 community moderation lane L6: the moderation command dispatch and
the three HTTP read endpoints that back the mod queue.

Command handler (handlers/moderation_commands.rs), kinds 9040-9044:
- 9040 ban, 9041 unban, 9042 timeout, 9043 untimeout, 9044 resolve-report.
- Every command routes authorization through the single
  `authorize_moderation_action` capability helper (L2) — never an inline
  role check — then performs its DB write (L1), records the audit row, and
  best-effort sends the relay-signed notice DM (L5).
- Freshness gate rejects stale/replayed commands (never stored).
- 9040 ban drives live enforcement via the paired
  `AppState::disconnect_pubkey_clusterwide` (L4): one call closes this pod's
  fenced sockets and fans the disconnect out cross-pod, so a live ban takes
  effect immediately, everywhere (decision 4). The DB ban row is the durable
  backstop for a dropped fan-out.

Read endpoints (api/bridge.rs) + routes (router.rs):
- GET /moderation/reports, /moderation/audit, /moderation/restricted.
- Each mirrors the existing count_events shape: HOST-bound tenant, NIP-98
  auth + replay check, then the mod-authz gate (ViewQueue) rather than
  plain relay membership. Reads resolve tenant-scoped rows (L1) only.

Depends on L2's `authorize_moderation_action` body, still `todo!()` and
human-gated: the authz-gated paths compile against the pinned signature but
panic at runtime until L2 lands. Handler unit tests cover the pure helpers
(tag parse, expiration vocab, report-tag validation) which do not hit authz.

Validation on base 0bff742: fmt --check, check -p buzz-relay, clippy
-p buzz-relay, test -p buzz-relay --lib all green (492 passed / 0 failed;
7 L6 handler tests included). git diff --check clean. Diff is three
buzz-relay lane files only.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Two small fixes in handle_resolve from Eva's #1599 review, plain delta on
8db239b.

F1 — orphan audit row on a lost resolve race: insert_audit ran before
resolve_moderation_report, so two mods resolving the same report left an
audit row behind the failed resolve. Check the already-fetched
report.status == "open" before insert_audit and error early. The DB's
WHERE status='open' stays the real guard; a comment notes the residual
tiny window (audit row + failed resolve only) is tolerated.

F2 — resolution rows indistinguishable from enforcement rows: a one-click
resolve with action=ban wrote an audit row "ban", and the client's paired
9040 wrote a second "ban" enforcement row — double-count, decision-vs-
enforcement ambiguity. Prefix the resolution decision row `resolve:ban`,
`resolve:delete`, etc. dismiss_report and escalate stay unprefixed
(escalate must remain queryable for the platform-safety lane). Module doc
records the audit vocab.

Validation on base 0bff742 (toolchain 1.95.0): fmt --check, clippy
-p buzz-relay --all-targets -D warnings, test -p buzz-relay --lib all
green (492 passed / 0 failed / 2 ignored). git diff --check clean. Delta
is moderation_commands.rs only, +30/-2.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…nts (#1599)

* commit 'e4823614':
  moderation(L6): fix resolve-report audit races (Eva review #1599)
  moderation(L6): command handler (9040-9044) + mod-queue read endpoints

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Implements `authorize_moderation_action` (the capability helper, plan §1),
the single seam every moderation decision routes through so a future
Moderator tier is a policy change, not a rewrite (decision 1).

Authority model:
- Community owner/admin (tenant-scoped `relay_members.role`) authorize
  every action in any channel of their community — the bridge
  `validate_admin_event` is missing today.
- Channel owner/admin keep channel-local authority for DeleteMessage/Kick
  within `channel_id`. No current call site passes a `channel_id` (every
  L6 handler + the queue bridge pass `None`), so this branch is the
  contract seam for the future `validate_admin_event` wiring — building it
  to contract now makes that a one-line consult, not a helper rewrite.
- Guard rail: an admin cannot ban/timeout the community owner or a fellow
  admin; only the owner may action an admin. Scoped to ban/timeout only —
  restriction-lifting (unban/untimeout) is unguarded because a banned
  admin can't self-unban (blocked at the auth seam before any command
  runs), so the only reachable case is lifting a fellow admin's
  restriction, which is benign, audited, and owner-reversible. The guard
  trips on a target *role* of owner/admin, never on a missing row, so a
  drive-by spammer who already left is still bannable.

Tenant fence: both role reads (`get_relay_member`, `get_member_role`)
filter on `tenant.community()` in SQL — authority never crosses tenants.

The policy is factored into a pure `decide_authority` from resolved roles,
exhaustively unit-tested (7 tests: owner all-actions, admin against
non-privileged and privileged targets, non-member bannable, guard scope,
channel-role delete/kick-only, member/stranger denied). The async wrapper
is thin I/O glue that reads only the roles a given path needs.

Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
* commit 'e4e8f8c5':
  Phase-1 community moderation authorization seam (L2).

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…. query

Wren's L7 sweep (#1591) found a NIP-98 auth mismatch that 401'd
`buzz moderation reports` and `audit` in normal use: the CLI signs the
full request URL — query string included (`?limit=…&status=…`) — but the
relay reconstructed the expected URL from the bare path only, and
buzz-auth URL normalization preserves query strings, so they never
matched. `restricted` was unaffected (no query).

Fix: `authorize_moderation_read` now takes the request's raw query
(axum `RawQuery`) and appends it verbatim to the path before building the
NIP-98 expected URL, so the relay verifies against the actual request URI
the client signed. Verbatim (not re-parsed) keeps the match byte-exact
regardless of param order/encoding. `restricted` passes `None` and keeps
its bare-path expectation. This is the direction Wren recommended:
verifying the full request URL keeps future filtered reads honest.

Regression coverage (4 tests, bridge.rs): a query-bearing GET verifies
iff the expected URL carries the same query (`reports?limit=20&status=open`,
`audit?limit=20`); an anti-regression control proves the same event is
rejected with a URL mismatch against the bare path (the pre-fix behavior);
and `restricted` still verifies query-less. A test-local helper mirrors
the production query-reconstruction so the seam is pinned without a DB
harness.

Validation on base 8059413 (toolchain 1.95.0, PATH=repo bin): fmt
--check, clippy -p buzz-relay --all-targets -D warnings, test -p
buzz-relay --lib all green (503 passed / 0 failed / 2 ignored — 499 prior
+ 4 new). git diff --check clean. Delta is bridge.rs only, +157/-5.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…query (#1607)

* commit '8bbc3dae':
  moderation(L6): verify moderation reads against full request URL incl. query

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>

* origin/quinn/mod-read-auth:
  moderation(L6): verify moderation reads against full request URL incl. query
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
* commit '7c83d54b':
  Route moderation ingest events

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>

* origin/max/mod-l3-ingest:
  Route moderation ingest events
L6 (Quinn), CLI half. Adds the `buzz moderation` command group and the
NIP-43-style community moderation command builders it signs.

buzz-sdk builders (kinds 9040–9044), tag vocabulary pinned by
moderation_commands.rs:
- build_moderation_ban (9040): p + optional expiration + reason
- build_moderation_unban (9041): p
- build_moderation_timeout (9042): p + required expiration + optional reason
- build_moderation_untimeout (9043): p
- build_moderation_resolve_report (9044): report + status + action + optional
  reason; validates status/action vocabulary at build time

buzz-cli `moderation` group:
- Mutations (ban/unban/timeout/untimeout/resolve) sign 9040–9044 and POST
  /events, mirroring the 9030-series relay-admin transport. Community is
  host-scoped, so no channel/community flag.
- Reads (reports/restricted/audit) GET NIP-98-authed /moderation/* endpoints,
  because queue/audit rows are structured DB rows, not stored nostr events —
  serving them over a REQ filter would mean synthesizing fake events and
  threading a privileged authz check into the public read path. Read-endpoint
  server side pending Eva's lane arbitration; CLI shape is stable either way.
- client.get_authed() helper for NIP-98 GETs.

Tests: 12 SDK builder tests (tag shape, pubkey lowercasing, vocab rejection);
command-inventory stability updated for the new group. cargo check + clippy
clean; buzz-cli + buzz-sdk suites green.

Contract base: 4c58489 on eva/community-moderation.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Wren's L7 re-clear found that build_moderation_{ban,unban,timeout,
untimeout,resolve_report} validated hex targets with check_hex_len(.., 64),
which is a *minimum*-length check (built for abbreviated git SHAs). The
relay's extractors (moderation_commands.rs extract_p_tag_bytes /
extract_report_tag) require exactly 64 hex. The CLI masked this by
pre-validating with validate_hex64, but a direct SDK caller could build and
sign a p/report tag with 65+ hex chars that the relay then rejects as
missing/invalid — the SDK vouching for an event the relay drops.

Switch to the existing exact-length helpers: check_pubkey_hex for the four
target pubkeys and check_hex_exact(.., 64, ..) for the report id. Both
return the normalized lowercase value, so the redundant inline
.to_ascii_lowercase() on the tag values goes away. Error variant for
short/overlong input moves from InvalidDiffMeta to InvalidInput accordingly.

Tests: add overlong-hex regressions for a target pubkey and the report id;
update the two existing short-input tests to the InvalidInput variant.
buzz-sdk 219 / buzz-cli 135, clippy + fmt clean.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
* origin/quinn/mod-cli:
  moderation(L7): CLI commands + SDK builders for reports/bans/timeouts/audit

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>

* origin/quinn/mod-cli:
  moderation: SDK builders enforce exact-64-hex for p/report tags
  moderation: buzz-cli moderation command group + SDK 9040–9044 builders
* origin/main:
  docs(readme): add Getting started section routing install paths (#1606)
  fix(desktop): restrict shared-agent sync to dev data dirs (#1597)
  feat(desktop): restart-required badge from spawn-time config hash (#1602)
  feat(desktop): boot-time reconcile of managed agents to relay events (#1601)
  feat(desktop): canonical <PubKey> component — hover to view/copy full keys, owner "you" labels (#1589)
  fix(desktop): hydrate reactions for Inbox context messages (#1596)
  fix: cleanup old screenshots that my agents committed (#1598)
  chore(release): release Buzz Desktop version 0.3.46 (#1585)
  fix(desktop): preserve agent model/provider when persona snapshot fields are blank (#1583)
  feat(acp,desktop): identify and reap stale agent harness processes (#1582)
  feat(desktop): active-draft badge, send-from-drafts confirm dialog, thread-deleted state (#1581)
  fix(desktop): treat baked build env vars as satisfying required agent config (#1580)
  feat(desktop): add "Copy image" to image right-click context menu (#1579)
  fix(nest): use buzz-dev symlink name for dev builds (#1587)
  fix(composer): address image-editor follow-up nits on #1491 (#1565)
  fix(desktop): render black static boot screen (#1570)
  feat(agents): group activity tool bursts (#1571)
  feat(desktop): aggregated overview rail, commit detail page, and full breadcrumbs (#1573)
  fix(desktop): fetch profiles for reaction actors and thread-reply authors (#1550)

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
The pgschema provisioning path (scripts/start-*-relay.sh, fresh-DB CI)
applies schema/schema.sql only and is blind to migrations/. Without this
fold, freshly provisioned DBs lack moderation_reports/community_bans/
moderation_actions and kind-1984 ingest 500s (hit live during the Phase-1
demo). Verbatim body of migrations/0006_moderation.sql; verified by
pgschema apply on a fresh DB + zero-diff pgschema plan against an
AUTO_MIGRATE-built DB.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
kind:1984 report ingest is live since the Phase-1 moderation merge but
the NIP-11 document didn't advertise it, so interop clients wouldn't
discover report support (Quinn's e2e cap note). Adds 56 to
SUPPORTED_NIPS with a mirror test.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Foundation for the Phase-2 community-moderation UI (U1/U2 both consume it).
Adds the client-side wire contract with no new relay surface:

- shared/api/moderation.ts: signed command events (report 1984, ban/unban/
  timeout/untimeout 9040-9043, resolve 9044) published over the existing WS
  path (mirrors relayMembers.ts); NIP-98-authed HTTP GET reader for the three
  /moderation/* reads (reports, audit, restricted). Command events carry no h
  tag — the relay binds the tenant from the connection host. The NIP-98 u tag
  is signed over the fully-finalized URL (query included) so the signed u and
  the fetched URL are always identical.
- features/moderation/hooks.ts: React Query hooks over that API, mutations
  invalidate the affected read caches on success (mirrors relay-members).
- constants/kinds.ts: KIND_REPORT + KIND_MODERATION_* kinds.

Tag shapes are pinned by buzz-sdk builders, report.rs, and api/bridge.rs.
Validated: tsc --noEmit clean, biome check clean.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…imeout parser

Member-facing U1 slice on top of the shared foundation (0657221):

- features/moderation/ui/ReportMessageDialog.tsx + a "Report message" entry
  in MessageActionBar's more-menu. NIP-56 category picker (spam/profanity/
  nudity/impersonation/malware/illegal/other) + optional note, submits via
  useSubmitReportMutation, toast on success. Gated to real delivered messages
  (hidden on pending sends and huddle system rows). Mirrors the existing
  delete-dialog pattern; self-contained via the hook, no new props from
  MessageRow.
- SystemMessageRow: render the kind:40099 message_deleted tombstone. When a
  moderator removed the message the relay stamps a sanitized public_reason,
  shown as "Removed by community moderators — <reason>"; a plain self-delete
  carries none and reads "<actor> removed a message". public_reason/reason_code/
  action_id added as optional payload fields. Content and reporter never shown.
- features/moderation/lib/timeout.ts (+ tests): parse the reactive timeout
  contract `restricted: you are timed out until <unix_secs>` from a send
  rejection. Defensive — the prefix identifies the timeout; a bad/absent
  timestamp yields a chip-without-countdown rather than a false negative.

Also fixes a type-lie in the shared reader: /moderation/* JSON carries `id`
as a UUID string and every timestamp as an RFC3339 string (bridge.rs serializes
DateTime<Utc>/Uuid directly, no numeric conversion), so the Raw* and exported
Moderation* types flip from number to string. Caught by Sami.

Validated: tsc --noEmit clean, biome clean, timeout tests 6/6 green.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
When a send is refused with the community-timeout reason, block the
composer and show a live countdown until the block lifts. No proactive
read exists in v1, so the timeout is learned reactively from the
rejection and cleared the moment a send is accepted.

- features/moderation/lib/timeoutStore.ts: a tiny process-wide external
  store (useSyncExternalStore) holding one timeout for the connected
  community. Written from the send mutation's error path via
  recordTimeoutFromRejection, cleared on success via clearTimeoutState.
  A cached snapshot object keeps the store value referentially stable
  and lets the countdown effect depend on real reactive fields
  (active/expiresAtMs) rather than a version counter. While a known
  expiry is active it ticks once a second and collapses to inactive
  exactly at expiry.
- features/moderation/ui/ComposerTimeoutBanner.tsx: presentational
  banner docked to the composer's top edge, mirroring the welcome
  banner. Shows "<remaining> left" when the expiry is known, a plain
  block message otherwise.
- features/moderation/lib/timeout.ts (+ tests): formatTimeoutRemaining
  renders the countdown as "2h 5m" / "3m 20s" / "12s", returning null
  when the expiry is unknown or already elapsed.
- ChannelPane: fold the timeout into the shared isComposerDisabled
  (so the thread panel inherits the block), render the banner above the
  main composer, and reflect the block in the placeholder. The banner
  takes precedence over the welcome banner.
- messages/hooks.ts: record/clear the timeout in the send mutation's
  onError/onSuccess.

Validated: tsc --noEmit clean, biome clean (useExhaustiveDependencies
satisfied), timeout tests 8/8 green.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…va/moderation-ui

* origin/eva/community-moderation:
  Carry moderation metadata into delete tombstones

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
@tlongwell-block tlongwell-block force-pushed the eva/community-moderation branch from cb32add to e2443f8 Compare July 8, 2026 01:44
Base automatically changed from eva/community-moderation to main July 8, 2026 17:36
tlongwell-block and others added 2 commits July 8, 2026 13:36
…1614)

Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
…ssage controls (#1615)

Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
@tlongwell-block tlongwell-block merged commit c9c868b into main Jul 8, 2026
28 checks passed
@tlongwell-block tlongwell-block deleted the eva/moderation-ui branch July 8, 2026 17:49
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