Commit Graph
17556 Commits
Author SHA1 Message Date
Claude 08f581f29c feat(buzz): import your workspace channels from a relay in Find groups
Buzz workspaces expose no public group directory (membership is server-side, no
kind-10009 join), so the generic "browse a relay" directory comes back empty for
them. Add a membership-scoped importer: from Find groups, enter the Buzz relay
you're already on and tap "Import your channels". It warm-authenticates the relay
(NIP-42), reads the relay's kind-44100 member-added notifications addressed to you,
fetches each channel's 39000 metadata, and lists your non-DM workspace channels
with Add / Add all.

Adding calls account.follow(channel) — a public kind-10009 group tag — so the
channel then flows through the existing relay-group machinery for free: it shows in
the Messages list (inline or grouped-by-community per relayGroupViewMode) and loads
at boot via the always-on relay-group subscription. No separate registry needed.
Entering the importer also joins + pre-approves NIP-42 for the relay so discovery
is served.

New: Route.BuzzRelayImport, BuzzRelayImportViewModel, BuzzRelayImportScreen, a
Find-groups entry point, and strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 21:23:01 +00:00
Claude 2621fed63c fix(buzz): hide DM + Console cards until there is a workspace
The DM inbox and Agent Console both query only your joined/dialect workspace
relays (BuzzWorkspaces + BuzzRelayDialect), so with zero workspaces they have no
relays to hit and can only ever be empty — yet the hub showed both as prominent
top-level cards, implying they're global features when they're workspace-derived
(a DM is a per-community NIP-29 group; the console aggregates the fleet on your
community relays). Gate both behind having >=1 workspace so the empty state is
just the "join a workspace" prompt; once you have one they sit above the list as
the cross-workspace inbox / fleet view.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 20:46:56 +00:00
Claude 06a64d01af fix(buzz): label the tab "Buzz Workspaces" instead of "Workspaces"
Renames buzz_workspaces_title, which names the tab in the drawer + bottom bar
and the screen's top bar. "Workspaces" alone was ambiguous (Concord also uses
that word); "Buzz Workspaces" makes the feature clear.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 20:26:36 +00:00
Claude aab52d31c9 fix(buzz): make the Agent Console Attest FAB round like the app's other FABs
It was an ExtendedFloatingActionButton (icon + "Attest" label), which Material 3
renders as a rounded-rectangle pill — visually out of step with the circular
FloatingActionButton(shape = CircleShape) used by the sibling chat/relay-group/
concord create actions. Switch to the same circular icon FAB, keeping the label
as the icon's contentDescription for accessibility.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 20:08:56 +00:00
Claude 9087565d98 fix(buzz): surface Workspaces in the drawer Feeds list
NavBarItem.BUZZ was added to the catalog and the bottom-bar settings picker but
never to DrawerFeedsItems, the list the left navigation drawer's Feeds section
renders. So Buzz Workspaces could be pinned to the bottom bar yet was missing
from the drawer alongside Relay Groups and Concord. Add it between them, matching
the chats grouping order.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 20:05:28 +00:00
Claude 45d18ab330 feat(buzz): resolve kind-20001 collision so BitChat + Buzz presence coexist
Kind 20001 is claimed by both BitChat's GeohashPresenceEvent and Buzz's
PresenceUpdateEvent. EventFactory's flat when(kind) could only route one, so
Buzz presence never materialized (parsed as a geohash event) in either
direction — this was the deferred "EventFactory collision".

Disambiguate inside the shared 20001 branch by BitChat's required `g` (geohash)
tag: present -> GeohashPresenceEvent, absent -> PresenceUpdateEvent. Verified
against the Buzz Rust ground truth (buzz-sdk build_presence_update + the relay's
synthesize_presence read form): Buzz presence carries the status in content plus
a `status` tag (client) or a `p` tag (relay-synthesized), never a `g` tag, and
BitChat presence always carries `g` with empty content. Both inbound parse
(EventDeserializer) and outbound signing (EventAssembler) route through this
factory, so one guard fixes both.

Make it usable, not just parseable: add BuzzPresenceState (process-wide latest
online/away/offline per subject, mirroring BuzzTypingState), a
PresenceUpdateEvent.subjectPubKey() accessor (the `p` tag or the author), and a
LocalCache branch that records presence and drops the ephemeral without storing
it. Tests cover both Buzz wire shapes, the BitChat guard, and latest-wins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 19:55:19 +00:00
Claude 4d0e3d5b46 feat(buzz): auto-auth joined workspaces, post-join UX, DM add-member, leave + UI polish
- Auto-authenticate relays for Buzz workspaces the user explicitly joined:
  their read-only #p=me channel/DM discovery is otherwise not first-party, so
  the p-gated 44100/30622 reads were never served and workspaces stayed empty.
