Relays that advertise NIP-77 in NIP-11 but refuse it at runtime answer a
NEG-OPEN with a connection-level NOTICE (which carries no subId) instead of a
subId-addressed NEG-ERR:
- strfry with negentropy disabled: "ERROR: bad msg: negentropy disabled"
- purplepag.es (no NEG envelope): "failed to parse envelope: unknown envelope label"
reconcileStreaming only routed NegMsg/NegErr for its exact subId into the driver
channel, so the NOTICE was dropped and the driver blocked in receiveWithinIdle
with no terminating frame. Worse, the connection-level idle watchdog was bumped
by every relay message, so unrelated refusal chatter (a rejected keep-alive REQ
being re-CLOSED on re-sync) reset it forever and it never fired. Net effect:
negentropySync/negentropyReconcile hung against relay.primal.net and
purplepag.es, and negentropySyncOrFetch never reached its paging fallback.
Fix, in reconcileStreaming's connection listener:
- route a CLOSED for our NEG subId into the driver as a terminal failure;
- treat a negentropy-refusal NOTICE as terminal, bound to this session by
phase (before the first valid NEG frame) + wording (isNegentropyRejectionNotice),
so an unrelated NOTICE on a healthy relay mid-reconcile cannot abort a
progressing sync;
- stop bumping the idle clock on NOTICE/CLOSED so refusal chatter can no longer
keep a dead sync alive; it now advances only on real progress (this session's
NEG frames and the download REQs' events/EOSEs).
The refusal now surfaces as NegentropySyncException(UNAVAILABLE); negentropySync
throws promptly and negentropySyncOrFetch pages the same filter (both relays
answer ordinary REQs fine). Verified live: primal 0-event hang -> pages 11.7k
events in 6.4s; purplepag.es 0-event hang -> pages continuously.
Tests:
- NegentropyRejectionFallbackTest: offline, deterministic; a scripted fake
relay answers NEG-OPEN with each observed NOTICE and asserts the sync throws
UNAVAILABLE fast and negentropySyncOrFetch sets pagedFallback.
- NegentropyStallRepro: gated live repro against the three real relays.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6ZVTixuc1ef8eGB6MQHRn
The previous two commits refetched a group's 39000-39003 whenever the relay
narrated a change, on the premise that Buzz could not stream them at all: it
signs them with `d`/`p` tags and no `h`, so an `#h` filter looked unable to match
and a filter without `#h` registers as a global subscription, which never
receives a channel-scoped event.
The first half of that was wrong. `filter_match_one` has an explicit fallback:
for an `#h` filter, when the event carries no `h` tag at all, it matches against
the stored `channel_id` — and these are stored channel-scoped. So an `#h` filter
both indexes the subscription under the channel (which is what makes it eligible
for the channel fan-out) and matches the events when they arrive.
So subscribe, like the rest of the app does. The per-channel `#h` subscription
that already keeps each joined group's chat live now carries its state kinds too,
and every screen updates through the flows it already observes. The fetch, the
event-bundle hook that triggered it and the cache lookup it needed are all gone.
Buzz-only: on a relay29-family relay these events are addressable with no
`channel_id` behind them, so an `#h` filter matches nothing there and the `#d`
directory filters keep serving them.
Verified on emulator-5554 with no fetch code left in the tree: promoting shows
the `admin` badge on the open members screen within seconds, and removing the
role clears it again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit hung the refresh off the members screen, so only that screen
recovered from a stale roster: a rename, a visibility flip or a join seen from the
chat, the channel list or a Messages row stayed wrong until the next cold start.
The rest of the app is reactive — relay to LocalCache to screen — and this should
be too.
Move it into AccountViewModel's always-on event collector: any kind-40099 that
lands names its channel, so re-read that group's 39000-39003 into LocalCache and
every screen observing the group updates through the flows it already has. One
rule, no screen has to know about it.
Two things this had to work around:
- The event names its channel but not its host, and a note's relay list can still
be empty when the bundle fires. LocalCache.relayGroupChannelsWithId resolves the
host from the channels already in cache.
- Invalidating the standing state subscription does nothing: its filters are
unchanged, so no new REQ goes out and no events come back. Measured — the badge
did not move. It takes an explicit fetch, which is what
Account.refreshRelayGroupState now does by group id.
Verified on emulator-5554: removing a role updates the badge on the open members
screen with no restart and no screen-local refresh code left in it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ContactCardEvent.indexableContent() already indexed the public summary
and topics; add the public petName() tag alongside them so a card can be
found by the nickname its author gave the target user. petName()/summary()
read the public tag array, so any petname kept in the NIP-44 encrypted
content stays out of the index (privacy preserved).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHSTACrFHaRX1o6C5cEk6N
**The roster went stale.** Promoting somebody changed nothing on screen until the
next cold start. Buzz signs its 39000-39003 with `d`/`p` tags and **no `h`**, yet
stores and fans them out channel-scoped — so a filter carrying `#h` does not match
their tags, and one without `#h` is a global subscription, which by design receives
no channel-scoped event. Neither shape can be live; measured it directly, a role
change delivers only the kind-40099 that narrates it.
So use that as the cue: the members screen re-reads the group's state whenever a
new system message lands in it, keyed on the message id so it fires once per
change rather than polling. Account.refreshRelayGroupState does the fetch.
**Unread badges never cleared.** loadAndMarkAsRead lived inside NormalChatNote —
the `else` of the render switch — so a row drawn by any specialised path (Buzz
system lines and activity rows, diffs, forum votes, NIP-28 admin lines, zaps)
never advanced the room's last-read marker. On a Buzz relay that is most rows:
joins, adds and role changes are all system messages, so a channel whose newest
events were those kept its badge no matter how often it was opened. Hoisted the
call to cover every row type; NormalChatNote's now-dead routeForLastRead
parameter is gone.
Verified on emulator-5554 against nosfabrica.communities.buzz.xyz: promoting a
member now shows the `admin` badge without restarting the app, and opening
`general` cleared its badge while the channels left untouched kept theirs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Massive review of every Event subclass in Quartz that carries
human-authored plaintext at rest but was not participating in the NIP-50
full-text index (SearchableEvent).
Add SearchableEvent to 15 kinds whose content is plaintext searchable
text a user would look for:
- 9737 Bolt12 zap intent (comment) — mirrors 9734/9736
- 5302 / 5303 NIP-90 content / people search request (search query)
- 31871 / 31872 attestation / attestation request — sibling family was
already searchable
- 1315 roadstr road-event report (free-text comment)
- 45001 / 45003 Buzz forum post / comment (body)
- 48106 Buzz huddle guidelines
- 30176 / 30175 / 30177 / 10100 Buzz team / persona / managed-agent /
agent-profile (name, description, system prompt)
- 30620 Buzz workflow definition (name + YAML)
- 3302 Concord chat edit (replacement message text) — mirrors kind-9 chat
Widen indexableContent() on 8 kinds that already implemented
SearchableEvent but dropped natural-language text carried in tags:
- 30020 auction — category hashtags were written by build() but never
indexed
- 12473 Birdex — species names
- 30382 contact card — public topics
- 9002 NIP-29 group-metadata edit — hashtags
- 30054 Podcasting-2.0 episode — topics
- 38192 PS1 save — region name
- 1111 comment / 1311 live-activity chat — hashtags
Encrypted-at-rest (NIP-04, giftwraps, MLS/marmot, NWC, cashu), purely
structural (relay/follow lists, reactions, deletions, moderation/presence
signaling), and ephemeral events were reviewed and deliberately left out.
The NIP-31 alt tag was also left out: it is frequently kind-level client
boilerplate and would dilute relevance.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHSTACrFHaRX1o6C5cEk6N
**Adding people.** The Members screen hid its search behind a FAB, so adding a
handful of people was "open dialog, search, pick, dialog closes, reopen" per
person. The field now lives at the bottom of the screen with its results rising
above it, like a chat composer: each pick lands in the roster above and clears
the query while the keyboard stays up. It clears the gesture bar and rides above
the IME, so the field you type into is not the part that gets covered.
**Promotions did nothing.** "Make moderator" published a kind-9000 and changed
nothing, on either client. Two reasons:
- NIP-29 carries roles inside the `p` tag; Buzz reads a top-level `role` tag
(`extract_tag_value(event, "role")`) and defaults to `member` without it. So
every promotion re-added the target as a plain member. PutUserEvent can now
carry that tag and Account maps our role onto Buzz's vocabulary before sending.
- That vocabulary is `owner`/`admin`/`member`/`guest`/`bot` — there is **no
moderator**, and a role the relay cannot parse fails the whole put-user. So the
action is hidden on Buzz rather than offered and silently dropped.
**The owner could not promote anyone.** membershipOf only mapped the literal
`admin` to ADMIN, but a Buzz channel's creator carries `owner` — leaving the one
person with full authority ranked below it, so "Make admin" never appeared. Both
role strings now mean ADMIN.
**The 3-dot button moved when tapped.** An expanded DropdownMenu still emits a
node into its parent, and it sat as a direct child of a `spacedBy(12.dp)` Row —
so opening the menu added a second gap and shoved the button sideways. Button and
menu now share a Box. ConcordMembersScreen had the identical bug and is fixed
too; GitBrowseUi looks like a third instance and is left alone as unrelated
territory.
Verified on emulator-5554 against nosfabrica.communities.buzz.xyz: promoting the
added member published the 9000, the relay narrated it, and after the roster
refreshed the member carries an `admin` badge. The 3-dot sits at the same pixel
column whether the menu is open or closed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adding someone to a workspace or channel offered a square button and a dialog
whose own hint told you to paste a hex key. Both are now what the rest of the
app does.
The dialog searched **only** LocalCache, so anyone this device had never seen
simply had no result — pasting a raw key was the only way through, which is why
the field said "Add someone (npub or hex)". It now runs on UserSuggestionState,
the same engine behind the @-mention typeahead: local cache, relay search
(NIP-50) and NIP-05 resolution, plus a pasted npub/nprofile. The hint asks for a
name; results show avatar, display name and a verified NIP-05 address.
The members-screen button was the app's only FloatingActionButton without
`shape = CircleShape`, so it rendered as Material3's rounded square next to
circular FABs everywhere else.
Two shared components needed to bend for this, both additive:
- SlimListItem took a `colors` parameter and then painted its container with a
hardcoded `MaterialTheme.colorScheme.background` regardless — so a row asked
to be transparent still drew an opaque block. It honours `containerColor` now,
defaulting to the same `background` it always painted, so every existing
caller is byte-identical.
- ShowUserSuggestionList's row colours, dividers and top padding are parameters.
Their defaults are the dropdown's existing look — opaque rows and a divider
each, which is what separates it from a composer it floats over. Inside a
dialog that chrome reads as a black box with a gap above it, so this one caller
passes transparent rows, no dividers and no padding.
Verified on emulator-5554 against nosfabrica.communities.buzz.xyz: searching
"cloudfodder" returns relay results with verified NIP-05s; pasting an npub
resolves to the person; adding them to a channel published the kind-9000, the
relay narrated "Vitor was added by Vitor Pamplona", the member count went 1 → 2,
and that member then posted from Buzz and the message rendered here. The mention
dropdown is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The community screen's FAB opened the NIP-29 create-group flow, and on a Buzz
relay it published two events and produced nothing: no channel in the list, no
channel anywhere. Two independent reasons, both silent.
**The create was rejected.** NIP-29 puts the id on the kind-9007 and leaves the
metadata to a following 9002. Buzz's ingest.rs validates the 9007 *before
storage* and rejects it with "invalid: channel name is required" unless the
create event itself carries a `name`; it also reads `about`, `visibility` and
`channel_type` off that same event. Our 9007 had only the `h` tag, so it never
stored, and the 9002 behind it addressed a channel that was never made. Send the
metadata on both events: relay29 ignores the extra tags and takes the 9002, Buzz
takes the 9007.
**The id was not a UUID.** Buzz keys channels by UUID and parses the `h` tag with
`val.parse::<Uuid>()`. Our 8-random-bytes hex id doesn't parse, so the relay
discarded it and created the channel under an id of its own — the app then opened
the id *it* had picked, which is why the one channel that did get created showed
a hex title over an empty feed. Generate a v4 UUID when the host speaks Buzz;
NIP-29 ids are opaque strings, so nothing else changes.
The screen matched NIP-29 rather than Buzz, too. It offered a photo, hashtags, a
geohash and four permission flags — of which Buzz's 9002 handler honours exactly
one (`visibility`, two-valued). Those controls looked like they configured the
channel and were dropped on the floor. On a Buzz relay it is now: name,
description, "Private channel" (Buzz's open/private in Buzz's words), and
"Forum channel" for `channel_type` — offered only on create, since Buzz has no
`channel_type` key on edit. Titled "New channel", because Buzz calls them
channels, and the FAB says so. Plain NIP-29 relays are untouched.
Also stops the NIP-11 gate blocking creation on Buzz relays, which advertise no
NIP-29 support yet implement 9007/9002, and makes RelayGroupMetadataViewModel's
`relay` snapshot state — it is assigned after first composition, so a plain var
left the screen stuck rendering its NIP-29 shape.
Verified against nosfabrica.communities.buzz.xyz: creating "amethyst-create-test2"
lands a real channel — right name in the title, Moderator badge, member count 1,
the relay's own "Vitor Pamplona created this channel" system line, and a row in
the channel list. BuzzChannelCreateTest covers both wire-level fixes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Resolve all `:quartz:compileTestKotlin*` warnings without changing any
test's behavior:
- Drop redundant bare `Secp256k1Instance` "force crypto lib load"
statements (and their now-unused imports). JVM object initialization is
thread-safe and lazy, and every one of these tests exercises crypto, so
the lib loads on first use regardless — the eager reference was a no-op
the K2 compiler flags as an unused expression.
- PairingEventTest: use `assertIs` instead of `assertTrue(x is T)` + cast.
- MergeQueryCorrectnessTest: drop `!!` that smart-cast already made moot.
- PodcastCommentScopeTest / MintExceptionTest: widen the declared type so
the `is` checks are genuine runtime checks rather than always-true.
- FetchAllIdleTimeoutTest / NostrConnectSignerServiceTest: opt in to
`ExperimentalCoroutinesApi` at the class level.
- GiantReqStreamTest: name the `WebSocketListener` overrides' parameters
to match the supertype.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyXakbYay4zNJ4cwoF4j7N
Leaving the Buzz community tab and coming back rebuilt the screen: Direct
Messages sat empty for about a second, and the channel list settled into a
different order than it had a moment earlier — every visit, and a different
order each time.
Three causes, all fixed here.
**The tab was destroyed, not left.** navBottomBar popped siblings without
`saveState`, so the entry — and its ViewModelStore — was thrown away on every
tab switch. Returning built new BuzzRelayImportViewModel / BuzzDmListViewModel
instances, whose bind() self-guard could not help because the guard lives on an
object that no longer existed. Adding saveState/restoreState keeps each tab's
state; other tabs get their scroll position back as a side effect.
**The DM inbox waited on the network to show what it already had.** bind()
called refresh() → discoverMemberChannels(), an 8s-timeout relay round-trip,
before anything could render — even though rebuildRows() reads nothing but
LocalCache and an in-memory map, and the always-on BuzzDmDiscovery has already
recorded those channel ids process-wide in BuzzDmChannels. Seed memberChannels
from that registry and project the rows before any network work; refresh still
runs behind it and corrects anything stale.
**The channel order was arrival order.** buzzGroupIds is "membership ids as the
ViewModel emitted them, then directory ids", and the only sort was
`sortedByDescending { it.id in starred }` — a stable sort over a boolean, which
preserves whatever landed first. Order by (starred, name) so the first frame is
the final order; a channel whose 39000 hasn't arrived sorts by id until its name
lands.
Verified on emulator-5554, capturing ~0.45s after the tab switch: channels in
alphabetical order and both DMs present, with no reshuffle in later frames.
Previously the same capture showed an empty Direct Messages section and a list
that reordered within the second.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Drop the CORD-02 §6 banner hero that showed when a community was expanded
to OPEN in the Concord home screen, so opening a community and viewing its
channels no longer displays the banner. Removes the now-unused CommunityBanner
composable and its ContentScale/height imports.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6rz1csKDADKc5nr7R4a1b
Two bugs found auditing the P2PK redeem path:
- Hex case: a lock's `data` pubkey is sender-formatted and NUT-11 doesn't
mandate a case, but our key index is keyed by lowercase x-only (Hex.encode
is lowercase). An uppercase/mixed-case lock we actually hold the key for was
falsely rejected as unredeemable. Normalize the lock to lowercase before the
lookup, and compare identity-key locks case-insensitively.
- Multi-mint partial redeem: callers redeem one mint-group at a time, each
swapping + publishing. An unsignable P2PK lock in a later group threw only
after earlier groups were already spent + published, leaving a half-redeemed
state the user was told had failed. Add `firstUnsignableP2pkLock` /
`requireP2pkRedeemable` and pre-flight every group before redeeming any,
mirroring the existing unknown-mint pre-check (wallet ViewModel + amy CLI).
Also document that P2PK.signWitness's `["P2PK"` prefix guard is load-bearing
for safety (prevents cross-protocol signature reuse when signing with the
identity key), not just for parsing. Adds tests for case-insensitive matching
and the pre-flight helper.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKeRaX749TYnJ7oR8UpqA4
Follow-up to the P2PK redeem support:
- Gate the redeem signing-key gathering behind an actual P2PK lock. The
wallet P2PK key is decrypted from kind:17375 via the signer — a network
round-trip on a NIP-46 bunker (and a possible approval prompt). The common
case (a plain, unlocked token) needs none of it, so only fetch keys when
`anyP2pkLocked()` is true. Applies to both the wallet ViewModel and the amy
CLI token-redeem path.
- Fast-reject in `P2PK.parseSecret`: NUT-10 well-known secrets are JSON
arrays, so bail before the throwing JSON parse when the string isn't one.
Redeem parses every proof's secret once, so this drops a thrown+caught
exception per plain proof (also benefits the nutzap redeem path).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKeRaX749TYnJ7oR8UpqA4
The Canvas icon showed on every Buzz channel including DMs, and its edit FAB
offered to create one there. Buzz itself never does: its canvas entry needs
`hasCanvas || canEditNarrative`, and `canEditNarrative` is
`canManageChannel && selfMember !== null && channelType !== "dm"` — so a DM can
only ever *display* a canvas that already exists, never write one
(ChannelManagementSheet.tsx). We were advertising "start a shared document" in a
two-person conversation, and a canvas created there would have been ours alone
to see.
Mirror both halves: a DM gets the icon only when a canvas is already in cache,
and the canvas screen drops its edit FAB for DMs so what remains is read-only.
Non-DM Buzz channels are unchanged — icon always, editing always.
The has-a-canvas check reads the same BuzzWorkspaceStates registry the canvas
screen renders from and recomposes on `canvasUpdates`, so the icon appears when
the document lands rather than on the next visit.
Verified on emulator-5554: the Buzz DM with Vitor (no canvas) shows only the
overflow, where it used to show Canvas + overflow; `general` on the same relay
still shows Canvas + overflow.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Threads compose FAB keyed on relay dialect, not channel type: on any Buzz
relay it published a kind-45001 forum post into whatever channel you were in.
Buzz only ever creates those in a `t=forum` channel — its client mounts the
forum view for `channelType === "forum"` alone — so a 45001 in a `t=stream`
chat channel is a post that no Buzz user can see. The relay accepts it
(nothing there gates 45001 by channel type), which is exactly why the client
has to.
Gate the FAB on the channel's declared type when the host speaks Buzz, and
leave every other relay alone: there the same button writes a NIP-7D kind-11,
which is valid in any group. A Buzz channel whose kind-39000 hasn't landed yet
reads as null and fails closed — a FAB appearing a moment later beats
publishing into the wrong channel.
Reading is untouched. An existing thread still renders wherever it came from;
this only removes the offer to create one where it would be invisible.
The empty state said "No threads yet. Start one with the + button." while the
FAB was hidden, which was already wrong for non-members and is now wrong for
chat channels too. Added a read-only variant.
The gate is a named function rather than an inline condition so the allow-path
has a test: no relay reachable in a manual pass exposes a `t=forum` channel, and
a gate that only ever denies is indistinguishable from deleting the feature.
Denials are covered on-device — a Buzz stream channel loses the FAB, a
NIP-29 group (basspistol.org) keeps it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pasting a P2PK-locked cashu token (NUT-11) into the wallet sent the proofs
to /v1/swap with no witness, so the mint rejected them with an opaque
`witness is missing for p2pk signature` 400. Only the NIP-61 nutzap path
signed witnesses; the generic redeem path had no P2PK support at all.
- quartz: add `signP2pkWitnesses` (pure, resolver-driven) + the
`P2PKUnredeemableException` it throws when a locked proof's key is
unknown, and a `CashuMintOperations.redeemToken` that signs then swaps.
- commons: `CashuWalletOps.redeemToken` now takes the wallet P2PK key and
(local-signer-only) identity key, indexes them by x-only pubkey, and
routes through the P2PK-aware path. Add `describeRedeemError`, which tells
a user whose token is locked to their own identity key (e.g. Bey Wallet's
P2PK send) — but who is on a bunker/external signer that can't sign a raw
witness — to import their nsec elsewhere to claim it.
- amethyst: `CashuWalletState.redeemSigningKeys()` surfaces both keys
(identity key only for a local NostrSignerInternal); the wallet ViewModel
wires them in and reports via `describeRedeemError`.
- cli: `amy cashu receive token` passes the same keys and reports a distinct
`p2pk_locked` error code.
Adds P2PKRedeemTest covering pass-through, x-only + compressed locks,
verifiable witnesses, and the unredeemable case.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKeRaX749TYnJ7oR8UpqA4
Two rows of "Kind conflicts — implemented but NOT registered in EventFactory" were
stale: 20001 and 39005 are both dispatched now, disambiguated by tag shape inside
the kind's block (`g` for BitChat presence, `h` for a Buzz thread summary). Split
the table into the disambiguated pair and the three where the incumbent really does
keep the slot, and record why the 39005 signal is safe — the NIP-29 relay-generated
39xxx family is `d`-addressed and never emits `h` — plus the fact that nothing
throws when it is wrong, so the failure is silent.
Also document reading Buzz's Rust without a checkout, which currently costs everyone
the same detour: KDoc across this package cites crate-relative paths
(`buzz-relay/src/handlers/...`) while the repo puts everything under `crates/`, and
`raw.githubusercontent.com` 404s on those paths with plain curl usually sandboxed —
`gh api ... contents/... | base64 -d` is the way in. Notes where the answers live
(handlers for what the relay emits, buzz-db/channel.rs for channel_type and the two
visibility values, desktop/src/features for what Buzz's own client renders — which
decides whether an event we publish is visible to anyone), and that the relay's tests
are the best spec: `channel_scoped_content_kinds_require_h_tags` is what establishes
that canvas and the forum kinds are per-channel, not per-workspace.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The relay-group top bar carried four affordances — Threads, Canvas, Share and
the overflow — which crowded the title to the point that a channel called
personalized-knowledge-graphs rendered as "personalized-..." with its relay
truncated too.
Threads was the worst offender because it advertises a feature that is usually
not there. On a Buzz `t=stream` channel the threads list is forum posts (kind
45001), and Buzz only ever creates those in `t=forum` channels — which the
relay's channel list already surfaces in their own Forums section, opening
straight into this same view. So on every chat channel the button led to "No
threads yet", and its + would have published a 45001 into a stream channel,
where Buzz's own client never renders it (it mounts ForumChannelContent only
for channelType === "forum"). It stays in the menu rather than being gated off,
because on vanilla NIP-29 relays the same screen is the legitimate NIP-7D
kind-11 thread view.
Canvas keeps its icon: it is this channel's shared document — content — while
Threads and Share are navigation you reach for occasionally.
Also fixes the overflow only existing in the member branch: a pending join or a
gated group you don't belong to replaced the whole menu with the Join button, so
Members was unreachable while browsing. The menu is now always present, with the
membership actions (Members/Edit/Invite/Leave) gated as before and Threads/Share
always available — you can want to hand out a group you are still only browsing.
Canvas is per-channel, not per-workspace: the relay requires an `h` channel tag
on kind-40100 (its own channel_scoped_content_kinds_require_h_tags invariant),
so correct the empty state from "shared in this workspace" to "in this channel",
say so in the KDoc, and name the channel under the title the way Threads does.
Verified on emulator-5554: Buzz channel shows Canvas + overflow (Threads, Share,
Members, Leave) and the full title now fits; a non-Buzz group (basspistol.org)
shows only the overflow with the same four items; the canvas screen names its
channel.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Forum channels store their posts as threads (a separate store), not in the
chat notes the activity preview reads — so a forum row was opening a kind-9
chat warmup subscription that returns nothing and could never render a
last-message preview, facepile, or unread badge.
Add a `showActivityPreview` flag (default true) to BuzzImportRow that gates
the warmup, the notes-flow collection, and the preview/facepile/unread reads.
The forum section passes false, so a forum row skips the useless subscription
and shows a member-count summary instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHnm6G9YytnLfs1ycYfK89
The in-chat markers read "member joined" / "visibility changed" — the relay's
raw payload type with the underscores swapped for spaces. Everything that makes
the line useful was parsed and then dropped: which member, who added them, and
what the setting changed TO.
Render the full sentence from the signed payload instead, with the pubkeys
resolved to the names the viewer knows them by and the avatar of whoever the
line is about:
member joined -> "Shawn was added by straycat" (actor != target)
-> "Vitor joined" (self-join, actor == target)
member removed -> "Bob was removed by Alice"
visibility changed -> "straycat made this channel open — anyone can find and join it"
-> "... made this channel private — invite only"
ttl changed -> "Alice set messages to disappear after 7 days" / turned off
topic/purpose -> the new value, or "cleared the topic" when blank
channel created/deleted/archived/restored, message deleted (+ public reason),
dm created -> named after their actor
The event is signed by the RELAY keypair, so the people come from the payload's
actor/target, never from note.author. Membership lines are about the member, so
that is whose avatar shows and whose profile the pill opens; everything else is
about the actor.
Also covers the neighbouring timeline narration, which had the same problem:
huddle joins/leaves now name the participant from the `p` tag instead of
"someone joined the huddle", job lines name the signer, and forum votes name
the voter. All of these move from hardcoded English into string resources.
Quartz's SystemMessagePayload was missing target_event_id, action_id,
reason_code, public_reason and participants — every field of the moderation
tombstone and the DM-created payload. The KDoc now carries the complete
vocabulary read off the relay's emit_system_message callers, and the type
strings are constants so the UI cannot typo a branch into dead code.
An unknown type still renders as "alice: some_new_type" rather than vanishing,
so a relay that grows new vocabulary stays legible.
Verified on emulator-5554 against nosfabrica.communities.buzz.xyz: the four
"was added by" lines, "straycat created this channel", "Matthias Debernardini
joined" and "straycat made this channel open" all render with the right subject
avatar, in the chat and in the Messages-list preview.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Link previews left " / " literal because replaceCharRefs only
whitelisted named entities. Parse terminated numeric refs as code points.
Fixes#3723
kind:39005 is claimed by two unrelated relay-signed addressable events, and
EventFactory mapped it unconditionally to NIP-29's GroupPinnedEvent, leaving
Buzz's ThreadSummaryEvent unreachable:
GroupPinnedEvent d = group id, e = pinned message ids, no h, empty content
ThreadSummaryEvent d = thread root id, e = that root, h = channel, JSON content
Nothing throws on the mismatch, so a summary would have parsed as a pin list and
pinnedEventIds() would have reported the thread root as a pinned message.
Discriminate inside the kind's block on the `h` tag, following the kind-20001
BitChat/Buzz presence precedent already in this file. `h` is a safe signal in
both directions: the whole NIP-29 relay-generated 39xxx family (metadata,
admins, members, participants, supported-roles, pinned) is addressed by `d`
alone and never emits `h`, and neither builder does either, so both classes
round-trip through the factory on outbound signing as well.
Buzz publishes summaries live to channel subscribers (not only over its HTTP
bridge), so this is what makes it safe for a channel-scoped filter to ask for
39005 at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>