- Invite screen: two-state hand-off — join + pre-approve NIP-42, launch the
  in-app window.nostr browser, then point back to the workspaces hub.
- DM inbox: add-member action (npub/hex dialog → kind-41011) alongside hide.
- Workspaces hub: leave-workspace overflow on each header.
- Elevate the Buzz surface with a shared BuzzBrand gradient design kit — hero
  masthead with live workspace/channel stats, cohesive across screens.
- Drop the dead kind-41001 DM-conversation path: the deployed relay never
  emits a queryable 41001, so BuzzDmRegistry is trimmed to the 30622 hidden
  set and LocalCache stores DmCreatedEvent without registry bookkeeping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 19:06:46 +00:00
Claude f8bb53848b Merge remote-tracking branch 'origin/main' into claude/buzz-repo-analysis-7k54ga 2026-07-22 18:28:40 +00:00
Vitor PamplonaandGitHub 1e874239b8 Merge pull request #3673 from vitorpamplona/fix/no-client-tag-for-external-signers
fix(signer): never stamp our client tag on someone else's template
2026-07-22 14:26:27 -04:00
Claude 7205d62de2 fix(buzz): warm-auth-then-retry on DM open so the OK channel id survives a cold connection
openBuzzDm reads the relay-assigned channel id from the DM-open OK response, but
a bare publish to an auth-required Buzz relay on a cold (not-yet-authenticated)
connection is rejected `auth-required` — the write path doesn't re-send after the
async NIP-42 AUTH lands (proven against the live relay via the amy CLI). The app's
persistent, proactively-authed connection usually masks this, but opening a New DM
before discovery has connected that relay would race it.

Port the amy fix: on an `auth-required` ack, warm the connection with a
pendingOnAuthRequired read (lets the AuthCoordinator finish the handshake), then
retry the publish and re-read the channel id from the OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 18:12:46 +00:00
Vitor PamplonaandClaude Opus 4.8 636dd2afe7 fix(signer): never stamp our client tag on someone else's template
The NIP-89 client tag says "this app composed this event", so it belongs only
on templates Amethyst authored. NostrSignerWithClientTag was applied at the
account level, which meant it also fired on every event we sign on behalf of
an external client.

That is wrong twice over. It misattributes the event, and — because the tag is
appended before signing — it rewrites the exact bytes the caller is about to
have hashed into an id. NIP-07 callers routinely re-check the returned event
against the template they submitted, and block/buzz compares tags outright
(web/src/shared/lib/nostr-signer.ts):

    JSON.stringify(actual.tags) === JSON.stringify(expected.tags)

so joining a Buzz community from the in-app browser failed with "The NIP-07
extension returned an invalid signed event". Probed live over the WebView
devtools protocol: kind, created_at, content and pubkey all round-tripped
intact and only tags differed, by exactly the ["client","Amethyst"] we append.

Add NostrSigner.withoutClientTag() and use it at the two boundaries where the
template belongs to someone else:

- the napplet broker, covering napplets, nSites and web apps over NIP-07
- Nip46SignerState, where we act as another client's bunker — the same defect,
  and quieter, since that client never learns why its event changed underneath
  it

Amethyst's own events are untouched and still carry the tag. Unwrapping keeps
everything layered below (metering, NIP-13 mining) and leaves pubKey alone.
There is no NIP-55 provider surface to fix; we are only ever the client there.

Verified against Buzz's own four acceptance conditions after the change:
pubkey matches, sameUnsignedEvent true, id and sig present — and the invite
join then succeeded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 13:57:39 -04:00
Claude ef1b5b6bc2 feat(buzz): workspace + DM discovery via 44100/39000, matching the live relay
The earlier discovery layer read the NIP-29 joined list (kind-10009) and
kind-41001 — neither of which the deployed relay uses, so a joined workspace
rendered nothing. Rework it to the model live testing confirmed.

Enabling layer — persist joined workspaces:
- commons BuzzWorkspaces: process-wide set of joined workspace relays (Buzz
  membership is server-side, so there's no join event to rebuild from). Joining
  also marks the relay a Buzz dialect. Unit-tested.
- BuzzWorkspacePreferences: device-global DataStore that restores the set at
  startup (so the app connects + authenticates + discovers on cold start) and
  mirrors changes. Eager init in AppModules. BuzzInviteScreen now `join`s.

Quartz:
- BuzzChannelMetadata: read the relay's `t` channel-type tag ("stream"/"forum"/
  "dm") and a DM's inlined `p` participants off kind-39000.

Discovery (both hubs now source from the relay's real signals):
- BuzzWorkspacesViewModel: fetch + live-subscribe kind-44100 member-added
  notifications (#p=me) across joined relays → my channels; fetch each channel's
  39000 metadata; keep the non-DM ones. BuzzWorkspacesScreen unions this with the
  NIP-29 joined list.
- BuzzDmListViewModel: same 44100 discovery, kept where 39000 `t`=dm (participants
  from the metadata `p` tags), minus the 30622 hidden set — replaces the dead
  kind-41001 path.
- Account.openBuzzDm returns the relay-assigned channel id from the OK response
  (`response:{channel_id}`); BuzzNewDmViewModel opens the chat from it instead of
  polling 41001.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 17:46:05 +00:00
David KasparandGitHub 9380de7813 Merge pull request #3672 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-22 17:53:10 +01:00
vitorpamplonaandgithub-actions[bot] 8247d49afa chore: sync Crowdin translations and seed translator npub placeholders 2026-07-22 16:46:03 +00:00
Vitor PamplonaandGitHub 4eb1231270 Merge pull request #3669 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-22 12:43:23 -04:00
Vitor PamplonaandGitHub 70bcbd0bdb Merge pull request #3666 from davotoula/fix/addressable-author-loader-cme-alternative
Stop ConcurrentModificationException in AddressableAuthorRelayLoaderSubAssembler
2026-07-22 12:43:16 -04:00
Vitor PamplonaandGitHub 5cfb7c3454 Merge pull request #3670 from vitorpamplona/claude/ci-android-test-report-plugin-k9p0kb
Replace abandoned Android test report action with mikepenz/action-junit-report
2026-07-22 12:42:34 -04:00
davotoulaandgithub-actions[bot] adf9b68c7a chore: sync Crowdin translations and seed translator npub placeholders 2026-07-22 16:32:32 +00:00
Claude b86bc5b798 ci: replace abandoned android-test-report-action with mikepenz/action-junit-report
asadmansr/android-test-report-action@v1.2.0 is a Docker action whose
Dockerfile is `FROM ubuntu:18.04` + `apt-get install python` (Python 2).
Ubuntu 18.04 (bionic) is end-of-life, so its apt archives are now
unreliable and Python 2 has no installation candidate — the image rebuild
runs on every CI invocation, takes ~8 minutes, and has started hard-failing
the test-and-build-android job (`E: Package 'python' has no installation
candidate`). The action was last released in 2020 and is unmaintained, and
it was referenced by a movable tag rather than a pinned commit.

Swap it for mikepenz/action-junit-report (actively maintained, Apache-2.0,
JS action — no Docker rebuild), pinned to the v6.4.2 commit SHA. Use
annotate_only so it needs no `checks: write` permission and keeps working on
pull requests from forks; fail_on_failure keeps the job red when a unit test
fails, matching the previous step's behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HMtpSuyr8FBh2ZQ22BV4ZP
2026-07-22 16:25:23 +00:00
davotoula e6f6edd29b update cs,sv,de,pt 2026-07-22 17:23:43 +01:00
davotoula 6ed955f0cf docs: language updates 2026-07-22 17:23:04 +01:00
Claude 2c1ec97bb4 refactor(buzz): single /invite/ substring gate in RichTextParser
Both the Concord and Buzz invite checks re-scanned the word for "/invite/".
Fold them under one guard — the two shapes stay disjoint (Concord carries a
`#` fragment, Buzz does not), so only one branch decodes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 16:00:29 +00:00
Vitor PamplonaandGitHub 52ce590505 Merge pull request #3668 from vitorpamplona/claude/quartz-jvmtarget-17-v0g1u3
Downgrade JVM target from 21 to 17 in quartz module
2026-07-22 11:44:46 -04:00
Claude 5f954c0e04 build(quartz): target JVM 17 instead of JVM 21
Lower the quartz module's Kotlin jvmTarget for both the JVM and Android
compilations from JVM_21 to JVM_17, broadening the range of runtimes that
can consume the published library.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1Hn61gQJ1joUznESzUHU4
2026-07-22 15:42:02 +00:00
Claude 7aca3da631 feat(buzz): intercept Buzz invite links into the in-app window.nostr browser
A Buzz workspace invite (`https://<host>/invite/<token>`) is redeemed over HTTP,
and the Buzz web app already drives that flow (policy consent + NIP-98 claim)
through `window.nostr`. Amethyst's existing in-app browser (NappletBrowserActivity
via FavoriteAppLauncher.launchUrl) injects an origin-scoped window.nostr backed by
the user's signer into any https origin — so routing Buzz invites there lets the
SPA claim membership as the user's key, with no native re-implementation of the
consent UI.

Mirrors the Concord invite intercept, at all three entry points, keeping every
other link external by default:
- Deep link: AndroidManifest intent-filter for *.communities.buzz.xyz/invite/ +
  a `buzzInviteRoute()` branch in MainActivity.uriToRoute.
- Tapped in-content link: a BuzzInviteLinkSegment classified in RichTextParser
  (commons) + a ClickableBuzzInviteLink that routes to Route.BuzzInvite.
- Search bar: a branch in SearchBarViewModel.directRouteResolver.

Route.BuzzInvite → BuzzInviteScreen confirms the workspace (host + role parsed by
the quartz BuzzInviteLink), marks its relay as a Buzz dialect, and opens the
in-app browser at the invite URL to finish joining. The matcher is host-agnostic
(BuzzInviteLink.parse), so self-hosted Buzz invites intercept via tap/search even
without a manifest filter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 15:37:37 +00:00
Vitor PamplonaandGitHub 6440445938 Merge pull request #3667 from vitorpamplona/fix/video-decoder-budget-and-aspect-ratio
fix(video): decoder-budget exhaustion and black bars on live streams
2026-07-22 11:35:53 -04:00
Vitor PamplonaandClaude Opus 4.8 edf30489d0 fix(video): size the player box so live streams stop rendering black bars
A live stream opened for the first time drew ~90px of black above and below
the picture. The video surface itself was correct 16:9; the box enclosing it
was not. Measured on a Pixel 9 emulator: container [0,274][1080,1062] (788px
= StreamingHeaderModifier's 300.dp cap) holding a TextureView of
[0,364][1080,972] (608px = 16:9 at 1080 wide), centred, so (788-608)/2 = 90px
per side.

Two independent causes, both needed fixing:

ContentWarningGate takes a `modifier` but drops it for anything not flagged
sensitive — the non-sensitive path emits `content()` bare. ZoomableContentView
was routing mediaSizingModifier() through exactly that parameter, so for
ordinary media the sizing never reached the layout at all. With no height
constraint the player stretched to whatever ceiling enclosed it and
letterboxed the frame inside. Apply the sizing to the inner Box, which is
always emitted.

Even applied, the ratio was unknown on a first play: a NIP-53 stream carries
no imeta `dim`, and MediaAspectRatioCache is only filled once the decoder
reports a size. The miss was frozen for the whole visit because the cache was
a plain LruCache read during composition, which triggers no recomposition when
it later fills — hence the bars vanishing only on a *second* visit to the same
stream. Back cache entries with snapshot state so a composition-time read
updates, and default an unknown video to 16:9 so the first layout already
lands in the right place.

VideoView keeps reading the cache inside remember() on purpose, with a comment
explaining why: making it observable there flips the ratio mid-playback, which
both adds an aspectRatio and emits an extra Spacer, and restructuring children
around a live AndroidView strands the player on a stale surface — the video
redraws at native size in the corner while layout bounds still look correct.

Verified on a cold cache: container and TextureView are both
[0,274][1080,882], against a header ending at 274 — zero gap. Feed image and
video layouts unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 11:24:30 -04:00
Vitor PamplonaandClaude Opus 4.8 041a4c1c71 fix(playback): enforce the decoder budget when acquiring players
MediaCodec instances are a per-process resource with a hard per-device
ceiling — the Android emulator's c2.goldfish.h264.decoder declares
`concurrent-instances max="4"`. Past that, MediaCodec.start() fails with
NO_MEMORY, MediaCodecRenderer reports "Failed to initialize decoder", and
the video surfaces to the user as "can't load". Opening a live stream after
scrolling a few feed videos reproduced this reliably; killing the process
made the same stream play, since that released every held codec.

The device ceiling was already computed by SimultaneousPlaybackCalculator,
but only reached ExoPlayerPool as `poolSize`, which governs how many idle
players are *retained*. The acquire path was uncapped
(`coldPool.poll() ?: builder.build(context)`), and MediaSessionPool held a
hardcoded LruCache(10) of sessions, each pinning a checked-out player. So a
4-decoder device would happily hold 10.

Enforce the budget where players are handed out:

- Track live decoders process-wide, counting checked-out and warm players
  (cold ones have been stop()'d and hold none). The counter and the pool
  registry are global because PlaybackService builds one pool for direct
  traffic and another for Tor-proxied traffic; a per-pool budget let the
  app hold twice the ceiling.
- Before a cold or fresh player is handed out, reclaim headroom by demoting
  warm players to cold — own pool first, then siblings. Warm entries are a
  scroll-back cache, so they are the right thing to give up under pressure.
- Size the session cache from the same device budget, keeping the previous
  10 as an upper bound so capable devices are unaffected.

Verified on the emulator: 9 codec allocations across a session with zero
NO_MEMORY and zero decoder-init failures, where allocation #5 previously
died.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 11:24:30 -04:00
Claude bb39fb29fc feat(buzz): invite-link redemption + live-interop fixes (amy)
Validated against the production relay wss://amethyst.communities.buzz.xyz
by joining and running a full DM round-trip. Adds the join primitive and
fixes the interop gaps that live testing surfaced.

- quartz: BuzzInviteLink — parse `https://<host>/invite/<token>` (relay-signed
  base64url payload → community/role/expiry). A Buzz invite is NOT a NIP-29
  code; it is redeemed over HTTP against the tenant host. Unit-tested with a
  real token; rejects the Concord `/invite/<naddr>#…` shape (no collision).
- cli: `amy buzz join <invite-url>` — the real 3-step claim: GET /api/join-policy,
  POST /api/invites/accept-policy, then NIP-98-signed POST /api/invites/claim.
  Proven live (status: joined, role: member).
- cli: Context.publish now authenticates-then-retries on an `auth-required`
  relay (warm the connection with a pendingOnAuthRequired REQ, then re-publish)
  — the write path had no NIP-42 handling, so every Buzz write was rejected.
- cli: Buzz reads (dm list / read / console / personas) use the auth-aware
  drain (pendingOnAuthRequired).
- cli: `dm open` surfaces the relay's synchronous OK `response:{channel_id}` —
  the authoritative DM channel id (the relay assigns it; it is not polled).
- cli: `dm list` rewritten to the relay's actual discovery — kind-44100
  member-added notifications (#p=me) filtered to the kind-40099 `dm_created`
  channels. The deployed relay does NOT emit kind-41001.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 14:56:37 +00:00
Claude c79e7d361f feat(buzz): wire Buzz direct messages end-to-end (app + amy)
A Buzz DM is a relay-authoritative NIP-29 group whose h/id is a
relay-generated UUID, so its timeline reuses the whole relay-group chat
stack unchanged. This adds the missing discovery + product layer:

- commons: BuzzDmRegistry — process-wide registry fed by LocalCache from
  the relay-signed DmCreatedEvent (41001) and per-viewer DmVisibilityEvent
  (30622); tracks conversations (channel id -> participants/relay) and the
  viewer's hidden set. Unit-tested.
- LocalCache: record 41001/30622 into the registry on consume (was
  store-only).
- Account: openBuzzDm (41010), hideBuzzDm (41012), addBuzzDmMember (41011).
  The relay assigns the channel UUID and confirms via 41001 — we never
  mint it.
- Android: BuzzDmListViewModel (two-phase fetch: discover 41001/30622 #p=me,
  then fetch each DM's 39000-39003 roster so the shared composer's member
  gate passes), BuzzDmListScreen (inbox), BuzzNewDmScreen (publish 41010,
  await the 41001, jump into the shared RelayGroupChatScreen). Reached from
  a Direct Messages card on the Workspaces tab.
- CLI: amy buzz dm list/open/hide/add-member, mirroring buzz-cli.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 14:09:45 +00:00
davotoula 71620380fb update KotlinJpsPluginSettings version 2026-07-22 14:41:16 +01:00
davotoula 53a823e1b2 Code review:
- private monitor, and correct the commit() KDoc
- replace atomics handshake with a single monitor
- flag userFinder re-entrancy assumption in commit() KDoc
2026-07-22 14:41:16 +01:00
davotoula 2e4afbf75e fix: stop ConcurrentModificationException in AddressableAuthorRelayLoaderSubAssembler
`activeSubscriptions` was a plain LinkedHashSet iterated (`SetsKt.minus`) and
mutated (`clear`/`addAll`) with no synchronization, while `invalidateFilters()`
ran synchronously on whatever thread called subscribe/unsubscribe.

Those callers are genuinely concurrent: `ComposeSubscriptionManager` invokes
`invalidateKeys()` after releasing its own lock, and
`LifecycleAwareSubscription`'s 30s grace-period unsubscribe fires on a
`Dispatchers.Default` worker while composition subscribes from elsewhere.
Hence the reported `ConcurrentModificationException` on
`DefaultDispatcher-worker-70`.

This is the only member of `EventFinderFilterAssembler.group` that implements
`IEoseManager` directly; its two siblings extend `BaseEoseManager`, whose
`invalidateFilters` hands off to `BundledUpdate` and is therefore never run on
the caller's thread nor concurrently with itself.

Fix, matching that existing pattern and avoiding locks:

- Route through `BundledUpdate`. `BasicBundledUpdate` holds `isProcessing`
  under a Mutex, so only one body runs at a time — the concurrent
  iterate-vs-mutate window is gone by construction. It also moves the
  `allKeys()` scan and per-stub `getOrCreateUser` off the caller thread, which
  `ComposeSubscriptionManager` documents as "called by main. Keep it really
  fast."
- Hold the state in an `AtomicReference<Set<...>>` of immutable snapshots
  swapped with `exchange()`, plus an `AtomicBoolean` teardown flag, mirroring
  the `AtomicReference` + CAS idiom in `FilterIndex`/`BanStore`.
- `bundler.cancel()` cannot stop a body already executing (no suspension
  points), so `destroy()` flags first and an in-flight body compensates by
  releasing what it just acquired. Double-unsubscribe is a no-op.

No locks are introduced; the hot path is strictly cheaper than before.

Tested: the new concurrency test reproduces the exact production failure
against the pre-fix code (`ConcurrentModificationException` alongside the
overlap detector) and passes after. Full :amethyst suite green (941 tests).

Note the pre-existing `UserFinderQueryState` identity-equality churn is
deliberately NOT addressed here: the set-diff never converges because each run
allocates fresh wrappers. It widens this race window but is an independent
defect needing its own design decision.
2026-07-22 14:40:14 +01:00
Vitor PamplonaandGitHub c9e8cd4e0c Merge pull request #3665 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-22 09:25:40 -04:00
vitorpamplonaandgithub-actions[bot] 1c9f8f8d54 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-22 13:24:31 +00:00
Vitor PamplonaandGitHub d72d7df0a0 Merge pull request #3664 from nrobi144/worktree-feat+desktop-polls
feat(desktop): NIP-88 polls (render, vote, create) + "Polls" search facet
2026-07-22 09:21:27 -04:00
Claude c788c3caa5 feat(buzz): workspace→channels shell, forum composer, attestation persistence
- Workspace shell: BuzzWorkspacesScreen now groups joined Buzz-dialect groups
  BY RELAY into workspace→channels (a Buzz workspace IS a relay/tenant, per
  buzz-core relay_url_authority), each an expandable section with its channels
  and a + to the relay's directory (Concord-style community→channels).
- Forum composer (45001): the Threads-tab FAB opens BuzzForumPostScreen on a
  Buzz relay, publishing a ForumPostEvent (mirrors build_forum_post) instead of
  the vanilla kind-11 thread a NIP-29 relay uses.
- Held-attestation persistence: BuzzAttestationPreferences mirrors
  BuzzHeldAttestations to the device DataStore and reloads at startup,
  re-verifying each credential against its agent key (drops tampered entries).

Deliberately NOT built: job/huddle composers — jobs (43xxx) and huddles (48xxx)
have no builder in buzz-sdk (reserved kinds), so a composer would encode an
unconfirmed schema. Presence (20001) skipped per request (EventFactory collision
with GeohashPresenceEvent).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 13:06:55 +00:00
nrobi144andClaude Opus 4.8 b2adb157ff feat(desktop): NIP-88 polls (render, vote, create) + "Polls" search facet
Adds full NIP-88 poll support to Amethyst Desktop and a search content-type
filter for polls.

Polls (DesktopPollCard):
- Render kind-1068 polls in feed + thread (and reposted/boosted polls) as an
  interactive card via NoteCard's bottomContent slot.
- Vote (single-choice radio / multi-choice checkbox), re-vote ("Change vote")
  seeded with the prior selection; hide-until-voted with a "View results" opt-in.
- Tallies reuse commons PollResponsesCache; responses are fetched from the
  poll's OWN declared relays (NIP-88 relay tags) unioned with connected relays,
  so the full tally loads regardless of the viewer's relay set. Votes are
  likewise published to the poll's relays (not just broadcastToAll).
- Result row marks the viewer's own choice (border + check), tap a row to see
  its voters, footer shows distinct-voter count + deadline/ended state, and the
  voter gallery draws the viewer front-most with a ring.
- Create polls from the composer (options, single/multi, optional deadline);
  the dialog content scrolls with a pinned Cancel/Publish row; a poll requires
  a question and >=2 options.

Wiring:
- DesktopLocalCache.consume for kind 1068/1018 (response links into pollState).
- DesktopFeedFilters + FilterBuilders surface polls; feed/thread interaction
  subscriptions fetch kind-1018 responses.
- Thread + profile pass myPubKeyHex so the viewer's vote-state renders.

Search "Polls" facet:
- KindRegistry preset + alias for kind 1068 (auto-renders the filter chip and a
  NIP-50 kind filter); SearchResultsList renders poll results interactively and
  SearchScreen fetches their responses.

Also:
- Read-only accounts see results instead of dead vote controls.
- Cold-start: the response subscription re-evaluates as relays connect.
- Pull the upstream fix for the pre-existing RelayLatencyTracker.sweep
  ConcurrentModificationException (synchronized(pending)) so relay-health
  reclassify no longer crashes the UI during search.

Ripple/shaping: clickable elements clip to their shape for bounded ripple.

Tests: commons PollResponsesCache (dedup/tally/WoT sort) + DesktopLocalCache
response-linking.

Deferred (noted in review): wall-clock re-check of a poll expiring mid-view;
mention-dropdown now inside the composer scroll.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 11:09:25 +03:00
Claude 12aa9fe954 feat(cli): first-class 'amy buzz' commands for block/buzz workspaces
Thin assembly over quartz + commons (no new protocol in cli/):
- buzz post RELAY GID <text>  — publish a kind-40002 stream message (h-scoped)
- buzz read RELAY GID         — drain the recent human-visible timeline (9/40002/40099)
- buzz attest AGENT           — sign a NIP-OA OwnerAttestation offline, print the auth tag
- buzz console [--relays]     — drain kind-44200 turn metrics (#p=me), NIP-44-decrypt,
                                and aggregate via the shared commons AgentFleetAggregator
- buzz personas [--relays]    — list my kind-30175 personas (newest per slug)

Join/leave/create reuse 'amy relaygroup' (Buzz workspaces are NIP-29 groups). Wired
into Main dispatch + usage; README command table + ROADMAP updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 05:01:25 +00:00
Claude 54364731a6 feat(buzz): 'Workspaces' bottom-nav tab + hub screen
Gives Buzz a first-class navigation identity instead of hiding inside the generic
Relay Groups list. NavBarItem.BUZZ (Route.BuzzWorkspaces) is a pinnable bottom-nav
destination (added to NavBarCatalog + BottomBarCategories + the preloader when).

BuzzWorkspacesScreen is a modern hub: an Agent-Console hero card up top, then the
user's joined groups filtered to Buzz-dialect relays as cards with a colored
monogram avatar, live name/host/member-count and an unread dot, plus an inviting
empty state that routes to Browse groups. It reuses the always-on relay-group state
subscription (no per-screen fetch) and the same RelayGroupChannel the chat opens.

Also fixes the open-chat-tail test for the added 20002 typing kind
(RELAY_GROUP_OPEN_TAIL_KINDS), and documents typing + the hub in the buzz README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 04:27:59 +00:00
Claude a0d50c69f6 feat(buzz): live typing indicators (kind 20002)
Brings Buzz to typing-indicator parity with Concord, verified against buzz-core
kind.rs (KIND_TYPING_INDICATOR=20002, requires_h_channel_scope=false).

- commons BuzzTypingState: process-wide, lock-guarded channel->typist->heartbeat
  registry with stale pruning + future-clamp (6 unit tests).
- LocalCache records 20002 heartbeats into it (still no feed row; own typing
  filtered in the UI).
- RELAY_GROUP_OPEN_TAIL_KINDS requests 20002 on the open channel's live tail only
  (ephemeral, scoped to the room on screen, never the joined fleet).
- Account.sendBuzzTyping fires a throttled heartbeat to the host relay; the
  composer sends it on text change (gated to Buzz relays).
- BuzzTypingIndicator: an animated three-dot '… is typing' row above the composer
  that slides in/out and ages typists out on a timer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 04:15:50 +00:00
Claude 5d9efc800a docs(buzz): correct reaction/delete claim — Buzz ALL_KINDS accepts 7/5/9005/1984/emoji
Earlier text wrongly said Buzz has no reaction kind. buzz-core/src/kind.rs ALL_KINDS
includes KIND_REACTION(7), KIND_DELETION(5), KIND_NIP29_DELETE_EVENT(9005)/GROUP(9008),
KIND_REPORT(1984), and emoji list/set (10030/30030); requires_h_channel_scope(7) is
false. So the shared chat action sheet's react/delete/report already work against Buzz.
Only zaps (no 9734/9735) aren't relay-stored (payment still works). Typing/presence are
accepted by Buzz but not yet rendered by Amethyst.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 03:57:51 +00:00
Claude f869834e9d docs(buzz): sync README with canvas viewer + edit composer; note no reaction kind
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 02:26:43 +00:00
Claude fb7bdf3057 feat(buzz): edit composer for stream messages (kind 40003)
Closes the edit loop: Amethyst already renders 40003 edit overlays but could
not create them. ChannelNewMessageViewModel gains a Buzz edit mode
(editBuzzMessage/clearBuzzEdit) — the next send publishes a kind-40003 edit
targeting the original instead of a new 40002, kept minimal to mirror Buzz's
build_edit (h + e + content). An 'Edit' action on the message sheet is gated to
my own 40002 messages (the kind alone marks it Buzz) and threads to the composer
via an optional onWantsToEditBuzz callback through the shared chat feed
(default-null, so no other chat surface is affected). EditFieldRow shows an
editing banner with a cancel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 02:20:34 +00:00
Claude f31e7a4c0b feat(buzz): workspace canvas viewer (kind 40100)
Adds a read surface for the Buzz canvas, which was consumed into overlay state
but had no UI. BuzzWorkspaceState gains a canvasUpdates flow; BuzzCanvasScreen
(Route.BuzzCanvas) renders the newest 40100 markdown for a channel and recomposes
when a newer revision lands. Entry point is a Dashboard icon in the relay-group
top bar, shown only on a Buzz-dialect relay once a canvas has arrived.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 02:12:17 +00:00
Claude 4a4baf6a23 feat(buzz): NIP-OA agent auth at connect (hold + inject auth tag)
Tier 1 connect path. BuzzHeldAttestations (commons) stores the OwnerAttestations
this device received, keyed by the agent pubkey each authorizes (verify-passing
only). AuthCoordinator.buzzAugmented appends the owner-signed auth tag to an
account's NIP-42 AUTH event when that account's key has a held attestation and
the relay speaks the Buzz dialect — and only that account's AUTH, never the
Concord stream-key AUTHs sharing the template, and never on non-Buzz relays.
So an un-enrolled agent key gets virtual membership while its owner stays a member.

AgentAttestationScreen gains a 'Hold an attestation' section (paste the auth tag
JSON, verified against the current account, stored/removed) alongside the existing
owner-side issuance. Store is in-memory for now — persisting per-account is a
follow-up. 4 store tests added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 01:34:56 +00:00
Claude bce544e8c3 feat(buzz): persona create/edit from the agent console
Tier 3 write flow. AgentPersonaEditScreen + AgentPersonaEditViewModel publish
a Buzz Agent Persona (NIP-AP kind:30175) to the workspace's Buzz-dialect relays
(falling back to the owner outbox). The Personas tab gains a 'New persona' row
and each card is tappable to edit.

On edit the existing PersonaEvent is loaded from LocalCache so fields the form
does not expose (name pool, respond-to allowlist, parallelism) are preserved
rather than dropped on republish; the slug (d tag) is locked once chosen and
validated against the relay's grammar (^[a-z0-9][a-z0-9_-]{0,63}$).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 01:28:30 +00:00
Claude 0ea5d0e876 feat(buzz): widen group REQ to richer kinds + kind-aware renderers
Read-path interop. Two changes:

1. RelayGroupFilterBuilders: BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS now also
   requests canvas (40100), forum posts/votes/comments (45001-45003), agent
   jobs (43001-43006), and huddle lifecycle (48100-48103) — all h-scoped, so
   the same #h group REQ returns them. Previously only 40002/40003/40008/40099
   were requested, so LocalCache could consume these kinds but they never
   arrived for a group feed.

2. RenderBuzzNotes: kind-aware rows dispatched from ChatMessageCompose —
   RenderBuzzDiff (40008: monospace, horizontally-scrollable diff with a
   repo/commit/file header instead of prose-wrapped gutters), RenderBuzzActivityRow
   (job + huddle lifecycle as centered system narration — huddles MUST be caught
   here since their content is JSON), and RenderBuzzForumVote (45002). Forum
   posts stay as plain chat bubbles (their content is already plain text).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 01:23:26 +00:00
Claude 5137b92046 feat(buzz): NIP-OA attestation issuance in the agent console
Adds AgentAttestationScreen, reached from an "Attest" FAB on the console.
The owner enters an agent pubkey (npub/hex) and optional AttestationConditions
(kind, created_at before/after), and OwnerAttestation.sign() produces the
signed auth tag, displayed for copy to hand to the agent operator out-of-band.

Entirely offline (nothing is published) and gated on a raw private key: the
NIP-OA signature covers a hashed commitment rather than a Nostr event, so
NIP-46 bunker / NIP-55 external-signer accounts get an explanation instead of
the form. Inputs are validated (kind 0-65535, unix bounds 0-u32, npub/hex key,
no self-attestation) with human-readable errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-22 00:36:15 +00:00
Vitor PamplonaandGitHub 72b8df7f6d Merge pull request #3663 from vitorpamplona/claude/sqlite-query-performance-bh2j27
SQLite query scaling: FTS contentless, tag-merge, pooled statements
2026-07-21 19:38:22 -04:00