Native targets reject a comma inside a backticked name, so five tests
that read fine on JVM broke every Kotlin/Native build. Renamed without
them.
The override parameters now match RelayConnectionListener — pingMillis,
compressed, cmdStr, cmd, msg, errorMessage — which silences six warnings
and, more to the point, fixes a misreading: onConnected's second and
third parameters are the connection's ping and whether it is compressed,
and I had them named attempt and success.
Both were missed the same way: jvmTest passes without ever compiling the
native TEST sources. All five targets now compile, main and test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
commonMain, so it compiled on JVM and broke every native target. Sorted
into a LinkedHashMap instead, which is the same output everywhere.
Found by CI on iosSimulatorArm64 because I had only compiled the JVM
target locally; all five now build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A monitor normally probes — opens connections purely to measure, then
throws them away. A client that is already subscribing, fetching and
publishing has better data for free: measured under real load, against
the relays it actually uses, at the concurrency it actually runs.
RelayObserver is a RelayConnectionListener, so it sees every connection
whichever code path opened it and none of them has to report anything:
rtt-open onConnecting to onConnected
rtt-read first REQ to its EOSE
rtt-write first EVENT to its OK
reachable it opened, or served something
auth-required it sent AUTH, or CLOSED saying so
the error, verbatim, when it never opened
Everything is OBSERVED. Nothing is copied from a relay's NIP-11: that is
the relay's own claim, available to anyone who asks, and republishing it
under a monitor's signature adds nothing but a chance to go stale. Where
the two disagree — a relay advertising open reads that then challenges
us — the observation is the half worth having, and copying the claim
would erase it. It also keeps quartz free of an HTTP dependency.
RelayMonitor is the whole wiring: construct one and connections are
measured, signed as 30166s on an interval, and folded into a cheap
in-memory isKnownDead for picking relays. That read has to be cheap — an
outbox picker runs per event — so it answers from a snapshot refreshed on
an interval, never a store query.
The signer is required. Measuring relay quality and letting others check
it IS NIP-66, and an optional signer would just add the failure mode this
library keeps designing out: configured, silent, doing nothing. A client
that should not publish does not construct one.
RelayObserver also replaces the CLI's RelayDiagnostics, which was the
same listener minus the timings. Porting it surfaced a bug both shared:
substringBefore(':') returns the WHOLE string when there is no colon, so
a relay's free-form CLOSED prose became its own tally key and the map
grew with the number of distinct sentences relays wrote. The colon is
now required.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
javap showed the hexToByte/byteToHex field re-loaded on every use inside
these methods (16 times per readLong call) — the JVM/ART doesn't reliably
prove the load loop-invariant. Hoisting it into a local measured ~25%
faster for decode and ~10% for isEqual and readLong on the JVM
(4096 random 32-byte ids, best-of-150 rounds, 3 repeats); encode was
neutral on HotSpot but is hoisted too since ART is historically worse
at this (see the internalIsHex comment).
Branchless variants of isHex/isHex64 were also measured and were a
wash-to-slightly-worse than the branchy early-exit versions on valid
input, so those keep their current implementations.
Also adds the new exact-size codecs to the on-device HexBenchmark
(decode64, decode64OrNull, encode64, decode128, encode128, toLong256,
and the old isHex64+decode two-pass for comparison) so ART numbers can
be collected with the existing benchmark harness.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hwv6XwT9mwGUQc57zH4ky4
javap on the previous version showed the hexToByte field re-loaded
twice per iteration, the trip count as a runtime parameter, and the
whole loop wrapped in an exception table (only needed because chars
above 0xFF overflow the 256-entry lookup table).
Now the table is hoisted into a local, the function is inline so the
32/64-byte length becomes a compile-time constant at each call site,
and out-of-range chars are rejected branchlessly: the index is masked
with 'and 0xFF' so it cannot overflow, while '255 - code' goes negative
for any char above 0xFF and is folded into the same sign-bit
accumulator that already catches invalid hex digits. No try/catch, no
exception table, no branches in the loop.
Measured on the JVM (4096 random ids, best-of-200 rounds, two runs
with variant order reversed to rule out JIT profile artifacts):
~25% faster than the previous version and ~2x faster than the
isHex64 + decode two-pass combination.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hwv6XwT9mwGUQc57zH4ky4
Adds exact-size codec entry points to the Hex utility so 32-byte
pubkeys/event ids (64 chars) and 64-byte signatures (128 chars) with the
wrong size or invalid characters are rejected instead of silently
decoded:
- decode64 / decode128 throw IllegalArgumentException; the OrNull
variants return null for untrusted input.
- encode64 / encode128 require exactly 32 / 64 input bytes.
The decode is single-pass: character validation is folded into the
decode loop via a sign-bit OR-accumulator (the lookup table yields -1
for invalid chars), so it is faster than the isHex64 + decode
two-pass combination.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hwv6XwT9mwGUQc57zH4ky4
The previous commit guarded EOSERelayList with a lock, which put the wrong path
under it. addOrUpdate runs on **every live event** — SubscriptionListener.onEvent
calls newEose for each one, hundreds per second across a few hundred relays — so
that was one monitor for every event arriving in the app.
Almost none of those events change the map. MutableTime exists precisely so a
known relay only bumps a Long inside its own entry; the map is structurally
written on the *first* frame from a relay, plus remove() and clear(). A couple of
hundred writes for the lifetime of the process, against millions of reads.
So the map is copy-on-write behind @Volatile: replaced wholesale under the lock,
never mutated in place after publication. Readers take nothing. The per-event
bump takes nothing. Only a relay's first frame pays, and it pays a map copy of a
few hundred entries, once.
The bump itself stays unsynchronized, which is deliberate: two socket threads
racing updateIfNewer can leave the older timestamp, and this value is a floor for
`since`, so losing a millisecond re-asks for a couple of events rather than
skipping any. Documented on the method rather than fixed with an atomic that
would cost a barrier per event.
Verified: iOS, JVM, Android, desktop and the full suite build and pass. Cold start
on emulator-5554 — no fatal exceptions, no ConcurrentModificationException, 0
nos.lol refusals, 182 relays connected and the purpose breakdown populated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOSERelayList backs SingleSubEoseManager with a plain mutableMapOf, and
addOrUpdate is called from SubscriptionListener callbacks — i.e. from each
relay's own socket-reader thread. A client holding a few hundred relays therefore
had that many concurrent writers to one unsynchronized map. EOSEAccountFast wraps
its lists in a lock for exactly this reason; a bare list handed to
SingleSubEoseManager had nothing.
The race predates this branch but the branch made it load-bearing: both merged
managers (notifications and account metadata, across every logged-in account and
every relay they read) now run through SingleSubEoseManager, and the EOSE refetch
fix added a second writer in remove(). Writes are serialized with KmpLock, the
same primitive ComposeSubscriptionManager uses.
Reads still go through the live map from since(), deliberately — but the two
merged managers no longer depend on that. They were reading `since` immediately
after clearing a relay and relying on the mutation being visible through it, so
hardening since() into a snapshot later would have silently disabled the refetch
with no test to notice. Growth now zeroes the cursor for that pass explicitly, in
addition to clearing it.
Verified: both iOS targets, JVM, Android, desktop and the full suite build and
pass; a cold start on emulator-5554 shows no fatal exceptions, no
ConcurrentModificationException, and 0 nos.lol refusals.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Dispatchers.IO` is an internal member in common code; the public form on
Kotlin/Native is the `kotlinx.coroutines.IO` extension property. Without that
import the member resolves on JVM and Android and fails only on iOS, which is
precisely what commons' compile-only iOS spike exists to surface.
MergedTopFeedAuthorListsState moved into commonMain in 3f4723437e and was the one
file that came across without the import — the other 22 commonMain users of
Dispatchers.IO already have it, and the only other three files that mention it do
so in comments. One error, one file, one missing import.
Reproduced locally with :commons:compileKotlinIosSimulatorArm64 before fixing;
both iOS targets, JVM, Android and the full test suite pass after.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The service layers were being re-enabled level-triggered. Before this branch the
inner flow was a Boolean behind distinctUntilChanged, so enableServiceLayers()
ran once per real transition. It now carries (all accounts, participating
accounts), and Account has no equals — so the flow re-emits whenever the account
map changes identity, and the outer collector restarts on every
foreground/background crossing, which includes every screen-off.
That matters because ServiceWatchdogManager.schedule() calls setInexactRepeating
with FLAG_UPDATE_CURRENT and a first fire of `elapsedRealtime() + 5min`. Every
call replaces the alarm and pushes that first fire out again. Screen-on happens
far more often than every five minutes, so L5 — the layer whose entire job is
noticing a dead service and restarting it — would effectively never have fired.
NotificationCatchUpWorker was unaffected: it enqueues with
ExistingPeriodicWorkPolicy.KEEP, so repeat calls leave the existing period alone.
Enable/disable is edge-triggered again, explicitly this time rather than as a
side effect of what the upstream flow happens to emit.
Also drops ActiveSubscriptionsState.busiestPurposeFilters, which has no readers —
the cards draw their share against attributedFilters.
Verified on emulator-5554: after a cold start, HOME, and return, the foreground
service is still running and the watchdog alarm is still registered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The wallet was the only account-level subscription whose lifetime was decided by
the model. CashuWalletState collected relay sets on the Account's own scope and
called subscribe/unsubscribe itself, so it ran for every Account object that
happened to be resident — including accounts loaded purely so pushed gift wraps
could be decrypted by their owner, which have no wallet anyone is looking at.
Gating that with a subscribedAccounts flow made it worse: a model object read the
relay layer's bookkeeping ("is this pubkey REQ-ing anywhere?") to decide whether
to talk to relays, and encoded a proxy for the rule rather than the rule. It
happened to work only because the registry mounts every account in the
foreground.
CashuWalletEoseManager is a PerUserEoseManager in the account group, exactly like
NwcNotificationsEoseManager already was. Start and stop now come from the same
mounts as notifications, DMs and gift wraps — the screen's for the account on
show, the registry's for the rest — and will follow whatever the
foreground/background rule becomes without knowing about it. Per user, never
merged: each wallet reads its own outbox for its own events and its own inbox for
nutzaps addressed to it.
commons keeps the query shape as a plain cashuWalletFilters() function and loses
the CashuWalletFilterAssembler wrapper. subscribedAccounts disappears entirely —
from Account, AccountCacheState, AppModules, both AccountViewModel previews, both
androidTests, and AccountFilterAssembler itself.
Verified on the wire, not just on the screen: with REQ logging temporarily on,
134 REQ frames carried kind 17375 across 9 relays naming all 4 logged-in accounts,
and 90 carried kind 9321. The subscriptions screen agrees — Wallet and Nutzap
Inbox under each of the four.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A merged filter keeps one EOSE cursor per relay. That is right while the set of
accounts it covers is stable and silently wrong the moment it grows: the account
that joins inherits a cursor it never earned, so `since` skips everything older
and its history is never requested. The subscription looks healthy, reports EOSE
and delivers new events forever after — it just never backfills.
This is the normal startup path, not an edge case. The account on screen mounts
from Compose and EOSEs within a second or two; the registry brings the rest in a
moment later, onto relays that have already reported EOSE. Account switching and
mid-session login hit it the same way.
It bites metadata harder than notifications. Notifications have a backward pager
that is deliberately still per-account and can recover the gap. Metadata has
none, so an account that joined mid-session would go without its own profile,
follows and lists until the next launch cleared the in-memory cursor.
MergedAuthorTracker records which accounts each relay's filter last covered and
reports growth; both merged managers drop that relay's cursor when it grows, so
the next filter asks from scratch. Growth only: an account leaving takes nothing
with it, and the accounts that remain already have their events.
Verified: MergedAuthorTrackerTest covers first-sight, join, no-op, leave, swap,
per-relay independence and clear — mutation-checked by making the predicate
return false, which fails three of the seven. On emulator-5554 all four accounts
keep their Notifications, DM Inbox and Account's data, and nos.lol refusals over
three clean cold starts were 0/0/7 against 8 before, so the extra refetch costs
nothing measurable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three findings from reading the branch end to end:
- AccountNotificationsEoseFromInboxRelaysManager carried a `pubkeyOf` helper with
no callers and a comment inventing a reason for it. Removed.
- `limit = 1 * pubkeys.size` said the multiplication out loud for no reason.
- The two merged managers scale their limits differently and nothing said why.
Metadata multiplies by account count, notifications does not, and that is
deliberate: metadata is replaceable so its limit is a safety bound, while
notifications are a stream whose limit is a page size — scaling would ask 4x
the data on every cold start and relays clamp it anyway. Documented, along with
the consequence (a busy account can crowd a quiet one out of the shared
newest-N) and what recovers it (the history pager, left unmerged).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every producer of FOLLOW_LISTS is a browse filter on the Follow Sets feed —
FilterFollowSetsBy{Authors,Community,AllCommunities,Geohash,Hashtag} and Global,
all under discover/nip51FollowSets. Nothing account-level assigns it: the
account's own follows ride ACCOUNT_DATA with the rest of its lists. So the
`runsInBackground = true` described a producer that does not exist.
The flag has no code consumer — isWorthNamingInNotification keys off `group`, not
this. It is read by whoever is looking at a backgrounded breakdown and asking
which of these purposes is allowed to be there, and the enum says a
`runsInBackground = false` purpose appearing while backgrounded is a leak. A
browse feed claiming to be allowed is exactly the entry that would get waved
through.
Nothing behaves differently. It stops the taxonomy lying to the next person who
uses it to diagnose one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the hex-string cache key with a small CacheKey holding the raw
(privateKey, pubKey) references — no byte copy and no ~400-byte hex String per
lookup. The precomputed Int hash is only a bucket selector; equals() does the
authoritative full-content comparison, so collisions share a bucket and are
disambiguated rather than returning the wrong peer's secret. Same value-type
immutability contract already relied on by X25519KeyPair as a map key.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PY7kpiTcFiDshYqBsQ5KVC
Two hardening fixes in the NIP-44/NIP-04 encryption primitives:
1. SharedKeyCache keyed conversation/shared secrets by a 32-bit polynomial
hashCode of (privateKey || pubKey). Distinct peers whose bytes hash to the
same Int collided on one LRU slot, so get() could return one peer's key for
a message meant for another — a silent wrong-key encrypt/decrypt. The hash
collides independently of the private key, so a pubkey aliasing a victim's
contact is grindable. Now keyed on the full (priv || pub) byte content.
Added SharedKeyCacheTest, which fails on the old hashCode key and passes now.
2. NIP-44 MAC verification (Hkdf.fastExpand and Nip44v2.checkHMacAad) used
ByteArray.contentEquals, which short-circuits on the first mismatching byte
and leaks a timing side channel on the HMAC tag. Switched to a shared
equalsConstantTime helper (utils/ConstantTime.kt), matching the constant-time
tag checks already used in ChaCha20Poly1305/XChaCha20Poly1305/MLS.
Also corrects the X25519 KDoc, which claimed the JVM/Android actual delegates to
java.security XDH; it is in fact the same pure-Kotlin Montgomery ladder as the
other targets.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PY7kpiTcFiDshYqBsQ5KVC
"Observing Profiles" showed up for accounts that had no screen at all, which is
the one thing that purpose is explained as never being: "profiles of the people
currently on screen".
ContactCardFilters has two builders sharing SubPurpose.PROFILE_METADATA, and only
one of them observes anything. filterContactCardsToTargetKeysFromTrustedAccounts…
fetches kind:30382 cards ABOUT the users on screen — that one is right.
filterContactCardsByAuthorInTheRelay fetches the account's OWN nicknames in the
login-time bulk download, from the account's own relays, with nobody on screen;
its own KDoc said as much while the purpose said otherwise.
It rides filterAccountInfoAndListsFromKey, so every logged-in account carried one
of these per relay — Dr Martha Liz's two relays showed as "Observing Profiles ·
2 filters · 2 relays" for an account nobody was looking at. It is account data,
and now says so.
Verified on emulator-5554: only the account on screen still reports Observing
Profiles; the other three carry Account's data alone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fills the on-disk gaps in the four target locales: channel archive/unarchive,
the new-forum title, the External Content screen title, the archived channel
section header, the three share targets (picture / short / video) and the URL
preview's open-in-browser action.
Wording follows each locale's existing siblings rather than being translated
fresh -- url_preview_open_in_browser reuses the string already in
git_repo_open_in_browser, share_target_as_picture follows new_picture, and
share_target_as_short_video follows home_content_type_shorts.
relay_group_section_archived heads a channel list, so cs/sv/pt take the plural
adjective.
Keys whose translation would be byte-identical to the English source are left
out on purpose: Crowdin strips source-identical entries on export and Android
already falls back to values/strings.xml at runtime. That covers the "workflow"
loanword in cs/de, and Highlights / Podcasts / Reposts / Torrents / Videos /
Name / Backlog in de, plus Podcasts / Torrents / Backlog in pt-BR.
The commons composeResources tree was already complete for all four locales.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WopdqNoZ9tYoMNppJG17BL
The row sits under an account header that already names whose it is, and with
several accounts on screen "Your Account's data" repeated under someone else's
name reads as a contradiction.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Your Account's data" fell into "Not attributed" one commit ago. None of the
metadata builders ever named their own account — they relied on
PerUserEoseManager stamping `attributedTo(pk)` over whatever they returned. Moving
the manager to SingleSubEoseManager took that away, because that base class
cannot attribute: one of its filters may serve several accounts, so there is no
single pubkey to stamp.
So the builders name themselves now, which is where the knowledge actually is.
Every filter in this package fetches an account's OWN profile, lists, bookmarks
and recent posts, so `authors` and the accounts asking are the same list.
The one exception is filterBasicAccountInfoFromKeys, which fetches OTHER people's
profiles for the account-switcher avatars: there the authors are precisely not
the requester, so it takes `requestedBy` and is attributed to that instead. Its
parameter carries the reason, since it reads like an inconsistency otherwise.
Notifications were unaffected — those builders already set accountPubKeys
themselves, which is why only this purpose lost its account.
Verified on emulator-5554: "Your Account's data" appears under all four accounts
again, and the unattributed count is back to 8 filters.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Account metadata was the largest single contributor to blowing a relay's
subscription cap: seven filters in a subscription, repeated once per logged-in
account. Every one of them is `authors`-keyed, so a relay that several accounts
read from can be asked about all of them by widening `authors` — the same shape
of merge the notification tail just took, and for the same reason.
The per-account `limit`s are summed rather than shared. These are mostly
replaceable events, so the limit is a safety bound rather than a page size, and
scaling it by the number of accounts leaves each one exactly the headroom it had
alone. That is the difference from gift wraps, which stay per-account because
their `limit` IS a page size over unsolicited content.
Measured on emulator-5554, four accounts, cold start, counting nos.lol's
`ERROR: too many concurrent REQs`: 13 before either merge, 11 after
notifications, 8 after this. The subscription count on that relay went from
24-26 to 23, against its cap of 20.
So the per-account multiplication is no longer the driver, and the remaining 23
are not account-level at all — they are the feed, channel and finder assemblers.
Cutting further means looking there, not at more merging.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every account-level loader is a PerUserEoseManager, which opens one subscription
per user. With four accounts open that multiplies, and shared relays run out of
room: nos.lol (strfry, `max_subscriptions: 20`) answered `ERROR: too many
concurrent REQs` — 0 times before this branch, 7 with three accounts subscribed,
13 with four. The refusal arrives as a NOTICE, which carries no subscription id
and never reaches RelayReqRefusals (wired only to CLOSED), and "too many
concurrent REQs" matches none of its markers. So the relay drops those REQs while
we still believe they are live: they never EOSE and never deliver.
Notifications are `#p`-scoped, so a relay serving several accounts can be asked
about all of them in one filter naming every pubkey — same query, wider tag. The
manager moves to SingleSubEoseManager: one REQ per relay instead of one per
(account, relay), with the `since` floor taken per relay as the OLDEST of the
participating accounts' floors so widening for one can never cut another short.
Gift wraps deliberately do NOT merge. They are unsolicited and opaque to the
relay, so it cannot rate-limit them per recipient; a merged query would let one
spammed account eat the shared `limit` and starve every other account's DMs.
Each account keeps its own budget there.
A merged filter serves several accounts, so accountPubKey becomes accountPubKeys.
The subscriptions screen shows such a filter under each account it serves —
"why is this relay busy for me" has to be answerable per account — which makes
the per-account counts a breakdown of a shared filter rather than a partition of
the total. attributedFilters is that sum, and is what a card's share is drawn
against now; dividing by the wire total would read as 100% for each of two
accounts sharing one filter.
Measured, and it is only part of the answer: nos.lol carries 24-26 subscriptions
against its cap of 20, and this removes 3 of them. The rest are the other
per-account managers — account metadata alone is 7 filters in a subscription per
account, and gift wraps another. Merging metadata (it is `authors`-keyed and
merges the same way) is the next lever; this commit does not get us under the cap
on its own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ServiceType.parse destructured the result of split(":", limit = 2) into
two components, so any value without a colon (e.g. the "client" of a
["client", "nostria"] tag) threw IndexOutOfBoundsException instead of
returning null. It also accepted "30382:" as an empty service type.
ServiceType.isOfKind had the same class of bug: it read
serviceType[kind.length] after startsWith, which is out of bounds when
the value equals the kind exactly ("30382" vs "30382").
Parse the separator by index and check the length before indexing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112ysMEzSaezH9mXFteNt13
"Keep this account active in the background" gated subscriptions everywhere,
not just in the background. An account you had not opted in for showed no
notifications and no DMs even with the app open in front of you — you had to
switch to it and wait for its subscriptions to mount from scratch. The setting's
name only ever promised something about being away.
So the rule now matches the name. While any activity is STARTED, every loaded
account pulls its own notifications, DMs and gift wraps: the user can switch
accounts at any moment and expects the one they land on to be current, and this
costs nothing once the app is away because it ends with the screen. When the app
goes away, the set narrows to the accounts that opted in — which is the only
thing the flag decides now.
The service layers stay where they were, gated on the master switch AND somebody
having opted in. A foreground-only account must never start a foreground service
that outlives the screen that wanted it, so those two questions are answered from
one snapshot of accounts + flags rather than two.
The registry loses "Background" from its name along with the assumption: it
mounts exactly the set it is handed and decides nothing, so the rule lives in one
place.
Verified on emulator-5554 with 4 loaded accounts, 1 opted in. Foreground: 4
mounted, and the account that had no section on the subscriptions screen at all
now shows Notifications 8 filters / 2 relays plus DM Inbox. On HOME: 3 mounted
(-1), releasing the one that never opted in.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
contactMetaData() decodes kind-0 content with the lenient JsonMapper, but
contactMetadataJson() ran the strict default parser. Profiles that sit in
that gap (bare keys, unquoted values) rendered fine yet read back as null,
so updateFromPast() started from an empty map and silently dropped every
field Amethyst does not edit itself the next time the owner touched their
profile — the exact thing its "tries to not delete any existing attribute
that we do not work with" contract promises not to do.
Both accessors now use JsonMapper.jsonInstance. The lenient reader tags
unquoted tokens as strings, so re-encoding still emits valid JSON.
Also pins the behaviour of two malformed kind-0s seen in the wild — a
JavaScript object literal and a value truncated by stray quotes. Neither
is recoverable by any parser; the test records that they are dropped with
a warning and never throw.
The Active Subscriptions screen reported two different things as "filters".
The header counted filters; a purpose card summed its per-entity rows. Those
only agree when every filter names exactly one entity — and the busiest ones
name many, because batching is the whole point of them: one `#e` filter per
relay carrying every followed chat, one `#d` filter per host relay carrying
every joined group.
So six chats on six relays read as 144 filters where 24 were on the wire, and
"8% of all" divided the inflated number by the real one. The screen exists to
answer "why do I have this many subscriptions", and it was overstating its
loudest purposes by exactly their batching factor — the opposite of the job.
A purpose now tallies each filter once as it is scanned, before the fan-out.
The per-entity rows stay, because "which chats is this relay serving" is worth
seeing, but they are a breakdown rather than a total: the field is renamed to
namedInFilters and says in its docs that it is not summable.
The aggregation moves out of the ViewModel into a pure aggregateSubscriptions()
so the invariant is testable without a relay pool. AggregateSubscriptionsTest
pins it on the reported shape, and was mutation-checked: restoring the old
`entityRows.sumOf { … }` fails three of its four cases, the fourth being the
relay count, which never depended on it.
On device, Public Chat goes from 144 filters over 6 relays to 18 over the same
6, and Relay Groups from 116 to 96. No filter changed; only the arithmetic.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- refactor(location): dedupe test fixtures and simplify the gate internals
- test(location): assert the release, not just its ledger side-effect
- test(location): cover the LocationState -> LocationFlow -> RefCountedSession seam
Commit 4f1bd6e0c2 left the six public-chat discovery producers untagged and
justified it as "they search for chats rather than serving known ones, so there
is no entity to name". The first half is right and the conclusion does not
follow: having no entity is not the same as having no explanation. Every one of
those filters is built from a top-nav selection — Global, your follows, a
hashtag, a geohash, a community — which was known where the filter was built and
simply had nowhere to travel. The screen could only render them as "All", which
is the one thing they are not.
3f4723437e already moved the 25 top-nav value types into commons for exactly
this, so ExplainedFilter now carries the scope. It carries the per-relay value
rather than the whole set: the filter is already scoped to one relay, so it
holds only the slice that applies to it and no reference to the other relays'
authors. It stays a typed value rather than a formatted string because
purposeDetail already taught that lesson — text built in commons can never be
translated, so the UI matches on the type and picks its own wording.
scopedTo() stamps it at each feed's make…Filter dispatch, the last place that
still knows the selection; below it the builders have flattened it into
authors/#t/#g and it is unrecoverable. IFeedTopNavPerRelayFilterSet grew
scopeFor(relay) so that stamping is compiler-enforced across all 11 sets rather
than a type-switch that silently misses the next one added.
The screen groups these rows by scope *type*, not contents: an author-based
selection sends a different slice of the follow list to every relay, so keying
on contents would shatter "People you follow" into one row per relay — the
opposite of what the screen is for.
ExplainedFilterTest had not compiled since 4d53bbea9e renamed entityId to
entityIds, because `./gradlew test` does not run :commons:jvmTest. Repaired, and
extended to pin the new field: the scope is a slice of the user's follow list or
their chosen hashtag, and handing a relay the selection rather than the authors
it already sees would tell it which of its neighbours' filters belong together.
Verified on emulator-5554: Home Feed's row now reads "People you follow" with
its 176 relays, as one row rather than 176. The public-chat discovery producers
take the identical path but only mount while the Discover→Chats screen is open,
which this device's bottom nav has no tab for, so that specific row is unproven
on device.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An account that opted into "keep this account active in the background" got
nothing from that setting but a running service. Subscriptions were only ever
created from a composable holding an AccountViewModel, so the account on screen
was the only one talking to relays; the rest were loaded purely so pushed gift
wraps could be decrypted by their owner. On a device with no push — no Play
Services, no UnifiedPush distributor, no Pokey — those accounts pulled nothing
at all, and the setting quietly meant less than its name.
BackgroundAccountSubscriptionRegistry mounts the always-on loaders for each
participating account directly, with no view model behind them.
AlwaysOnNotificationServiceManager already watched the two flags that define
participation (the account's own toggle, or its NIP-46 signer), so it now keeps
the set instead of collapsing it to "is anyone participating" and hands it to
the registry on every change.
Running headless meant the key had to stop assuming a screen. AccountQueryState
drops to what an Account alone can supply, and AccountUiQueryState adds the feed
states for screens — the only always-on reader is the notifications cold-start
floor, which reads a feed nothing fills without UI and so could only ever be
null for these accounts. An account can now be mounted twice, on screen and in
the background; the managers dedup by user but keep whichever key arrived first,
so preferredKeys picks the screen's key and the account being looked at keeps
its floor rather than losing it to a race.
The Cashu wallet had the mirror-image bug: it subscribed from every Account's
own scope, ungated, which put a Wallet and a Nutzap Inbox on the wire for every
saved account — including accounts that never opted in and have no wallet. It
now follows AccountFilterAssembler.subscribedAccounts, the same truth both mount
paths write to.
Verified on emulator-5554 with 4 loaded accounts, 3 participating: each of the
3 gets its own Notifications and DM Inbox (12 and 3 filters for a background-only
account), filters scale per account while relays overlap, and the 4th account —
loaded but not participating — now holds no subscriptions at all where it
previously held Wallet and Nutzap Inbox.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment called it a backward-paging boundary that asks for everything
older than the feed's oldest card. It is neither: backward paging lives in
AccountNotificationsHistoryEoseManager, and `since` means newer-than, so
it floors the query at the depth the feed already reaches instead of
asking all-time again. It is also read only until a relay has an EOSE.
Records that it is always null for an account with no UI, since nothing
fills that feed — which is what makes the field inert on a headless path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The topNavFeeds package holds two layers. The value layer — the per-relay
filter sets, plain `Map<relay, filter>` of authors, hashtags, geohashes —
is pure data. The resolution layer around it (TopNavFilter, FeedFlow, the
loaders and decryption caches) evaluates feeds against the live event
graph and needs LocalCache, NoteState and the outbox loaders, so it is
app-coupled by nature and stays put.
Moving the 25 value types lets commons describe a feed selection without
depending on the app, which is what an ExplainedFilter needs if it is to
carry the top-nav scope instead of a flattened List<HexKey>. Desktop gets
them too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Public Chat collapsed into a single unnamed row because none of its
producers carried an entity id, so the screen could say how many filters
were running but not which chats caused them.
The six channel-scoped producers now tag their channel ids — including
the batched ones, which carry every channel a relay serves so the screen
fans them into a row each.
The six discovery producers (global, by author, by community, by hashtag,
by geohash) stay untagged on purpose: they search for chats rather than
serving known ones, so there is no entity to name.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tapping the always-on notification landed on whatever tab was last open,
which does not answer the question that notification raises. It now
deep-links to Active Relay Subscriptions via an `activesubs` route, and
the screen gets its own entry in All Settings.
The expanded breakdown also held its last value across reconnects. It is
derived from the *connected* relays, so a drop to zero — the "connecting"
state — emptied it and collapsed the expanded view to a single line
exactly when someone was most likely reading it. What each connection is
for does not change while it re-establishes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entity rows resolved to a short hex whenever LocalCache could not place
the id, which covered Marmot groups, Concord communities and geohash
cells — the three that most needed naming.
Each now resolves through the path its own subsystem uses: Marmot reads
the per-account chatroom StateFlows and its encrypted Blossom avatar,
Concord reads the folded Control Plane keyed on the session revision, and
geohash cells go through the app's reverse-geocode cache so a cell reads
as a place. All three route to their screen on tap.
Relay groups pivot on the host relay, expandable to the groups it carries:
a group is keyed by (id, host relay) and one community relay routinely
hosts many, so the flat list repeated one hostname seven times.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Filters carry an accountPubKey so the relay screens can group by account,
but attribution only happened for keys implementing AccountScopedQuery —
and ~50 query states held an `account` without declaring it, so the cast
failed silently and their filters showed as unattributed.
Two of the gaps were real bugs rather than display issues:
- CashuWalletFilterAssembler took `keys.first().pubkey` while flat-mapping
every account's relays, so with two wallets logged in the second was
never subscribed and its inbox relays were queried for the first
account's nutzaps. Now built per account.
- UserReportsSubAssembler unioned every account's follow list into one
per-relay map, asking one account's follows of another's outbox relays.
Now one pass per account.
Where a subscription genuinely pools accounts (outbox discovery, on-screen
event watching, profile metadata), it is attributed only when a single
account is asking rather than inventing an owner.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PUBLIC_CHATS was a catch-all carrying NIP-28 chats, NIP-29 relay groups,
ephemeral chats, geohash chats and live-stream chat under one row labelled
with the NIP-29 name — so the subscription screen could not say where any
of them came from, and the label was wrong for four fifths of what it
counted.
Each is now its own purpose with its own label and explainer. Geohash
cells carry their id too, so location chats list one row per cell and
render as a place name instead of a single opaque group.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Correct the KDoc on the Shorts and Longs composers. They claimed
everything posted from them lands in that feed; VideoPostKind only
governs videos, and the gallery picker takes images with no mime
filter, so a picked JPEG still posts as a kind-20 picture that
neither feed reads. Say so instead of asserting a false invariant.
- Forward the shared text as the composer's caption. The media targets
dropped EXTRA_TEXT entirely, so sharing a photo with a caption lost
it — while the DM target kept it. The routes now carry the message
and NewMediaModel.load() seeds the caption field from it.
- Accept SEND_MULTIPLE on the three media targets. Sharing several
files at once previously did not offer Amethyst at all, even though
the picture composer publishes N images as one kind-20 event. Routes
carry a URI list; other targets take the first.
- Replace, rather than stack, a feed entry when a second share arrives
while the first is still open. Only entries that carry attachments
are replaced, so a feed reached from the bottom bar keeps its
tab-root marker underneath.
- Rename NewImageButton to NewVideoFeedButton: it is the Video feed's
composer and handles pictures and video alike.
Adds ShareTargetManifestTest, which pins the activity-alias names in
AndroidManifest.xml to the constants ShareIntentRouting matches them
by — in both directions, plus the SEND_MULTIPLE filters. That link is
invisible to the compiler and fails silently at runtime by routing a
share to the wrong composer. Verified the guard bites by renaming an
alias in the manifest alone: three of its four tests go red.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4WsYWaNMPD34SBc6Ej6hx
The probe that catches mentions delivered to the wrong relay was subscribing to
every relay the user's follows post to — ~330 relays, ~670 filters held
permanently, by a wide margin the largest thing the client ran. Despite the class
name nothing about it was random and nothing bounded it; `updateFilter` returned
`followsPerRelay.keys - notificationRelays` in full, and a grep for
take/shuffled/random/subList across the manager found nothing.
It now watches a rotating window of 5 relays that slides every 5 minutes, so the
whole space is still swept, just never all at once. That matches what the job
actually needs: a background sweep for misdelivered mentions, while the inbox
relays carry the real traffic.
The window is a contiguous slice of a URL-sorted list, not a fresh random draw.
Re-picking at random on each invalidation would re-REQ a different set every few
seconds — the manager already invalidates on follows changes (debounced 5s) and
notification-feed updates (sampled 5s) — which would cost more than the
subscriptions it replaced. Deterministic order means the window only moves when
the rotation timer says so. The rotation job is registered alongside the existing
two and cancelled with them in endSub.
Measured on device, cold start, same account:
before NOTIFICATIONS 670 filters 168 relays
after NOTIFICATIONS 58 filters 13 relays (8 inbox + 5 sampled)
The Active Subscriptions explainer was updated in the same commit: it described
the old behaviour, and an explainer that lies is worse than none.
HOME_FEED is now the largest at 2,485 filters across 355 relays. That is the
outbox model working as designed rather than an anomaly, but it is the next thing
worth looking at.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the per-relay purpose chips with a screen whose only job is to answer
"why do I have this many subscriptions right now".
The chips were the wrong shape. Pivoting on relay hides the thing worth finding:
the notifications straggler probe holds ~670 filters across 168 relays, and on a
relay-shaped list that is one unremarkable chip repeated on 168 rows. Pivoted on
purpose it is a single line that dwarfs everything under it, which is exactly how
it was spotted in the first place.
Account is the outer grouping — several accounts are normally logged in, they do
not share relay sets, and a mixed total cannot be acted on. Purposes sort by
filter count, expand to per-entity rows, and carry an explainer describing the
actual strategy rather than the intent. Those explainers are written from the
code they describe: MODERATION says it asks each relay your follows publish to
because UserReportsSubAssembler walks declaredFollowsPerOutboxRelay, and
NOTIFICATIONS mentions the follows-wide probe because
AccountNotificationsEoseFromRandomRelaysManager subscribes to every follows relay
with no sampling.
Names resolve at render time from LocalCache and fall back to a short id — a name
captured when the filter was built would usually be missing (profiles arrive
later) and would go stale on rename.
Untagged filters are counted and shown rather than hidden. A total that claims to
be fully attributed when it is not would defeat the point of the screen.
Reached from the Connected Relays list, which is where the question occurs to
people. Notification filters now carry accountPubKey so the largest purpose
groups correctly; the remaining assemblers still report under "Not attributed to
an account" until they are threaded through.
Counts use two separate plurals composed at the call site rather than one string
with two %d, so "filter" and "relay" decline independently.
NOT visually verified: reaching the screen needs drawer navigation that was not
worth scripting. Compile-clean, tests green, and it reads the same
activeRequests data already verified on device.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds entityId and accountPubKey to ExplainedFilter so a chip can answer "which
community made me connect here, and for whose account" rather than a bare
"Communities". Both ride copy() and are covered by the existing
never-reaches-the-wire test.
Ids, not names: names change, are often not loaded when the filter is built, and
would pin a stale copy into a subscription that outlives them. The UI resolves
against LocalCache at render time.
A pubkey, not an Account reference: filters are held by the relay pool for the
session and outlive the objects that built them, so a reference would keep a
logged-out account alive. Several accounts are normally active and do not share
relay sets, so without this one account's communities read as another's.
purposeEntities() returns the (purpose, entity, account) rows a relay is serving,
which is what a per-relay or per-subscription screen needs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds three share targets next to "New Post", "Send as DM" and
"New Highlight":
- New Picture (image/*) -> the picture feed's composer, publishing a
NIP-68 kind 20 picture post
- New Short (video/*) -> the Shorts feed's composer, publishing a
NIP-71 kind 22 short
- New Video (video/*) -> the Video feed's composer, publishing a
NIP-71 video event
Until now every SEND intent landed in the kind-1 composer, so a shared
picture became a text note with a link instead of a post in the feed
the user was aiming for.
Each alias resolves to MainActivity like the existing ones, so
ShareIntentRouting now maps the launching component class to a
ShareTarget enum instead of a growing chain of isShareAsX() booleans.
The media targets navigate to the destination feed carrying the shared
content URI, and the feed's existing composer button opens on it.
Video kind is no longer guessed from orientation alone in feeds that
only read one of the two kinds: the Shorts composer always publishes
kind 22 and the Longs composer always publishes kind 21, so a post
lands in the feed it was composed from whatever the footage's shape.
The mixed Video feed and the picture feed keep the automatic choice.
Also fixes the New Post launch path passing the literal string "null"
as the attachment when a share carried no media.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4WsYWaNMPD34SBc6Ej6hx
Adds the purpose chips to the Connected Relays rows, and retires the invented
vocabulary the notification was using.
"Relay lists" and "Moderation" read as fuzzy because they were words I made up
that match nothing the user can navigate to. Every label now points at a string
the app already shows somewhere else — nav routes (Home, Discover, Messages,
Notifications, Chess), event-kind names (Reports, Profile, Follow List, Outbox
Relays, Drafts, Reactions) and feature names (Communities, Nests, Marmot Group,
Wallet). Twelve invented strings deleted; nine remain, only for jobs the app had
never had to name (Media, Hashtags, Topics, Conversation, Search, Quoted posts,
Add-ons, Relay info, Other). Those twelve also needed translating and now do not.
Checking those two labels found three real mis-buckets from the sweep's rule
ordering, all now fixed:
FilterHomePostsByAuthors RELAY_LISTS -> HOME_FEED (nip65Follows in the
FilterPictureAndVideoByAuthors RELAY_LISTS -> MEDIA_FEED path matched first)
FilterDraftsAndReportsFromKey MODERATION -> ACCOUNT_DATA (kinds = DraftKinds;
"report" in the filename)
So "Relay lists · 173 relays" was mostly the home and media feed fan-out, and
"Moderation" was counting drafts. RELAY_LISTS now covers only the outbox finder
and MODERATION only reports-about-you, which is what the words claim.
SubPurposeLabels is the single place a purpose becomes words, shared by both
surfaces so they cannot drift. Which jobs earn a notification line is now derived
from the taxonomy — the ACCOUNT and MESSAGES groups — rather than a hand-kept
list that happened to contain the same twelve.
The two surfaces stay deliberately different: the notification names only the
dozen jobs that survive backgrounding, while the relay screen someone opened on
purpose shows every job, feeds and current-screen work included.
Verified on device, notification re-read from the system:
Notifications · 168 · Reports · 164 · Follow List · 134 · Relay Chats · 19
Profile · 14 · Wallet · 11 · Communities · 8 · Messages · 6
Marmot Group · 5 · Drafts · 3 · Browsing · 165
The chips themselves are compile-verified and read from the same
already-device-verified data, but the rendered row has NOT been seen — reaching
that screen needs drawer navigation that was not worth scripting. Worth a look
before merge.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Renames SubPurpose.isBackground to runsInBackground, and puts the per-job
breakdown behind the notification's expand affordance.
The collapsed line is byte-for-byte what it was — "Connected to 188 relays" —
because that is all most people want from an ongoing notification, and this one
sits in the shade permanently. The breakdown goes in a BigTextStyle, so it costs
nothing until someone deliberately expands it to ask why their phone is talking
to that many relays. It is skipped entirely when nothing is attributed yet, so an
empty section can never render.
Verified by reading the notification back out of the system rather than trusting
the code path (`dumpsys notification --noredact`):
android.text Connected to 188 relays <- unchanged
android.bigText Connected to 188 relays
Relay lists · 173 relays
Notifications · 169 relays
Moderation · 159 relays
Your follows · 154 relays
Public chats · 21 relays
Profiles · 12 relays
Wallet · 12 relays
Communities · 9 relays
Direct messages · 6 relays
Encrypted groups · 6 relays
Your account · 3 relays
Browsing · 173 relays
Only the twelve jobs worth naming to a user get a line. Feeds and whatever screen
is open have no label and collapse into "Browsing" — they tear themselves down
once the app is backgrounded, which is exactly when this notification matters, so
itemising them would add noise precisely when nobody is interested.
The counts overlap on purpose and sum well past the relay count: a typical relay
carries four jobs at once, so there is no partition to show. The copy answers "how
many relays carry my DMs", not "how is the pool split" — which is why the earlier
sketched wording ("4 for notifications, 3 for DMs") was dropped; it implied a
partition that does not exist.
Strings are one reused plural plus thirteen labels, so the count agrees with its
noun in languages that decline it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first pass put 208 of 343 filters into SCREEN_CONTENT — a bucket covering the
whole Discover tab, every media feed, search, threads, profiles, badges, chess
and a mis-filed wallet screen. Useless for explaining anything. MODERATION ended
up with zero filters despite kind-1984 subscriptions being live, and
ENCRYPTED_GROUPS with zero because Marmot builds its filters in quartz and wraps
them later, so the sweep's `filter = Filter(` pattern never saw them.
SubPurpose is now 27 specific jobs on two axes:
- `group` rolls them up (ACCOUNT / MESSAGES / FEEDS / CURRENT_SCREEN) so a
notification can say something short while a relay screen or bug report keeps
the fine value. Adding a fine value stays cheap.
- `isBackground` marks what may outlive the foreground.
SCREEN_CONTENT is gone, replaced by DISCOVER_FEED, MEDIA_FEED, TAG_FEED,
COMMUNITY_FEED, TOPIC_FEED, THREAD, USER_PROFILE, SEARCH, ENGAGEMENT,
REFERENCED_EVENTS, ADD_ONS, GAMES and RELAY_INFO. CHATS splits into PUBLIC_CHATS,
COMMUNITY_CHATS, ENCRYPTED_GROUPS and LIVE_ROOMS. Marmot is tagged through
ExplainedFilter.of() at the wrap site, since quartz cannot see commons.
Every one of the 27 values is applied by at least one filter — checked, not
assumed. Nothing in a relay-bound path is left untagged.
Measured on device, cold start then HOME:
foreground @45s 13 purposes HOME_FEED 337 relays · NOTIFICATIONS 329 ·
RELAY_LISTS 337 · MODERATION 326 · ENGAGEMENT 20
background @90s 9 purposes all isBackground=true
Both `isBackground = false` purposes in flight (HOME_FEED, ENGAGEMENT) were gone
after backgrounding, which is the invariant worth having: a CURRENT_SCREEN
purpose alive in the background is a leak, and that is now assertable.
The same run corrected the flag's documentation. RELAY_LISTS and FOLLOW_LISTS are
marked background-capable yet absent at @90s — because those loaders had nothing
to fetch, not because they were torn down. So `isBackground` is a ceiling, not a
promise: absence proves nothing, presence of a `false` one proves a bug. The KDoc
now says that instead of implying the stronger claim.
Worth a second look before this ships: WALLET holds 21 filters across 12 relays
while backgrounded — more relays than DIRECT_MESSAGES (5) or NOTIFICATIONS (8).
That may be correct for nutzap watching, or it may be more sockets than the
feature warrants. The tagging is what makes the question askable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes the sweep: all 343 filters that reach a relay now carry a SubPurpose,
so `client.activeRequests(relay)` can answer "what is this relay doing for me?"
for every connection, not just the three assemblers wired as proof.
Scope was bigger than the "12 assemblers" first estimated — that count was files
calling newSubId(). The real target is filters wrapped in RelayBasedFilter, since
those are the ones that reach the wire: 202 files. The other ~400 `Filter(` sites
in the app query LocalCache and never leave the device, so they are deliberately
untouched.
Purposes assigned by subsystem: DIRECT_MESSAGES for the DM/giftwrap paths,
NOTIFICATIONS for the inbox filters, FOLLOW_LISTS / PROFILE_METADATA /
RELAY_LISTS for the account loaders, CHATS for public channels, relay groups,
Concord, nests and Marmot, HOME_FEED for the follow feed, WALLET for Cashu and
nutzaps, and SCREEN_CONTENT for everything opened on demand (thread, profile,
hashtag, video, polls, music, podcasts, git repos, workouts, chess).
Verified on device rather than by inspection — 369 relays attributed through
client.activeRequests() after a cold start, with no untagged relay-bound filter
left (`0` by grep). Observed distribution across relays:
FOLLOW_LISTS 921 · HOME_FEED 895 · PROFILE_METADATA 881 · NOTIFICATIONS 690
CHATS 82 · SCREEN_CONTENT 81 · WALLET 38 · DIRECT_MESSAGES 17 · RELAY_LISTS 8
which reads correctly: the outbox fan-out puts follows/feed/metadata/notifications
on almost every relay, while DMs and relay-list discovery stay on the few relays
that actually serve them, and the account's own relay carries everything.
That measurement also corrects an assumption behind the planned notification
copy: relays are NOT partitioned by job. A typical relay serves four purposes at
once, so "4 for notifications, 3 for DMs" would be wrong — per-purpose counts
overlap and sum to more than the relay count. The popup wording needs to reflect
that, which is why it is still not written here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The always-on notification says "connected to N relays" and nothing more. That
count is emergent, not curated: NotificationRelayService owns no subscriptions,
and nothing closes a socket when it stops being useful, so a relay stays
connected exactly as long as some filter still references it. Neither a user nor
a developer can tell whether those N relays are carrying DMs or re-dialling a
stale outbox hint.
The client already tracks every in-flight REQ per relay with its filters
(INostrClient.activeRequests, which the Connected Relays screen reads). A filter
says *what* is matched — kinds, authors, tags — but not *who asked*, and subIds
are random. This adds the missing half.
`Filter` becomes `open`, with `copy()` open too, and commons gets
`ExplainedFilter` carrying a `SubPurpose`. The purpose rides on the filter, so it
arrives with the data the relay screens already read — no parallel registry to
keep in sync or leak on teardown.
**It never reaches a relay.** FilterSerializer is registered against Filter and
writes an explicit protocol field list, and Jackson applies a serializer
registered for a class to its subclasses — so an ExplainedFilter serializes to
byte-identical JSON. That is the point: telling relays what each REQ is *for*
would hand them a ready-made fingerprint of client intent and correlate
subscriptions that are deliberately kept apart.
`copy()` is overridden because filters are copied on the live path — assemblers
call copy(since = …) after every EOSE. Inheriting the base implementation would
downgrade to a plain Filter on the first window refresh, so the purpose would
survive the opening REQ and vanish seconds later.
Both invariants are pinned by tests, and both were mutation-checked rather than
assumed:
- removing the copy() override -> `copy preserves the purpose` fails
- unregistering FilterSerializer -> 3 tests fail, one reporting the literal
leak: `purpose leaked to the wire: {…}`
A test also asserts FiltersChanged does not see the new field, so tagging a
filter cannot trigger a re-REQ storm across ~400 relays.
Wired three assemblers as proof (metadata, reactions, outbox finder) and surfaced
the derived set on BasicRelaySetupInfo.purposes for the Connected Relays screen.
Verified on device: 8 relays attributed RELAY_LISTS through
client.activeRequests() — purpleplag.es, user.kindpag.es, indexer.coracle.social,
directory.yabu.me and friends, which is semantically right — across 2,709 sent
REQs containing zero occurrences of "purpose" or any SubPurpose name.
The remaining assemblers are untagged, which is why `purposes` is documented as
"not yet attributed" rather than "idle". Notification-popup copy is deliberately
not built yet: it should describe the measured background grouping, not the
intended one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four kind-0 events collected from relays exposed two content-parsing
problems. Both `MetadataEvent.contactMetaData()` and `contactMetadataJson()`
are shared code, so Jackson (JVM/Android) and kotlinx (native) were equally
affected — verified against both mappers.
Empty content is a valid, empty profile — someone wiping their metadata —
not a parse failure. It logged "Content Parse Error" and returned null,
which made `LocalCache.consume` drop the event, so the stale profile stayed
in place forever even though a newer replaceable event had arrived. Blank
content now decodes to a blank `UserMetadata` (and an empty `JsonObject`).
A string field wrapped in a one-element array (`"nip05":["a@b.com"]`) has
only one possible reading, so `TolerantStringSerializer` now unwraps it
rather than silently dropping the user's NIP-05 verification. Empty,
multi-element, and non-primitive arrays stay ignored.
The other two events already behaved correctly and are pinned as
regressions: `"nip05":{}` with foreign client keys, and Ditto's ambiguous
`"birthday":"10-24"` — both drop just the offending field.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGYcqfBKsP9SCvRdPzdkSx
`loadMetadataBatched follows the same retry semantics` failed on the macOS
build-desktop runner (1431 tests in the module, 1 failed). The coordinator is
fine; the test was wall-clock coupled.
It fired call 1 with `timeoutMs = 200`, waited a flat `delay(350)`, then
asserted that call 2 re-subscribed. That leaves a 150ms budget for
`scope.launch` to be scheduled, subscribe, `awaitAll(200)`, unsubscribe and roll
the pubkeys out of `inFlightBatchedMetadata`. On a loaded runner the launch
itself can be queued past the margin, so the roll-back lands late, call 2
short-circuits by design, and the assertion fails on healthy code. Every test in
the file had the same shape.
"Has call 1 finished?" is a question about the job tree, not the clock, and the
test owns the scope — so it can just ask. `awaitCoordinatorIdle()` waits until
no child of the test scope is active; `waitUntil {}` polls the few preconditions
that aren't expressible that way (listener registered before EOSEs are fired).
Both carry a generous deadline and fail with a message rather than hanging. Safe
here because the coordinator launches nothing at construction and
`BatchEoseGate` closes and joins its consumer inside `awaitAll`, so the scope
does reach idle.
Also made the test fake thread-safe. `subscriptions` and `subscribeCalls` were a
plain map and list, written from the coordinator's `Dispatchers.Default`
coroutines (and `Dispatchers.IO` in the concurrent-EOSE test) and read from the
test thread: an unsynchronized `size` read can be stale, and `fireEose`
iterating `subscriptions.values` while a coordinator coroutine calls
`unsubscribe` can throw ConcurrentModificationException. Concurrency is the
thing under test, so the fake shouldn't be the weak link. Now
ConcurrentHashMap + synchronizedList + AtomicInteger, with a snapshot in
`fireEose`.
Verified deterministic rather than just green:
- 3/3 idle, 4/4 under 24 busy loops on 12 cores (the old shape needed the
margin; the new one is load-independent).
- Still catches its regression: reintroducing the original mark-on-send bug
(`eosedRelays > 0` -> `>= 0`) fails exactly this test. A deflaked test that no
longer detects the fault would be worse than the flake.
This is a pre-existing flake (test dates from 5217035f94, 2026-07-07) unrelated
to the rest of this branch, which touches no commons code — fixed here because
it blocks the branch's CI.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three findings from auditing the previous commit.
observeChannel registered a ChannelFinder query per invite row that could
never produce a filter: every assembler under ChannelFinderFilterAssemblyGroup
is gated on `is PublicChatChannel` / `is LiveActivitiesChannel`, so a
RelayGroupChannel contributes nothing and the registration only churned the
app-wide key set (an allKeys() Set copy per bundled invalidation) on every
mount and unmount. Collect the channel's metadata stateFlow directly instead —
same live name updates when the group's kind-39000 lands, none of the churn.
TimeAgo used the default Dotted style inside a row that already spaces its
children, so the variant's own leading " • " doubled the gap. DottedTight is
what the note header uses in exactly this position.
Key each row by channel id. The list is sorted newest-first, so an arriving
invite shifts every row below it; without a key Compose matches children by
call-site position and each shifted row recomposed against a different invite,
re-resolving the actor and reloading their avatar. Also remember the row
modifier rather than rebuilding the chain on every recomposition.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGB1z4VcNfDw5cMt9V5NCG
The INFO default landed a readable log but an incomplete one: only the boot
census came from our own code, so the default read as "warnings + census" with
nothing between process start and the 20s mark. The first four seconds — the
expensive part — were silent.
Adds the missing spine, ten INFO lines a boot:
- `AmethystApp`: version, process and effective log level at start. The
napplet-sandbox skip is promoted too — it is the one line that explains why
`instance` is unset and `LocalCache` empty in that process, which otherwise
reads as a broken app rather than a deliberately secret-free sandbox.
- `LocalPreferences`: how many accounts are saved (nearly every per-account
subsystem multiplies by this), then each account's load with its elapsed ms.
Decrypting one account's settings is among the most expensive things a cold
start does and "which account was slow" is the first question when a boot
drags. The six intermediate steps stay at DEBUG.
- `TorService`: every status transition, with time since bootstrap started. All
status writes now go through one `setStatus` helper instead of nine scattered
assignments, so a transition is logged exactly once; self-transitions are
skipped because the reset paths reassign `Off` defensively. A stuck
`Connecting`, an `Active` that took 40s and a silent drop to `Off` are three
different bugs that used to look identical.
- `BootRelayDiagnostics`: adds a census at 5s. The pool is assembled and
dialling well before 20s, and the early datapoint is what separates "slow to
connect" from "connected fine, slow to serve" — on device it reads pool=21
at 5s against pool=359 at 20s. Paid for by folding the `=====` banners down
to DEBUG, so a census is 3 INFO lines instead of 5.
Reads on device as:
I AmethystApp: Amethyst 1.13.1-DEBUG starting in main process (log level INFO)
I TorService: Off -> Connecting
I LocalPreferences: Found 4 saved account(s)
I LocalPreferences: Loaded account npub1gcx… in 394ms
I LocalPreferences: Loaded account npub142g… in 760ms
I BootRelayDiag: census @5s pool=21 opened=17 served_events=14 …
I TorService: Connecting -> Active after 9910ms
I BootRelayDiag: census @20s pool=359 opened=158 served_events=78 …
Verified by tag on a cold start: +10 INFO lines from our code, no other change
to volume. (Raw totals moved more, but that run happened to autoplay video, so
15 extra INFO lines came from Android's media/codec stack — not from this.)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Audit follow-ups on the previous commit's gating.
1. The `hasLoadedContent` gate over-suppressed. Cached channels in
LocalCache are proof the socket answered *once*, not that it answers
now — so a relay whose circuits died mid-session, leaving a full but
frozen screen, would never get the offer. Replace it (and the
one-shot grace timer) with a debounce on the connection itself: the
countdown restarts on every drop and clears the moment the socket
returns. That covers the cold-start case, the reconnect blip and the
mid-session death with one signal, and no longer depends on what
happens to be cached.
2. Don't offer what can't help. The action adds the relay to the
kind-10089 Trusted list, which only moves it to clearnet while
trusted relays are off Tor. Under the Small-Payloads and
Full-Privacy presets (trustedRelaysViaTor = true) it changed no
routing at all — the old `relay !in trustedRelays` condition merely
hid the banner afterwards, so the dead button looked like it worked.
Withhold the offer under those presets instead of silently flipping
a global privacy setting from a per-relay banner.
Tests cover both gates plus the preset routing they read.
The invite prompt was a floating Material card with a surfaceVariant fill,
sitting above the Notifications feed and Messages > New Requests as its own
visual language — nothing else on either surface looks like that.
Render it through NoteComposeLayout instead, the same layout every note and
notification card uses: the actor becomes the row's author (picture, name and
time in the usual header), "Added to <channel>" plus the host relay become the
row's content, and the three choices take the reactions slot. So the prompt now
reads like the reply and mention notifications it sits next to, and a divider
closes each row the way the feed does.
The channel name is now read through observeChannel, the metadata-only observer
the channel rows use, so a stranger's add resolves to a name instead of a raw
group id. It does not open the channel's message subscription — holding that
back until the viewer answers is the whole point of the prompt.
Also relabel "Show" to "Add to Messages". acceptChannelInvite is defined as
`= addRelayGroupToMessages(channel)`, literally the call behind the channel top
bar's "Add to Messages" item, so one action had two words for it. Reusing the
existing string drops channel_invite_accept and channel_invite_body (the actor
and the question both live in the row now).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGB1z4VcNfDw5cMt9V5NCG
A Buzz/NIP-29 group id is only unique within its host relay, so a message
notification titled just "#general" doesn't say which #general it came from.
The Notifications card already draws a RelayGroupChannelHeader naming the
channel; add the relay next to it, mirroring what a Concord message gets on
the same cards (ConcordCommunityPill naming its parent community) and what the
Messages row already does for these groups.
Extracts the relay chip out of ChatroomHeaderCompose into a shared
RelayNameChip so the Messages row and the Notifications feed render the same
chip, the way ConcordCommunityPill is already shared between them. The chip
taps through to the relay's channel list; the name/avatar still open the room.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DsoLSb42C3CdjMceJW2Pq
Closes#3817.
collectWarmTargets() harvested link URLs from note content but not from
NIP-73 `I`-tag scopes, so the external-content preview cards popped in
mid-scroll while every other link preview was warmed ahead of view.
Gated on `is CommentEvent` so only kind 1111 walks the tags, keeping the
other kinds off that path. Reuses scope() -- the same accessor the
renderer uses -- so a URL is only warmed when it will actually be shown.
Inherits the existing showUrlPreview() data-saver gate already applied to
targets.links.
An entity edited after a CORD-06 Refounding was frozen at its pre-Refounding
version and its newer state silently discarded on every refold. Observed on
device: entity e83ee182 of a real community held at v1 across 21 consecutive
refolds while the current epoch offered v2 citing v1's own hash.
`EditionFold.foldEntity` anchored the walk by requiring the offered set to
*contain* the floor edition. But a Refounding re-wraps ONE edition per entity
(CORD-06 §3, "the last Control Plane state is simply rewrapped"), so an entity
edited afterwards has only its successor on the new epoch while the floor
edition stays behind on the old one. `prev == floor.hash` is a stronger proof
the chain connects than mere presence, and it was being ignored. Worse, the
gap made `admissible` — the pre-filter every derived fold shares — keep only
`version < floor.version`, so the newer edition was dropped every refold: a
role edit, channel rename or banlist entry reverting itself for that user.
Checked against Armada (semantics only, AGPLv3) and the canonical spec before
changing consensus behaviour. Armada selects an arm per entity, and Amethyst
was missing both halves:
1. Its chain-walk arm has three anchor branches to our one; the missing
`versions[0] === floor + 1n -> bytesEq(lo.prevHash, floorHash)` is now
implemented.
2. Once an entity has been re-wrapped into the epoch being folded it does not
chain-walk at all — it anchors on version alone (`bootstrapHead`), because
behind a compaction dangling `prev`s are normal and, since seal signatures
survive re-wrap, any group-key holder can re-serve a genuine old edition
under the current group. A re-wrap cannot raise the version inside the
signed seal, so version is what bounds that. This is the half that fixes
the observed pin; branch 1 alone would still refuse a floor-v1 entity whose
only served edition is v3.
Implemented as an optional `snapshot` (the rumor ids of the epoch being
folded), defaulting to null = pure chain walk for every other caller.
`ConcordCommunityState.fold`/`authorizedHeads` capture it from their own
editions argument before `admissible` re-seats older-epoch heads — Amethyst
folds exactly one epoch per call, so the argument is the snapshot. `admissible`
and `candidates` now ask `foldEntity` whether it gapped rather than
re-deriving the test, so the three sites cannot drift apart again.
`LOG_GAP` also dedups on (entity, floor, offered): the same refusal was
re-reported on every refold, 22 byte-identical warnings a boot, which read as
22 attacks rather than one unchanged state. Deduping is what made the two
genuinely distinct refusals visible and led here. Fails open above 4096
distinct refusals — a flood is when the warnings matter most.
Anti-rollback is unchanged: all 12 pre-existing floor tests still pass, and
three new negative tests cover the fork, the below-floor re-serve, and an
entity absent from the snapshot. Device gaps for the pinned entity: 21 -> 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A cold start emitted 6,198 app lines in 80s (~77/sec) on a real device.
Six tags produced 83% of it, and the lines that are actually actionable —
relay protocol refusals like `auth-required`, `rate-limited`,
`unsupported: too many filters` — were buried among them.
`Log.minLevel` was `DEBUG` in debug and `ERROR` in release: two settings,
both wrong. Release dropped all 460 `Log.w` call sites, so the field never
saw a single relay refusal. Debug kept everything.
Now debug defaults to INFO and release to WARN, with `Amethyst.VERBOSE_LOGS`
to restore the firehose. 333 files already route through `quartz.utils.Log`
(only 13 use raw `android.util.Log`), so `minLevel` is a real global gate —
which is why three of the four loudest buckets needed no code change at all:
relay socket lifecycle (1,659 lines), `RelaySpeedLogger` (1,308) and Arti's
per-stream SOCKS errors (753) were already `Log.d`.
The rest:
- `RelayLogger.onCannotConnect` E -> D. Under the outbox model a boot dials
~400 relays and most fail identically; one line per dead socket is not
actionable, and its multi-line TLS certificate dumps were 3-4 logcat lines
each. This one mattered most: at `Log.e` it survived every level.
- `BootRelayDiagnostics` rollup -> INFO, per-relay WASTE/SERVE tables -> DEBUG.
The census line carries the aggregate the 655 per-socket errors spelled out.
- `Duplicated/SPAM` -> DEBUG: a detection is the filter working, and it is
already reported via `relayStats.newSpam()` and `flowSpam` to the UI.
- `MarmotDbg` "not a member of group" -> DEBUG: relays serve kind:445 for
every group they carry, so this is the steady state. It was burying the
real warning next to it ("Generation N already consumed").
Measured on emulator (4 accounts, Tor on): 6,198 -> 433 lines. Flipping
VERBOSE_LOGS gives 5,574 back, so nothing was deleted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The "Can't reach this relay over Tor" card on a community's screen was
driven by a bare 6s timer: `connectTimedOut` flipped true after the delay
and never flipped back, and the condition never consulted the relay at
all. On a Buzz relay the banner is an unconditional item in the sectioned
list (the empty-list guard only covers the vanilla NIP-29 branch), so it
appeared six seconds after opening any community and stayed up while the
relay was connected and delivering messages.
The Tor half was wrong too: `torType != OFF` says Tor is enabled somewhere,
not that this relay is routed through it — onion, localhost and the
per-role presets (trusted / DM / new) each decide independently.
Gate the offer on live signals instead, extracted into a pure predicate:
- routed over Tor — TorRelayEvaluation.useTor, the same predicate the
relay pool dials with
- Tor is up — TorManager.status is Active, so a bootstrapping Tor
isn't misreported as the relay blocking exits
- not connected — client.connectedRelaysFlow(); a relay that answers is
reachable by definition
- nothing loaded — no channels/DMs on screen, so a reconnect blip can't
flash the banner over a live list
- grace elapsed — unchanged, so a slow first connect isn't nagged
Adds unit tests for each gate, including the reported regression
(connected and delivering -> banner stays hidden).
Flipping either local-cache setting called probe.invalidate(), which only
clears the TTL timestamp — it never re-runs the probe. Nothing else does
either: isAvailable() is reached only from the one-shot startup warm and
from inside findServers().
That leaves the feature dormant when enabled mid-session. The feed image
path gates on `available.value` (AccountViewModel.useLocalBlossomBridge
and shouldBridgeBlossomCache), so while the probe reads false no request
ever routes through the resolver, and the resolver is the only thing that
would have refreshed the probe. The settings "detected" chip reads the
same flow, so it also stays stale.
Re-probe right after invalidating so enabling the toggle activates the
feature — and reports its true state — in the same session.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PCgrM83QetNPJTg3Z5VWnF
BlossomServerResolver.findServers and LocalBlossomCacheProbe.probe are
suspend functions that don't confine to Dispatchers.IO. They're reached
from Compose produceState/LaunchedEffect (RichTextViewer,
MarmotGroupIconDisplay, ZoomableContentView), which run on the main
dispatcher, so the pre-suspension work — BlossomUri regex parsing,
LruCache lookups, the probe's OkHttpClient build on TTL expiry, and the
server-list flow setup — executed on the UI thread on every uncached
resolution during feed scroll.
Wrap findServersInner and the probe's client-build + HEAD in
withContext(Dispatchers.IO) so that work stays off the main thread.
Behaviour is unchanged; callers already on IO (Coil BlossomFetcher,
PlaybackService) are unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PCgrM83QetNPJTg3Z5VWnF
Review feedback from PR #3810:
- Gate both new preview call sites behind settings.showUrlPreview(), so a
user on WIFI_ONLY over cellular or NEVER no longer triggers an outbound
OpenGraph request and image download per external-scoped comment. Falls
back to the plain link chip / URL header.
- Wrap the open-in-browser icon in an IconButton so it gets a real touch
target instead of a 14dp one nested inside the card's own clickable,
where a near-miss silently navigated instead.
- Only show that icon when onCardClick is set; without it the icon and the
card tap did the same thing, so existing note-body link cards stay as-is.
- staticCompositionLocalOf for LocalCurrentExternalScope (constant per
screen, avoids read tracking per feed row).
- Inset the loading/error URL header to match the card, and use the
hoisted Size14Modifier.
The leftover inner "a" ring sat in front of the ostrich, making the logo
look like it was behind the circle. Drop the inner-a segments from the @
outline, leaving just the outer ring + tail, and size the ostrich so it
reads clearly as the "a".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
Route the Concord community screen and hub through ShorterTopAppBar (50dp)
instead of the raw Material3 TopAppBar (64dp default), so the top bar — and
its 3-dot overflow menu — sits at the same height as the Buzz/relay-group
community screen, which already uses the shorter bar.
Also drop the recent-posters facepile from the Concord and Buzz channel
rows, and move the last-message time up to the first line (next to the
channel name), leaving the unread-message badge on the second line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017x22okBh6HbiCrJN3zf5xn
RenderNoteRow matched the four concrete video subtypes (VideoNormalEvent 21,
VideoShortEvent 22, VideoHorizontalEvent 34235, VideoVerticalEvent 34236) with
four identical branches. Collapse them into a single `is VideoEvent` branch,
matching how ThreadFeedView's NoteMaster already dispatches, so any future
VideoEvent subtype renders through VideoDisplay automatically instead of
falling through to the text fallback.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCKP4tD2seoDpr3ZcdPJNx
The create screen no longer shows a "Forum channel" toggle — the type is decided
by the community's per-section "+" (Channels vs Forums) and a Buzz channel's
channel_type isn't editable anyway (the relay's 9002 has no such key). The screen
loads with the fixed isForum parameter and creates the right type; its top-bar
title reads "New forum" when isForum, "New channel" otherwise (on Buzz).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
Tapping a note in the thread view toggles the collapse/expand state of
its children. For the user's own drafts, tap now opens the edit-draft
screen (via routeEditDraftTo) so the post can be resumed, matching the
behavior everywhere else a draft is tapped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQCNuxdYkfouNN3ujxm3mR
A channel deleted elsewhere (or before this device saw its metadata) keeps its
kind-44100 membership — the relay never retracts it — but the relay soft-deletes
its 39000, and it does NOT serve a channel_deleted 40099 for an already-deleted
channel, so the discovery fetch can't catch it. Such a channel arrived with no
metadata and was shown optimistically: a bare UUID row with "No messages yet".
The reliable signal is that very metadata absence. BuzzRelayImportViewModel.discover
now drops channels the relay serves no 39000 for — but only when the fetch clearly
worked (some channel returned a 39000); if none did, the read failed (auth/
connectivity) and the whole set is kept rather than blanking the community. A
genuinely new channel whose 39000 is merely slow returns on the next bind once its
metadata is cached, so the prune is live (not persisted) and self-correcting.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
The "+" was gated on BuzzCommunityMembership (kind-13534), but that roster is only
fetched by the Members screen — nothing on the channel-list screen subscribes to
it, so isMember read false and the "+" hid even from admins. Match the workspace
overflow menu's approach instead: offer create on any Buzz community and let the
relay reject a non-member's kind-9007 (its own doc: "any member sees them, the
relay only serves the owner/admin ones").
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
Kind 1111 comments scoped to a NIP-73 external identifier (e.g. a web
article) rendered as a bare URL with no context. Reuse the existing
OpenGraph preview pipeline so feed rows, expanded threads, the
reply-compose banner, and the URL thread screen all show a rich
image/title/description card instead, and give the URL thread screen
a proper scrolling header instead of a bare title.
- Mention: gem centered like the "a" with a near-complete circle around it,
opened at the lower-right with a small tail — a proper @ shape, ring no
longer touching the gem.
- Chess: enlarge the gem 2%.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
- Code: widen the < > chevrons (0.62 -> 0.85) and shrink the gem (12 -> 10)
for a more even balance.
- Chess: enlarge the Amethyst gem so it rides over and blends into the
bottom of the knight.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
- Mention: thinner ring whose open lower-left end flows into the gem's
bottom-left facet via a tapered stroke, echoing how an @ is drawn.
- Zap: enlarge the bolt and shrink the top-right gem.
- Code: squeeze the < > brackets narrower (width only, height kept) so they
hug the centered gem.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
Two channel-management gaps vs the Buzz interface (both ride kind-9002 tags; no
new protocol), verified against block/buzz:
Archive/Unarchive — the reversible hide-from-the-sidebar the Buzz client has,
distinct from delete. EditMetadataEvent gains an `archived` tag; Account/
AccountViewModel expose archiveRelayGroup; the channel and forum top bars offer
Archive/Unarchive to admins (no confirm — it's reversible). The relay stamps
`["archived","true"]` on the 39000, so GroupMetadataEvent.isArchived() /
RelayGroupChannel.isArchived() read it directly; the community list drops archived
channels out of Channels/Forums into a collapsed "Archived" tail from which they
can be reopened and unarchived.
Visibility-on-edit — a Buzz relay reads its own `visibility` (open/private) tag,
not the NIP-29 `private` status flag, so the edit screen's private toggle was a
silent no-op on Buzz. editRelayGroupMetadata now sends the `visibility` tag on
Buzz relays (status flag still sent for vanilla NIP-29).
Not gaps (checked): topic/purpose/TTL are in the relay's system-message
vocabulary but not extracted on 9002/9007, so there's nothing to mirror.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
drainPeerInitiatedUniStreamsIntoBlackHole_keeps_connection_alive passed
in isolation but flaked under parallel load with the audit-4 #3
"slow consumer overflowed incoming channel" teardown.
Root cause: the test ran the black-hole drainer on the shared
Dispatchers.Default pool. The drainer only has to keep the 64-deep
per-stream incomingChannel drained, but when the rest of the JVM test
suite saturates every Default worker thread the drainer coroutine can be
denied a scheduling slot long enough for the synchronous feed loop to
push more than 64 chunks ahead of it, tripping the slow-consumer
teardown. The fixed-delay yields (delay(1) every 16 chunks) do not help
when all pool threads are pinned.
Fix: run the drainer on the runBlocking coroutine's own single-threaded
event loop (CoroutineScope(coroutineContext + SupervisorJob())) and pace
the feed loop with cooperative yield() instead of wall-clock delays. Each
yield deterministically runs the drainer's queued channel-receive
continuation before the producer resumes, so the buffer never approaches
its bound regardless of machine load. The drain contract under test
(parser trySend -> incomingChannel -> collect -> discard) is exercised
identically; only the scheduling is pinned.
Verified with a temporary reproduction that pins every Default worker
thread: the old Default-scheduled drainer overflows to CLOSED while the
confined drainer stays CONNECTED under the same saturation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGZt6D298kGv2XgAi3Uuaj
Two ways the bubble could get stuck shifted sideways (dragOffset frozen at a
non-zero value, reply icon left visible), both fixed:
1. Cancelling the settle-back animation on awaitFirstDown — which fires for
every touch, including one that turns out to be a vertical scroll — while
settleBack() only ran on the horizontal-drag path. A scroll started during
an in-flight settle froze dragOffset. Now the settle is cancelled only once
a horizontal drag is actually committed, so every cancel is paired with a
settleBack().
2. Keying pointerInput on the onSwipeReply lambda. The caller allocates a fresh
lambda every recomposition (it captures the note), so the detector was torn
down and restarted on every row update. A restart mid-drag cancelled
horizontalDrag before settleBack() ran, stranding the bubble; idle restarts
also churned the gesture coroutine across all visible bubbles. Now keyed on
the stable isLoggedInUser, with the callback read through rememberUpdatedState
and the drag wrapped in try/finally so an interrupted drag always settles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016f7dTu8RoJRXjnHM7FKAE2
- Reply: keep the arrow at its size but move it to the bottom-left and put
the Amethyst gem in the top-right corner.
- Zap: scale the bolt up to fill the canvas and knock the gem out of its
center (inverted), so the logo reads in front of the bolt.
- Reaction: grow the heart's knocked-out gem ~10% and nudge it toward the
bottom of the heart.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
The community list rows (channels, forums, DMs) each carried a 3-dot overflow —
Pin/Unpin and Add/Remove-from-Messages. Those are gone; each row is now a clean
tap-to-open target and its actions live in the top-bar overflow of the screen it
opens:
- Channels & DMs open RelayGroupChatScreen (RelayGroupTopBar): add Pin/Unpin
(Buzz, non-DM) alongside the existing Messages toggle. A DM has no kind-10009
entry, so its Messages toggle drives the DM-specific hide/unhide (kind-41012 /
re-open) read from the per-viewer 30622 snapshot; the DM overflow is always
shown so that action stays reachable.
- Forums open RelayGroupThreadsScreen: give its top bar an overflow with Pin/Unpin
and Add/Remove-from-Messages.
Shared BuzzPinDropdownItem / RelayGroupMessagesDropdownItem keep the two bars
consistent. AccountViewModel gains hideBuzzDm/unhideBuzzDm wrappers. The
community-list top-bar overflow (Add people, Invite, Agent Console, Add all) is
unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
The reply icon's fade/scale read dragOffset in composition, and because Box is
an inline composable that read invalidated the whole ChatBubbleLayout on every
drag and settle frame. Gate the icon on a derivedStateOf boolean that flips only
at the 0<->non-0 edges (icon added/removed twice per gesture) and move the
progress math into the icon's graphicsLayer block so it's read in the layer
phase. The bubble translation was already deferred; now the entire swipe and
settle animate with zero recomposition.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016f7dTu8RoJRXjnHM7FKAE2
Mirrors the Homebrew cask change. The old design stored a classic `public_repo`
PAT as WINGET_TOKEN and handed it to the third-party
vedantmgoyal9/winget-releaser action: a token with write access to every public
repo the owning account can reach, given to code we do not control, in a place
any push-access collaborator could read it from (a pushed branch containing a
workflow runs with repo secrets).
- CI (bump-winget.yml, now "Sync Winget Manifest Reference") uses GITHUB_TOKEN
only: downloads the MSI, computes the sha256, reads the ProductCode from the
MSI Property table via msitools, and opens an in-repo PR syncing
desktopApp/packaging/winget/. Runs on ubuntu (1x billing) rather than Windows
since msitools reads the Property table fine.
- scripts/bump-winget.sh does the upstream PR. It needs NO new token: it drives
`gh`, which a maintainer already has authenticated, and it does not need
wingetcreate (Windows-only) because the manifests are plain YAML — so it runs
from macOS or Linux.
Add the three reference manifests under desktopApp/packaging/winget/, matching
the schema 1.12.0 shape used upstream. All three validate against Microsoft's
published JSON schemas.
Validation caught one real bug worth noting: an all-digit 64-char InstallerSha256
parses as a YAML *integer* and fails the schema's `string` type, so it is written
quoted. ProductCode is re-read every release because jpackage regenerates it per
build; it is the ARP key `winget upgrade` matches on.
Drops the last package-manager PAT from the secret inventory.
Only a Buzz community member may create a channel (the relay makes the creator
its owner; a non-member's kind-9007 is rejected), so the section-label "+" now
shows only when the viewer is in the community's NIP-43 roster. Reactive: the
relay-signed roster snapshot (kind 13534, republished on every membership change)
can arrive after first composition, so BuzzCommunityMembership is re-read whenever
one lands. The section labels themselves still render for everyone; only the "+"
is gated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
- Repost: scale the repeat-arrow glyph up ~15% so it fills the canvas like
the original, with the centered gem enlarged to match.
- Badge: replace the medal ring + inner-circle-with-logo with a solid disc
that has the Amethyst gem knocked straight out of it (inverted), sized to
fill the disc. Ribbon tails kept.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
`brew bump-cask-pr` forks homebrew-cask into the token owner's account, which
requires a CLASSIC PAT with the `repo` scope. That scope cannot be narrowed: it
grants write to every repository the owning account can reach, including this
one. As an Actions secret it would be usable by anyone with push access here,
because a pushed branch containing a workflow runs with repo secrets — a strict
escalation for a channel that ships one DMG a month.
Split the work so the token never becomes a CI secret:
- CI (bump-homebrew.yml, now "Sync Homebrew Cask Reference") does the
error-prone bookkeeping with GITHUB_TOKEN only: downloads the DMG, asserts it
is notarized + stapled, computes the sha256, and opens an in-repo PR syncing
desktopApp/packaging/homebrew/amethyst-nostr.rb. This makes it a third sibling
of the amy/geode formula-sync workflows rather than a special case.
- scripts/bump-homebrew-cask.sh does the upstream PR from a maintainer's shell,
re-verifying sha256 and the notarization ticket against the live asset first,
and refusing to submit if either fails. Verified against v1.13.1: the sha256
matches and it correctly refuses on the missing notarization ticket.
Drop HOMEBREW_TOKEN from the secret inventory, and rewrite the two formula
TODO(bootstrap) notes that proposed reintroducing it so a future homebrew-core
submission follows the same local pattern.
Bumps the gem so the brand mark reads bigger inside each glyph: the heart
knockout (6.7 -> 8.2), the repost loop center (5.4 -> 6.9) and between the
code brackets (6.2 -> 7.6).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
The inverted DM bubble read smaller than the original because its body
stopped short and the tail was a small stub. Grow the bubble to the same
footprint as the original glyph, scale the knocked-out gem and the three
chat lines up to match, and give it a full corner tail.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
On a Buzz community the FAB (which created a channel) is replaced by a "+" on the
Channels label — matching how Direct Messages already offers a "+" — and a Forums
label gains its own "+" that starts the create flow pre-set to a forum channel.
Both section headers now always render so the "+" is reachable even before any
channel loads; the collapse chevron is shown only when the section is non-empty.
The create route gains an isForum flag (threaded onto RelayGroupCreateScreen →
RelayGroupMetadataViewModel.isForum) so the Forums "+" opens the create screen on
a forum, the Channels "+" on a chat channel. The vanilla NIP-29 relay (a flat
directory with no sections) keeps its FAB. The stale "accept the invite in the
browser" empty text is dropped — an empty community is now a starting point.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
The inverted reaction icon carried a leftover inner-heart cutout from the
previous outlined design plus a duplicated gem subpath, so the gem rendered
as a solid island inside a heart-shaped hole instead of a clean knockout.
Regenerated as a solid heart with the Amethyst gem punched straight out.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
BUILDING.md called for a fine-grained PAT scoped to Homebrew/homebrew-cask.
That token cannot exist: a fine-grained PAT's repository selector only lists
repos owned by the resource owner, so a repo you do not own can never be
selected, and Homebrew's API layer authorises against classic OAuth scopes
(x-oauth-scopes) which fine-grained tokens do not emit.
brew bump-cask-pr forks homebrew-cask into the TOKEN OWNER's account, pushes a
branch there, and opens the PR upstream. Homebrew declares the requirement as
CREATE_ISSUE_FORK_OR_PR_SCOPES = ["repo"] in utils/github.rb, so it is a classic
PAT with the `repo` scope.
Add the create-token link and call out that `repo` grants write to every repo
the account can reach -- including this one -- so the CI secret should belong to
a dedicated bot account whose only asset is the fork.
Per design feedback, the direct-message and reaction icons now invert:
instead of an outlined shape with a filled gem inside, the shape is solid
and the Amethyst gem is punched out of it (evenOdd knockout).
- Direct message: solid speech bubble; the gem is knocked out full-height
on the left, with three chat lines knocked out on the right to read
clearly as a conversation. The inner rectangle cutout is gone.
- Reaction: solid heart with the gem knocked out of its center.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
The macOS leg ran only `packageReleaseDmg`, which SIGNS the DMG but does not
notarize it — notarization is a separate Compose task. The `notarization {}`
block in desktopApp/build.gradle.kts only supplies credentials; nothing invoked
it. So every release up to v1.13.1 shipped a signed but unnotarized DMG:
`xcrun stapler validate` reports no ticket on either the .app or the .dmg, and
Gatekeeper blocks it on first launch.
Append `:desktopApp:notarizeReleaseDmg` on the macOS leg. Compose 1.11.1's
AbstractNotarizationTask runs `notarytool submit --wait` and then `stapler
staple` in place, so the asset-collection step still finds the same file. Raise
that leg's inner timeout to 45m since the submit blocks on Apple.
Gate it on the cert AND all three notary secrets, so forks and credential-less
runs keep producing a plain unsigned DMG instead of failing.
Add a verify step that asserts the stapled ticket. The bug survived many
releases precisely because nothing ever checked the outcome.
Also update the reference cask to 1.13.1 and make it pass `brew audit --new
--cask` + `brew style` cleanly: add the missing conflicts_with (the tiling
window manager's cask installs the same Amethyst.app), add depends_on :macos,
fix stanza order, drop the unnecessary `verified:` (url and homepage share a
domain), and widen the zap to the state dirs the source actually uses.
Correct BUILDING.md's macOS state table, which listed a
com.vitorpamplona.amethyst.desktop.plist that does not exist and omitted
~/.amethyst. Note that Java's Preferences API writes to a SHARED
com.apple.java.util.prefs.plist, which is why the cask must not zap it.
Every per-category status-bar icon now carries the Amethyst crystal so a
notification reads as an *Amethyst* reply/zap/mention at a glance, not a
generic Material glyph. Android renders small icons as a flat alpha-only
silhouette, so the gem is composed as a spatially distinct mark rather than
layered behind the glyph:
- Direct message, mention, repost, reaction, code and badge: gem sits in
the natural cavity of the glyph (bubble, @ ring, repeat loop, heart,
brackets, medal center).
- Media: gem is the sun rising over the hills.
- Article: gem is the drop-cap starting the first line.
- Chess: switched to the outlined knight from the app's icon set
(MaterialSymbols.ChessKnight) with a small brand gem.
- Reply and zap: open glyphs that can't hold a centered gem keep a small
gem corner mark.
Action buttons stay unbranded — a gem on a "Reply"/"Mark read" button is
noise, not identity — so those now use dedicated plain glyphs
(ic_action_reply, ic_action_mark_read).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
Flag names and error strings are the contract between amy and the wrapper
scripts (and their operators), but nothing type-checks them: renaming --base-ref
or rewording an error breaks callers while compiling clean. This harness pins the
ones the buzz-agent path drives.
14 cases, all offline against an isolated ~/.amy via the `amy.home` seam the JVM
tests use: the shared `repo-naddr-or-coordinates` positional on git
browse/cat/log, `pass --channel GID` on the workflow verbs, --base-ref accepted
while --base-reff is rejected on both agent up and agent serve, and the
no_relays detail.
Confirmed to discriminate: 14/14 on this branch, 10/14 when built against
main-upstream's BuzzCommands, with the four failures printing the old misleading
"no relays: pass --relays ws://…" for input the user did pass.
Worth recording from writing it: a bare word like `garbage` is *accepted* as a
relay (the normalizer prepends wss://), so the realistic trigger for the empty
set is a pasted http:// url — which is what the cases use.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WopdqNoZ9tYoMNppJG17BL
bump-homebrew.yml pinned macauley/action-homebrew-bump-cask to
ad984534de44a9489a53aefd81eb77f87c70dc60, commented "v4.0.0". That SHA does not
exist — the action's only release is v1 (445c4239) from 2021. GitHub resolves
every `uses:` during "Set up job", so the job died before running a single step,
which no `if:` gate can avoid. It stayed invisible because the workflow had
never been triggered; fixing the trigger surfaced it immediately.
Call `brew bump-cask-pr` directly instead. It is the same command BUILDING.md
already documents for manual recovery, and it removes an unmaintained
third-party action that would hold a PAT with write access to homebrew-cask.
Split the workflow into check (ubuntu) + bump (macOS): cask commands are
macOS-only, but the bump is gated on a cask that does not exist yet, so the
10x-billed macOS job is only created once there is something to bump.
Three findings from the review of the Sonar literal-extraction pass. None were
introduced by that pass — it renamed or sat next to each one.
no_relays told the wrong story. `relaysFor` returns an empty *non-null* set when
--relays was given but every entry failed `normalizeGroupRelay`, so the elvis to
`outboxRelays()` never fires and the user was told to pass the flag they had just
passed. The message now names the real problem and echoes the rejected value; the
`no_relays` error code is unchanged.
`participants` was string-munged. `arr.toString().trim('[',']').split(",")` turns
any shape other than a flat string array into plausible-looking fake pubkeys, and
splits a comma-bearing string into two entries. Now decoded as a JsonArray, with
non-string elements skipped rather than stringified. Extracted to
`participantsOf` so it is testable; BuzzParticipantsTest covers the shapes and
was confirmed to fail (3 of 5 cases) against the old implementation.
Nothing kept the two copies of the buzz-agent wrappers in sync. They exist as
byte-identical trees under cli/src/main/resources/buzz-agent (what `agent up`
extracts) and tools/buzz-agent (what the README tells people to use), with no
build step or test pinning them — I had to hand-apply the same patch twice this
week. BuzzAgentWrapperSyncTest now asserts it. The tools/ tree is declared as a
`:cli:test` input, without which Gradle calls the task up-to-date after a
tools/-only edit and misses exactly the drift being guarded (verified both ways).
The chat bubble used detectHorizontalDragGestures, which claims the pointer
as soon as horizontal movement crosses touch-slop regardless of how much
vertical movement is happening. A mostly-vertical scroll with a little
diagonal drift got grabbed as a swipe, so the bubble slid sideways while the
user was just trying to scroll the conversation.
Replace it with a custom awaitEachGesture detector that only consumes the
pointer once the motion is clearly more horizontal than vertical
(abs(over.x) > abs(over.y)). A predominantly-vertical drag leaves the pointer
unconsumed, so the enclosing scroll keeps it and the bubble stays put. Once
committed to a horizontal drag, behavior (clamp, haptic tick at threshold,
release-past-threshold to reply, settle-back animation) is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016f7dTu8RoJRXjnHM7FKAE2
All four bump workflows triggered on `release: types: [released]`, which never
fires here: create-release.yml publishes the release with GITHUB_TOKEN, and
GitHub suppresses workflow-triggering events for GITHUB_TOKEN actions. They had
zero runs across every release up to v1.13.1.
Switch them to `workflow_run` on "Create Release Assets" completion, filtered to
a successful tag push. That also removes a latent race: `released` fired while
the matrix legs were still uploading assets, whereas workflow_run fires after
all of them finish.
The workflow_run payload carries no draft/prerelease flags, so add a
resolve-release composite action that reads them back from the API and feeds
assert-stable-release, keeping the defense-in-depth guard intact instead of
inferring stability from the tag string alone.
Also gate the cask/winget bumps on the package existing upstream. Neither
`amethyst-nostr` nor `VitorPamplona.Amethyst` has been bootstrapped, and
bump-cask-pr/winget-releaser can only update an existing package — without the
gate, fixing the trigger would file a spurious [release-ops] issue on every
release.
Docs: correct the claims this uncovered — Homebrew/Winget are not shipping,
macOS is arm64-only (no Intel DMG), the release carries 31 assets (13 Android,
not 12), Maven Central publishes from a step inside deploy-android and lags
repo1 by tens of minutes, RELEASE_NOTES_ID is minor-releases-only, and note the
git-credential-manager hang that blocks the release push.
The first pass only recorded a deletion when THIS device published the 9008, so a
channel deleted on another Buzz client — or before the fix existed — kept showing
and survived restarts. The Buzz relay's handle_delete_group soft-deletes the
channel and its 39000/39001/39002 discovery events but emits NO member-removed
notification and never retracts the kind-44100 that seeds the browse list; its
only cross-device signal is the relay-signed kind-40099 system message with
type=channel_deleted (verified against block/buzz side_effects.rs).
Consume that signal: LocalCache records a relay-signed 40099 channel_deleted into
RelayGroupDeletions (gated on isRelaySignedGroupEvent so a spoofed one can't hide a
channel), and BuzzRelayImportViewModel.discover now also fetches the #h-scoped
40099 for its candidate channels and drops deleted ones — the relay keeps
re-announcing their 44100 with blank metadata, so this is the only place they
leave the list. Cross-device and retroactive: the relay replays the 40099 on
subscribe.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
Separate the Home feed video toggle into two independent groups,
matching how the app already classifies them (see ShortsFeedFilter):
- Videos (long-form): normal (21) + horizontal (34235)
- Shorts: short (22) + vertical (34236)
Each is its own Settings › Home tile with distinct icon/string. The
relay assembler filters and New Threads DAL already list all four
kinds, so only the HomeFeedType grouping changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198T5VTjA1x2dCfBLhXZaiL
1. `title="$(git log -1 … | cut …)"` aborts the whole script under
`set -euo pipefail` when the worktree has no commits
2. `base_branch` only fell back to `main`
This reverts commit 76b3323583. The probes did their job.
Verified on a Pixel 9a / Android 17 against beam.mapboss.co.th — a
third-party LL-HLS packager, unrelated to zap-stream-core, so this is not
tuned to one vendor's output:
PARSE STRIPPED 2360B of 3426B chunklist_0_video_..._llhls.m3u8
PARSE STRIPPED 3002B of 4068B chunklist_2_audio_..._llhls.m3u8
26 reloads of each rendition over 50s of continuous playback, ~70% of every
playlist removed, and zero DataSpec.subrange occurrences — the crash that
androidx/media#3350 tracks.
Both branches confirmed. Eight ordinary HLS streams logged "no LL tags" and
took the identity path untouched, so the stripper is inert on normal
playlists. Routing was correct on ~15 distinct real URLs:
`hls=true -> HlsMediaSource` for application/x-mpegURL, and
`hls=false -> ProgressiveMediaSource` for video/mp4 — which is the
isHlsMediaItem coverage that unit tests cannot provide.
The 18 ERROR states in the same capture were all unrelated: dead URLs
(403/404/502/504), four UnknownHostException from a network drop, and two
UnrecognizedInputFormatException from a youtu.be link on the progressive
path, which ExoPlayer has never been able to play directly.
The pure predicates keep their unit tests, which are the durable guard.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uE2Db4pzmhi5cFKKkyq2m
DO NOT MERGE — revert this commit before the branch goes anywhere.
PLAYBACK_DIAG_TAG logs at DEBUG and Amethyst.onCreate sets
`Log.minLevel = if (BuildConfig.DEBUG) DEBUG else ERROR`, so nothing on that
tag survives in the benchmark build. Three logcat captures during this work
had zero PlaybackDiag lines for exactly that reason, which left the routing
decision unverifiable — the fix was only ever confirmed indirectly, by the
crash no longer happening.
Adds one ERROR-level tag, HlsVerify, on the three paths unit tests cannot
reach:
- CustomMediaSourceFactory.createMediaSource — the routing decision, so
isHlsMediaItem's verdict and the chosen factory are visible. This is the
predicate with no test coverage, since android.net.Uri stubs to null
under unitTests.isReturnDefaultValues.
- LowLatencyStrippingParser.parse — whether the stripper actually engaged
and how many bytes it removed, so a silent stop-stripping regression is
visible rather than inferred.
- HlsLivenessRecorder.maybeRecord — the other consumer of the shared
predicate, unreachable without a Player.
The pure predicates underneath are already unit-tested and remain the durable
regression guard; this is only for confirming the wiring on a device.
Capture with `adb logcat -s HlsVerify:E`. Remove with `grep -rn HlsVerify` or
by reverting this commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uE2Db4pzmhi5cFKKkyq2m
- retire the `.m3u8` substring predicate across the UI
- cover the preload hint and the byte/charset layer
- share one HLS predicate with the liveness recorder
- unify the HLS predicate and track the media3 bug upstream
media3 crashes fatally on an LL-HLS playlist whose parts are byte ranges —
what zap-stream-core emits (`#EXT-X-PART:...,BYTERANGE="359712@0"`).
Reproduced on media3 1.10.1, Pixel 9a / Android 17, ~0.6s after
ExoPlayerImpl.Init:
IllegalArgumentException
at DataSpec.<init> // checkArgument(length > 0 || length == LENGTH_UNSET)
at DataSpec.subrange
at HlsMediaChunk.feedDataToExtractor
at HlsMediaChunk.loadMedia
Use commons showAmount() for the Zaps tab total and each zapper row instead of
raw sats, matching Amethyst Android and other Nostr clients.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The filter (your own posts that mention this profile) matches Android's
UserProfileMutualFeedFilter, but Android's user-facing string is 'Yours'
(<string name="mutual">Yours</string>). The internal filter name stays
DesktopMutualFeedFilter.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1. Header actions were direct children of a SpaceBetween Row, so the overflow
(⋮) menu floated to the center. Group Edit/Follow/⋮ in their own Row and put
the overflow last (after the primary Follow action), per convention. Extract
the Follow button into FollowButtonRegion for the reorder.
2. Followers/Following rows now subscribe to kind-0 metadata (batched, capped)
and observe each user's metadata flow so names + avatars render instead of
raw npubs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Wire real PrivateZapCache(signer) into DesktopIAccount (was a null stub)
- Subscribe to kind 9735 receipts p-tagging the profile; aggregate sats per
zapper (private zaps decrypt only on your own profile, else fall back to the
public zap-request pubkey)
- New ProfileZapRow; tab header shows the total sats
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Message opens the Messages column + 1:1 room via a DesktopDmRoute bridge
(observed by both deck and single-pane layouts)
- Add to list: pack picker from LocalFollowPacksState, appends via
FollowListEvent.add + broadcast
- Share copies a nostr: profile link to the clipboard
- Actions live in the existing writeable/other-profile overflow menu, so
self/read-only are already excluded
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Scrollable tab row; Followers/Following render UserSearchCard from live
contact-list subscriptions; Relays render a new RelayRowCard from kind 10002;
Bookmarks (kind 10003 → DesktopBookmarkFeedFilter) and Mutual (new
DesktopMutualFeedFilter) render FeedNoteCard
- Respects the shared mute/block hidden-set via DesktopFeedViewModel
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Render kind-0 banner image in the profile card header
- Render bio as rich text (mentions/hashtags/links) via DesktopRichText
- Add CLINK offer (noffer) field to Edit Profile + shared EditProfileFields
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deleting a channel (kind-9008) published the delete and dropped it from the
user's kind-10009 list, but the community's browse list is built from the
cached kind-39000 metadata and the Buzz membership (kind-44100) set — neither
of which the delete touched — so the channel lingered in the list, and a stale
44100 re-announcement could bring it back after a restart.
Track deleted relay-group channels in a device-global RelayGroupDeletions
registry (keyed by GroupId.toKey, so it stays relay-scoped), persist it via
RelayGroupDeletionPreferences, mark the channel on deleteRelayGroup, and filter
deleted keys out of both the directory channels and the Buzz membership list in
RelayGroupChannelListScreen. The delete now removes the row live and it stays
gone across restarts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
Extend the per-content-type Home feed toggles to the remaining root
feed (non-chat) kinds that render as cards but weren't yet part of the
Home feed:
- Pictures (NIP-68, kind 20)
- Videos (NIP-71: normal 21, short 22, horizontal 34235, vertical 34236)
- Torrents (NIP-35, kind 2003)
Each gets a HomeFeedType group (kinds stay disjoint), is added to the
always-on home relay assembler filters, and is accepted by the New
Threads DAL (which also feeds the Everything tab). The addressable
video kinds are registered in the DAL's addressable-kind scan. Wired
into Settings › Home with icons/strings; toggling still drops the kinds
from both the relay REQ and the rendered feed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198T5VTjA1x2dCfBLhXZaiL
Address composer feedback:
- The annotation field is now the short-note composer's rich MessageField
(@-mention + custom-emoji autocomplete, inline previews). On publish it becomes
the highlight's `comment` tag, with the mentions (`p`), emoji, URLs (`r`),
hashtags and quotes it references emitted as their own tags — via
NewMessageTagger + a new tag-builder initializer on HighlightEvent.build().
- A nostr source now renders as a reply-style preview card (NoteCompose, quoted
style) instead of showing a URL field; the URL field appears only for web
sources. The source note is resolved from the a/e coordinate carried in the
route.
Verified with :quartz:jvmTest and :amethyst:compileFdroidDebugKotlin.
- Add a "Highlight" action to the note action menu (3-dot menu + chat long-press
sheet). It opens the NIP-84 composer pre-tagged with the note as source — an
`a` reference for an addressable article, else an `e` reference, plus the
author `p` — with the passage left for the user to type/paste. Prose kinds
only (text notes, long-form), and never a private rumor (a public highlight
would leak an e-tag of the unsigned rumor). This is the in-app entry point for
"post with the source as an event".
- Remove the live preview from the New Highlight screen per review.
- Add two real-world regression tests for the shared-highlight parser: Chrome
"copy link to highlight" with a prefix/suffix fragment (incl. an encoded
hyphen inside the prefix) and a long start-only fragment with encoded commas.
Verified with :quartz:jvmTest and :amethyst:compileFdroidDebugKotlin.
Redesign the New Highlight composer as a pull-quote you craft: a rounded tonal
hero card with a quotation-mark watermark, a highlighter-amber accent bar, and
the passage in large type, plus a live highlighter-pen preview (reusing the feed's
HighlightedQuote) and icon-led source/note fields.
Also extend HighlightEvent.create()/build() to emit a/e/p tags, so the builder
now covers every NIP-84 source — a web page (r), a nostr article (a), a nostr
note (e), each with optional author attribution (p) and context — not just web
highlights. Route.NewHighlight and the composer ViewModel carry the nostr source
(address/event/author) and context through so a future in-app "highlight this
passage" action can open the composer for a nostr article/note.
Covered by a new builder test for the a/e/p tags; verified with :quartz:jvmTest
and :amethyst:compileFdroidDebugKotlin.
`trimUrlEnd` strips a wrapping `)` off the URL token, but the `(` it opened
stayed at the tail of the passage, so sharing
`See this quote (https://example.com/article)` published a highlight whose
content read `See this quote (`.
Trim an unbalanced bracket left at the end of the passage after the URL token
is removed. Only an unbalanced one at the very end goes, so `He said (see
below) https://…` keeps its matched pair, and `.../Mercury_(planet)` — where
nothing was trimmed off the URL — is untouched on both sides.
The existing paren tests only asserted `result.url`, which is why this slipped
through; the new cases assert `result.quote` too, including a regression guard
for the slug-paren direction.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds a browsable Highlights feed (NIP-84 kind:9802) modeled file-for-file on
the Git Repositories feed pattern, since highlights are announcement-like
regular events with the same follow-list top-nav filtering.
New feed stack under ui/screen/loggedIn/highlights:
- dal/HighlightsFeedFilter — AdditiveFeedFilter scanning LocalCache.notes for
kind 9802 (regular events, so notes rather than addressables), gated by the
selected top-nav follow list via FilterByListParams.
- datasource/ assembler + compose subscription + PerUserAndFollowList sub-
assembler + makeHighlightsFilter dispatcher, with per-relay subassemblies for
follows / authors / muted / global / hashtag / geohash. Communities aren't a
highlight concept, so those sets fall through and aren't offered.
- HighlightsScreen (DisappearingScaffold + shared RenderFeedContentState; the
existing RenderHighlight card handles rendering) with a HighlightsTopBar
FeedFilterSpinner and a NewHighlightButton FAB into Route.NewHighlight.
Wiring into the shared plumbing mirrors gitRepositories exactly: Route.Highlights
+ AppNavigation, NavBarItem enum/catalog/drawer lists (FormatQuote icon, already
in the subset font), AccountFeedContentStates (feed state + update/delete/trim),
RelaySubscriptionsCoordinator, TopNavFilterState.highlightsRoutes,
AccountSettings.defaultHighlightsFollowList + changer, Account live follow-list
flows, LocalPreferences persistence, ScrollStateKeys, and the bottom-bar
preloader. Verified with :amethyst:compileFdroidDebugKotlin.
The URL trailing-punctuation trim now leaves a closing bracket in place when
it is balanced within the token, so a shared Wikipedia link like
`.../wiki/Mercury_(planet)` keeps its `)` while a wrapping `(https://…)` still
loses the paren the surrounding prose added.
Adds a "New Highlight" share target and composer so a user can select text in
a browser, hit Share → Amethyst, and publish it as a kind:9802 highlight.
- Manifest: a text/plain-only ShareAsHighlightAlias activity-alias, matched at
runtime by ShareIntentRouting.isShareAsHighlight (mirrors the Send-as-DM
alias pattern).
- AppNavigation: both the launch-intent and warm onNewIntent parse blocks now
branch on the highlight alias, run the shared text through
SharedHighlightParser, and open Route.NewHighlight pre-split into
quote/url/prefix/suffix.
- NewHighlightScreen + NewHighlightPostViewModel: a trimmed composer (passage,
source URL, optional note — no polls/zaps/media/scheduling) that publishes via
account.signAndComputeBroadcast(HighlightEvent.build(...)).
Also adds HighlightEvent.build(), the unsigned EventTemplate counterpart of
create(), for the sign-and-broadcast pipeline. Verified with :quartz:jvmTest
and :amethyst:compileFdroidDebugKotlin.
Adds a shared-highlight parsing layer under nip84Highlights/parse that turns
the free-form text a browser hands Amethyst on "Share selection" into the
pieces of a kind:9802 highlight:
- SharedHighlightParser normalises selection-only, selection+URL, URL-only and
"link to highlight" (#:~:text= fragment) shares into a SharedHighlight.
- TextFragmentParser decodes/strips WICG text-fragment directives (prefix,
start, end, suffix), leaving literal '+' verbatim.
- UrlTrackerCleaner strips utm_*/fbclid/etc. from the source URL per NIP-84's
"clean the URL from trackers" guidance, preserving path and fragment.
Also adds a HighlightEvent.create() builder overload that assembles the r,
textquoteselector, context and comment tags from parsed data, centralising
what the desktop publish action does by hand.
Covered by commonTest suites for each piece plus a builder round-trip.
A kind:9802 highlight that mentions users before its author rendered the
wrong name and a stray "published by …" caption.
- HighlightEvent.author() took the first p tag regardless of role, so a
highlight with leading "mention" p tags was attributed to a mention
instead of the "author"-marked one. Prefer the NIP-84 author marker,
falling back to the first p tag for highlights (including Amethyst's own)
that omit the marker.
- DisplayEntryForNote used the source note's title/subject/alt as a caption.
For a kind-1 note there is no title/subject, so it fell to the NIP-31 alt
tag — which clients like Jumble fill with a generic "This event was
published by https://jumble.imwald.eu." line. Drop alt from the lookup and
name the source by its event kind instead, kept clickable to the note.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJh3QYonEa9hw6mJcnrmve
The NIP-84 highlight comment was passed to TranslatableRichTextViewer with
an empty tag list, so custom emoji (:shortcode:) never resolved against the
event's `emoji` tags and rendered as raw text.
Thread the highlight event's tags through to the comment's rich-text viewer
so the emoji map is built from them, matching how regular text notes render.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyyodHWxSHeiY6pndPxGSb
blossomHashOrNull runs on every media URL the feed loads. Quartz's unrolled
Hex.isHex64 is the fast path for validating a 32-byte hex id (~30% faster
than isHex, far faster than a regex match). It only checks the first 64
chars and doesn't verify length, so keep an explicit length == 64 guard to
reject longer segments.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ao9w26c2gAm4gjJdhgvLyp
The retry-on-401 interceptor re-probed anonymously on every blob, so each
image from an auth-gated host (e.g. a Buzz community feed, where nearly all
media is on one host) paid a wasted 401 round trip before the signed retry.
Remember hosts that answered 401 and attach the cached token up front on
their subsequent blobs — one round trip instead of two in steady state. The
first blob per host still costs the probe; the learned set falls back to an
anonymous request when no signer is available so a logged-out user never
loops.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ao9w26c2gAm4gjJdhgvLyp
Merges nostr proposal ed443f87 into main:
- Cache the Keyring behind double-checked locking so the OS backend is
opened once per SecureKeyStorage rather than per save/get/delete. Cold
start touches storage at least twice (metadata AES key, then the active
account nsec), which surfaced as two keychain unlock prompts.
- Add a KeyringHandle seam so the test suite can substitute an in-memory
backend instead of touching the real OS keychain, plus tests pinning the
single-open invariant, including a 16-thread concurrent first-touch race.
Sharing one handle across Dispatchers.IO threads is safe on all three
backends: the macOS path resolves to pt.davidafsilva.apple.OSXKeychain,
itself a static singleton with no mutable instance state, so every
Keyring.create() already shared it; the Freedesktop and Windows backends
hold a single final collaborator assigned in their constructor.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merges nostr proposal 22a8247d into main:
- Wrap MainContent in key(account.pubKeyHex) so the account-scoped subtree
is torn down on an account switch. Deck layout and workspace state are
remembered outside this key and survive.
Fixes a stale-identity leak, not just staleness: NotificationsScreen holds
its accumulated items in an unkeyed `remember { EventCollectionState(...) }`,
so account A's notifications stayed on screen under account B even though
the subscription below re-keyed correctly. ReadsScreen has the same unkeyed
collection, and DeckColumnContainer's ColumnNavigationState is keyed on
column.id only, so each column's screen stack also survived the switch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A private/closed NIP-29 group serves its content (kind-9 chat, kind-11
threads, …) only over a NIP-42-authenticated connection. Amethyst's
group-content reads are all `#h`-scoped with all authors, so they never
name the user's pubkey, and a group host relay (e.g. wss://chat.wisp.talk)
is not in any of the NIP-65/DM/search/… lists that feed
`account.trustedRelays`. The per-account first-party AUTH gate therefore
returned false and Amethyst never AUTHed, so the relay refused the content
with `auth-required` — the group's public 39000 metadata still loaded, so
the group appeared but showed no messages.
Treat a NIP-29 relay group the user explicitly joined (their kind-10009
list) as a first-party reason to authenticate with its host relay,
mirroring the existing `BuzzWorkspaces.isJoined` carve-out. Reads of a
group the user has not joined (e.g. browsing a relay's public directory)
still do not AUTH.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018puT2YnevbLHYG2PdCLAeh
Buzz's private media relay (*.communities.buzz.xyz) gates blob downloads
behind BUD-01 read auth, returning `401 {"error":"authentication failed"}`
to anonymous GETs. Amethyst loaded every media URL anonymously through
Coil/OkHttp, so those images (and their thumbnails) never decoded.
Quartz already had BlossomAuthorizationEvent.createGetAuth but nothing in
the app ever called it. Wire it into the media HTTP client:
- BlossomReadAuthInterceptor: on a 401 for a GET whose URL last segment is
a Blossom sha256 filename (covers `<hash>.png` and `<hash>.thumb.jpg`),
retry once with a signed `Authorization: Nostr <event>` header. Narrowly
gated so unrelated 401s never trigger a second request or any signing.
- BlossomReadAuthTokenProvider: bridges the suspend signer synchronously
(runBlocking + timeout so a slow remote/external signer can't pin the
OkHttp thread) and caches one server-scoped token per host, which also
covers derived blobs like thumbnails.
- BlossomAuth.createGetAuth exposes the read-auth builder to the app layer.
Public Blossom/NIP-96 hosts stay zero-overhead: they answer 200, so no
token is ever signed for them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ao9w26c2gAm4gjJdhgvLyp
Add a "Content in the feed" section to the Home settings screen with a
switch per event-kind group (text notes, reposts, comments, articles,
polls, voice, live activities, chess, ...). Disabling a group both drops
its kinds from the always-on home relay filters (the assembler) and hides
them from the New Threads / Conversations / Everything tabs (the DAL).
- HomeFeedType: single source of truth mapping each toggleable group to
its Nostr kinds, with stable codes + encode/decode for persistence
(mirrors the existing ChatFeedType pattern for Messages).
- AccountSettings.enabledHomeFeedTypes (+ setHomeFeedTypeEnabled),
persisted per-account as the set of disabled codes so new groups
default on.
- Assembler: HomeOutboxEventsEoseManager strips disabled kinds from every
home relay filter at the single choke point, and re-arms on toggle.
- DAL: HomeNewThreadFeedFilter / HomeConversationsFeedFilter reject
disabled kinds; AccountFeedContentStates rebuilds the home feeds when a
toggle flips.
Also extracts the inbox / relay-auth / feed-type preference reads out of
LocalPreferences' account-load lambda into a helper: that lambda was
already at the JVM per-method bytecode limit and the new field tipped it
over ("Method too large").
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L576BhgU3c638PkGHL8YUS
Add a CONTINENT(1) level to GeohashChannelLevel so the coarsest selectable
geohash channel is a single character (~5000 km, one of 32 cells for the
globe), previously floored at REGION(2). Every precision picker iterates
GeohashChannelLevel.ordered, so the map picker, Teleport, "Near me" list and
New-geohash-chat all pick it up automatically; GeohashChatsScreen now labels a
1-char cell "Continent" instead of showing no level.
Adds the "Continent" label and "~5000 km" chip subtitle, and extends the
GeohashChannelLevel test coverage. REGION..BUILDING (2-8 chars) still mirror
the Bitchat channel levels; CONTINENT is an Amethyst extension beyond them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vtvo2iNSnG3M21QwbFPznU
Both nostr proposals merged just now (82e72369, bdb4b03e) referenced types
by fully-qualified name inside function bodies, which CLAUDE.md's Kotlin
style rule forbids. Merged them as authored rather than rewriting someone
else's patch mid-merge; this is the follow-up.
- NamecoinNameResolver: import kotlinx.coroutines.CancellationException.
Both catch sites were inline-qualified (one added by the proposal, one
already there), and the sibling resolvers in this same package
(Nip05Client, UserHexResolver) already import it.
- desktop Main.kt: import the five notification symbols in the block the
proposal touched — the two Preferences* factories and the three
Local*Notification* CompositionLocals. Each occurs exactly once, so no
fully-qualified stragglers are left behind for those names.
Deliberately scoped to that block: Main.kt has ~130 other inline
fully-qualified names, and sweeping them belongs in its own change, not
tacked onto a style follow-up.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merges nostr proposal bdb4b03e into main:
- Catch NamecoinLookupException in performLookup and return null, restoring
resolve()'s documented "null on any failure" contract. nameShowWithFallback
throws NameNotFound / NameExpired / ServersUnreachable, which escaped
resolve() and reached Nip05State.checkAndUpdate as a hard error.
- CancellationException is rethrown first so coroutine cancellation is not
swallowed.
- resolveDetailed() is unaffected: it goes through performLookupDetailed(),
which still distinguishes each failure reason.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merges nostr proposal 82e72369 into main:
- Provide LocalNotificationSettings / LocalNotificationReadState at the app
root. Both CompositionLocals were already consumed by NotificationsScreen
and NotificationSettingsScreen but never provided, so each screen fell back
to its own PreferencesNotificationSettings(); disk prefs stayed in sync but
the in-memory StateFlow updates never crossed instances.
- Flip the master toggle when the OS permission is granted, so the "Enable OS
notifications" button matches its label end to end.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Removing the per-row Cards also removed the only thing separating adjacent
rows. Restore that with the 0.25dp outlineVariant hairline the vanilla
NIP-29 branch of this same screen and the Concord server list already use.
Drawn before each row except the first, so a section's own header keeps
providing the separation at its boundaries — no stray line under the last
channel or above "Direct Messages". Applied to every row list on the screen
(chat channels, forums, inline DMs, hidden DMs) rather than just the
Channels section, so the screen stays one consistent surface; the vanilla
branch now shares the same helper instead of inlining it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each channel row was its own filled Material3 Card. They stack with no gaps,
so their container colour merged into one continuous grey block behind the
whole Channels (and Forums) section — reading as a box drawn around the
channels that the Direct Messages rows immediately below, which are plain
rows, didn't have. The two halves of the same screen looked like different
surfaces.
Render the row as a plain Box on the screen background instead, keeping the
click on the container so the whole row still opens the channel. Matches
BuzzDmInlineRow directly below it and the Concord server list.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`AccountManager.switchAccount()` correctly emits a new
`AccountState.LoggedIn` and the `remember(account, ...)` blocks around
`iAccount` / `accountRelays` in Main.kt already rebuild those. But the
Composables *inside* MainContent — feed screens, notification inbox,
profile screen, messages list — all held their own `remember { ... }`
state (LazyListState scroll position, expanded rows, per-column filter
tabs, in-flight metadata observers, view-models keyed on nothing) that
did NOT include the account as a key. So after a profile switch the
user still saw account A's feed items, notification unread state, and
follow-status overlays rendered under account B's identity, until they
navigated away and back to force the column to recompose from scratch.
Wrap the `MainContent(...)` call in `key(account.pubKeyHex) { ... }`.
This is the idiomatic Compose pattern for "identity changed — tear
down the entire subtree and rebuild it fresh": every child's
`remember { ... }` block re-runs, every `LaunchedEffect` re-enters,
every subscription restarts. Outer state (`deckState`, `workspaceManager`,
`singlePaneState`, `pinnedNavBarState`) lives above this call site so
the user's column layout / workspace / nav backstack are preserved —
only the account-owned content resets.
Concretely, after this change:
- Home Feed on account A → switch to account B → column now shows
account B's home feed, following account B's follow-list.
- Notifications tab on A → switch to B → tab reloads with B's unread
cursor, B's notification-kind toggles, B's mute/block enforcement.
- Direct Messages column stays on the Messages screen, but the
chatroom list is B's, DM subscriptions restart against B's kind:10050
inbox relays, giftwrap decryption uses B's signer.
- Profile screen viewing @alice: still viewing @alice, but the follow
button / mute button reflect B's follow/mute state instead of A's.
No new tests: verifying this needs a Compose UI test harness Amethyst
Desktop doesn't ship yet. The scoped-teardown behaviour of `key()` is
Compose runtime contract, not app-level state to pin down.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Two independent gaps meant the desktop notifications flow silently
did nothing after the user clicked the settings button:
1. `LocalNotificationSettings` and `LocalNotificationReadState` were
declared but never `.provides()`'d anywhere. Both
`NotificationSettingsScreen` and `NotificationsScreen` fell back to
a fresh `PreferencesNotificationSettings()` / `PreferencesNotification-
ReadState()` instance each time — the java.util.prefs backing store
kept the values consistent across cold restarts, but each Composable
held its own `MutableStateFlow`, so a `setEnabled(true)` in Settings
never fired the collectors in the inbox banner or in the auto-
dispatcher. The user could toggle the master switch back and forth
and nothing observable happened until the app was restarted. The
auto-dispatcher in Main.kt was already using its own hoisted
`notifSettings` instance too — just never handed down through the
composition — so it never even saw the toggle.
2. The "Enable OS notifications" button (only shown on macOS when
permission == NotRequested) called `dispatcher.requestPermission()`
and updated `permissionState`, but it never touched `settings.enabled`.
So the OS prompt appeared, user clicked Allow, the UI cheerfully
said "Permission granted" — and toasts still didn't fire because the
master switch was still off (defaults to false, first-launch UX
choice). The button label promised the whole flow; the code did
half of it.
Fix:
- Hoist `notifSettings` (already existed for the auto-dispatcher) into
the app-level `CompositionLocalProvider` as
`LocalNotificationSettings provides notifSettings`, so every consumer
reads/writes the same instance. Same-instance sharing means StateFlow
emissions actually propagate.
- Add a per-account `PreferencesNotificationReadState`, keyed on
`loggedIn?.pubKeyHex` via `remember(pubKeyHex)`, provided through
`LocalNotificationReadState`. This also fixes the "Mark all as read"
button in the inbox, which was `enabled = false` because the
composition-local was always null.
- In `NotificationSettingsScreen`, if `requestPermission()` returns
Granted and the master switch is currently off, call
`settings.setEnabled(true)` alongside setting the status message. The
master switch UI observes `settings.enabled` and recomposes
automatically, so clicking one button now does the whole handshake.
Behaviour on other platforms is unchanged: Windows / Linux start in
`PermissionState.NotApplicable`, so the "Enable OS notifications" branch
never renders there — they see the "Send a test toast" button directly.
The auto-enable is guarded by `!enabled`, so users who deliberately
disabled the master switch and then re-granted permission (e.g. after
denying in System Settings) don't get overridden if the switch was
still on.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
On the Concord home list the row's `clickable` expands/collapses, and the
only way to open a community was a separate `clickable` on the 40dp
avatar — nothing marked it as its own target, and tapping the name (the
thing most people reach for) silently expanded the row instead.
Give the name/subtitle column the same `onOpen` the avatar already has, so
the identity block — icon and title together — opens the community while
the rest of the row and the chevron keep expanding it. Tapping a title to
open the thing it names is the convention users arrive with; the disclosure
control stays the disclosure control.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`SecureKeyStorage`'s three keyring paths (save/get/delete) each called
`Keyring.create()` on every invocation. Each call opens a fresh backend
session:
- macOS: a new Security Framework session against `login.keychain`.
Depending on the user's keychain policy (short access window, ACL on
the amethyst-desktop item, or first-touch after unlock timeout), this
surfaces a Keychain Access prompt every time.
- Linux Secret Service / KWallet: a fresh session may re-trigger the
wallet-unlock prompt if the daemon closed the previous session.
- Windows Credential Manager: less user-visible but still redundant.
Amethyst's cold-boot touches the store at least twice — once for
`DesktopAccountStorage`'s AES-256-GCM metadata key
(`account-metadata-key`), then again for the active account's nsec —
so the user was seeing the OS keychain unlock prompt twice in a row
before the UI was reachable.
Fix: memoise the `Keyring` handle for the lifetime of the process. The
`Keyring` object is thread-safe for the three ops we call, so a
double-checked lazy singleton behind `keyringLock` is sufficient. The
NPE hit path is a proper lazy: any `BackendNotSupportedException` bubbles
up on the first call and is caught by the existing outer try/catch,
which flips `keyringAvailable=false` and falls back to the encrypted
file path (unchanged).
Includes a small package-private `KeyringHandle` interface + real
delegator so `SecureKeyStorageKeyringCacheTest` can substitute an
in-memory handle and count backend-open invocations without touching
the OS keychain. Three cases:
1. Cold-boot storm (save/get/delete across metadata + account keys)
opens the Keyring exactly once.
2. Repeated `hasPrivateKey` reuses the cache.
3. Concurrent first-touches from 16 threads still open the Keyring
exactly once (double-checked locking is race-free).
No behavioural change beyond the prompt-count fix.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Audit follow-ups on the highlight context window:
- Blank lines sitting at the very start/end of a `context` tag were passed
through untouched when a side was short enough not to be trimmed, so a
highlight whose context began or ended with blank lines still rendered
empty space above/below the quote. Trim the outer edges of the windowed
passage (re-basing the marked range accordingly).
- `locate` enumerated every occurrence of the quote — a full context scan
plus a list allocation — even in the common no-prefix case where only the
first match is needed. Short-circuit to a single indexOf there.
- Clamp the returned marked range to the trimmed text length so it can never
point past the end (e.g. a quote ending in trimmed whitespace).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApuEseGcFjUFqYoLhCuR91
Same defect the relay-group and Buzz DM lists had: the Scaffold's padding
carries the top/bottom bars but deliberately not the FAB (a FAB overlays
content by design), so the last channel row's manager overflow menu sat
underneath it and couldn't be tapped.
As contentPadding rather than a modifier, so rows scroll *through* the
strip instead of the viewport shrinking and leaving the FAB over dead
space. Gated on canManageChannels because that is what renders the FAB —
a plain member has nothing to clear, and no reason to lose the space.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A kind:9802 highlight can carry a huge `context` tag — a quote pulled from
the middle of a long article may ship several paragraphs of surrounding
text. Rendered whole, that fills the feed card with paragraphs around a
one-sentence highlight.
Trim the context in `HighlightQuote.of` to at most ~160 characters on each
side of the marked quote, snapping the cut to a whole-word boundary and
marking it with an ellipsis. The quote itself is always kept in full and
the marked range is re-based onto the trimmed text, so the in-context
marker still lands exactly on the highlighted passage. Short contexts are
left untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApuEseGcFjUFqYoLhCuR91
Posts were publishing a flood of bogus `r` tags — the v1.13.0 release note
shipped 11 junk references such as `https://.deb/`, `https://window.nostr/`,
`https://kind:30166/` and `https://crowdin.pretended462/`.
The reference extractor `findURLs` fed the raw, scheme-less UrlDetector output
straight into `r` tags. That detector is deliberately eager: to let the
rich-text renderer linkify a bare `example.com`, it also reports every
`word.word`, `word/word` or `word:port` token with no real-TLD whitelist.
Prose is full of those (`.deb`, `.rpm`, `[database].backend`,
`nostr-wallet-connect/nwc`, `~2.5x`, `@mentions`), so each one became a
reference on the published note. The rich-text side already guards against
this via UrlParser (TLD validation + scheme separation); the tag path never
got the same guard.
Require an explicit http/https scheme and a valid TLD before a detected URL
becomes a reference. Rendering is untouched (separate parser), so bare domains
still show as links — they just no longer pollute the tags.
Adds regression coverage over the exact fragments from the v1.13.0 note plus
checks that real, explicitly-schemed links are still extracted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L8KXx8UQ6mgyxjBSWW6sQV
"Remove from Messages" was hardcoded on every surface, so it never showed
an "Add to Messages" counterpart for a channel that was already off the
list, and the Buzz workspace rows read a session-local snapshot of the
kind-10009 list that was seeded once and only ever grew — a channel taken
off Messages still rendered as a disabled "Added", leaving no way back.
Removal itself always worked (verified on-device: the kind-10009
republished and the row left Messages); what was missing was any read of
that list on the way back.
- RelayGroupListState: expose liveRelayGroupIds, the joined groups as
normalized GroupIds, so the UI can ask whether a channel is on Messages
without string-matching a raw relay url another client may not have
normalized the way we do.
- RelayGroupTopBar / BuzzImportRow: one Add/Remove toggle driven by that
flow. Remove no longer pops back — you stay a member reading the
channel, and staying is what makes the entry flip so the action is
visibly undoable. Leave still pops.
- BuzzRelayImportViewModel: track "added" against the live list instead of
a one-shot seed, and add remove(); add() now also clears the dismissal
so a relay's kind-44100 re-announcement isn't filtered back out.
- AccountViewModel: addRelayGroupToMessages() as the counterpart to
removeRelayGroupFromMessages(); acceptChannelInvite() delegates to it.
Buzz DMs had the same one-way shape for a different reason: hiding is a
relay-side per-viewer flag (kind-41012 -> the kind-30622 snapshot), and
rebuildRows dropped hidden DMs on the floor, so a hidden conversation was
gone for good. There is no unhide command — re-opening is the unhide, a
kind-41010 with the same participants resolving to the same canonical
channel. Hidden DMs are now projected into their own list behind a
collapsible "Hidden (N)" header, faded but still openable, each offering
"Add to Messages". Also added to the community view's inline DM rows,
which had no menu at all and are where DMs actually live — the full inbox
sits behind a "see all" row that only appears above six DMs, so in a small
workspace the hidden section would have been unreachable.
Both list screens now leave bottom room for the FAB, which the Scaffold's
padding deliberately doesn't account for; the last row's overflow menu was
sitting underneath it.
Adds SimpleGroupListEventTest covering the removal path, including that a
renamed channel still matches (removal keys on group id + relay only).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A kind:9802 highlight with no `context` tag falls back to reconstructing
the surrounding passage from the W3C `textquoteselector` prefix/suffix.
Those fragments are scraped from the source web page, so they carry the
page's block-boundary whitespace (runs of newlines/spaces between DOM
nodes). Glued in verbatim as `prefix + content + suffix`, they render as a
stack of blank lines above the marked quote.
Collapse each whitespace run in the prefix/suffix to a single space (and
trim the outer edges) in `HighlightEvent.contextOrReconstructed()`. The
highlight's own `content` is left verbatim so its offsets inside the
reconstructed context stay exact for the in-context marker.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApuEseGcFjUFqYoLhCuR91
EventListMatchingFilter had the same mutable-sort-key defect as
NoteListMatchingFilter: it stored Notes in a ConcurrentSkipListSet ordered by
the live created_at, so a newer replaceable version (which mutates the shared
AddressableNote in place) stranded its node and let the same instance be
inserted twice — the emitted event list then carried the same event twice. It
hadn't surfaced as a crash only because its consumers (app recommendations,
relay groups, room reactions) happen to dedup downstream.
Apply the same capture-key + idHex-dedup + per-key compute design, but preserve
EventListMatchingFilter's update-reflecting semantics: an addressable update
re-emits (the snapshot reads the refreshed event live off the note) rather than
being ignored. It keeps the entry's captured position instead of re-sorting —
re-sorting via remove+add let two entries with different captured keys for the
same note transiently coexist and both read the same live event, duplicating it.
Also harden both filters' emission: a ConcurrentSkipListSet iterator is weakly
consistent, so under concurrent add/remove churn a single traversal can
momentarily surface a key twice. snapshot() now dedups by idHex so the emitted
list — the LazyColumn's source of keys — is always unique, regardless of
transient internal states. Corrected the over-claimed "can never hold two"
docstrings accordingly.
Adds EventListMatchingFilterTest mirroring the note tests: update-reflection,
version-note re-emit, sorted order, remove-after-mutation, and two concurrency
stress tests (with/without limit) that failed before this fix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ah1aCniyjnzc27x4pwq2Df
Wire up the existing kind-9008 DeleteGroupEvent, which was parsed on
receipt but never sent. Admins/owners can now delete a whole channel
(Buzz) or group (NIP-29) from the channel overflow menu.
- Account.deleteRelayGroup: sends the kind-9008 delete-group to the
channel's relay(s) and drops it from the local list.
- AccountViewModel.deleteRelayGroup: signer-dispatched delegate.
- RelayGroupTopBar: admin-only "Delete channel" / "Delete group" item
(error color), gated on RelayGroupMembership.ADMIN — the same
authorization as Edit — behind a confirmation dialog that pops back
off the deleted channel's screen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfd77tUsZ8h6ywcgwY7VVB
canPop() read the globally-current back-stack entry via
currentBackStackEntryAsState(). A pop commits the instant it is accepted
— most visibly during a predictive back-swipe, whose exit animation is
long and finger-driven — so controller.currentBackStackEntry flips to the
destination while the screen being dismissed is still on screen, sliding
out and still composing its top bar. Evaluating canPop against the
incoming destination there dropped the back arrow (and re-showed the
bottom bar) before the outgoing screen had finished leaving, which is
exactly the flicker seen when back-swiping to Home or a bottom-nav root.
Evaluate poppability against the screen's own NavBackStackEntry — the one
the NavHost provides to each destination via LocalViewModelStoreOwner —
which is intrinsic to that screen and never changes for the life of its
composition. The arrow now stays put until the screen itself is gone.
Callers outside a NavHost destination (shell chrome, drawer) see the
account-scoped owner instead of an entry and fall back to the previous
globally-current behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011matx1mGJ6ZyS5dS77EQUo
The `buzz_dm_hide` string key exists only in translation files but has no
entry in the default `values/strings.xml` and is not referenced anywhere in
code. This triggered the lint ExtraTranslation error and failed the
`:amethyst:lintFdroidBenchmark` build. Removed the orphaned key from all 7
locale files.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ksnCb8x45HnL7nFPzxh4B
Add coverage for a real consumeBaseReplaceable call the suite was missing:
observers are also notified with the "version" note (getOrCreateNote(event.id),
a regular Note carrying the AddressableEvent), which the addressable-list guard
must drop while still listing the AddressableNote for the same event.
Confirmed the concurrency the stress tests exercise is real, not theoretical:
relay events are verified+consumed inline on per-relay socket dispatchers, so
distinct relays drive new()/remove() on the same note instance concurrently.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ah1aCniyjnzc27x4pwq2Df
Raise every ResourceUsageAlerts threshold to 2x so the "this app is
consuming too much" report prompt only fires at twice the previous
consumption levels, cutting false positives on heavy-but-normal days:
- background mobile data: 50 MB -> 100 MB / day
- background mobile relay-connection time: 12 h -> 24 h / day
- notification wakelock: 30 min -> 60 min / day
- app process starts: 75 -> 150 / day
- completed relay (re)connections: 5000 -> 10000 / day
Tests reference the constants symbolically, so the alert suite stays green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2efhyiQHKFoNjQo6WnGRH
The recent-posters facepile in a Buzz community's channel rows drew each
poster with ObserveAndDrawInnerUserPicture — a bare cropped image wrapped
in a surface-coloured ring, overlapped into a deck. That omitted the
following badge and trust-score tag every other user avatar in the app
carries.
Draw each poster with ClickableUserPicture (the regular BaseUserPicture
path) instead, so the facepile shows the following icon (top-right) and
trust-score tag (bottom-centre). Lay the avatars out with a small gap
rather than an overlapping stack so those badges stay readable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013f3JZdoChYEEDtqTArFAxn
"Leave" meant three different things and the actions were scattered across
each channel's own screen, so they were hard to find from Messages. Make the
vocabulary consistent and reachable:
- "Leave" now always means "renounce membership / you're out" — the kind-9022
LeaveRequestEvent for NIP-29/Buzz groups, and the kind-13302 self-list removal
for Concord (its only exit).
- "Remove from Messages" is the single soft action: take it off my list but keep
membership. For a joined relay group this drops the kind-10009 entry without a
9022 (I stay in the roster) and dismisses the invite so a Buzz kind-44100
re-announce can't bounce it back. The Buzz DM "Hide conversation" reuses the
same label.
Surface both on the Messages rows via long-press (previously only reachable
inside each group/community screen):
- Relay-group row: "Remove from Messages" + "Leave".
- Concord row: "Leave" (reuses the existing confirm dialog; a community has no
soft/hard split since the list entry is the whole membership).
Split the relay-group top-bar menu into the same two actions, thread an optional
onLongClick through ChannelName, and consolidate the buzz_dm_hide string into the
shared remove_from_messages string.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NRifAJ75U4zWbg3V3Y3g8g
The Buzz relay group (community) top bar and its sub-screens (members,
threads, browse, channel list), the Buzz canvas, geohash chat/new/teleport
screens, the single-URL and single-geohash viewers, and the Marmot group
chat all forced FontWeight.Bold (geohash chat also titleMedium) on their
top-bar titles, while every sibling channel header — DM rooms, public
chats, ephemeral chats, live activities — and the shared TopBar wrappers
render the Material3 titleLarge default at normal weight.
Drop the bold (and size) overrides so all top nav bar titles share one
consistent treatment.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019N7b8D4vBvafhG9eU7M9iJ
Audit follow-up. The previous fix used two independent concurrent structures
(ConcurrentSkipListSet + ConcurrentHashMap) coordinated with putIfAbsent, but
observer callbacks fire from multiple consume threads at once (relay ingest +
UI-side justConsume). A new()/remove() interleaving for the same idHex could
desync the two structures — remove() clears byId and no-ops on the sorted set
before new() has added the entry — leaving an orphan that a later new()
duplicates, reintroducing the duplicate-key crash.
Keep it lock-free (this observer is used everywhere and needs the throughput):
every write to the sorted index for a given idHex now happens inside that key's
ConcurrentHashMap.compute critical section, so the sorted set and membership map
move together. ConcurrentHashMap stripes per key, so same-idHex ops serialize
while different keys stay fully parallel. Invariant: an entry is added to the
sorted set only while its key is absent from byId, and every path that frees a
key removes its sorted entry first, so the set can never hold two entries for
one idHex.
Adds concurrency stress tests (with and without a relay limit) that fan out 8
threads hammering new/remove while created_at churns; both fail against the
non-atomic version and pass here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ah1aCniyjnzc27x4pwq2Df
Address review feedback: keep the incrementally-maintained, created_at-sorted
structure (a feed must stay sorted like a relay) instead of re-sorting a hash
map on every emission.
The root cause is unchanged: there is one Note instance per id/address
(LocalCache owns creation), but a note's sort key is mutable — a newer
replaceable event swaps the event on the SAME AddressableNote instance,
changing created_at in place. A sorted set ordered on that live value corrupts:
the moved node leaves the add()/remove() search path, so the same instance is
inserted twice and the emitted list carries a duplicate idHex, crashing the
App Recommendations LazyColumn (keyed on idHex).
Fix: snapshot the sort key into an immutable Entry when the note first enters,
order a ConcurrentSkipListSet on that snapshot (never read live again), and
index entries by the stable idHex (ConcurrentHashMap + putIfAbsent) so
membership stays unique and removal is reliable regardless of later created_at
changes. Ordering and "new versions do not update the list" are preserved.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ah1aCniyjnzc27x4pwq2Df
Three top nav bar titles overrode the Material3 titleLarge default with
titleMedium, rendering ~16sp while every other top bar title inherits
~22sp. Drop the titleMedium override so the Calendar event detail and Git
repository (home + sub-screen) titles match the rest of the app.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019N7b8D4vBvafhG9eU7M9iJ
Sweep of every imePadding() in the app for the same nav-bar-over-IME
double-count fixed for the chats. Two more bare Material3 Scaffold screens
applied the scaffold content padding (which carries the nav-bar inset) and
then imePadding() without consuming in between, so the nav bar stacked on top
of the IME while the keyboard was up:
- NewGeohashChatScreen (geohash create form)
- MarmotGroupInfoScreen (has the add-member search field)
Both now consumeWindowInsets(pad) before imePadding(), matching the idiom the
other forms already use. Every other imePadding() call was verified fine:
DisappearingScaffold-based screens are handled by the scaffold's own
reservation, dialogs/bottom sheets carry no nav-bar content padding, and the
rest either apply imePadding() alone at a root or already use the
navigationBarsPadding()/consumeWindowInsets union.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dT2RLbPZzan2hULL2cmc6
NoteListMatchingFilter (backing LocalCache.observeNotes) stored notes in a
ConcurrentSkipListSet ordered by CreatedAtIdHexComparator. AddressableNotes are
mutable: when a newer replaceable event arrives, LocalCache swaps the event on
the SAME note instance (consumeBaseReplaceable -> loadEvent), changing its
createdAt in place, then re-notifies observers. A sorted set cannot survive a
member's sort key mutating underneath it — the moved node is no longer found on
the add() search path, so the same note gets inserted a second time and the
emitted list carries a duplicate idHex.
The App Recommendations screen keys its LazyColumn on note.idHex (an
AddressableNote's address, e.g. 31990:<pubkey>:nostr-dvm-labeler), so the
duplicate crashed with IllegalArgumentException: "Key ... was already used".
Dedupe by the immutable idHex instead of a createdAt-ordered set; ordering is
computed fresh on each emission. Adds a regression test reproducing the
multi-item corruption path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ah1aCniyjnzc27x4pwq2Df
A dissolved community (owner-signed kind-3308 tombstone) is sealed
read-only per CORD-02 §9: held keys still open history, but nothing new
is honored. The `dissolved` flag was folded in quartz but ignored by the
write gates, so members — and the CLI — could still post to a dissolved
community.
- commons: ConcordChannel now tracks `dissolved` from the folded state
and `canPost()` returns false when set, so the Android composer (which
gates on it) is hidden. The self-delete carve-out is unaffected — it
runs through the note context menu, not the composer.
- amethyst: show a read-only notice where the composer would be so the
seal is explained rather than silent.
- cli: `amy concord send` folds the community and refuses with a
`dissolved` error before building/publishing; `amy concord channels`
surfaces the `dissolved` flag.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HateTjrutJ23wAEttEwHQA
The Browser home "Discover nsites/napplets" sections render each followed
manifest in a LazyVerticalGrid keyed by "ns:"/"np:" + coordinate. The
observed store (NoteListMatchingFilter, backed by a ConcurrentSkipListSet
ordered by created-at/id) can surface the same addressable note twice when a
replaceable manifest gets a new version — mutating the note's sort key inside
the set breaks dedup. Both copies map to the same coordinate, producing a
duplicate grid key and crashing with IllegalArgumentException.
Collapse the mapped list by coordinate so each app appears once and grid keys
stay unique.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9TomvuP9YnPUDCZuiQae6
While the keyboard was up, the public / relay-group / live-activity /
ephemeral chats — and the Concord channel + minichat views — left an extra
navigation-bar-height gap between the composer and the keyboard. WindowInsets.ime
is measured from the bottom of the screen, so it already spans the nav-bar
band; reserving the nav bar again on top of it double-counts. NIP-17 DMs were
unaffected because their scaffold reserves the nav bar with the consuming
navigationBarsPadding(), which excludes the already-consumed IME inset (a
union), while the bottom-bar chats reserved it as a plain, non-excluding pad.
- DisappearingScaffold: when the bottom-bar slot renders empty it now reserves
max(0, navigationBars - ime) instead of the full nav-bar inset, so the
reservation drops to zero while the keyboard is up (the root imePadding has
already lifted the scaffold above it). Covers every bottom-bar chat at once.
- ConcordChannelScreen / MinichatScreen: use the standard
padding(padding).consumeWindowInsets(padding).imePadding() union instead of
summing a plain padding(padding) with imePadding().
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dT2RLbPZzan2hULL2cmc6
Leaving a chat with the keyboard up via a back gesture left a large IME
padding stranded at the bottom — and, because WindowInsets.ime is a single
app-wide holder, on every other screen too — until some later inset pass
happened to rebalance it. It only reproduced on release builds.
Root cause: the chat composers install a BackHandler that flushes the draft
and pops the screen. When that pop runs while the keyboard is still visible,
the predictive-back window animation races the IME's close animation. On an
optimized release build the window animation wins, and the IME
WindowInsetsAnimation is cancelled before its terminal (zero) frame reaches
Compose, so the shared insets holder stays "animating" and every
imePadding() in the app freezes at the keyboard height. Debug and benchmark
builds are slow enough that the IME animation completes first, which is why
they were unaffected.
Fixes:
- New KeyboardAwareBackHandler: while the keyboard is on screen it does not
consume back, so the system dismisses the keyboard first (its animation
completes cleanly); the next back runs the original handler. The top-bar
back arrow remains an always-available exit. Adopted in the DM, public-chat
and new-group-DM composers.
- Rederive keyboardAsState() from WindowInsets.ime instead of the
pre-edge-to-edge getWindowVisibleDisplayFrame/OnGlobalLayout heuristic,
which under enableEdgeToEdge() could itself latch Opened after the keyboard
closed. Now it tracks the same inset that drives imePadding().
- Concord channel chat and the minichat thread view used a bare Material3
Scaffold whose content insets ignore the IME; add imePadding() so the
composer rides above the keyboard while typing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dT2RLbPZzan2hULL2cmc6
- Rename LocalCache Dao overrides to match supertype parameter names
(getOrCreateUser: pubkey->hex, getOrCreateNote: idHex->hex,
getOrCreateAddressableNote: key->address) so named-argument calls stay safe.
- Replace deprecated MenuAnchorType with ExposedDropdownMenuAnchorType in the
Buzz dropdown composables.
- Remove the buzzChannelType() extension in BuzzChannelMetadata, now shadowed by
the equivalent (stricter) member on GroupMetadataEvent, and drop its unused
imports.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018VBP6HGj9M2kzA4WKAivjC
Move the bottom navigation bar entry out of App Settings and into the
Account Settings section of the All Settings catalog, since its state is
stored per account (AccountNavigationPreferencesInternal.bottomBarItems in
the account-synced settings blob).
While auditing the App Settings section for other per-account entries, two
more were purely per-account and moved alongside it:
- Reactions settings (reaction row actions) writes account-synced
reactionRowItems and now sits next to the existing "Reactions" entry.
- Messages settings writes account-scoped chat feed toggles and view modes
(account.settings.enabledChatFeeds / relayGroupViewMode / concordViewMode).
The remaining App Settings entries stay put: UI preferences, Home tabs,
Profile UI, Calendar reminder, OTS, Namecoin and Resource usage are all
device-global, and Compose/Notification settings are mixed (they hold
genuine app-global preferences alongside a few per-account toggles).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH88gCbbdTfdzp9wrr7buJ
Replacing the markdown pipeline dropped TranslatableRichTextViewer, and
with it the ML Kit auto-translation of the quoted passage. Restore it
without giving up the marker.
A translation rewrites the passage, so the character offsets that locate
the quote inside its context stop pointing at anything. Translate the
quote as well and re-find it in the translated context: ML Kit works
sentence by sentence, so a quote that is one or more whole sentences --
the common case, since people highlight sentences -- comes back identical
whether it is translated alone or inside its paragraph.
untranslated -> context, original span marked
translated + quote found -> translated context, translated span marked
translated + not found -> translated quote alone, fully marked
The fallback is free: HighlightQuote.of already returns the quote alone
when the needle is not in the haystack, so the not-found case needs no
special casing. Marking a guessed span, or claiming the whole paragraph
was highlighted, would both be worse than showing less.
That second translation must not draw its own status bar, so add
rememberTranslation() to both flavors: play reuses the existing
translateAndCache and its cache; fdroid, which ships no translation
service, returns the content unchanged.
Also indent the "Auto-translated from X to Y" line to the quote's own
15dp so it lines up with the text rather than the bar.
Verified on device: an English highlight renders as a fully translated
Portuguese paragraph with the quoted sentence marked inside it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The renderer never drew a highlight. It synthesised a markdown string --
blockquote each line with "> ", wrap the quoted span in "**" -- and handed
it to the rich-text viewer, so a highlight arrived as bold text. That also
meant the quoted article prose was parsed as markdown, so any *, _, # or [
in it was interpreted as formatting rather than shown.
Drop the markdown round-trip and paint the marker behind the glyphs. The
stroke is drawn per visual line from the TextLayoutResult, so it follows
soft wraps and stops at real glyph edges. Per-line rounded rects rather
than SpanStyle(background), which can only ever be a hard full-line-height
rectangle -- that is what buys the rounded pen ends.
Size the stroke from the baseline and font size, not the line box, so
leading and stroke weight stay independent knobs.
Along the way:
- Locate the quote as an index range instead of context.replace(), which
marked every occurrence when a quote repeated. Use the W3C
TextQuoteSelector prefix -- already on the event, previously ignored --
to disambiguate.
- Restore 1.35em leading. The markdown path forced 1.5em via
MarkdownTextStyle; the ambient bodyLarge sets no lineHeight at all, so
rendering plain text inherited the font's intrinsic ~1.2em.
- Indent the source attribution by the quote's own 15.dp so it lines up
with the text rather than the bar, and space the comment, quote and
attribution 8.dp apart -- they were flush at 0.dp.
- Clamp the stroke to the column so it cannot be clipped on full-width
lines.
Light keeps a near-opaque yellow with dark glyphs reading through it. Dark
cannot do that, so it gets a translucent amber that glows rather than
covers. Not derived from the user's accent: a highlighter reads as yellow.
The quoted passage no longer routes through TranslatableRichTextViewer, so
it loses its auto-translate affordance; drawing the marker requires owning
the text layout. The author's own comment above the quote keeps it.
Verified on device in both themes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Persisting a bottom-bar edit went through `launchSigner { account.change... }`,
which dispatches on a multi-threaded pool. Because the reactive StateFlow emit
happened inside that coroutine, two quick edits (e.g. tapping Add on several
catalog rows) could complete out of order: the older list would win the flow,
and the settings screen — which re-seeds its editable list from the flow via
`LaunchedEffect(savedItems) { syncFrom(...) }` — would visibly revert the newer
edit and publish the stale list in the NIP-78 event.
Apply the change to the in-memory flow synchronously on the caller (UI) thread
via `Account.applyBottomBarItems` (a non-suspending emit + local save, both
non-blocking) so rapid edits stay strictly ordered, and run only the
sign/encrypt/publish off-thread. Restores the ordering guarantee the previous
synchronous `tryEmit` had.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJiPHArXZ7P5EvZa7GN9fP
Replace the hand-rolled RobohashFallbackAsyncImage + observeUserInfo +
CreateTextWithEmoji in the background-accounts list with the app's standard
user components: LoadUser resolves the User behind each npub, and
ClickableUserPicture / UsernameDisplay render the picture and name. This
reuses the shared metadata observation, robohash fallback, custom-emoji
rendering, per-user nickname handling, and npub fallback instead of
duplicating that logic here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvB5t1ittHpw4PdJQYZYfp
The bottom nav row configuration was an app-global setting stored in the
shared DataStore, so every account shared one bar. Move it into the
per-user NIP-78 app-specific data event (AppSpecificDataEvent) so each
account keeps its own bar and it syncs across the user's devices.
- Add `navigation.bottomBarItems` to AccountSyncedSettingsInternal (the
serialized/encrypted synced-settings blob) and mirror it as a StateFlow
in AccountSyncedSettings (seed / toInternal / updateFrom).
- Add AccountSettings.changeBottomBarItems, Account.changeBottomBarItems
(republishes the NIP-78 event), and AccountViewModel.changeBottomBarItems
/ bottomBarItemsFlow().
- Remove bottomBarItems from the app-global UiSettings / UiSettingsFlow /
UiSharedPreferences (including the now-unused encode/decode migration
helpers).
- Point the live bar, navigation rail, preloaders, subscriptions and the
Bottom Bar settings screen at the per-account flow.
No migration from the previous app-global setting: accounts start from the
default bar, matching the requested behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJiPHArXZ7P5EvZa7GN9fP
The background-accounts list in the Notification Settings screen showed
each account by its npub only. Resolve the User behind each account and
observe its live metadata so the row displays the profile picture and
best display name (with the npub as a secondary line), falling back to
the short npub while metadata loads.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvB5t1ittHpw4PdJQYZYfp
DesktopLocalCache stores users in LargeSoftCache, which holds each User
via a WeakReference. FindUsersTest populated the cache but kept no strong
reference to the created users, so a GC between consumeMetadata and
findUsersStartingWith could collect them — LargeSoftCache.forEach then
skips (and evicts) the cleared entries, making the search return fewer
results. multipleUsersWithMetadata allocates the most objects and tripped
this most often (AssertionError at FindUsersTest.kt:115).
Verified with a forced-GC probe: without a strong reference all three
users were evicted (found=0); holding a reference kept all three (found=3).
Pin each test's users in a strong-reference list that stays reachable
through the assertions, mirroring how Notes/Account/follow lists keep
authors alive in the real app.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X4BThbs8tz9egmFDYv2RPB
Highlights carried full feature descriptions that the sections below
already repeated. Reduce each to a single line and fold the detail down
into the section that owns it.
Audit the notes against all 1961 non-merge commits since v1.12.6:
- Drop the claim that WebSocket frame dispatch moved to a dedicated pool.
It landed in a5c2a8b2c1's parent and was reverted 23 minutes later
because the pool regressed. Replace it with the two receive-path wins
that did ship (CachingEventDecoder, ParallelEventVerifier).
- Add the chat redesign, which had no section at all: bubbles,
swipe-to-reply, name colors, jumbo emoji, day headers, the two-stage
long-press sheet, and the new-conversation chooser.
- Add in-app podcast authoring, which the Podcasts section omitted in
favour of consumption only.
- Promote the resource-usage ledger out of a single Wallet line into its
own section, alongside the background-service master switch and
memory-pressure trimming.
- Add large-screen support, NIP-85 nicknames, Birdstar and PS1 cards,
compose signature, Marmot group icons, and other unreferenced work.
Remove the Upgrading section. No prior release has one, and all three of
its items were consequences of features documented further down, so they
now sit with the feature that causes them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Break the newly added BOLT12, Buzz Agent Work board, Blossom, Cashu,
NWC, search-indexing, and geode bullets into short verb-first sentences,
removing em-dash run-ons to match the changelog house style.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017hmbpUJPxrsN4m85Kwemcw
LocalCache already has getOrCreateUser/getOrCreateNote/getOrCreateAddressableNote,
so make it implement Dao and default NewMessageTagger's `dao` to LocalCache
itself — no wrapper object, no interface-default methods. Dao drops the
`suspend` modifiers (LocalCache's lookups are synchronous) so the object
satisfies the interface; AccountViewModel's delegating overrides follow suit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RcWSCNMTVcvKMXo7xA2hue
Move the LocalCache calls off the Dao interface (back to a pure abstract
contract) and into the implementations: AccountViewModel keeps its own
overrides, and the default Dao used by callers without a ViewModel is an
anonymous implementation on NewMessageTagger's `dao` parameter.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RcWSCNMTVcvKMXo7xA2hue
Replace the standalone LocalCacheDao object with default method
implementations on the Dao interface itself, backed by LocalCache. The
NewMessageTagger `dao` parameter now defaults to a bare `object : Dao {}`,
so callers with no AccountViewModel (model-layer sends, the notification
receiver) just omit it, while UI callers keep passing their AccountViewModel
(which resolves to the same LocalCache calls).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RcWSCNMTVcvKMXo7xA2hue
The lightweight reply paths built their events from raw text, so a person
cited with `@name`/`nostr:` was neither resolved into a `nostr:` reference
nor tagged with `p`. Run NewMessageTagger on these paths too, backed by a
new LocalCache-based Dao so they work without an AccountViewModel:
- New `LocalCacheDao`: a `Dao` delegating to `LocalCache`, for mention
resolution outside a ViewModel (model-layer sends, background receivers).
- `Account.sendMinichatReply` (kinds 9 + 1111): resolve mentions and emit
`p` tags for cited users across the public-chat, Buzz, and NIP-29 group
branches (parent author excluded to avoid a duplicate).
- `NotificationReplyReceiver.sendPublicReply` (kinds 1 + 1111): same
resolution for the system notification quick-reply.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RcWSCNMTVcvKMXo7xA2hue
Extends the mention p-tag fix to the shared chat/thread composers, which
resolved `@`/`nostr:` mentions via NewMessageTagger but then dropped the
cited users from the signed event:
- kind 9 (ChatEvent, NIP-29 group chat): the reply path tagged only the
parent author and the plain build path tagged no one. Emit `p` tags for
every cited body user (reply path keeps the parent's relay-hinted tag and
excludes it from the extra set to avoid a duplicate).
- kind 1111 (CommentEvent) minichat reply: replyBuilder auto-tags the
parent/root author but body mentions were dropped. Notify the other
cited users (parent excluded to avoid a duplicate).
- kind 11 (ThreadEvent, NIP-29 group thread): the ShortNote thread branch
added no `p` tags at all; emit them from the cited body users, matching
the sibling Poll/ZapPoll branches.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RcWSCNMTVcvKMXo7xA2hue
Adds NegentropyMultiRelayLiveTest (gated NEG_MULTI=1): runs negentropySyncOrFetch
against 30 reachable public relays and asserts none hangs. Live run: 0 hangs,
0 errors — 10 reconciled via native negentropy, 20 fell over to paging, across
9+ relay softwares (strfry, ditto, purplepag.es, nostr.wine, nostr-rs-relay,
NFDB, rockstr, wot-relay, nostrcheck).
Confirms both fallback paths: the NOTICE fast-path (~1-4s) for relays whose
refusal names negentropy / unknown-envelope, and the idle-watchdog backstop
(~20s) for the rest (e.g. damus silently ignores NEG-OPEN, snort answers
"Unknown message type: NEG-OPEN") — now reliable because NOTICE/CLOSED no longer
reset the watchdog. Matrix recorded in the plan doc.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6ZVTixuc1ef8eGB6MQHRn
Audit follow-ups on the NIP-77 client, found while reviewing the notice-rejection fix:
- isOverflow was too broad. A bare "too many"/"too large" match meant a
NON-shrinking error ("too many requests", "too many concurrent subscriptions")
was read as a set-too-large overflow. Such an error doesn't shrink with the
window, so every created_at split re-triggers it and reconcileWindows walks
toward 1-second leaves, queueing up to ~2^31 Filters (OOM + relay hammering).
Tightened to result-set-qualified phrases (too many records / too many query
results / result set too large / max_sync_events); rate/quota errors now fail
over to paging.
- Added a MAX_WINDOWS (100k) backstop in reconcileWindows: a wording-independent
guard that bails to paging if a split ever fails to converge, so no novel
overflow-looking-but-non-shrinking error can storm.
- Window split dropped future-dated events. On overflow the upper child was
copy(until = hi) with hi = until ?: now(), so once any split happened, events
with created_at > now() (clock skew) were excluded though the un-split path
included them. The upper child now keeps the window's original until (may be
null = unbounded); the split math still uses now() so it converges.
- Hardened the NOTICE rejection matcher. isNegentropyRejectionNotice matched
bare "envelope"/"NEG-OPEN"/"NEG-MSG"; since a NOTICE has no subId and every
connection listener sees it, an unrelated notice on a shared connection could
abort a healthy reconcile mid-handshake. Narrowed to "negentropy"/"unknown
envelope"; the now-un-defeated idle watchdog is the wording-independent backstop.
- NegentropyStoreSync up-direction memory: haveBatches was an UNLIMITED channel
drained by a single network-bound uploader, so a first push of a large store
buffered O(local-set) ids. Bounded it like needBatches to back-pressure the
reconcile.
- Docs: flagged negentropySyncOrFetch's O(delivered) cross-phase dedup memory
(steer bulk mirrors to negentropySync/negentropyReconcile); corrected the
stale onEvent "reader thread" note (it runs on the delivery consumer).
Tests: NegentropyErrorClassificationTest pins both wording classifiers;
NegentropyRejectionFallbackTest adds a rate-limit NEG-ERR case asserting paging
after exactly one NEG-OPEN per phase (no split storm).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6ZVTixuc1ef8eGB6MQHRn
The Buzz composers dropped `p` mention tags for people named in a
message body, so a member cited with `@name` was neither notified nor
linkable and the relay couldn't resolve the `nostr:` reference:
- Stream chat (kind 40002): only the reply-parent author got a `p` tag;
body mentions from the tagger were ignored. Emit `p` tags for every
cited user (dedup covers the reply author).
- Stream edit (kind 40003): carried no mentions at all — a mention added
by an edit lost its `p` tag. Emit them from the edited text.
- Forum reply (kind 45003): never ran NewMessageTagger, so `@`-mentions
stayed literal and only the reply-parent author was tagged. Run the
tagger to resolve mentions and emit their `p` tags.
- Forum post (kind 45001): the root composer built the event from raw
text with no mentions. Run the tagger there too.
Mirrors the mention p-tag handling every other chat kind already does.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RcWSCNMTVcvKMXo7xA2hue
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
Collapses the operator setup from an 8-flag command + two hand-written scripts to
essentially two commands, without removing any of the safety.
- `buzz workflow run` gains `--accept-from-channel` (parity with the job
scheduler): scope intake to the channel's kind-39002 roster instead of pasting
every teammate key. `--worktree` now defaults to the current directory.
- Ship the gated reference wrappers (tools/buzz-agent/workflow-agent.sh →
agent+commit; workflow-ship.sh → push+PR after the gate), split around the
approval gate the way agent-exec.sh is the one-shot ungated version.
- `buzz agent up RELAY --repo DIR --approver NPUB` — one command: resolves the
channel (the relay's only one, or --channel), defaults worktree/intake, extracts
the bundled wrappers to ~/.amy/buzz-agent, and delegates to `workflow run`. The
only thing it can't default is the human approver.
- `buzz agent doctor [--repo DIR]` — preflight that turns the security checklist
into a green/red report: gh authenticated, token can write to the repo, default
branch protected against force-push, worktree clean. Exits non-zero if not.
- cli build: set duplicatesStrategy on processResources (the explicit
resources.srcDir re-adds the default root, which now doubles the bundled scripts).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
**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>
Collapses the two near-identical agent backlogs (jobs 43xxx, workflow runs
46xxx) into a single board that speaks one vocabulary, so users stop having to
tell two protocols apart. The human-approval gate is no longer its own screen —
it's the top-sorted, glowing card state (NEEDS_APPROVAL).
- AgentWork.kt: AgentWorkState {NEEDS_APPROVAL,WORKING,QUEUED,SHIPPED,CLOSED} +
AgentWorkItem, with mappers from WorkflowRun and JobView and a merge() that
sorts needs-approval first, then working, then the upvote-ranked queue, then
shipped/closed.
- AgentWorkBoardViewModel: runs both subscriptions (jobs+upvotes, and workflow
base + by-author decisions) off subscribeAsFlow and folds them via the two
existing aggregators into one List<AgentWorkItem>.
- AgentWorkBoardScreen: one list; inline approve/deny on the gate card, View PR
on shipped, upvote/cancel on jobs, and a "New task" sheet whose single
"Require approval before it ships" toggle picks the protocol — on → a gated
workflow run, off → a direct job (no workflow definition required).
- Nav: the channel top bar's two board glyphs (Backlog + Workflow runs) collapse
to one "Agent work" entry (Route.BuzzAgentWork). The standalone JobBoard and
WorkflowRunBoard screens/routes stay as reference for now.
Prototype scope: strings inline pending adoption; the standalone board's
confirm-before-approve dialog and full localization are the carry-overs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
Watches, Strava, and auto-pause often split one long effort — a 5-hour run
with breaks — into several back-to-back exercise sessions. The New Workout
carousel offered each fragment as its own suggestion.
Add WorkoutMerger, which walks the detected sessions in start-time order and
folds any run of same-type sessions less than an hour apart into a single
DetectedWorkout: distance, calories, steps, elevation and duration are summed,
heart rate is duration-weighted, max heart rate is the max, and sessionCount
records how many were combined. Gaps are tracked per exercise type so a brief
cross-training block mid-run doesn't split the two run segments. The carousel
shows the combined count so the user sees it's one thing.
HealthConnectManager.readNewWorkouts now applies the merge before returning, so
the composer offers the whole effort as one post.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UHCr6tW2xAXPJUVZjZPvA
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
The two new boards were added as top-bar icons, which put a Buzz channel back to
four (Canvas, Backlog, Workflow runs, ⋮) and truncated the title row the bar is
there to show — "nosfabrica.commun…" instead of the full relay. That is the
crowding #3729 had just removed; this branch predates it and the merge stacked
them.
Canvas keeps the only icon: it is the channel's shared document, i.e. content.
Backlog and Workflow runs are two more views of the channel, reached
occasionally, so they join Threads and Share in the overflow — and pick up the
`!isDm` gate the icons never had.
Also gives the job board a string resource; the branch's i18n pass left
"Backlog" hardcoded in JobBoardScreen and in the icon's contentDescription.
Adds cli/tests/buzz/agent-exec.sh, which covers what the two loop harnesses
stub out. job-loop.sh proves the scheduler drives *an* --exec program; nothing
committed exercised the real one — the wrapper that turns a job into a PR. With
a stubbed `gh` and agent (no network, credentials, or Claude Code) it asserts
the happy path end to end (task on stdin → agent → commit → push the job branch
→ PR url on stdout) and, as importantly, the paths that must fail: an agent that
changed nothing becomes a job error, an empty task is rejected before the agent
runs, missing scheduler env is a hard error rather than a silent no-op, an agent
that committed for itself is not double-committed, and the default-branch guard
holds — asserting `main` on the remote is left untouched. 19/19.
Verified on emulator-5554: the bar is Canvas + ⋮ again with the full relay name
visible, the menu reads Threads / Backlog / Workflow runs / Share / Members /
Leave, and Backlog still opens from it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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 workflow run board, the attestation screen, and the persona editor
hardcoded English. Move their user-facing copy to values/strings.xml under the
existing buzz_ naming convention and read it via stringRes, so the surface is
translatable through Crowdin like the rest of the app.
- Snackbar/section/def-editor strings are resolved in composable scope into
vals (the coroutine lambdas and LazyListScope.section can't call stringRes).
- pillContent returns string res ids resolved by StatePill; a runHeadline()
composable centralizes the task/workflow-id/placeholder line.
Not extracted (documented): near-unreachable non-composable validation strings
in buildAttestation/parseHeldAttestation, the WorkflowDefOption id fallback, and
the kind/model/provider/runtime option *values* (data, not prose).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
- Attestation time conditions echo the entered epoch as a readable UTC time
in supporting text (mirrors the kind field) — no more eyeballing unix seconds.
- Agent-key picker shows an invalid-paste error inline on its own field
(isError + supportingText) instead of far down the form.
- WorkingLine pulse animates via graphicsLayer (draw phase) instead of
Modifier.alpha read in composition, so active cards redraw rather than
recompose each frame.
- Hoist the shipped-result URL Regex to a process-level val.
- LazyColumn items carry contentType so sub-compositions are reused across scroll.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
The boards derived their state from LocalCache.filter(kinds = 46xxx / 43xxx),
but LocalCache.filter only matches notes whose kind.isRegular() (< 10_000).
Every run/lifecycle/job kind is >= 43001, so the filter returned nothing and the
boards never displayed a single run/job against live data (verified with a probe:
a consumed 46020 matched 0, a 30620 def matched 1). Definitions (30620,
addressable) were the only thing that showed.
Aggregate straight off subscribeAsFlow, which accumulates the channel's stored +
live events (deduped by id) and re-emits the list — the data the aggregators
need. This also removes the per-batch whole-cache rescans.
- WorkflowRunBoardViewModel: base #h subscription + a nested by-author decisions
subscription (rebuilt only when the approver set changes via distinctUntilChanged)
so grant/deny now arrive live for every observer, not just once at open. Drop
46004/46011/46012 from the fetch set — the aggregator can't correlate them.
- JobBoardViewModel: aggregate jobs + kind-7 upvotes from the one #h subscription.
- Real success/failure feedback: trigger/approve/deny/defineWorkflow return a
result; snackbar only on confirmed publish; the sheet stays open on a failed
trigger; the definition editor shows an error + a Publishing… state instead of
hanging open and inviting duplicate 30620s.
- Gate write actions on isWriteable(): a read-only login no longer sees a false
"Approved" success, and the New-run FAB is hidden.
- WorkflowRunAggregator.fold: parse each event's JSON content once.
- Empty-state hint in the New-run sheet when no definitions exist yet.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
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>
Replace raw-code text fields in the Buzz agent flows with pickers, matching
the workflow-definition picker and the app's existing people-typeahead.
- Agent Attestation "Agent public key": a single-agent people picker
(name typeahead over the local user cache + removable chip), with
npub/hex paste kept as the Enter escape hatch for keys that aren't
contacts yet.
- Agent Attestation "Restrict to kind": an editable dropdown of common
named kinds (1 Text note, 7 Reaction, …), free numeric entry preserved,
with the resolved name shown as supporting text.
- Agent Persona edit "Model / Provider / Runtime": editable dropdowns of
well-known values (claude-*, gpt-*, anthropic/openai/…, goose/…), free
entry preserved since these stay free-form on the wire.
- New reusable EditableSuggestDropdown (BuzzOptionDropdown.kt) backing the
open-ended pickers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
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>
Replace the free-text "Workflow id" field in the New Run sheet with a
dropdown of the channel's published workflow definitions (kind-30620),
shown by name, plus an inline editor to define a new one.
- Account.publishBuzzWorkflowDef: sign + publish a 30620 with a minted
workflow UUID, name, and YAML recipe; returns the new id.
- WorkflowRunBoardViewModel: fetch + watch the channel's 30620 defs
(#h-scoped) alongside the runs, expose them name-sorted as
WorkflowDefOption, and drive defineWorkflow(name, yaml).
- NewRunSheet: WorkflowPicker dropdown + DefinitionEditor; a just-created
definition auto-selects once it lands in the channel list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
Tor wedged across app restarts with ~87% of relay connections failing, and
neither existing recovery fired. Captured from the device:
guards.json default: 60 guards, 59 disabled, 1 unlisted -> 1 usable
Arti log AllGuardsDown { n_accepted: 0, n_rejected: 60 } x21,490
sockets 36,007 failures vs 5,276 successful opens
Everything above Tor degraded with it. Concord was the visible casualty: its
control-plane sync drained exactly 12 wraps on every launch and never folded, so
the Messages tab showed zero Concord rows for two restarts running.
Two recoveries exist and this state slipped between both:
- `ArtiGuardState.hasNoUsableGuards` required `usable == 0`. Its own KDoc claimed
"a single usable guard is enough to recover" — the device disproved it. One
survivor that is merely *unreachable* is useless for recovery but sufficient to
veto it, so the wipe never ran.
- `TorManager`'s watchdog only arms while status sits at Connecting. Tor had
bootstrapped: the SOCKS proxy was bound, `hasEverBootstrapped` was true, ~13% of
connections still worked, so status reached Active and the watchdog never fired.
It is built for "Tor never came up"; this is "Tor came up and its guards rotted".
Nothing observed the steady-state failure rate, so all three gates evaluated the
same way on every launch and `guards.json` carried the wedge forward forever.
1. Proportional disk rule. A sample of at least MIN_SAMPLE_TO_JUDGE_RATIO whose
usable guards fall under 1/USABLE_RATIO_DIVISOR of the total is wedged. Below
that size only the strict `usable == 0` rule applies, so a young sample Arti is
still filling is never wiped out from under a legitimate first bootstrap.
2. Runtime detector. `TorService` counts Arti's own AllGuardsDown log lines
(GUARDS_DOWN_THRESHOLD within GUARDS_DOWN_WINDOW_MS) and exposes
`TorBackend.guardsDownSignal`; `TorManager` routes it through the same
rate-limited self-heal to `resetWithCleanState()`. This is the general fix: it
believes Arti when it says every guard was rejected, so it fires even while
`guards.json` still looks healthy and status is Active — precisely the blind
spot between the two older heuristics. The count lives in the log callback
because that is the only place Arti surfaces it; the reset stays in TorManager,
which owns the cadence.
Verified on the wedged device. Next launch, unprompted:
W TorService: No usable Arti guards left on disk — wiping state to rebuild the guard sample
guard sample 60 total / 1 usable -> 20 total / 20 usable / 0 disabled
AllGuardsDown 21,569 -> 0
sockets 36,007 fail / 5,276 -> 444 fail / 232 open
Concord wraps 12, 12, 12 (pinned) -> 12 -> 37 -> 258
Concord rows 0 -> 4 and climbing
Tests cover the 59-of-60 field case, a small-sample false-positive guard, and a
healthy-majority sample. The pre-existing 22-guard "poisoned but not wedged"
fixture still passes, so the ratio does not regress the earlier variant.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Approving publishes a signed grant that makes the runner push code and open
a PR — too consequential for a single stray tap. Tapping Approve (or Deny)
now raises a confirm dialog that states the boundary plainly ("Approving
never merges or deploys — that still happens on GitHub"); denying warns the
work is discarded. On confirm, a snackbar gives immediate, truthful feedback
("Approved — the runner is opening a pull request"), and the card moves to
Working/Approved, then genuinely lands in Shipped with the PR link when the
runner's 46005 arrives — rather than faking "PR opened" at tap time.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
The BOLT12 zaps feature was built against a placeholder NIP identifier
("nipXX" / "NIP-XX", and "NIP-2421" in one plan). The number NIP-B1 has
now been assigned, so update the naming across every module:
- rename the Quartz package `nipXXBolt12Zaps` -> `nipB1Bolt12Zaps`
(commonMain + commonTest) and every import referencing it.
- KDocs/comments: `NIP-XX` -> `NIP-B1` in quartz, commons, amethyst, cli.
- wire binding prefix: `nostr:nipXX:` -> `nostr:nipB1:`
(Bolt12ZapValidator.NIP_URI_PREFIX, NIP-47 pay `payer_note`, tests).
- KindNames: Bolt12 Zap / Bolt12 Offers NIP number "XX" -> "B1".
- plan doc references `NIP-2421` -> `NIP-B1`.
Leaves the unrelated `nipXXPodcasting20` package and the audio-rooms
draft (also placeholder "NIP-XX") untouched. quartz main + test compile
and spotless is clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012P3krSe92wicpswBr2CP9s
"Approve & ship" over-promised — approval pushes the branch and opens a PR;
it never merges or deploys (merge stays a human action on GitHub). The
clearer label states the actual boundary.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
Two halves of the same gap: a row could show its blue dot while nothing above it
agreed.
1. Bottom-bar envelope counted only DMs
------------------------------------------------------------------------------
`messagesHasNewItems` mapped each Messages row through `unreadPrivateChatRoute`,
which opens with `if (newestMessage !is ChatroomKeyable) return null`. Only
NIP-17/NIP-04 DM events implement that interface, so eight of the nine row types
were silently skipped — public chats, ephemeral rooms, geohash cells, Marmot
groups, NIP-29/Buzz channels, Concord channels, and both collapsed "grouped"
rows. Each of those rows already computed its own dot from its own last-read
route; the badge just never asked.
`rowHasUnreadFlow` now answers, per row, the same question the row composable
answers for itself, keyed off the note's gatherer (a Buzz channel and a Concord
channel can both carry a kind-9, so event kind alone can't tell them apart). It
returns a Flow rather than a (route, createdAt) pair because the two collapsed
rows fan in over every child channel — approximating those by their newest child
would miss an older channel that is still unread.
2. Buzz thread replies notified nothing
------------------------------------------------------------------------------
Buzz's clients thread with `["e", <id>, "", "reply"]` and only ever `p`-tag
@mentions, so a reply to my message names me nowhere. `isNotifiablePublicChatRep
ly` — the rule that lets a reply notify without a `p` tag — bails unless the
event is a ChannelMessageEvent (kind 42), so a kind-9 thread reply qualified
under nothing. Combined with thread replies now being kept out of the channel
timeline and its unread dot, a reply to my message in a Buzz channel had become
invisible on every surface.
`isBuzzThreadReplyToMyEvent` mirrors the fix already used for Buzz reactions
(`isReactionToMyEvent`): when a chat event carries no `p` tag, resolve the author
of its `root`/`reply` marked `e` targets instead of trusting a tag. Deliberately
only the MARKED targets — a bare `e` is WhiteNoise/Marmot's in-chat reply, not a
thread. It is OR'd into the same three gates the reaction case uses, so a reply
from a channel member I don't follow still notifies.
Also admits kind-40002 into NOTIFICATION_KINDS: nothing writes it any more, but
legacy Buzz thread replies exist and were being dropped at the kind gate before
any relevance check ran.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fills the on-disk translation gap for the recently merged Buzz workspaces,
Blossom file import and BOLT12 offers features, plus the channel-invite and
payment-notification strings.
Inserted per-locale (128 cs, 124 de-rDE, 126 sv-rSE, 128 pt-rBR) driven off
each file's own diff rather than a shared union block, since Crowdin strips
source-identical keys asymmetrically across locales.
All 7 new <plurals> are covered in every locale; Czech gets the full CLDR
one/few/many/other set. Source-identical entries (Canvas, Workspace, OK,
LIVE, Media, Social, Reposts, and the bare %1$d+ count formats) are
deliberately left out — Crowdin strips those on export and Android falls
back to values/strings.xml at runtime.
Verified: no duplicate keys, well-formed XML, and format-placeholder parity
with the English source across all four files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T45dBdJ8GRBy8zw9yDQRPW
A minichat thread reply was showing as a group's "last message" on Messages, and
lighting its unread dot, even though the channel timeline correctly hides it.
Three surfaces disagreed about what counts as a message:
channel timeline ChannelFeedFilter !isMinichatReply(..) && isAcceptable
Messages preview newestChatNote isGroupChatContent() && isAcceptable
unread dot hasChatNewerThan isGroupChatContent() && isAcceptable
So a reply that the channel deliberately routes to its thread still became the
row summary, and still lit the dot — you would open the group, see nothing new,
and watch the dot clear. The Concord side already solved exactly this by sharing
one predicate (`isConcordTimelineMessage`) across feed, preview and badge; this
gives NIP-29 the same treatment:
isRelayGroupTimelineMessage = isGroupChatContent && !isMinichatReply && isAcceptable
Now used by:
- `newestTimelineNote` (replaces the local `newestChatNote`) for the row preview,
in both INLINE and GROUPED view modes
- both additive paths in ChatroomListKnownFeedFilter, so an arriving reply cannot
bump a row either
- `hasChatNewerThan`, which backs the per-channel dot (`relayGroupChannelHasUnread
Flow`, also used by the workspace channel list), the collapsed per-relay dot
(`relayGroupServerHasUnreadFlow` composes it), and transitively the Messages row
dot, which reads the createdAt of the note the preview picked
The bottom-bar Messages badge follows automatically: it derives from the feed rows
themselves. (It only counts private DMs today — `unreadPrivateChatRoute` returns
null for anything that isn't ChatroomKeyable — which is a separate, pre-existing
scope decision, untouched here.)
Verified on device by creating the case rather than waiting for it: posted a reply
into a thread so it became the newest event in the channel. The thread shows it
(chip 4 -> 5 replies), the channel timeline does not, and the Messages row still
previews the newest timeline event. Before this change that reply would have been
the row summary. The earlier screenshot only looked right because a system message
happened to be newer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A reply written by any current Buzz client is a kind-9 carrying a NIP-10
`reply`-marked `e` tag. Amethyst rendered it as a flat row in the main channel
with the parent quoted above it, and it never appeared in the thread on its
parent — the exact inverse of where Buzz puts it. Observed on the wire:
parent kind 9 tags: [h, <channel>]
reply kind 9 tags: [h, <channel>], [e, <parent>, "", "reply"]
`isMinichatReply` is the single definition three consumers share — the channel
timeline filter drops these, the reply-count chip counts them, and the minichat
feed shows them — but it was type-gated to CommentEvent (1111) and
StreamMessageV2Event (40002), so a kind-9 ChatEvent fell through to `false`.
Every downstream behaviour followed from that one gap: the reply stayed in the
timeline, the parent showed no "N replies" chip, and the thread was empty.
Accepting a marked `e` on kind 9 is NIP-C7 compliant. C7 defines exactly one
reply mechanism for kind 9 — `["q", <id>, <relay>, <pubkey>]` — and never
mentions `e` at all, so a marked `e` carries no C7 meaning and is free to denote
a thread reply. Matching on the MARKER (never the bare tag) is what keeps
WhiteNoise/Marmot working: they thread kind-9 chat with a plain, unmarked `e`,
which is an in-chat reply and must keep rendering as a quote bubble. Tests pin
all four cases: marked direct, marked nested (root+reply), unmarked, and `q`.
Also stop writing kind-40002 for Buzz minichat replies. Nothing in Buzz writes
40002 any more: every send path in their mobile, desktop and CLI clients emits
kind 9, the ~50 remaining references are all reads (filter kind lists, feed
query sets, archive constants), and their NOSTR.md grades kind:9 as supported
against 40002's "Buzz-only — no standard NIP-29 client renders these". It is a
read-compat tail from the 10002 -> 40001 -> 40002 migration, and Amethyst was
the last active writer — so our replies threaded nowhere but our own client.
We now emit kind 9 with tags byte-identical to Buzz's `_buildReplyTags`
(direct -> one `reply` marker; nested -> `root` + `reply`), which is what
`buzzThread` already produced. Reading 40002 stays supported for events already
in the wild, including the ones we wrote.
Deliberately NOT changed: `computeReplyTo`'s ChatEvent branch still links every
`e` tag to the parent. That link is what populates `note.replies`, which the
thread and the chip read — discriminating there would unlink Buzz replies from
their parents. The marker distinction belongs in rendering, not in linkage.
Verified on device against a real thread: the reply now sits in the thread on
"howd you get bumble working?" with a "3 replies" chip on the parent, and is
gone from the channel timeline.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Design polish pass on the Buzz boards, focused on the moment that matters:
a human authorizing an AI to ship code.
- The approval gate is now the board's centerpiece: an elevated, softly
pulsing card (glow border + gradient) with a filled circular gavel badge,
a headline-sized task, and a dominant "Approve & ship" action (Deny stays
a quiet text exit). Non-approvers see a "waiting on <approver>" pill.
- Real depth: state-tuned shadow elevation on every card, soft-filled status
pills, and section headers as an uppercase label + count chip.
- Motion that reads as "alive": an indeterminate progress bar on actively
running work (both boards), alongside the existing working-line pulse.
- Matching pass on the job board so the two surfaces stay one visual system.
No behavior change; pure presentation. Amethyst compiles clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
Phase 2 of the Buzz workflow support: a per-channel mobile surface for the
source-confirmed workflow primitive, mirroring the existing job board but
built around the human-approval gate.
- WorkflowRunBoardScreen + WorkflowRunBoardViewModel (per channel, entered
from RelayGroupTopBar on Buzz relays via a new Route.BuzzWorkflowBoard).
Folds the workflow kinds via the shared WorkflowRunAggregator, groups runs
by state with "Needs your approval" pinned first, and lets the named
approver grant/deny a paused run inline (46030/46031). A shipped run shows
its PR; merge stays on GitHub.
- Account: triggerBuzzWorkflow / approveBuzzWorkflowRun / denyBuzzWorkflowRun
(same sign → local-echo → publish-to-group-relay contract as the job
helpers).
- RelayGroupFilterBuilders: subscribe the #h-scoped workflow kinds (46020,
46001-46007, 46010-46012) on every group REQ. The client-signed
grant/deny (46030/46031) carry no h tag, so the board fetches them by
author — every 46010 gate names its approver in a p tag — matching the CLI.
- NotificationFeedFilter: a workflow approval gate (46010) addressed to me
notifies and is push-eligible (added to NOTIFICATION_KINDS + an
acceptableEvent early-return gating on approver()==me).
The quartz EventFactory registration and LocalCache ingest for these kinds
already existed, so no protocol/ingest changes were needed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
Switch the agent-support-channel prototype from the speculative agent-job
kinds (43001-43006, reserved with no upstream builder) to Buzz's real,
source-confirmed workflow primitive: 30620 def / 46020 trigger / 46001-46007
lifecycle / 46010 approval gate / 46030-46031 grant-deny (pinned against
buzz-relay's command_executor.rs). This bakes the human-approval gate into
the protocol — anyone in the channel can drive a run, but a human grants
before anything is pushed.
- commons WorkflowRunAggregator folds trigger + lifecycle + grant/deny into
per-run state (TRIGGERED/RUNNING/AWAITING_APPROVAL/APPROVED/COMPLETED/
FAILED/DENIED), correlating by run id (= trigger event id = approval token);
8-case test.
- cli `amy buzz workflow` — trigger/list/show/approve/deny plus the `run`
runner: per new trigger it does the agent work in a fresh worktree+branch,
posts the 46010 gate, and on a later poll runs --on-approve (push + PR) and
emits 46005 completed; a deny discards the worktree (run is DENIED).
On a real Buzz relay the relay executes the workflow YAML; self-hosted on
geode there is no engine, so amy is the runner and emits the lifecycle events
itself (documented divergence). Two store realities, both verified against
geode: decisions are fetched by author (quartz's store serves #d only for
addressable kinds, and 46030/46031 are regular), and the runner is
restart-safe (runs at the gate are rebuilt from the deterministic run id).
Also hardens the exec helper against a broken pipe when --exec doesn't read
stdin, and fixes worktree teardown to run git against the owning repo.
End-to-end headless harness (cli/tests/buzz/workflow-loop.sh) covers the
grant and deny paths through an embedded geode relay: 14/14 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
On a Buzz relay, channel membership is server-side: another member can add you,
the relay writes you into the kind-39002 roster, and you can read and post
immediately. The relay then addresses you a kind-44100 naming who did it.
Amethyst funnelled every 44100 into BuzzDmChannels — treating it as a DM — which
silently subscribed you to that channel's messages, while the Messages list
(which reads the self-published kind-10009) showed no row for it. A channel could
therefore be joined, streaming, and invisible at the same time: the channel
screen offered no Join button and accepted posts, the RelayGroups screen listed
it from the relay's 39000 directory, messages arrived — and Messages had nothing.
Nothing here is auto-accepted any more. 44100 carries `{"type","channel_id",
"actor"}`, and the relay emits the SAME kind for a self-join with `actor == you`,
so the actor is the only thing separating "I joined this" from "somebody put me
here". Channels are classified by the `t` tag on their 39000 (stream/forum/dm/
workflow — read through a dedicated accessor because on buzz the type shares the
tag name with real hashtags): only `t = dm` belongs in the DM list, everything
else becomes a pending invite that subscribes to nothing.
The prompt appears on both surfaces, driven by one state holder so they cannot
disagree — Notifications, in the same header slot as the missing-inbox-relay
prompt, and Messages > New Requests, beside the pending DMs it is the exact
analogue of. Rendered as a list row rather than a modal: these arrive in bursts
when somebody sets up a workspace, and a blocking dialog on cold start would be
miserable. It is also the spam surface, so Ignore stays cheap.
Three actions, and Ignore is deliberately not Leave:
- Show -> writes the group into kind-10009 (Account.follow), after which the
ordinary joined-group path owns it and it syncs to other devices.
No kind-9021: the relay already has you in the roster, so this
records only your decision to surface it.
- Ignore -> local, reversible display choice. You stay in the roster and can
still open and post.
- Leave -> kind-9022 LeaveRequestEvent, the one that actually removes you.
A kind-44101 removal now withdraws any pending prompt, so the relay taking the
membership away cannot leave a card offering an action that would fail.
The invites section is passed as the chatroom feed's header rather than stacked
beside it: the collapsing top bar draws over that area, so a header outside the
list renders underneath it. It shows in the empty state too, otherwise an account
with no pending DMs would have no way to reach the prompt.
Verified end to end on device: "straycat added you to personalized-knowledge-
graphs" rendered on both surfaces, and Show republished kind-10009 with the
channel appended.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Messages list showed a stale last message for every NIP-29 group while the
open chat screen stayed live. The joined-group tail batched every group on a
host relay into ONE filter carrying all their ids in `#h`. That is valid NIP-01
and relays answer it correctly for stored queries — which is what made it so
confusing: the boot backfill populated every group, so the list looked right
until it needed to change.
`block/buzz` indexes each live subscription under a single channel uuid resolved
from `#h`. When two or more distinct ids appear anywhere across a subscription's
filters, `extract_channel_id_from_filters` returns None and the subscription is
registered as *global* — and global subscriptions deliberately never receive
channel-scoped events, guarding against leaking private channel content to a
subscriber whose membership was not checked per channel. So the batched tail
backfilled at EOSE and then went permanently deaf.
Measured on device: same relay, same connection, same subscription id, only the
`#h` count changed — one value delivered live, two delivered nothing.
The resolver scans every filter of a subscription, so splitting into one filter
per group is not enough; each channel needs its own subscription. The EOSE
managers are therefore keyed on GroupId, and the preloads mount one subscription
per joined channel. Relays leave room for this: buzz allows max_subscriptions
1024 against max_filters 10, so this also sidesteps the filter cap for anyone in
more than ten groups.
Also folded into the same per-channel subscriptions:
- Group activity addressed to me (reactions/zaps/replies) moved off the
account-wide notifications subscription. That one also carries inbox filters
with no `#h` at all, and buzz forces a subscription global on the first
channel-less filter, so no reshaping there could ever have worked.
- Reactions and deletions (kinds 5/7/9005) now ride the channel's own `#h`
subscription, the shape Buzz's own client uses (`channelEventKinds`). Amethyst
otherwise learned about them only through the shared `#e` EventFinder query,
which carries no `#h` and is therefore global — so reaction chips appeared
only on a re-query, never as they happened. Kept out of the timeline kind set
so reactions cannot consume a history page's limit and walk the `until` cursor
past undelivered messages.
- A `limit = 1` preview filter with no time floor. The tail floors at now-7d to
keep recent chat warm; on its own that stranded any channel quiet for longer
on a "No messages yet" placeholder sorted to the bottom by createdAt 0. Every
other roster-driven protocol on that screen already bounds by count for this
reason (NIP-28 `limit = 1`, Concord `limit = 10`) — their row set comes from a
list event, unlike NIP-17/NIP-04 whose rooms are discovered from the messages
and so need the backward pagers.
The Messages row now says "Loading" instead of "No messages yet" until the fleet
settles, so an in-flight channel is not mistaken for an empty one. That required
the shared WindowLoadTracker to describe the whole joined fleet rather than
whichever channel rebuilt last.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Finished/failed jobs now land in the Notifications tab, addressed to the requester.
1. NotificationFeedFilter: an early-return branch accepts a JobResultEvent (43004) or
JobErrorEvent (43006) when it p-tags me (the requester) and isn't my own event —
mirroring the existing Buzz-DM branch, since I don't "follow" the workspace bot and
the job kinds aren't in the generic relevance path. It maps to the generic NoteCard,
so it renders the result (PR URL) / error text.
2. JobErrorEvent now carries the requester as a `p` tag (new requester() accessor + a
`requester` param on build, mirroring JobResultEvent); the scheduler passes
job.requester on both error paths. Previously a failed job wasn't addressed to anyone,
so a failure could never notify. Tests updated.
Relay sourcing (verified): a job outcome reaches LocalCache via the always-on `#h`
joined-group chat tail on the workspace relay (RELAY_GROUP_ALL_TIMELINE_KINDS includes
43001-43006), so for a shared channel the team has joined, results are pulled continuously
and now notify. A channel you haven't joined (or a job event with no `#h`) would still need
a dedicated `#p`=me subscription (mirroring BuzzDmDiscovery) — not added, since the
support-channel model always has members joined.
App compiles (fdroidDebug); quartz + commons tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
A thin server-side policy so `amy serve --buzz` hosts a private, agent-authorized
Buzz workspace on one JVM process — no Block Rust relay + Postgres/Redis/MinIO.
- quartz: BuzzMembershipPolicy : FullAuthPolicy (buzz/relay/). Runs the NIP-42
handshake, then layers Buzz's two authorization rules: (1) only members (the team)
may read/write; (2) NIP-OA virtual membership — an un-enrolled agent key is granted
membership for its connection when its AUTH event carries an owner-signed `auth` tag
whose owner is a member and whose signature authorizes that agent. An unauthenticated
read is told `auth-required` (not `restricted`) so the client runs NIP-42 and retries.
Deliberately does NOT emit relay-signed 39000-39003 metadata or run workflows (a
policy can't emit events; the job channel doesn't need them). 8 unit tests.
- cli: `amy serve --buzz [--members npubs]` composes the policy into geode's RelayEngine.
Members = admins + --members.
Verified end-to-end against a live `amy serve --buzz`: a member writes (published:true),
an outsider is rejected (restricted: not a workspace member); plus the 8-case unit suite
(member/non-member/unauth/reads/allowed-kinds/NIP-OA-agent/non-member-owner/tampered).
Plan doc + cli README updated with the two relay options (amy serve --buzz vs the Rust
stack) and when you'd need each.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
tools/buzz-agent/agent-exec.sh — the last mile from the scheduler to a live channel.
Honors the `amy buzz agent serve --exec` contract: reads the task on stdin, runs a
coding agent (Claude Code by default, or any $AGENT_CMD) inside the job's git worktree,
verifies a diff exists, commits, pushes the `claude/job-*` feature branch, opens (or
reuses) a PR, and prints the PR URL as the job result (kind-43004); any failure exits
non-zero → job error (kind-43006). It never touches the default branch and never
force-pushes — merge stays a human action on GitHub.
README documents the load-bearing guardrails, since Buzz enforces none of them: a
PR-only fine-grained token (Contents + Pull requests write, nothing else), branch
protection on the default branch (require PR + review + CI, block force-push/deletion),
and scoped intake (--accept-from) + agent tool allowlist.
Verified end-to-end against a throwaway repo with a stubbed gh + agent (happy path
pushes the branch and prints the PR URL; no-change path errors cleanly). Plan doc
follow-up #3 marked done.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
Second batch of audit fixes (an independent review confirmed C1 and surfaced these).
Scheduler (BuzzAgentCommands):
- H1: the runForever poll ran directly in supervisorScope, so a transient relay
error/drain-timeout thrown by selectPending killed the unattended daemon. Wrap each
poll in try/catch (rethrow CancellationException) and keep looping.
- H2: worktrees + branches leaked on Ctrl-C/kill (coroutine finally doesn't run) and a
leftover branch made a job permanently un-runnable on rerun. Add a JVM shutdown hook
that force-removes still-active worktrees/branches, and use `worktree add -B` so the
branch is reset-or-create (idempotent).
- M2: worktree lifecycle moved inside the try so the finally always cleans up, including
the failed-setup early return.
- M3: runOnce isolated each job in a try/catch so one throw can't cancel the batch.
App:
- M1: fileBuzzJob/upvoteBuzzJob/cancelBuzzJob now cache.justConsumeMyOwnEvent(signed)
before publish, so the board reflects the action immediately (publish only sends to
relays; the event wasn't in LocalCache yet, making the optimistic reload a no-op).
- L2: RelayStatusBar derives this relay's booleans with derivedStateOf, so global relay
churn no longer recomposes the whole bar.
- L4: the upvote reaction now carries NIP-25 `p` (job author) and `k` (kind) tags.
- L5: JobBoardScreen DisposableEffect keyed on (channelId, relayUrl).
Verified: cli/tests/buzz/job-loop.sh 11/11 green; app compiles (fdroidDebug).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
Audit fixes.
- BuzzAgentCommands.runExec: the responder read the child's stdout fully and THEN
its stderr, which deadlocks a chatty child (a coding agent easily fills the ~64 KB
stderr pipe while we block on stdout) and let the child hang past --exec-timeout
(readBytes has no timeout). Now stdout/stderr are drained on their own coroutines
and stdin is fed on another; the timeout is enforced by waitFor, and on expiry
destroyForcibly closes the pipes so the readers finish. Same concurrent-drain fix
applied to the git() helper. Verified: cli/tests/buzz/job-loop.sh 11/11 green.
- JobBoardScreen: bucket the backlog into a JobGroups holder under remember(jobs)
so the four filter/sort passes run once per new backlog, not on every recomposition
(e.g. each isLoading toggle).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
Make the shared backlog modern, alive, and transparent.
- RelayStatusBar: a live, tappable header that makes ALL of the workspace relay's
state observable in context — connection (client.connectedRelaysFlow), NIP-42 auth
phase (authCoordinator.authStateFlow, with a pulsing dot + lock icon), Buzz-dialect
marker, and an expandable NIP-11 panel (software/version, supported NIPs, limitations,
description, latency). Every signal is a real StateFlow/produceState, so it tracks
reality live. Reusable across agent surfaces.
- JobBoardScreen redesign: "Working now" is the visual hero (primaryContainer, a
breathing pulse on the streaming progress line, elapsed time); queued cards carry a
colored status rail and reorder by upvotes with animateItem; shipped cards are a
success tone with a prominent "View PR" (extracts the PR URL from the result);
closed cards mute. Real avatars + display names (UserPicture/UsernameDisplay/LoadUser)
instead of hex. Upvote chip animates its count; the composer is a friendly
ModalBottomSheet with example chips; a warmer empty state.
App compiles (fdroidDebug). No font regen (reused existing MaterialSymbols).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
Evaluate placement, then add the first interactive agent surface in the app.
Placement: the existing agent screens (AgentConsole/Attestation/PersonaEdit) are
owner-global telemetry entered per-relay and buried behind a channel-list footer.
A Buzz job is h-scoped to a channel, so the backlog is per-channel — it belongs
where Canvas/Forum already live (RelayGroupTopBar, gated by BuzzRelayDialect.isBuzz),
not inside the owner Console. Keep the two surfaces separate.
- JobBoardScreen + JobBoardViewModel (ui/screen/loggedIn/buzz/): the shared backlog
of one channel. Reads the job kinds (43001-43006) + their kind-7 upvotes scoped to
the channel h, folds via the shared BuzzJobAggregator, groups by lifecycle state
(In progress / Queued-by-upvotes / Done / Closed), live via subscribeAsFlow. Every
member sees the same board.
- Three write actions via new Account helpers: fileBuzzJob (43001, FAB → New task
dialog), upvoteBuzzJob (kind-7 "+" e-tagging the job, h-scoped so the scheduler +
board count it), cancelBuzzJob (43005, own jobs only). Merge is deliberately NOT
here — a done job's result is its PR; merge happens on GitHub.
- Route.BuzzJobBoard(channelId, relayUrl) + AppNavigation wiring + a Checklist entry
in RelayGroupTopBar next to the Canvas action.
Reuses the AgentConsoleViewModel fetch/watch pattern and existing MaterialSymbols
(no font regen). App compiles (fdroidDebug).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
Evolve `amy buzz agent serve` from a sequential responder into a scheduler that
manages a shared feature-request backlog by itself — the model where a whole team
drives an AI in one channel, not a 1:1 chat.
- Parallel execution with isolation: `--parallel N` runs up to N jobs at once,
each in its own `git worktree` + branch (`--worktree REPODIR`, off `--base-ref`,
named `<branch-prefix><jobid>`) so concurrent autonomous runs never clobber one
working tree. `--parallel > 1` requires `--worktree`; worktree add/remove is
mutex-serialized while the agent work runs concurrently. Branch/worktree/base-ref
are exported to `--exec` (BUZZ_BRANCH/WORKTREE/BASE_REF) so it commits, pushes the
branch, and opens the PR. Merge stays on GitHub — never here.
- Group-driven priority: BuzzJobAggregator now folds kind-7 upvotes (distinct
reactors, dislikes excluded) into JobView.upvotes, and `byPriority` orders the
backlog most-upvoted-first, oldest-first tiebreak. The stack reprioritizes itself
as the channel reacts. `buzz job list/show` surface upvotes.
- Channel-as-allowlist: `--accept-from-channel` obeys any member of the channel's
kind-39002 roster ("anyone in the channel can drive"), union with explicit
`--accept-from` npubs.
- Harness cli/tests/buzz/job-loop.sh gains a parallel case (3 jobs, --parallel 3,
one branch/worktree each, cleaned up); aggregator gains upvote + priority tests.
11/11 harness checks + all unit tests green.
Fits entirely inside amy: amy is the scheduler, the coding agent is whatever
`--exec` points at, GitHub owns merge. Plan doc updated with the model + the
"can this live in Amy" architecture note.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
Prototype the "human drives an AI coding agent to develop Amethyst" support
channel on top of the existing block/buzz integration, all through amy.
- commons: BuzzJobAggregator (BuzzJobs.kt) — a pure, tested folder that
correlates the Buzz agent-job kinds (43001-43006) by their `e` request
reference into JobView records with a REQUESTED→ACCEPTED→IN_PROGRESS→
COMPLETED/FAILED/CANCELLED state machine (newest terminal wins). Shared so a
future mobile Jobs board reuses one correlation path. 9 unit tests.
- cli: `amy buzz job request|list|show|cancel` (requester side) and
`amy buzz agent serve --exec CMD` (the responder loop): polls for REQUESTED
jobs targeting my key, gates intake on `--accept-from` (allowlist) and
`--channel`, then accepts (43002) → progress (43003) → runs `sh -c CMD`
(task text on stdin; BUZZ_JOB_ID/REQUESTER/CHANNEL/RELAY/AGENT in env) →
result (43004) or error (43006). Point `--exec` at a coding agent to drive it.
- cli/tests/buzz/job-loop.sh — self-contained headless harness over an embedded
`amy serve` relay; asserts the full loop AND the permission gate (an allowlist
excluding the requester handles nothing).
- Design doc cli/plans/2026-07-25-buzz-agent-support-channel.md: the three-layer
permission model (Buzz scopes by identity, not capability flags — so
"can't merge/destroy main" lives in GitHub branch protection + the `--exec`
credential, not the relay), the MVP architecture, and the prioritized mobile
app gap list (approvals inbox, jobs board, diff/PR review, …).
Schema caveat: kinds 43001-43006 are reserved in Buzz with no upstream builder;
the tag layout is Quartz's best-effort model, to be reconciled upstream.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
The `if (canEditBuzz || canEditConcord)` guard already proves
`onWantsToEditChatMessage` non-null — both booleans are local vals whose
definitions begin with a null check, and K2 propagates that through them.
The `!!` compiled to an assertion that could never fire, and produced an
"Unnecessary non-null assertion" compiler warning.
No behaviour change: the smart-cast invoke is what was already happening.
If either guard is later loosened, this now fails at compile time instead
of becoming a runtime NPE.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WopdqNoZ9tYoMNppJG17BL
Redesign the Buzz workspace community view to feel closer to the Concord
server view — richer, more informative channel and DM rows, with actions
tucked behind overflow menus so the list reads cleanly.
- Title bar now uses a middle ellipsis on the community name so both ends
stay visible instead of truncating the tail.
- Channel cards (BuzzImportRow) now show a last-message preview (author +
snippet, or the Buzz activity summary for system/diff/job rows), a
recent-posters facepile, an unread-count badge, and the last-activity
time — reusing the Concord facepile/unread-badge composables. Each card
warms its recent messages while visible so previews fill in ahead of a tap.
- Pin/Unpin and Add-to-my-list move off the channel row into a per-channel
3-dot overflow menu.
- DM rows gain the same last-message preview line.
- Add-all and Agent Console move from the inline header button / footer card
into the community's top-bar 3-dot overflow menu.
- Add relay-group timeline helpers (newestTimelineNote, recentAuthorHexes,
relayGroupChannelUnreadCountFlow) mirroring the Concord ones.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHnm6G9YytnLfs1ycYfK89
Align the discovery row's last-message preview with the pattern Concord's
list previews use: compute the newest chat message from the channel's note
cache reactively (keyed on the notes flow) instead of reading
Channel.lastNote. For relay groups the two are equivalent today — kind-11
threads live in a separate collection and kind-1111 comments aren't
attached to the group's notes — so this is a consistency/robustness change
that keeps the preview, its timestamp, and the message count all derived
from the same source, not a behavioral fix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAFP7qqYNxqRpxtofGWYsq
A NIP-29 relay pinned to the bottom bar navigates to Route.RelayGroupServer
(RelayGroupChannelListScreen), but that screen always drew a back arrow and
never rendered a bottom bar — so tapping the pinned relay icon dropped the
bottom nav and showed a back arrow, unlike every other bottom-nav root.
Mirror the norm the analog Concord server screen already follows: read
nav.canPop() once, show the back arrow only when pushed (drawer / another
screen), and add an AppBottomBar keyed to the relay's own route. AppBottomBar
hides itself on a bottom-nav root, so the relay now behaves both ways — a
bottom-nav tab (bar visible, no arrow) when tapped from the bar, and a pushed
detail (arrow, no bar) when opened from the drawer or elsewhere.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU7StQsshfjcaLDXTBtnB2
Add a compact "2h"/"3d" last-activity timestamp to each discovery row's
name line, so the preview reads as recent activity with a clear recency
signal rather than an implied live feed.
On the discovery screen most groups are ones you haven't joined, and the
app's always-on live chat tail is joined-groups-only — a non-member isn't
streamed a group's new messages — so the last-message line is a snapshot
of recent public activity that fills in and refreshes as the row loads,
not a real-time ticker. Reword the code comment to match.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAFP7qqYNxqRpxtofGWYsq
Update the on-device GenAI prompt dependency (play flavor only) from
1.0.0-beta3 to 1.0.0-beta4, the latest in-track release.
All other catalog entries are already at their latest stable versions.
appfunctions could not move to alpha10 because appfunctions-service
only publishes up to alpha09 and the three artifacts share one version.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZJ8ekaDDihWWhcJ6X2Fjz
The message-activity badge used the accent (violet) color, which reads as
an unread indicator elsewhere in the app. Render it as a muted
onSurfaceVariant chat-icon + count instead, so it clearly signals
activity volume rather than unread messages.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAFP7qqYNxqRpxtofGWYsq
fix(cache): keep LargeCacheAddressableFilterTest mocks strongly reachable
LargeSoftCache stores values as WeakReferences, so the cache alone does
not keep the mock AddressableNotes alive. Hold each note in a companion
strongRefs list for the lifetime of the test class, so a GC between
class-load and the read can no longer clear them.
Audit of the compressed-proof work found one real defect and one coverage gap.
Defect: a hostile BOLT12 proof/offer can carry a 9+ byte `invoice_amount`
(or any tu64 field) that parses as a valid TLV. `TlvStream.tu64` then called
the strict `Bolt12Values.tu64`, which throws `require(size <= 8)`. On the
`amy bolt12 verify` path (`Bolt12ZapActions.validate`, no surrounding catch)
that surfaced as an uncaught exception and abnormal exit instead of a clean
`Invalid`; the Android ingest path was already contained by LocalCache's broad
catch. Make the nullable stream accessor `TlvStream.tu64` return null for an
over-8-byte value so every amount read (invoice_amount, invreq_amount, offer
amount) degrades to a clean rejection. Regression-tested at the codec level.
Coverage: the writer's `proof_note` (1005) branch and the `with_note` vector's
note were never exercised. Add a `Bolt12PayerProof.proofNote()` reader and
thread the vector's note through the writer round-trip so 1005 is asserted.
The forged-proof, DoS, and reconstruction-accounting paths were reviewed and
found sound (the reconstructed root is only ever a BIP-340 message; the NIP
offer-binding gate still pins invoice_node_id to the offer's issuer).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
The type/label chips that sit beside a room name on the Messages screen — the
NIP-28 "Public Chat" pill (HeaderPill), the NIP-29 relay-host chip
(RelayNameChip), and the Concord community chip (ConcordCommunityPill) — could
grow with a long relay URL or community name and crowd the room name out.
Cap each at ChatLabelMaxWidth (140.dp, ~half a phone row) via widthIn(max); the
room name stays weighted so it keeps whatever the capped chip doesn't take, and
each chip's label truncates with a middle ellipsis (TextOverflow.MiddleEllipsis)
so the informative head and tail both survive. RelayNameChip switches from a
plain end ellipsis; ConcordCommunityPill drops its char-count truncation
(maxChars) for width-based truncation.
Also give the Concord chip the NIP-29 chip's highlighted look — secondaryContainer
background / onSecondaryContainer content (a gray on the dark theme) — so both
"which server/community does this room belong to" chips read the same.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KQ9Cz2QjLMvemzVyJS1f5V
The File Sync / Import flow (and the mirror-on-upload fan-out) copy blobs
across the user's Blossom servers with BUD-04 `PUT /mirror`, but not every
server implements that endpoint. Blossom has no capability-discovery
mechanism, so a target without /mirror just answered 404/405/501 and the
whole copy was silently counted as failed.
Detect the "endpoint absent" statuses (404/405/501) as a typed
BlossomMirrorUnsupportedException — distinct from a mirror the server
understood but rejected (400/403/413/…) — and add BlossomClient.mirrorOrUpload,
which falls back to downloading the blob and re-uploading it (PUT /upload)
when mirror is unsupported. The downloaded bytes are verified against the
expected sha256 before re-upload, since a Blossom server is untrusted and
could substitute content, and the same t=upload auth is reused.
Wire every mirror path through mirrorOrUpload: the app-level
BlossomMirrorQueue (sync-all + import sweep), the blob manager's per-blob
mirror (including the paid-mirror retry), and UploadOrchestrator's
mirror-on-upload. Task now carries the descriptor content-type so the
fallback upload preserves the MIME.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168TWLTgrMxUR6yjjCCiBLS
multiRelayPoolReturnsContentFromEachRelay flaked with
"expected:<from-b> but was:<null>": the SubscriptionListener wrote the
per-relay results into a plain HashMap/HashSet, but each relay delivers
its EVENT/EOSE on its own InProcessWebSocket scope (Dispatchers.Default)
and PoolRequests dispatches the listener callbacks outside any lock. Two
relays therefore call `received[relay] = ...` concurrently, and a
HashMap.put racing a rehash can drop an entry, leaving a relay's value
null and failing the assertion.
Use ConcurrentHashMap and ConcurrentHashMap.newKeySet() for the shared
collections. Reproduced within 7 runs before the fix; 80 stress runs
clean after.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HeAjLDNBvGPjb5bfViU3ad
Real BOLT12 wallets emit selective-disclosure payer proofs: `invreq_metadata`
is always withheld and other invoice fields may be elided for privacy, with
`proof_omitted_tlvs` / `proof_missing_hashes` / `proof_leaf_hashes` carrying
enough to rebuild the invoice signature's merkle root. The verifier previously
reported these as unsupported (cryptoVerified = false), so a zap paid through a
real wallet never counted locally.
Implement the lightning/bolts#1346 reader:
- Bolt12Merkle.reconstructRoot rebuilds the invoice root from the disclosed
LnLeaf hashes + supplied nonce leaves (proof_leaf_hashes) + omitted-field
markers + missing subtree hashes (consumed post-order DFS, smallest-to-largest).
Add emitMissingHashes as the writer dual, unify both on one tree builder.
- Fix two latent interop bugs the vectors exposed: the nonce leaf hashes the
record's type bytes (not the full encoded TLV), and the payer proof signs
under fieldname `proof_signature` (not `signature`).
- Bolt12PayerProof gains marker/leaf/missing accessors and the invoice-field
range predicate; the verifier reconstructs on every proof (type 0 is always
the implied first omitted leaf) and drops the Unsupported result.
- Add Bolt12ProofBuilder to mint spec-compliant proofs (tests + future interop),
and rewire Bolt12ProofFixture onto it.
Validated byte-for-byte against the draft's own conformance suite
(bolt12/payer-proof-test.json): all 5 valid vectors verify, all 23 invalid are
rejected, and the writer reproduces every vector's compression fields exactly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
markAllChatNotesAsRead only enumerated public chats (IsInPublicChatChannel)
and DMs (ChatroomKeyable), so every newer room type fell through the when()
with no branch: NIP-29 relay groups, Concord communities, Marmot groups,
geohash chat, and ephemeral relay chat. Their unread dots on the Messages
screen could only be cleared by opening each room — "mark all as read" left
them lit. The collapsed per-server rows (RelayGroupServerRoomNote,
ConcordServerRoomNote) were skipped too.
Extract markRoomNoteAsRead(account, note), mirroring ChatroomEntry's type
dispatch so each row's last-read route is resolved the same way its unread
dot reads it: synthetic grouped rows first (fanning out to every joined
group on the relay / every channel in the community), then gatherer-attached
channels (Marmot, NIP-29, Concord, geohash), then the h-tag group fallback,
then the raw event type (public chat incl. ChannelCreateEvent, ephemeral,
DM, and drafts wrapping those). markAllChatNotesAsRead now just maps the
visible notes through it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KQ9Cz2QjLMvemzVyJS1f5V
The message-preview second line on every Messages-screen row (channels,
groups, and DMs) rendered at the ambient bodyLarge (16sp), matching the
bold title above it. Drop it to bodyMedium (14sp) so the title and the
muted preview read as two tiers instead of one block of same-size text.
Covers both renderers all rows funnel through: ChannelName (public
chats, ephemeral/geohash chats, Marmot/NIP-29 groups, Concord) and
LastMessagePreview (NIP-17/NIP-04 DMs), including the
"event not found" fallback line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KQ9Cz2QjLMvemzVyJS1f5V
A Concord community pinned to the bottom bar folded only a fraction of its
channels — Soapbox showed 1 of 12. The live subscription collapsed a
community's Control + Guestbook + rekey + every channel plane into ONE
kind-1059 filter per relay, and the channel list is folded from the Control
Plane. On an AUTH-gated relay that caps a REQ per filter (measured ~100
events/filter on relay.dreamith.to), the chatty Guestbook plane crowded the
channel-defining control editions out of the cap, so only a fraction of the
channels folded. On the strict relay.ditto.pub the collapsed multi-author
filter is refused wholesale until every author is authenticated.
- Split the Control Plane into its OWN filter, apart from the Guestbook /
rekey / channel planes (ConcordSubscriptionPlanner.controlIsolatedFilters),
so it gets an isolated per-filter budget. Both filters still ride the same
per-relay REQ (no extra socket).
- Add a COMPLETE-mode Control-Plane sweep (Account.syncConcordControlPlanes):
re-fetch the whole plane with no `since`, paging past the per-filter cap via
fetchAllPagesFromPool, so a forward cursor can never hide an edition and the
cap can never truncate the fold. Mounted account-wide, fired on load +
membership/held-epoch change + relay reconnect (not a wall-clock poll — the
persistent live subscription keeps a connected relay complete).
Mirrors Armada's plane-sweep design (one filter per plane scope, COMPLETE-mode
control). Verified on-device: Soapbox now folds all 12 channels.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cross-backend defects in the kotlinx (native/iOS) NWC-321 parsers, found by the
audit and empirically reproduced. Jackson (JVM/Android) writes null-valued keys,
so a native peer parsing that output hit two bugs:
- parsePay/parseReceive crashed on `metadata: null` — `?.jsonObject` doesn't
short-circuit on JsonNull (a non-null element). Use `as? JsonObject`.
- parsePaySuccess/parseReceiveSuccess (and parsePay's string fields) read an
explicit JSON null as the literal string "null" via `?.jsonPrimitive?.content`.
Use `contentOrNull`.
Only affects the kotlinx path (Android/JVM use Jackson), but violates the KMP
mapper-interchangeability contract. Adds Nip47KotlinSerializationNullTest hitting
the kotlinx serializers directly so it's covered regardless of platform actual.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
- amy bolt12 verify: the id-only query with a <Bolt12ZapEvent> type crashed
with ClassCastException when the id pointed at a non-9736 event. Constrain
the filter to kind 9736 and cast defensively (query<Event>() as?), returning
a clean not_found instead.
- Bolt12ZapActions.decodeOffer/decodeProof: a parseable bech32 with an
over-8-byte amount TLV threw in tu64 on field read instead of honoring the
null contract; guarded the field extraction in runCatching. Adds a
malformed-amount regression test.
- Account.sendBolt12Zap: wrap the NWC response callback in try/catch/finally so
a post-payment receipt-assembly failure (e.g. a remote signer error) steps
progress and surfaces "paid, no receipt" instead of vanishing as an uncaught
coroutine exception.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
Widen the fix beyond the plain stream chat message: every Buzz kind the chat
feed renders as a row — stream messages (40002), system lines (40099), diffs
(40008), and the agent-job (43001-43006) and huddle (48100-48103) lifecycle
events — is `h`-scoped and attaches to the same RelayGroupChannel as a kind-9
via consumeBuzzTimelineEvent. So all of them must count as a room's newest
message and toward its unread dot; leaving them out left the Messages-list
preview stale whenever the newest thing in a channel was one of these.
- Add `Event.isBuzzChatTimelineContent()` (quartz buzz) enumerating exactly the
kinds consumeBuzzTimelineEvent attaches / the chat renders — excluding edits
(folded into their target), canvas, and forum kinds. `isGroupChatContent()`
now ORs it in, so the initial scan, the live additive update, and the unread
dot all agree.
- These kinds carry JSON/diff in `content`, so previewing raw `content` would
dump `{"ephemeral_channel_id":…}`. Extract the in-chat labels into pure
helpers (`buzzSystemMessageText`, `buzzActivityLabel`,
`buzzTimelinePreviewSummary`) so the Messages-list preview shows the same
human-readable summary the chat row shows ("🔊 huddle started", "topic
changed", "⚙ job progress: …", "📄 <file>") instead of raw payload.
- Extend the regression test to cover stream/system/huddle counting and edit/
reaction not counting.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dtx8Shek4sSAXmHjnNMJF
benthecarman/nostr-wallet-connect-lnd implements NWC-321 pay/receive (confirming
Phase 0), but is LND-backed so it rejects BOLT12 lno and returns no payer_proof.
The blocker for real BOLT12-zap testing is a CLN/LDK-backed NWC-321 service.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
The bottom-bar settings picker only exposed one level of each grouped chat
system: NIP-29 let you pin an individual group but not the host relay, while
Concord let you pin a community but not an individual channel. Since a NIP-29
relay is the analog of a Concord community (the container) and a Concord
channel is the analog of a NIP-29 group (the item), both systems now offer
both levels.
- Add BottomBarEntry.RelayServer(relayUrl) -> Route.RelayGroupServer (the
relay's home page of all joined groups) and BottomBarEntry.ConcordChannel(
communityId, channelId, relays) -> Route.Concord (a specific channel), with
stable @SerialName discriminators and stableKeys.
- Resolve their live avatar/label/route: the relay via its cached NIP-11 doc,
the channel via the community session's folded Control Plane (community icon
+ channel name).
- Regroup the picker by container: each relay/community is an addable "server"
row with its groups/channels nested beneath it, so you can add the whole
server or a single room in both systems.
- Bootstrap a pinned channel's community list (importConcordCommunities and the
pinned-community preloader now also read ConcordChannel entries).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012v531djZmQBxVoyCm45BNY
A Buzz stream-channel chat message is a kind-40002 StreamMessageV2Event, not a
NIP-C7 kind-9 ChatEvent. It is `h`-scoped and attaches to the same
RelayGroupChannel as a kind-9, but `isGroupChatContent()` only recognized
ChatEvent/PollEvent/ThreadEvent/CommentEvent, so the Messages-list "newest
message" logic (initial scan `newestChatNote` + the live additive
`filterRelevantRelayGroupMessages`) and the unread-dot check all skipped it.
Result: a Buzz channel's row never reflected its real chat and never updated
live as new messages arrived.
Include StreamMessageV2Event in `isGroupChatContent()` (the Buzz dialect of
NIP-29), fixing the Messages preview and unread dot in one place. Adds a
regression test asserting kind-40002 counts as group chat content while a
group-scoped reaction does not.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dtx8Shek4sSAXmHjnNMJF
Adds a BOLT12 zap (NIP-XX) command group over a new shared commons
Bolt12ZapActions (assembly-only, mirrors ZapActions):
bolt12 decode LNO1|LNP1 decode an offer or payer proof
bolt12 verify EVENT-ID validate a kind:9736 in the local store
bolt12 offer get/set read/publish a kind:10058 offer list
bolt12 intent … / zap … two-step out-of-band send (amy has no NWC
rail): intent prints the payer_note; zap
wraps the signed intent + settled proof
into a validated kind:9736 and publishes
Keeps cli a thin assembly layer — all logic is quartz's Bolt12ZapBuilder/
Validator/codecs via commons Bolt12ZapActions. Adds Bolt12ZapActionsTest;
updates README + ROADMAP. Interop harness and NWC-fetched proofs remain TODO.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
Rework the NIP-29 Relay Groups discovery feed from a flat list of tall
ElevatedCards into the "Relay Rail" layout: groups are clustered under a
slim per-relay header (NIP-11 icon + name, group count, favorite star)
and rendered as thin inbox-style rows inside a rounded block.
Each row now previews what's actually happening in the group instead of
a static blurb: the newest message as "author: text", a green LIVE pill
when the group has an active audio room (hasLivekit), the people you
follow who are already inside, or a compact message-activity badge. The
old `about` description and the standalone Join button are dropped to
keep rows thin — tapping a row opens the group where you can join.
The feed is bucketed by host relay in the composable (a cache lookup, no
extra subscription); per-row metadata and the message preview still
stream lazily as rows scroll on, via the existing warm-up subscription.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAFP7qqYNxqRpxtofGWYsq
- Swap the Buzz community channel favorite from a Star to a PushPin icon
(and rename the buzz_star/buzz_unstar strings to buzz_pin/buzz_unpin),
since the action only pins a channel to the top of the list — it never
publishes anything, unlike Add which imports into the kind-10009 list.
- Give the "Added" state in BuzzImportRow trailing padding so its label no
longer jams against the row edge when the Add button flips to Added.
- Prefix a Messages-list DM preview with "You:" when the newest message was
sent by the logged-in user, so a room shows who spoke last.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dtx8Shek4sSAXmHjnNMJF
Phase 3 capability gating. NwcSignerState caches the default wallet's advertised
NIP-47 methods; Account refetches them via nwc#2 get_info whenever the default
wallet changes. A zap now prefers the BOLT12 pay rail only when the wallet
advertises `pay` (Account.defaultWalletSupportsBolt12Pay) — otherwise the
recipient falls back to lightning through the existing partition, so a wallet
without pay support degrades gracefully instead of erroring. The profile
"pay with wallet" action is gated on the same signal.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
The bespoke applicationIOScope watcher that opened its own relay
subscription for NIP-47 wallet notifications is replaced by an EOSE
manager registered in AccountFilterAssembler.group — the same always-on
scheme as the account's zap/notification inbox subscriptions. It is now
owned by the single AccountFilterAssemblerSubscription in LoggedInPage,
kept warm in the background by NotificationRelayService, and torn down on
logout — matching zap-receipt lifecycle exactly (and not gated on OS
notification permission).
- NwcNotificationsEoseManager (PerUserEoseManager): one filter per
connected wallet's own relay (kind 23197/23196, #p = per-wallet client
pubkey), re-invalidating when the wallet set changes, `since`-floored at
watch start, deduped by a seen set. Because these events are ephemeral,
encrypted, and never land in LocalCache, onEvent decrypts them via
NwcSignerState.handleIncomingNotification.
- NwcSignerState.handleIncomingNotification decrypts with the matching
wallet's connection secret, drops zap-carrying payments, and publishes
non-zap payments to a new incomingNonZapPayments SharedFlow — a clean
seam a future in-app Notifications-tab consumer can also drain.
- NwcPaymentNotificationWatcher is now just the Context-bound bridge that
drains that flow into an OS tray notification (no relay work, no client
dependency).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDAAS4ktFbWtRnEVXQsjfs
The app-level "sync all" / import progress banner switched to "Sync
complete" when the sweep finished but then lingered until the user
tapped X. Auto-dismiss it a few seconds after completion, keeping the X
for dismissing early. Keyed on the running flag so a new sweep cancels
the pending dismiss and tapping X re-keys it to a no-op.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5G515Grhc4t7ACoza9eyN
A NONZAP (pay-without-receipt) zap must not publish a public 9736. sendBolt12Zap
now settles the offer over NWC without binding an intent or emitting a receipt
when the zap type is NONZAP, matching bolt11 NONZAP privacy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
Integrates BOLT12 zap-sending into the zap pipeline (nwc#2 `pay` returns the
payer proof). Account.sendBolt12Zap signs a 9737 intent, pays the offer over
NWC with the intent-bound payer_note, and — only if the returned proof passes
Bolt12ZapValidator — self-consumes and publishes the 9736; otherwise reports
"paid, no receipt" (fail-safe against a wallet that misroutes the note).
ZapPaymentHandler.zap now resolves each recipient's kind:10058 offer and
partitions recipients into a BOLT12 lane (offer present + NWC wallet configured)
and the existing lightning lane, sharing split weight across both so mixed
splits stay proportional. Anonymous/public follows the account zap type. Adds
Bolt12ZapBuilderTest proving the send-side assembly round-trips to a
validator-accepted, crypto-verified zap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
Follow-up fixes from an audit of the import feature:
- Cancel an in-flight scan when the source selection changes
(toggle/add/remove/enable-all). Without this a scan started against
the old selection could land afterwards and offer blobs sourced from a
server the user just de-selected — which importSelected() would then
mirror from.
- Sign the BUD-02 list token once per scan and reuse it across every
source and target. The token carries no `server` scope tag, so it's
valid everywhere; per-server signing was a round-trip storm with
remote NIP-46 signers.
- BlossomMirrorQueue.start() now returns whether it actually started a
sweep. importSelected() keys the Started/Busy result off that instead
of a separate isRunning check, closing a TOCTOU where the import would
report "started" but the queue silently dropped the work.
- The import screen's empty-state now collects the kind-10063 server
list reactively, so the "add servers first" ↔ picker switch recomposes
when the list arrives from a relay after the screen opens.
- init() re-points at the current account each call (matching the
sibling BlobManager VM) while still seeding the source list only once.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5G515Grhc4t7ACoza9eyN
verifyOts wrote otsVerification = Verifying before the suspending blockchain
verifyState() call and only overwrote it with the real verdict afterwards. That
verification runs inside LoadOts's cancellable LaunchedEffect, so a row scrolling
off-screen cancels the coroutine at the network suspension point — the verdict
write never happens and the note is left stuck at Verifying. cacheVerifyOts
treats Verifying as terminal, so earliestOtsVerifiedTime then returns null and the
confirmed-timestamp pill silently vanishes for the note's lifetime. The old
VerificationStateCache had the same write but its LRU eventually evicted the stuck
entry; anchoring the verdict on the long-lived note removed that recovery.
Store only terminal verdicts (Verified / Error / NetworkError). A cancelled or
in-flight verification leaves the field null and simply retries on the next read;
the cost is at worst two concurrent first-time verifications, already benign.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014D5fenZbAhCbDiwv7Rtpvj
Two issues found in an audit of the NWC changes:
1. (correctness, high) NIP-44 negotiation never triggered against real
wallets. The info event carries schemes in a single space-separated
tag value (["encryption", "nip44_v2 nip04"]), but encryptionSchemes()
returned tag.drop(1) = ["nip44_v2 nip04"], so the nip44_v2 membership
check never matched and every request fell back to NIP-04. Split each
tag value on whitespace in encryptionSchemes()/notificationTypes() so
both the spec's space-separated form and a multi-element tag normalize
to individual tokens. Adds NwcInfoEvent tests for the wire format.
2. (performance) NwcPaymentNotificationWatcher subscribed via
subscribeAsFlow, which accumulates every event into an ever-growing
list and re-emits the whole list per event — wrong for a lifetime
subscription (unbounded retention + O(n) rescan per event). Replace
with a raw client.subscribe listener (callbackFlow) that emits each
event once; reconnect re-delivery is still de-duped by the seen set.
Also documents why the watcher keys the account flow on pubkey.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDAAS4ktFbWtRnEVXQsjfs
Maintainer confirmed payer_note maps to invreq_payer_note for BOLT12 and
payer_proof is returned for successful BOLT12 payments. Notes the
validate-before-publish fail-safe so a non-conforming wallet never yields an
invalid receipt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
Replace the single-purpose per-wallet "supports nip44" boolean with a
shared NwcInfoCache that stores each wallet's full kind 13194 info event
(capabilities + encryption schemes + notification support), keyed by
wallet pubkey and owned by Account.
- Entries expire after 2 days so a wallet that changes its advertised
capabilities is eventually re-checked. Reads never block: the payment
path reads the cached value and nudges a background refresh when the
entry is missing or stale (self-healing without holding up the tx);
failed fetches are not cached, so a transient error retries next use.
- NwcSignerState derives the NIP-44 preference from the cache.
- NwcPaymentNotificationWatcher now consults supportsNotifications() and
skips opening a relay subscription for wallets that advertise none
(fail-open when the info event is unknown).
Adds NwcInfoCacheTest covering caching, TTL expiry, no-cache-on-failure,
and definitive-missing-info caching (injectable clock).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDAAS4ktFbWtRnEVXQsjfs
NIP-03 OpenTimestamps attestations (kind 1040) were stored loosely in the main
note cache and found via a full-cache scan, with their blockchain verdicts held
in a separate, id-keyed VerificationStateCache LRU. Nothing tied either to the
lifecycle of the note being timestamped, so a deleted/pruned note leaked its
attestations, and consume(OtsEvent) invalidated the attestation's own
(observer-less) flow instead of the target's — so a live-arriving proof never
pinged the target's UI.
Mirror the recent edits→Note migration:
- Note gains a `timestamps` child collection (like `edits`/`reactions`), wired
into clearChildLinks/removeNote, so an attestation survives exactly as long as
its target and is collected when the target is pruned or deleted.
- consume(OtsEvent) anchors the proof on its target via the `e` tag and
invalidates the target's `ots` flow; unlinkAndRemove detaches it symmetrically.
- Each attestation memoizes its own verdict in `Note.otsVerification`, so the
result shares the note's lifecycle. This replaces VerificationStateCache
(deleted) and the full-cache scan: the OTS pill now folds `note.timestamps`
via the new Note.earliestOtsVerifiedTime / cacheVerifyOts helpers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014D5fenZbAhCbDiwv7Rtpvj
Adds a "pay with connected wallet" action to the BOLT12 offer dialog on a
profile: when the account has a NIP-47 wallet configured, an amount sheet
collects sats and settles the offer over the wallet via the nwc#2 `pay`
method (BIP321 bitcoin:?lno=). Falls back to the external bitcoin: intent
otherwise. Outcome is surfaced as a toast. This is a plain payment, not a
NIP-XX zap (no receipt) — that's the deferred Phase 2.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
Adds the generalized `pay` (BIP321 payment instruction, incl. BOLT12 `lno=`)
and `receive` NIP-47 methods, plus the UNSUPPORTED_PAYMENT_INSTRUCTION /
UNSUPPORTED_NETWORK error codes. The `pay` result carries `payer_proof`
(lnp1…) — the proof a kind:9736 BOLT12 zap needs. Wired through both
serialization backends (kotlinx + Jackson) with round-trip tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
Every chat composer in Amethyst already had a picture/media attach button
(SelectFromGallery) except the minichat "thread" screen — the kind-1111
reply composer opened from the "N replies" chip — which was text-only. This
brings it to parity with every other chat.
- quartz: ChannelChat.imageReply() — a kind-1111 thread reply carrying
encrypted image imeta(s), combining reply()'s NIP-22 pointers with
imageMessage()'s ciphertext-URL/imeta handling (+ round-trip test).
- commons: ConcordActions.buildChannelImageReply().
- Account.sendMinichatReply() now accepts imetas and routes per backend:
Concord sends an encrypted image reply; NIP-28/NIP-29 public chats append
the URL to the content and carry a plaintext imeta on the comment; Buzz
appends the URL to the stream message content.
- Extract toConcordImeta()/toPlainImetas() into a shared UploadImetas.kt so
the minichat and Concord composers build imeta the same way.
- MinichatScreen: add the SelectFromGallery leading icon + ChatFileUpload
dialog, encrypting only when the backend is end-to-end (Concord).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D8FD5xm8nyEKzd8dk9VfT1
NIP-44 encryption preference: NwcSignerState now fetches each wallet's
kind 13194 info event (via the relay client, injected by Account),
caches whether it advertises `nip44_v2`, and sends pay/RPC requests
with NIP-44 when supported — satisfying NIP-47's "client should always
prefer nip44 if supported by the wallet service". Falls back to NIP-04
(the legacy default) when unknown or unsupported; response decryption
already auto-detects the scheme.
Non-zap payment notifications: NwcPaymentNotificationWatcher keeps a
standing subscription to each connected wallet's NIP-47 notification
stream (kind 23197/23196) on the wallet relay, decrypts payment_received
events, and posts a tray notification on a new Payments Received channel.
Payments carrying a NIP-57 zap request are skipped, since those already
surface through the kind-9735 ZapNotification path — so only plain,
non-zap incoming payments are announced.
Deep-link pairing: the "Connect wallet via app" button now builds its
`nostrnwc://connect` URI through the tested quartz Nip47DeepLink helper
instead of a hardcoded percent-encoded string.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDAAS4ktFbWtRnEVXQsjfs
Add Nip47DeepLink for the NWC-07 same-device pairing convention:
build/parse the `nostrnwc://connect` request (client -> wallet) and
the callback URI that returns the `nostr+walletconnect://` pairing code
(wallet -> client). All params are URI-encoded per the spec.
Also thread `useNip44` through LnZapPaymentRequestEvent.create so
pay_invoice requests can opt into NIP-44, matching createRequest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDAAS4ktFbWtRnEVXQsjfs
Videos in the gallery now show a decoded first frame (Coil
VideoFrameDecoder) with a play badge instead of a generic glyph; the same
preview is reused in the detail header, with a type-glyph fallback for
blobs Coil can't decode (e.g. HLS playlists).
Tapping an image or video tile now opens a full-screen viewer: images are
zoomable/pannable (engawapg zoomable, matching ZoomableImageDialog) and
videos play inline. Its top bar carries back, a new share action (system
share sheet for the blob URL), and a button that reveals the file's
storage matrix and sync/copy/open/share/report/delete actions in a bottom
drawer. Non-visual blobs (PDFs, arbitrary files) still open straight to
that action sheet. The actions list is now a shared BlobActionsContent so
the sheet and the viewer drawer stay identical.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EDLTQZM6yX13EwaYFNWTz7
On the "My Blossom Files" screen, replace the top-bar sync icon with a
3-dot overflow menu offering "Refresh" and "Import files…".
The new Import flow lets the user pull files they've uploaded to other
Blossom servers into their own. They pick source servers to check —
seeded from the recommended bootstrap list (minus servers already in
their own list, with an enable-all toggle) and/or hand-typed addresses.
A scan fans a BUD-02 GET /list/<pubkey> across the enabled sources,
works out which of those blobs are missing from the user's own
kind-10063 servers, and hands the gaps to the existing app-level
BlossomMirrorQueue so each server fetches them via BUD-04 mirror —
reusing the same floating progress banner as the on-screen sync.
- BlossomImportViewModel: source list management, scan, gap detection,
mirror hand-off.
- BlossomImportScreen: server picker, custom-URL field, scan + results.
- New Route.ImportBlossomBlobs wired into AppNavigation.
- Strings + plurals for the import UI.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5G515Grhc4t7ACoza9eyN
Captures the design for paying BOLT12 offers over NIP-47 and — via the pay
result's payer_proof — sending real kind:9736 zaps. Maps the reusable NIP-47
plumbing, breaks the work into phases, and flags the linchpin risk: nwc#2 does
not guarantee payer_note lands in the BOLT12 invreq_payer_note that NIP-2421's
zap binding depends on.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
Replace the single-column list of large blob cards with an adaptive
thumbnail grid so many files no longer mean an endless scroll. Each tile
shows an image preview (or a type glyph) plus a corner badge summarizing
how many of the user's servers hold it (green check when on all, amber
cloud with a present/total count otherwise).
Tapping a tile opens a bottom sheet with the file's details: hash,
type/size, the per-server storage matrix ("Stored on"), the sync
(mirror-to-missing) button, and the copy/open/report/delete actions that
previously lived behind the card's overflow menu.
The ViewModel is unchanged — only the presentation layer moved from
list-of-cards to grid-plus-detail-sheet.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EDLTQZM6yX13EwaYFNWTz7
Audit of the three edit paths (feed 1010 / Buzz 40003 / Concord 3302) found:
1. Bug (feed regression): a deleted edit kept overlaying its message. Edits
anchor on the target's Note.edits with no `replyTo` back-link, and
removeNote didn't cover `edits`, so unlinkAndRemove never dropped them — the
old cache-scan resolver dropped deleted edits for free, Note.edits did not.
Fix: removeNote now also removeEdit()s, and unlinkAndRemove resolves the
edit's `e`-tag target and unlinks it there (editedTargetIdOf covers all
three kinds). New test: deleting an edit un-overlays and unlinks it.
2. Perf: every chat row ran two edits-flow collectors (observeConcordEdit +
observeBuzzEdit). A message is only ever one kind, so they're merged into a
single observeChatEdit that resolves latestConcordEdit() ?: latestBuzzEdit()
— one collector per row, dispatched by the winning edit's event type.
3. Nits: latestBuzzEdit now tie-breaks by idHex (deterministic on same-second
edits, matching Concord); dropped a redundant takeIf in latestConcordEdit.
The author check stays at read time on purpose: an edit can be consumed before
its target loads (author unknown), so an attach-time gate would wrongly drop
early-arriving legit edits.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
The payment-hash dedup kept the LOWER amount and OR'd cryptoVerified across
entries. Because a payer proof publishes its proof_preimage, once a BOLT12 zap
is public anyone can replay its payment hash in a compressed (unverifiable)
proof with a 1-msat amount; the merge would keep that amount AND inherit the
verified flag, driving the counted total to ~zero and mislabeling a fabricated
amount as verified.
Per the NIP, dedup by invoice_payment_hash applies among *validated* proofs.
Only a crypto-verified proof has a signature-bound amount, so a verified entry
now always wins over an unverified duplicate; the lower-amount rule applies only
between entries of the same verification status. Adds a two-order regression
test for the replay-griefing case.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
findLatestModificationForNote (and the Buzz resolver) were pure Note.edits
filters with no LocalCache state — they only lived there for historical
reasons (back when resolving edits meant scanning the whole cache). Now that
every edit is a hard-referenced child of its message, resolution is a cheap
in-memory fold that belongs on the note.
New NoteEditOverlays.kt collects all three as Note extensions, so every edit
kind resolves the same way and none touches LocalCache:
- Note.textNoteModifications() (1010, author-only + NIP-40, version list)
- Note.latestBuzzEdit() (40003, author-only, newest by created_at)
- Note.latestConcordEdit() (3302, author-only, newest by CORD-02 send time)
Callers updated: observeEdits, observeNoteModifications, observeBuzzEdit,
observeConcordEdit, and the Buzz test. observeConcordEdit also drops its
early-return-before-produceState guards (a conditional-hook hazard) since the
resolver returns null for a non-Concord note anyway. LocalCache no longer
carries any edit-filtering logic.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
A BOLT12 offer (lno1…) is not a lightning: payload — that scheme is for BOLT11
lnbc… invoices. Per BIP21/BIP321 an offer travels as the lno parameter of a
bitcoin URI (bitcoin:?lno=lno1…), with the on-chain address optional so a
Lightning-only offer stands alone. The bech32 offer value is URL-safe, so no
percent-encoding is needed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
Adds a profile action that reads the recipient's kind:10058 offer list and,
for each published BOLT12 offer, hands it to an installed wallet via the
lightning: scheme (payViaBolt12Intent, sibling to the BOLT11 payViaIntent).
This is the first payment step — a plain wallet handoff, not a NIP-XX zap, so
it produces no Nostr receipt. NWC payment instructions are left for later.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
Adds a Settings entry (mirroring the Payment Targets editor) that lets the
logged-in user add/remove reusable BOLT12 offers (lno1…) and publishes them
as a replaceable kind:10058 offer list. Offers are validated against
Bolt12Bech32 before being accepted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
It existed to serve observeEdits a cheap synchronous value (an LRU read)
distinct from the expensive IO-only findLatestModificationForNote cache scan.
Since edits fold from Note.edits, findLatestModificationForNote is itself
cheap and thread-safe, and cachedModificationEventsForNote had become a plain
alias for it. observeEdits now calls findLatestModificationForNote directly —
the same function observeNoteModifications already uses — and the LocalCache +
AccountViewModel aliases are removed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
The first cut appended :geode:test to the build-desktop matrix command.
That was wrong twice over: geode is JVM-only, so it ran 3× across the
ubuntu/macos/windows matrix, and — because org.gradle.parallel=true —
its default suite's CPU-heavy throughput benchmarks (a 1M-event mirror
sync, WireReqFloor, NegentropyServerReconcile) ran concurrently with the
timing-sensitive quartz relay-client tests, flaking
NostrClientReqBypassingRelayLimitsTest.denseSecondBeyondCapIsSteppedPastWithoutStalling.
Move :geode:test into its own test-geode job (needs: lint, ubuntu, JVM
21) so it runs once and its benchmark load can't starve another module's
timing assertions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KCdJwdhGtmLZ12ViS56S3k
Downloads and keeps kind 10058 fresh the way the per-user relay/payment lists do,
so a recipient's BOLT12 offer is available for discovery before a zap.
- LocalCache consumes 10058 as an addressable note (consumeBaseReplaceable),
keyed by Address(10058, pubkey, "").
- User gains a pinned bolt12OfferListNote + bolt12OfferList()/bolt12Offers()
accessors — this is how a payer reads a recipient's offers.
- Bolt12OfferListState: the logged-in user's own offer list as live account state
(a StateFlow<List<String>> of offers), persisted across restarts and published
via saveOffers() — cloned from NipA3PaymentTargetsState. Wired into Account
(bolt12OfferList + saveBolt12Offers), AccountSettings (backup + updater), and
LocalPreferences (persist/restore).
- Subscriptions: kind 10058 added to FilterUserMetadataForKey (fetches OTHER
users' offers — the key edit for zap recipients) and to the account's
FilterAccountInfoAndListsFromKey (fetches your own at login).
Editor UI and the payment intent follow next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
The NIP was updated to publish a recipient's BOLT12 offer(s) in a dedicated
replaceable event (kind 10058, `bolt12_offer`) instead of a kind:0 field — this
is what ties an offer to a Nostr identity (the author's signature) and gives
BOLT12 zaps the send-side addressability that lightning zaps get from lud16.
- Bolt12OfferListEvent (kind 10058): a BaseReplaceableEvent holding one or more
`["offer","lno1..."]` tags (reusing OfferTag), with offers()/firstOffer()
accessors and create/updateOffers factories (mirrors ChatMessageRelayListEvent).
- Registered in EventFactory + a KindNames display entry.
- Tests: offers round-trip + factory typing, malformed-offer-tag filtering, and
updateOffers replacing the offer set while keeping other tags.
Discovery: a payer fetches the recipient's latest 10058 and picks an offer. The
app-side caching, subscription, editor, and payment intent follow in later commits.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
geode was runnable only via ./gradlew :geode:run and was absent from CI.
Give it the same release process as the amy CLI (it's the same kind of
application-plugin JVM module), plus the pieces a long-running server
daemon needs that a one-shot CLI does not.
- Main.kt: add terminal --version/-V and --help/-h flags so a packaged
binary has a fast, exit-0 command (Homebrew test block, package smoke
checks, Docker healthcheck).
- build.gradle.kts: jlinkRuntime + geodeImage (portable flat app-image
with a bundled JRE, plus config.example.toml + geode.service under
share/) + jpackageDeb/jpackageRpm, mirroring cli/. No Compose to
exclude — geode depends only on :quartz.
- Dockerfile + .dockerignore: multi-stage image (gradle installDist ->
temurin JRE), the primary channel for relay operators.
- packaging/: systemd unit, macOS hardened-runtime entitlements, and a
reference Homebrew formula.
- scripts/asset-name.sh: geode_asset_name/collect_geode_assets under the
canonical geode-<version>-<family>-<arch>.<ext> scheme.
- create-release.yml: build-geode matrix (tarball + deb/rpm + no-JRE jvm
bundle, with a serve+NIP-11 smoke test of the jlink image) and a
docker-geode job pushing ghcr.io/<owner>/geode:<version> (+ :latest).
- bump-homebrew-geode-formula.yml: auto-sync the reference formula on
stable releases.
- build.yml: run :geode:test in CI (it ran in no workflow before).
- README.md + plans/2026-07-24-geode-release.md: operator docs + design.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KCdJwdhGtmLZ12ViS56S3k
The Buzz edit overlay applied the newest kind-40003 by created_at regardless
of who signed it, so a 40003 signed by anyone — targeting someone else's
message — would rewrite that message in every reader's UI. The send side
already gates Edit to your own messages, but the apply side re-checked
nothing and effectively trusted the relay to reject cross-author edits.
Enforce author-only on apply, matching feed (1010) and Concord (3302) edits:
observeBuzzEdit now resolves through LocalCache.findLatestBuzzEditForNote,
which keeps only edits whose author is the original message's author (newest
by created_at — Buzz's own last-write-wins rule otherwise). There is no Buzz
feature that edits another user's message; moderation is delete/hide.
Added a test: a verified forged edit by a different author lands in the store
but never overrides the message, while the real author's later edit does.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
All three edit kinds now anchor on the message they edit via the same
Note.edits collection, instead of each maintaining its own store:
- Feed edits (kind 1010): consume(TextNoteModificationEvent) calls
editedNote.addEdit(note); findLatestModificationForNote folds note.edits
(author-only, NIP-40 expiry) instead of scanning the whole cache. Drops
the O(all-notes) scan and the 20-entry modificationCache LRU;
cachedModificationEventsForNote is now synchronous (no Loading state).
- Buzz edits (kind 40003): consume(StreamMessageEditEvent) calls
target.addEdit(note); observeBuzzEdit reads note.edits (newest by
created_at, no author gate — Buzz's own rule). Removes the channel-keyed
BuzzWorkspaceState edit store, its editUpdates/editFor/effectiveContentFor/
addEdit and the pruneEdits reaping (edits now prune with their message).
- Concord edits (kind 3302): already on note.edits.
Each reader keeps its own semantics by filtering note.edits on its event
type; the shared field only unifies storage + lifecycle, so an edit lives
exactly as long as the message it edits. Buzz edit tests rewritten against
note.edits (6/6 green).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
- don't double-return the player if register() throws
- guarantee pool teardown even if a session retire throws
- simplify pool ownership code, one funnel and paired counters
- fix(playback): remove pool from livePools after its own teardown
- fix(playback): floor decoder decrements and report teardown drift
- fix(playback): own the player across the acquire-to-register window
- fix(playback): give every pooled player exactly one owner
Sweep of Kotlin compiler warnings in every module's main source sets
(quartz, commons, cli, desktopApp, amethyst, nappletHost).
Genuine code fixes:
- Drop unnecessary !!/safe-calls and redundant elvis/casts (OkHttp's
now-non-null `body`, smart-cast callbacks, non-null String receivers).
- Remove provably-redundant conditions (`canvas == null` after a
non-null content check; `account != null` implied by `canModerate`).
- Migrate deprecated kotlinx.collections.immutable persistent ops
(add/remove/put/addAll -> adding/removing/putting/addingAll).
- Migrate LocalClipboardManager -> LocalClipboard (+ scoped setText),
ContextCompat.startActivity -> context.startActivity, TabRow ->
SecondaryTabRow, and @ConsistentCopyVisibility on a private-ctor data class.
- Delete dead ReceiveDialog.onGenerate param (never invoked).
- Fix a platform-Boolean type-mismatch on a ThreadLocal read.
Deprecations with no available successor are narrowly @Suppress-ed with
a reason: androidx.security.crypto (EncryptedSharedPreferences/MasterKey),
androidx.privacysandbox.ui, WebView.databaseEnabled, BluetoothDevice
.connectGatt, media3 setEnableAudioTrackPlaybackParams, FirebaseMessaging
.token, and InputMethodManager.SHOW_IMPLICIT.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Xb9YbBqhdZsHxMzitmyvn
Geode hard-wired the SQLite EventStore. Add a `[database].backend`
selector (and `--store` CLI flag) so an operator can choose the store
implementation:
- "sqlite" (default): the SQLite EventStore, unchanged.
- "fs": quartz's filesystem FsEventStore, rooted at [database].file.
- any other value: a fully-qualified class name of a custom
IEventStore on the classpath, instantiated reflectively via one of
`(NormalizedRelayUrl?, IndexingStrategy)`, `(NormalizedRelayUrl?)`,
or `()` — the "plug in anything" escape hatch.
Store construction moves into a new StoreFactory (mirrors cli's
StoreFactory) shared by the serve path and the import/export verbs, so
both open the same store from the same config. The SQLite-only
`PRAGMA optimize` maintenance loop now runs only when the resolved
store is the SQLite one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GG3TBvLUv5uB5js1naG5sc
LocalCache.notes is a soft cache, and a Concord rumor is decrypted exactly
once per session (the community session dedups re-delivered wraps), so a
kind-3302 edit left orphaned there could be GC'd on navigation and never
re-downloaded — the message would silently revert to its pre-edit text.
Resetting the channel EOSE doesn't help: the re-delivered wrap is swallowed
by the session's isNew dedup, so its rumor never re-emits.
Fix it the way reactions/replies already survive: attach the edit to the
message it edits. consume(ConcordChatEditEvent) now calls target.addEdit(note),
so the edit is held for exactly as long as its channel-retained message (and
released with it via clearChildLinks). observeConcordEdit reads note.edits
directly — author-matching, latest by CORD-02 §4 send time — instead of
scanning/observing the soft cache.
- Note: new hard-held `edits` collection + addEdit/removeEdit, wired into
clearChildLinks like the other child-event links.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
LocalCache.computeReplyTo had two `is StreamMessageV2Event ->` branches in the
same `when`. The `when` matches the first, so the second (the older
buzzThreadRoot+buzzThreadReply variant) was dead code — the condition Sonar
flags as duplicating the earlier one. The surviving first branch is the newer,
deliberate Concord-style threading behavior that links a 40002 thread reply into
its parent's replies via buzzThreadReply. Also drops the now-unused
buzzThreadRoot import.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoYFvsXvPigRWebuoGiRoG
- rememberNoteMenuActions is now the single source for the note menu; both the
⋮ overflow (ShareMenu) and the feed right-click ContextMenuArea render the same
items (copy ×5 / broadcast / mute / report).
- LocalSnackbarHost exposes the app SnackbarHostState so moderation actions show a
confirmation: 'Muted user', 'Report sent', 'Reported & muted', 'Broadcast to
relays', and failure toasts. Wired for the note menu + profile ⋮ actions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Report/mute actions published silently with no feedback. Add a DesktopModeration
logger: every report/mute logs kind+id+relay-count on publish, WARNs when there
are 0 connected relays (so a dropped publish is visible instead of silent), and
signing/publish failures are caught + logged (were swallowed by the launching scope).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Manual testing showed the note moderation actions were hard to find:
- Provide LocalDesktopIAccount on MainContent's own (non-null) provider so every
deck/feed/profile/settings surface reliably resolves the account.
- Add a right-click ContextMenuArea on feed notes: Mute user / Report… / Copy text
(moderation items for other authors on a writeable account).
- Swap the note action-row Share icon for a MoreVert (⋮) overflow — the menu it
opens already carries copy/broadcast + mute/report, matching the profile ⋮.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Going / Maybe / Can't go buttons in the calendar feed card wrapped
to two lines because the default button content padding (24dp horizontal)
left no room for "Can't go" in an equal-weight third of the row.
Tighten the content padding, shrink the row spacing, and constrain each
label to a single line so the three buttons fit horizontally.
The parent message shown above a reply in the MessagingStyle notification was
unconditionally labeled "Me" with the account's avatar. That's wrong whenever
the account is notified as the *root* author of a thread but the direct parent
belongs to someone else — e.g. Vitor starts a thread, fiatjaf replies, a third
person replies to fiatjaf: fiatjaf's note was rendered as Vitor. This surfaced
through the NIP-22 comment and public-chat reply paths, which notify on
root-authorship, not just direct-parent authorship.
ReplyNotification now receives the parent Note (not a bare content string) and
resolves its actual author: attributed to the MessagingStyle `me` Person only
when the parent truly is the account's, otherwise shown as the real author with
their name + avatar, observed for enrichment like the replier. postConversation
reuses the `me` Person for a self-authored parent so it still renders as you.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r
When a mention/article body contains an inline `http(s)` image link, show the
image as the notification's big picture (BigPictureStyle) instead of leaving the
raw URL in the text. NotificationContent.renderNoteText() now pulls the first
image URL out of the content — scanning all lines, since images usually sit on
their own line — using the cheap RichTextParser.isImageUrl extension check, and
strips that link from the excerpt. Videos are left in the text (Coil can't load
them as a still). The Mention and Article renderers pass the extracted URL as
bigPictureUrl; Reply stays MessagingStyle (a big picture doesn't fit a chat
bubble) and keeps its text as-is.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r
Notification bodies previously showed raw `nostr:npub1…` / `nostr:nprofile1…`
tokens for anyone cited in the text — the name never filled in even after the
cited user's kind:0 loaded, because the excerpt was a static substring taken
once and never re-resolved against the metadata cache.
Add NotificationContent.resolveMentions(), which rewrites each cited npub/
nprofile token to `@<best display name>` and returns the cited Users so the
renderer can add them to its enrichment window. Event references (nevent/note/
naddr) are left verbatim — they have no name to show. Mention, Reply, Media and
Article renderers now recompute the body inside the observable build closure and
observe the cited users, so the text flips from `@npub1abc…` to `@RealName` in
place, matching the author-name enrichment the title already had.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r
Watch kind-3302 edits reactively through LocalCache.observeEvents, narrowed on
the edit's `e` tag, instead of a whole-cache scan wired to the target note's
edits flow. The index-backed observer seeds from any edit already cached and
wakes on each new one, matching how the app observes reactions, Nest presence,
and git PR updates.
- observeConcordEdit now collects observeEvents<ConcordChatEditEvent>({kinds,
"#e"}); it filters to the original author and takes the latest by send time.
- consume(ConcordChatEditEvent) drops the manual edits-flow invalidation and
just lands the event + wakes observers (refreshNewNoteObservers).
- Removed the now-unused findLatestConcordEditForNote scan and concordEditCache.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
Verified against the Concord v2 reference client (Soapbox Armada,
src/concord-v2/lib/kinds.ts): a chat message edit is a dedicated KIND_EDIT =
3302 rumor, NOT a kind-1010 modification. It names the target with a single
`e` tag (no `k` — Armada adds `k` only to deletes), carries the replacement
text, and rides the channel/epoch binding. The fold applies only edits
authored by the original message's author (latest by CORD-02 §4 send time
`created_at*1000 + ms`), non-destructively.
The prior commit used kind-1010 TextNoteModificationEvent, which would not
interop with Armada. Corrected:
- New ConcordChatEditEvent (kind 3302) in quartz, registered in EventFactory;
ChannelChat.edit now builds it. orderingMs() honors the `ms` remainder tag.
- LocalCache.consume(ConcordChatEditEvent) wires the edit to its target note
and invalidates the edits flow; findLatestConcordEditForNote returns the
author-matching kind-3302 edits ordered by send time (latest wins).
- observeConcordEdit reads that finder instead of the kind-1010 machinery.
Send/compose/action-sheet plumbing is unchanged (it routes through
ChannelChat.edit). Note: Amethyst does not yet emit the `ms` remainder tag on
Concord rumors (a pre-existing, message-wide gap), so its own edits order at
one-second granularity; received Armada edits are ordered at full precision.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
Brings feed-post-style edits to Concord chat. A Concord message rumor is a
standard kind-9 event, so an edit reuses Amethyst's native kind-1010
TextNoteModificationEvent: a channel/epoch-bound rumor that e-tags the target
and carries the replacement text, wrapped and published on the same channel
plane as any other Chat Plane rumor. Receivers overlay the newest edit through
the existing shared machinery (LocalCache.findLatestModificationForNote), which
only applies edits authored by the original message's author, so a member can't
rewrite someone else's message. Clients that don't understand kind-1010 keep
showing the original text, so it degrades gracefully.
- ChannelChat.edit + ConcordActions.buildChannelEdit build/wrap the edit rumor.
- Account.editConcordChannelMessage gates to my own kind-9 messages and
publishes the wrap (local echo + relays), mirroring reactToConcordMessage so
the edit never leaks the private rumor id onto public relays.
- The chat bubble overlays the newest edit (RenderConcordEditedNote) with an
"(edited)" marker, matching the Buzz kind-40003 edit presentation.
- The long-press action sheet offers Edit on my own Concord messages; the
composer enters edit mode with an editing banner and publishes the edit on
send. The former onWantsToEditBuzz callback is generalized to
onWantsToEditChatMessage, shared by the Buzz and Concord surfaces.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
Concord fetched a channel's messages only when its screen was open (the history
pager mounts on the channel screen), so an un-opened channel showed "No messages
yet" in the community list and never appeared in the Messages inbox — unlike
NIP-28 / NIP-29, which preload a last-message per room. Add a one-shot warm
drain, `Account.warmConcordChannelPreviews`, triggered on community-screen open
and app-wide per subscribed community from the account preload (both debounced
so the cold-boot fold burst warms once).
Per channel (`ConcordSubscriptionPlanner.channelPreviewFilters`):
- never read -> the newest `previewLimit` (10) wraps: a preview plus a rough
sense of how busy the channel is, without pulling the whole backlog.
- read -> everything `since lastRead - 1` (capped at `catchUpLimit`): the unread
badge is accurate and the missed messages are cached for on-open; the `-1`
re-includes the last-read message (its created_at == lastRead) so a caught-up
channel still shows a preview, and unread stays exact (the count is strict `>`).
Filters group by relay into one REQ per relay (one filter per channel); the
wraps ingest through the normal cache path, and the always-on plane subscription
keeps them fresh afterward. Full history still pages in on open.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A public channel carries no delivered key, so a writer lists it under an
entry's `channels` with only {id, epoch, name}. `WireChannel.key` was a
required field, so kotlinx.serialization threw MissingFieldException — and
`decodeDocument`'s catch-all turned that one bad channel into an empty list,
silently dropping EVERY joined community from the kind-13302 list (communities
"won't load" at all).
- Default `WireChannel.key = ""` so a keyless (public) channel no longer throws.
- Decode entries one at a time and keep any we still can't parse verbatim in
`ConcordListResidue.unparsedEntries`, re-emitted on write — so one malformed
entry can never wipe the whole list, and a read-modify-write never deletes a
membership this version can't model.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- run.sh: add a Buzz-style bare reaction check (kind-7 with an `e` tag but no `p`
tag) asserting it still lands on the Reactions channel, and a note that Buzz DM
isn't auto-triggered.
- README: coverage row for the bare reaction, plus a "Buzz DM (manual)" section
with the channel-setup steps (kind-39000 `t=dm` + participant, then post
kind-9/40002) since participant-routing needs cached channel metadata.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r
From an adversarial audit of the BOLT12-zap feature.
Security / correctness:
- Offer↔invoice binding: `cryptoVerified=true` was asserted even when the offer had
no `offer_issuer_id` or used blinded paths — cases where the invoice's node key is
payer-chosen and can't be tied to the offer. An attacker could self-sign a "verified"
proof having paid nothing. Now cryptoVerified requires the invoice to be provably the
offer's (issuer_id present, no paths, invoice_node_id == issuer); unbindable proofs are
accepted but flagged unverified, not verified. Definite contradictions still hard-reject.
- Counting: `updateZapTotal` now counts ONLY crypto-verified BOLT12 zaps. An unverified
(compressed / unbindable) proof carries a self-chosen preimage+amount with no settled-
payment guarantee, so counting it let anyone inflate a note's total for free. Unverified
entries stay stored + shown (dimmed), never summed.
- Dedup: `innerAddBolt12Zap` now honors the NIP's "count the LOWER amount for the same
payment hash" rule (was order-dependent last-writer-wins, inflatable by re-publishing a
bigger amount tag). Keeps the stronger verification flag.
- Precision: divide millisats in BigDecimal, so fractional sats survive and match the
millisat-native lightning column (was integer `/1000`, flooring sub-sat zaps to 0).
Codec hardening (quartz):
- TLV length now range-checked (was a signed compare that let a high-bit BigSize length
slip through and get truncated by toInt()).
- BigSize enforces minimal encoding (also rejects >=2^63 values that read back negative).
- bech32 alphabet membership is O(1) via a lookup table (was O(32n) indexOf per char).
UI:
- ReusableZapButton's "you zapped" gate now includes bolt12Zaps (and nutzaps/onchain),
so a BOLT12-only zap correctly shows the zapped state.
- The reactions gallery renders the blank/unknown author for anonymous zaps, matching the
standalone card (was showing the throwaway ephemeral key's avatar).
Tests: validator issuer-less-offer downgrade; TLV non-minimal-BigSize + oversized-length
rejection; model lower-amount dedup (both orderings), verified-only counting, and
fractional-sat survival. NoteBolt12ZapTest 6→8, Bolt12ZapValidatorTest 11→12, TlvTest 6→8.
The audit also surfaced a NIP-level gap that is NOT fixable in code and is captured in the
plan doc: there is no offer↔recipient-identity binding, so even a crypto-verified proof
only proves payment to the *embedded* offer, not to the p-tagged recipient.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
Buzz DM messages (NIP-29 relay-group StreamMessageV2Event kind 40002 / ChatEvent
kind 9 in a `t=dm` channel) carry no `p` tag — participation is the relevance
signal — so main added them to the in-app feed only. This wires them into push:
- BuzzDmNotification renderer: MessagingStyle on the Private Messages channel,
channel name as the conversation title, "sender: text" body, sender avatar,
deep-links to the relay-group chatroom via the channel's kind-39000 naddr
(reusing the existing naddr → Route.RelayGroup path). Honors the
"show messages in notifications" toggle and enriches the sender observably.
- Dispatcher: adds kinds 40002/9 to NOTIFICATION_KINDS and gates them
EXCLUSIVELY on Buzz-DM membership (buzzDmChannelForMe) so ordinary kind-9
chats (Concord / NIP-C7) don't leak into the tray.
- NotificationRoutes.relayGroupUri for the channel deep-link.
Also closes a mute-parity gap the audit surfaced: the push muted-thread check
covered Reaction/LnZap but not Repost/GenericRepost (the feed mutes all four) —
so a bare Buzz repost of your note on a muted thread now stays muted in push too.
Buzz reactions/reposts (bare no-`p`-tag likes) already route to the existing
Reaction/Repost renderers, which are correct for Buzz (plain kind-7/6/16, same
target resolution and emoji semantics) — verified, no change needed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r
Speed:
- Bolt12ZapValidator reorders checks cheap-to-expensive: all structural, cross-event,
and payer-proof binding checks run first; the schnorr signature + proof crypto
verifications run only once an event has passed them. A malformed or mismatched
event now rejects with zero schnorr ops. Cannot change accept/reject, only which
reason a doubly-invalid event reports.
- validate() gains verifyEventSignature (default true); LocalCache.consume passes
false since the relay pipeline already verified the outer event — removing a
redundant schnorr on every ingested zap (3 verifies instead of 4).
- Bolt12Merkle precomputes SHA256("LnLeaf")/SHA256("LnBranch") once and hashes the
per-call "LnNonce"||first-tlv tag once per rootHash instead of once per record.
Tests:
- New validator rejections: preimage-mismatch, invalid-invoice-signature,
payer-tag-mismatch, proof-does-not-match-offer, plus the verifyEventSignature
skip-flag both ways (fixture gains corruptPaymentHash / breakInvoiceSignature).
- New commons NoteBolt12ZapTest: millisat→sat total, dedup by payment_hash,
verified-not-downgraded-by-unverified, remove-by-source, clearChildLinks, and
combined totals.
Docs: quartz/plans/2026-07-23-bolt12-zap-interop-vectors.md captures the two
upstream-gated follow-ups (vector-driven interop test + compressed-proof merkle
reconstruction) for when lightning/bolts#1346 merges.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
A Buzz like is a bare ["e", <id>] reaction with no p tag, so the push gate's
(isTaggedUser || publicChatReply) pre-clause rejected it even though
tagsAnEventByUser already recognizes a reaction/repost whose reacted note is
mine. Relax the pre-clause for reaction/repost kinds; tagsAnEventByUser keeps it
scoped to reactions on my own note. Reuses the existing Reaction/Repost
renderers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r
Wires the receiving side of NIP-XX BOLT12 zaps (kind 9736) into every place a
NIP-57 lightning zap is counted or shown. Sending is intentionally left for
later. Modeled on the lightning-zap scheme (synchronous, the proof carries the
amount, counted the moment it validates) rather than the onchain scheme (async
chain backend, PENDING/CONFIRMED, CONFIRMED-only) — BOLT12 proof verification is
a self-contained synchronous check, so no resolver/backend is needed.
Model (commons):
- Bolt12ZapEntry + Note.bolt12Zaps map keyed by the proof's invoice_payment_hash
(the spec dedup key); addBolt12Zap/removeBolt12ZapBySource; folded into
updateZapTotal (millisats → sats) alongside lightning/onchain/nutzap amounts;
wired into clearChildLinks, moveAllReferencesTo, removeNote,
hasZapsBoostsOrReactions, hasZapped, and the isZappedBy family.
Ingestion (LocalCache):
- consume(Bolt12ZapEvent): validate synchronously via Bolt12ZapValidator, then
addBolt12Zap on the resolved targets (e / a / profile); computeReplyTo and
live-activity channel routing branches; dispatch case.
Subscriptions: added kind 9736 to every filter carrying LnZapEvent.KIND
(notifications, replies/reactions to notes & addresses, profile received-zaps,
live-activity goal + messages, nest room + collectors, notification dispatcher,
shared NotificationKinds, app-functions).
Aggregation / notifications: UserProfileZapsViewModel (mapper), NotificationSummaryState
(both passes), NotificationFeedFilter (kinds, zap-receipt detection, payer author
resolution, muted-thread + own-event gates), NotificationKinds own-event exception,
ThreadAssembler.anchorsItsOwnThread, and the commons live-activity aggregators
(RoomZapsState, LiveStreamTopZappers, NestViewModel).
UI: RenderBolt12Zap standalone card (styled like the lightning card, labeled
BOLT12) wired into NoteCompose + ThreadFeedView; Bolt12ZapGallery in the
reactions row (payer avatars + amounts, unverified/compressed proofs dimmed);
reaction-row counter gate; KindNames / KindDisplayName entries.
Note: validated BOLT12 zaps are counted immediately; a compressed proof whose
signatures aren't yet verifiable (pending lightning/bolts#1346 merkle
reconstruction) is stored with cryptoVerified=false and dimmed in the gallery.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
Cancelling a proof-of-work mining job used to silently discard the post
(the sign+broadcast continuation only ran on a successfully mined
template). Users had no way to publish the note un-mined once they'd
started waiting.
Now abandoning a template post asks what to do instead of discarding:
- The × on the mining banner opens a dialog with "Send without PoW",
"Discard post", or tap-away to keep mining. Only shown for jobs that
carry a plain un-mined fallback (template posts); opaque work jobs
(reactions, reposts, anonymous posts, gift wraps) keep the direct
cancel since they have no template to fall back to.
- The mining foreground-service notification gains a "Send now" action
that publishes every eligible queued post without proof of work.
Implementation: the queue keeps the un-mined publish continuation
alongside the miner. sendWithoutPow() sets a flag the worker picks up on
its next isActive poll; the miner aborts and the plain template is
published through the same sign+broadcast path the mined template would
have used, off the worker pool. PoWJobState exposes canSendWithoutPow so
the UI knows which jobs support it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012kLVFV7ps4HfXDDJPNqi82
Kotlin/Native marks the stdlib `assert()` with `@ExperimentalNativeApi`, so
the iOS test target (`compileTestKotlinIosSimulatorArm64`) failed to compile
without an opt-in — while JVM/Android were fine. Swap it for `assertTrue`
from `kotlin.test`, which needs no opt-in on any target and matches the
assertions already used in this file.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
Implements the quartz-side of the proposed "BOLT12 Zaps" NIP
(nostr-protocol/nips#2421): public, self-verifying zap events that prove a
BOLT12 payment without an LNURL server or recipient-operated receipt publisher.
Events (nip-88 style templates/tags/builders):
- Bolt12ZapEvent (kind 9736) and Bolt12ZapIntentEvent (kind 9737)
- shared tags: amount (msats), offer, proof, P (payer), zap_id, description
- registered both kinds in EventFactory
BOLT12 decoding (new, no existing KMP library):
- Bolt12Bech32: canonicalization (+ continuation / whitespace) + no-checksum,
no-length-limit bech32 for lno1 offers and lnp1 payer proofs
- Tlv: BigSize codec, TLV stream reader/writer, tu64 helpers
- Bolt12Offer / Bolt12PayerProof parsers (proof TLV types per lightning/bolts#1346)
- Bolt12Merkle: BOLT12 tagged-hash + signature merkle root + signature digest
Validation:
- Bolt12ZapValidator runs the NIP's steps (structure, embedded-intent match,
payer-proof binding: invreq_payer_note == nostr:nipXX:<intent-id>,
invoice_amount == amount) and returns a typed result with the payment-hash
dedup key
- Bolt12ProofVerifier checks preimage->payment_hash and the invoice/proof
BIP-340 signatures for fully-disclosed proofs; compressed proofs are reported
as unverified pending the (still-draft) lightning/bolts#1346 test vectors
- Bolt12ZapBuilder assembles+signs the intent and the final zap
Tests: 24 commonTest cases covering bech32/TLV/merkle round-trips, event tag
structure + factory typing, and validator accept/reject paths (self-signed
BOLT12 fixtures exercise the full merkle + schnorr path).
Note: this is the receive/verify + assembly layer only. Origination is blocked
on the upstream BOLT12 payer-proof spec merging and a wallet/NWC rail exposing
lnp proofs; no Amethyst payment rail returns one today.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
The two workspace-owner actions (Add people to this workspace, Create invite
link) sat as an inline button row above the channel list. Move them into a
3-dot overflow (MoreVert) menu in the top-right of the workspace top bar,
matching the Concord community pattern. Fold the invite-mint flow (spinner,
result Copy/Share dialog, error dialog) into the new BuzzWorkspaceOverflowMenu
and drop the now-obsolete BuzzInviteMintButton.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
A Buzz like is a bare kind-7 `["e", <msg-id>]` — no `p` tag (unlike NIP-25,
which p-tags the reacted author) and no `h` tag. The notifications filter's
p-tag gate and follow filter therefore both dropped it, so a like on my Buzz
message never surfaced even in Global mode, despite the reaction being loaded
(it rendered as a chip in the open conversation).
Recognize a reaction/repost that carries NO `p` tag whose reacted target — the
last `e` tag, already loaded — is my own note, and let it bypass the follow
filter and satisfy the p-tag gate (like a Concord reaction). Scoped to the
no-`p`-tag case so well-formed NIP-25 reactions keep their normal routing, and
to an already-loaded target so it can't accept blindly. The per-kind gate
already resolves the same target author, so it needs no change.
Verified on emulator: a 👍 + ❤️ on my Buzz DM message now appears on the
Curated notifications tab.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The group-notification query (reactions/zaps/reposts on my messages inside a
NIP-29 group, kind-7 etc. scoped #p=me + #h on the host relay) sourced its #h
set from the published kind-10009 group list — which deliberately excludes Buzz
DM channels (membership is server-side, tracked in BuzzDmChannels). So a
reaction on my DM message was only ever loaded as a chip inside the open
conversation, never surfacing on the Notifications tab.
Include DM channels (from BuzzDmChannels, minus hidden ones) alongside the
joined groups in both the live-tail (AccountNotificationsEoseFromInboxRelays-
Manager) and backward-history (AccountNotificationsHistoryEoseManager) queries,
reusing the same filterGroupNotificationsToPubkey builder, and re-subscribe when
a DM is discovered/hidden. Regular-channel reactions already worked via the
joined-group path; this closes the DM gap the same way.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- docs(dm): note the profile Reports tab also reads reportsNamingUser
- refactor(dm): push report-tag typing to quartz and simplify the warning stack
- fix(dm): narrow report indexing and address final review findings
- perf(dm): resolve a 1:1 chat row's counterpart once per row
fix(dm): dedupe reporter avatars and align warning card styling
feat(dm): warn in the room when the counterpart is reported by a follow
feat(dm): expose a report-warning flow per user
refactor(reports): extract reusable reportTypeLabel composable
feat(reports): index author-named reports even when they target an event
feat(reports): add pure DM report-warning classifier
feat(reports): add additive reportsNamingUser index to UserReportCache
Batch of correctness, performance, and code-quality fixes across the Buzz
workspace feature surfaced by a full audit of the branch's modified files:
- ChannelNewMessageViewModel: agent auto-invite ran inside createTemplate(),
which sendDraftSync() calls per keystroke — so @mentioning a member
published real kind-9000 invites while still typing. Move the invite to the
send path (sendPostSync) and stage the pending mentions instead.
- AgentConsoleViewModel: fix a decryptCache read/reload race by guarding
reloadFromCache (not refresh) under the mutex; bound observerSeen growth.
- BuzzNewDmViewModel: broaden start()'s catch so signer/IO/timeout failures
surface as an error instead of leaving Start stuck on "Sending"; rethrow
CancellationException.
- BuzzPresenceState / BuzzAgentActivityState: replace per-record full-map
copies with persistent maps (structural sharing) to cut GC churn; the
StateFlow now holds the map directly.
- RelayGroupChannelListScreen / RelayGroupMembersScreen / RelayGroupTopBar /
RenderBuzzNotes: correct remember/LaunchedEffect keys so state rebinds when
the relay or channel changes.
- RelayGroupDiscoveryFeedFilter: hoist joinedGroupIds() out of the per-item
matches() loop.
- RelayGroupChannel / BuzzWorkspaceStates: @Volatile the fields read off relay
dispatcher threads.
- BuzzInviteMinter: build the request URL through HttpUrl.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
Follow-up to the Buzz-DMs-in-Notifications work: a Buzz DM on the Notifications
tab rendered like a public post and its actions didn't fit a DM.
- Render as a conversation: new RenderRelayGroupMessage draws a
RelayGroupChannelHeader (titled by the other participant for a DM) above the
message body — the group/DM analog of RenderChatMessage/RenderChannelMessage.
NoteCompose routes a group-scoped kind-9 (ChatEvent with a RelayGroupChannel
gatherer) and kind-40002 (StreamMessageV2Event) to it. The in-conversation
chat screen renders via RefreshingChatroomFeedView, not NoteCompose, so its
bubbles are unaffected.
- Reply goes into the conversation, not a public kind:1111: routeReplyTo now
detects the gathering RelayGroupChannel (mirroring the Marmot/Concord cases)
and opens the group/DM instead of falling through to Route.GenericCommentPost.
- Action row matches the card: a relay-group message (incl. a Buzz DM) hides
Repost and Share — both would reference a membership-gated group event that
non-members can't fetch, and a DM shouldn't be rebroadcast. Reply/like/zap stay.
Verified on emulator: the DM card shows the participant header + body + a
reply/like/zap-only row, and tapping reply opens the DM conversation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A Buzz DM is a relay-authoritative NIP-29 group whose messages carry no `p`
tag, so nothing made them eligible for the Notifications tab and nothing
fetched them app-wide (discovery was scoped to the open DM inbox, which only
pulls 44100 + 39000 — never the message bodies). Two halves fix that:
- NotificationFeedFilter now early-accepts a group chat message (kind-9 or
kind-40002 — the deployed relay uses both) when it resolves to a `t=dm`
channel whose 39000 participants include me, honoring the same "Messages in
notifications" toggle and never notifying for my own message. LocalCache
gains `getRelayGroupChannelForContent`, the read-only reverse-lookup this
needs (same serving-relay-then-single-channel keying as the consume path).
- An always-on discovery (BuzzDmDiscoveryPreload) subscribes 44100 #p=me across
joined workspaces into the new BuzzDmChannels registry and fetches each DM's
39000 directory; BuzzDmJoinedChatTailFilterAssembler then keeps those
channels' recent messages warm app-wide (reusing the joined-group #h tail),
excluding hidden DMs. Both mount in LoggedInPage. This is what makes a Buzz
DM show on Notifications / in push without opening the conversation.
Tests: BuzzDmChannels registry; and a LocalCache resolution test proving a
40002 and a kind-9 message both resolve back to their DM channel (and a
non-dm channel does not).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three owner/admin add-people paths (the relay enforces the role gate):
- Channel add-member: an "Add member" FAB on the channel members screen
opens a user-search dialog that publishes a NIP-29 kind-9000 put-user for
the picked pubkey (plain member).
- Community add-member: an "Add people to this workspace" action in the Buzz
community view publishes the Buzz relay-admin add-member command (kind
9030) so the relay updates its NIP-43 membership list (13534). Wires the
existing quartz RelayAdminAddMember/RemoveMember events into Account +
AccountViewModel (addCommunityMember / removeCommunityMember).
- Invite-link mint: BuzzInviteMinter does the Buzz-specific, NIP-98-signed
POST /api/invites on the relay host (via HTTPAuthorizationEvent + the
trusted-relay OkHttp client) and returns {code, url, expires_at}; a
"Create invite link" button surfaces the link with Copy / Share.
The user search is extracted into a reusable BuzzAddPeopleDialog shared by
the channel and community add flows. /api/invites is Buzz-proprietary (only
NIP-98 is standard); community add uses Buzz's 9030 admin command, since
NIP-43 itself has no owner-initiated add.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
The home feed always emitted a live-bubbles item followed by a 5dp
Spacer, even when the live section (live streams, geohash/ephemeral
chats) was empty — which is the common case. With no bubbles to draw,
that Spacer reserved dead vertical space at the very top of the list,
stacking on top of the LazyColumn's FeedPadding and the first note's own
top padding. The result was an oversized gap between the top-bar divider
and the first author's picture, most noticeable when the New Threads /
Replies tab row is hidden (single tab) so the divider sits right under
the top bar.
Move the row and its trailing Spacer inside a non-empty check so they
only render when there are actual bubbles. When the live section is
empty the item now collapses to zero height, bringing the home feed's
top gap in line with every other feed screen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTFiQykXfCShHUCA3yM2pN
Give the donation card a bolder, more exciting look:
- Add a gradient hero header (bitcoin orange → amethyst purple) with a
glowing circular bolt badge and a larger, white title
- Round the card corners (16dp) and add elevation for a modern, floating feel
- Overlay the close button on the hero and keep the description, version
credit, zap splits and donate button in a clean body section
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019bM2CKE5Mnao4FG85HPuAV
An agent-runnable script that drives the whole push-notification pipeline on a
real device/emulator: a second identity publishes each notification kind through
`amy` to the account on the phone, and the script reads back the notification
shade over `adb` to assert the right notification on the right channel — then
exercises cold-push metadata enrichment (title flips from raw pubkey to display
name in place) and dismiss-on-read (opening the note clears the tray entry).
Covers mention, reply, reaction, repost, picture, and DM; prints PASS/WARN/FAIL
and exits non-zero on any hard failure. tools/notification-e2e/{run.sh,README.md}.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r
Concord threads chat messages with kind-1111 minichat comments, but Buzz relays
reject kind-1111 — so Buzz replies fell back to inline 40002 and rendered as flat
quotes, never opening a thread.
Buzz already threads over 40002 NIP-10 markers (matching block/buzz): a reply-marked,
non-`broadcast` 40002 is a thread reply; a `broadcast=1` one is an inline timeline
sibling. Teach Amethyst's minichat to speak that dialect:
- Add `isMinichatReply(event)`: kind-1111 CommentEvent, OR a Buzz 40002 thread reply
(reply-marked, non-broadcast). Used by all three read sites so they agree.
- `LocalCache.computeReplyTo`: link a 40002 thread reply into its parent's replies.
- Timeline filter, minichat reply-count, and minichat feed now key on that predicate,
so 40002 thread replies leave the timeline, get counted on the "N replies" chip, and
render inside the minichat.
- Send: an INLINE reply on Buzz sets `broadcast=1` (stays inline); MINICHAT omits it
(threads). `sendMinichatReply` posts a 40002 thread reply on Buzz instead of kind-1111.
Verified on device: a message shows an "N replies" chip, opens the Thread view with the
root + replies + composer, replies post and increment the count, and thread replies no
longer appear inline in the main chat.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
NIP-64 chess is debug-only for now (see the isDebug gate on the drawer entry),
so its notifications and settings channel shouldn't appear in release.
- ChessNotification.notify early-returns when !isDebug.
- NotificationChannels omits the Chess channel from the settings list (and thus
never creates it) in release builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r
The Relay Groups discovery feed listed every kind-39000, including Buzz DM channels
— private 1:1 conversations that belong in the DM section, not a browsable group
list. Exclude channels whose 39000 carries the NIP-29 `hidden` tag (which Buzz sets
on t=dm channels) from both the constraint match and the "My Groups" path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deep-audit follow-up on the per-kind notification rework. Fixes two correctness
bugs and cuts the enrichment path's CPU/battery/IPC cost.
Correctness:
- Cold-push loss: the immediate notification post ran on a detached
applicationIOScope coroutine OUTSIDE any wakelock (the dispatcher's wakelock
was already released), so in Doze the CPU could sleep before it reached the
tray. The enrichment wakelock now wraps the initial render too.
- Resurrection: enrichment re-posts a notification as metadata arrives; if the
user read/dismissed the post in-app mid-window it came back seconds later.
dismissNotificationForEvent now records the event id, postStandard/
postConversation skip a dismissed id, and the enrichment window stops early
once dismissed.
Performance / battery:
- Channel creation is cached per-process again (was 2 Binder IPCs on every post
AND every re-render; now once, matching the original field-caching).
- Re-renders are debounced (250ms), collapsing the StateFlow initial-value burst
and rapid relay arrivals into one rebuild instead of ~3+ back-to-back — cutting
redundant bitmap loads/crops, notify() calls, and group-summary re-posts.
- Concurrent enrichment windows are capped (Semaphore of 4) so a push burst can't
hold N simultaneous 25s relay windows + wakelocks + subscriptions.
Behavioral audit found no regressions: routing is a strict superset of the
original and all gates/deep-links are preserved.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r
Completes the deferred items from the moderation & safety plan:
- Thread + Profile feeds now pass the real hidden lambda (via LocalDesktopIAccount),
so mutes hide replies-in-thread and profile-tab notes live. Thread root stays shown.
- 'Always show sensitive content' toggle: new PreferencesSensitiveContentSettings
(commons/jvmMain, java.util.prefs) backs DesktopIAccount.showSensitiveContentSetting
(null=blur / true=show, never false) + setAlwaysShowSensitive; unit-tested.
- ModerationSettingsSection in the Content Filters settings: the toggle + management
lists for muted users (unmute), hidden words (add/remove), muted threads (unmute),
driven by the live hidden-users flow so removing an entry un-hides immediately.
- Profile header overflow (MoreVert): Mute/Unmute + Report… (reuses ReportNoteDialog)
for other users on writeable accounts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Generalize SpamCheckedNoteRender to also gate sensitive content: a note tagged
content-warning / NSFW renders behind a collapsed scrim (reason + 'Show') unless
the account opted into 'always show sensitive content'. Reuses the same
per-note rememberSaveable reveal + forceReveal as the spam collapse.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surface the account-layer moderation write path in the UI:
- LocalDesktopIAccount CompositionLocal (provided at the app content root) so
deeply-nested note surfaces can reach mute/block/report without prop-drilling.
- ShareMenu gains 'Mute user' + 'Report…' items (writeable accounts only).
- ReportNoteDialog: NIP-56 reason picker + optional comment, with
'Report' and 'Block & report' actions.
- DesktopIAccount.reportEvent(Event, …) overload for the menu (report(Note) delegates).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Account-layer write path for moderation (UI wiring to follow):
- hideUser/showUser/hideWord/showWord/hideThread/showThread build+sign+publish
an updated kind-10000 MuteListEvent (create/add/remove via quartz), applied
optimistically to the local cache then broadcast.
- report(note|user, type, comment) publishes a NIP-56 kind-1984 ReportEvent.
- All are no-ops for read-only accounts.
- Add DesktopMuteEnforcementTest: proves the feed filters drop muted authors
(even when followed) and hidden-word notes, and pass clean notes through.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Desktop DesktopIAccount.isHidden()/isAcceptable() were stubs (false / deletion-only)
and DesktopFeedFilters never consulted them, so muting/blocking a user did nothing.
- Add DesktopHiddenUsersState: assembles the kind-10000 mute list (users, hidden
words, muted threads) + kind-30000 block list into a live StateFlow<LiveHiddenUsers>,
decrypting the private section via the shared Mute/PeopleListDecryptionCache.
- Wire DesktopIAccount.isHidden/isAcceptable + the content-filter fields to it.
- Chain !note.isHiddenFor(...) into every note-rendering DesktopFeedFilter
(global/following/custom/profile/reads/search/notification + thread replies).
- DesktopFeedViewModel re-invalidates the feed when the choices change, so mutes
hide live without a restart.
- Subscribe to the account's kind-10000 mute list in Main.kt so it hydrates.
Reuses the shared commons LiveHiddenUsers + Note.isHiddenFor; the chatroom DM list
already called isAcceptable, so DMs now enforce mutes too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`IElectrumXClient.nameShowWithFallback` implementations always throw a
`NamecoinLookupException` subtype (NameNotFound, NameExpired,
ServersUnreachable) on failure — the return-nullable signature is a
legacy of the old contract but no live impl (`ElectrumXClient`,
`CompositeNamecoinBackend`) actually returns null anymore.
`NamecoinNameResolver.performLookup` (the code path taken by the
non-detailed `resolve()` used by `Nip05Client.verify()`) has no
try/catch around that call. So any transport-level failure propagates
out of `resolve()` into `Nip05State.checkAndUpdate`, where the
`catch (e: Exception)` handler calls `markAsError()` and the NIP-05
verification badge in the UI turns into a red "Report" icon with the
`nip05_failed` string — regardless of whether the actual cause was
"servers all unreachable" or "resolver returned the wrong pubkey".
That collapses two very different failure modes into the same
red-icon state:
1. Real verification failure (kind-0 nip05 doesn't match the
Namecoin record's pubkey) — user should investigate.
2. Transient network issue (custom ElectrumX server offline;
`fallbackToDefaultElectrumx` disabled; carrier blocking
port 50002/57002; Tor toggle on with Tor unreachable; etc.)
— user should retry or check settings.
`resolveDetailed()` already has the try/catch and returns structured
outcomes (`NameNotFound` / `ServersUnreachable` / etc.). This change
brings `performLookup` in line so `resolve()` honours its documented
"returns null on any failure" contract, matching how
`expandImportsIfPresent` already treats `NamecoinLookupException`
during import-target fetches (best-effort → null).
Repro:
* Namecoin Settings → set backend to "Namecoin Core RPC" with a
localhost URL, no fallback. Or set a custom ElectrumX server
that the phone can't reach (different network, cellular vs.
Wi-Fi, etc.). Or leave `fallbackToDefaultElectrumx = false`
with unreachable customServers.
* Open any profile whose `nip05` field ends in `.bit` — you
get the red icon (Error state) even though the record itself
is fine on-chain and the pubkey matches.
* After this fix: same setup surfaces null through
`resolve()` → `verify()` returns false →
`markAsInvalid()` → still red (Failed state, not Error), but
no exception leaks. And the identical
`NamecoinLookupException` handling on both the primary lookup
and the import-target fetch means resolution behaves
consistently regardless of which hop fails.
Tests: `NamecoinNameResolverExceptionTest` pins the contract with
7 hermetic cases covering NameNotFound / NameExpired /
ServersUnreachable / generic transport failure / CancellationException
propagation / and continued `resolveDetailed()` visibility of the
specific outcome subtype.
Verification:
./gradlew :quartz:verifyKmpPurity \
:quartz:compileKotlinLinuxX64 \
:quartz:compileKotlinIosArm64 \
:quartz:spotlessCheck \
:quartz:jvmTest --tests \
'com.vitorpamplona.quartz.nip05.namecoin.*'
BUILD SUCCESSFUL. All namecoin nip05 tests green; KMP purity + iOS
+ Linux native + spotless all clean.
Rebuilds the Android tray-notification layer around a per-kind design system so
every Nostr notification reads at a glance and looks native to modern Android.
Framework:
- NotificationCategory: one table-driven entry per kind carrying its channel,
accent color, monochrome status-bar icon, importance, group key, and settings
icon. Reuses existing channel ids (no orphaned user settings) and adds
Reposts/Media/Articles/Code/Badges channels, organized into system-settings
channel groups (Messages/Social/Payments/Content/Developer/Games) so users can
silence a single kind or a whole family.
- NotificationUtils: rewritten as a category-driven builder — postStandard
(BigText, or BigPicture for media/badges) and postConversation (MessagingStyle
for DMs/replies/chat/group). Adds setColor accents, circular avatars, colorized
zaps, kept the NIP-30 emoji badge overlay, and setOnlyAlertOnce so enrichment
updates replace silently.
- NotificationEnricher: makes notifications observable — renders immediately from
cache, then subscribes to the involved npubs (author/zapper/chat members) and
notes via userFinder/eventFinder, re-rendering in place as names, pictures,
content, and post images arrive over a bounded relay window.
One file per notification (service/notifications/renderers/): DirectMessage,
GroupMessage (+welcome), Reply, Mention, Reaction, Zap (Lightning+nutzap+onchain),
Repost, Media, Article/Highlight, Code (git), Badge, Chess. EventNotificationConsumer
is now a slim policy dispatcher (account match + shared gates) delegating to them.
Closes push-vs-feed parity gaps: nutzaps (9321), onchain zaps (8333), reposts
(6/16), badge awards (8), and git PRs/updates (1618/1619) now render.
Dismiss-on-read is preserved (per-event id keying) so reading a post in-app
clears its tray notification. Adds monochrome status-bar drawables and the new
channel/title/group strings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122uQ8BLHLeHDni81RBP26r
Adds amethyst/plans/2026-07-23-push-notification-redesign.md: a rendering/UX
design for the Android tray notifications that gives each Nostr notification
kind (zap, reaction, reply, mention, DM, repost, nutzap, media, git, badge,
chess) its own accent color, status-bar icon, and notification style
(MessagingStyle / BigPictureStyle / colorized zap card), plus aggregation,
Conversations/Bubbles, and richer actions. Also documents the spec+builder
refactor, channel restructuring, icon/string work, parity gaps to close
(nutzap/onchain/repost/badge), a phased rollout, and testing.
Indexes the plan in amethyst/plans/README.md.
A Buzz DM channel showed the generic "DM" metadata name and offered Forum-threads
+ Share actions that don't fit a private 1:1 conversation.
- Title the DM by the OTHER participant's display name (from the 39000 inlined `p`
tags), reactively resolved, falling back to the channel name until it loads.
- Hide the Forum/Threads button and the Share button when the channel is a DM
(isBuzzDm): a DM has no forum, and its private, membership-gated naddr is
meaningless to share.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Match Buzz's reference forum thread and fix insets:
- Add a "{N} replies" count header and a "No replies yet" empty state (flat root +
replies, mirroring block/buzz's ForumThreadPanel — replies are NOT hierarchical).
- Use DisappearingScaffold instead of a plain Scaffold so the composer + list get the
app's shared imePadding + navigation-bar insets (same as RelayGroupChatScreen); the
composer is content at the bottom of the Column, not a Scaffold bottomBar.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The console did a single fetch on bind and read the cache back immediately,
which races the NIP-42 auth handshake: the warm-auth fetch can return before
the authenticated read actually delivers, so personas (30175) and turn
metrics (44200) landed in LocalCache a moment after the one-and-only
reloadFromCache — leaving both tabs empty with no way to refresh (the VM's
bind guard blocks a re-fetch on re-entry).
Adds a live subscription (startWatching/stopWatching, mounted for the
console's whole lifetime) for 44200 #p=me + 30175 authors=me on the
community relay; each arriving batch re-derives both tabs from LocalCache.
The relay re-serves the subscription once the connection authenticates — the
same path that unlocks the channel roster — so the console now populates on
its own instead of showing nothing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
Mentioning someone who isn't yet a member of a Buzz workspace channel now
adds them (kind-9000 put-user) before the message is published, so the
`@`-mention resolves to a real member — mirroring Buzz's own composer, which
is how you pull a bot into a channel by naming it.
Gated on the sender being able to moderate (only a moderator can issue
kind-9000; it's a silent no-op otherwise), skips self and already-present
members, and is best-effort so a failed add never blocks the message.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
Brings the Buzz community + member screens closer to Buzz's own clients:
- Presence dots (BuzzPresenceState → online green / away amber) overlaid
on DM-row and member-list avatars; offline/unknown shows nothing rather
than implying we track a peer we don't.
- Community view sections (Channels / Forums) are now collapsible, each
channel row carries an unread dot (relayGroupChannelHasUnreadFlow) and a
star toggle; starred channels float to the top. Stars persist
device-globally (BuzzChannelStars + BuzzChannelStarPreferences), mirroring
the joined-workspaces store.
- Canvas editing: the canvas screen gains an edit mode that publishes a
fresh kind-40100 CanvasEvent to the channel's host relay (last-write-wins);
the top-bar canvas button now shows on any Buzz channel so an empty one can
be created.
- Bot "Working…" indicator: a process-wide BuzzAgentActivityState, fed by a
members-screen observer (24200) subscription, lights a live "Working…" line
next to an agent currently emitting frames; tapping opens the community's
Agent Console. Owner-scoped by nature (frames are #p=owner).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
The forum thread's reply field was a bare OutlinedTextField. Replace it with the
same rich composer chats use (EditFieldRow): emoji + @mention suggestions, media
attach, drafts, and the typing indicator, with the standard send button styling.
Reuse works by making ChannelNewMessageViewModel.createTemplate() protected/open
and adding ForumReplyNewMessageViewModel, which overrides only the built event so
a send publishes a kind-45003 ForumCommentEvent scoped to the open thread.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Buzz forum roots (kind 45001) and comments (45003) were consumed onto the chat
timeline (consumeBuzzTimelineEvent → addNote), so a forum post showed up as a
kind-9 chat bubble and replies leaked into the chat feed.
Route them like the content they are:
- 45001 → the group's Threads collection (addThread) via a new consumeBuzzForumPost,
so it surfaces in the forum/Threads view; 45003/45002 are now store-only.
- ThreadRow renders a ForumPostEvent (body-as-heading, no title) alongside kind-11.
- The Threads REQ (RELAY_GROUP_THREAD_KINDS) now also fetches 45001/45003.
- A forum-type channel (channel_type=forum) opens the Threads view directly from
the community Forums section instead of the chat view.
- New BuzzForumThreadScreen: the root post + its 45003 replies + a reply composer
(ForumCommentEvent.build). Own replies aren't re-delivered on the open sub, so a
send restarts the REQ to re-fetch the thread.
Verified on device: forum posts list as threads, open to root+replies, replies post
and appear; the forum channel's chat no longer shows the posts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A Buzz community member (kind 13534 roster / 8000-8001 deltas) can read and post
across every channel on the relay, but `RelayGroupChannel.membershipOf()` read
only the per-channel NIP-29 roster (39001/39002), so a community member/admin who
wasn't explicitly added to a private channel resolved as NONE and was wrongly
gated (Join lock, hidden composer).
Add `BuzzCommunityMembership`, a per-relay registry fed by the relay-signed NIP-43
events, and consult it as a Buzz-only fallback in `membershipOf` (mapped to MEMBER
only, never channel ADMIN, to avoid over-granting per-channel moderation). Wire
`LocalCache.consume` for kinds 13534/8000/8001 to update the registry (LWW on
created_at) and poke the relay's channels so the gate re-renders live.
This complements the open-channel fix: open channels no longer require membership
at all, and members of private channels are now recognized community-wide.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A Buzz workspace channel you can participate in still showed a Join lock and
"invite-only — you need an invite to post": `RelayGroupChannel.membershipOf()`
reads only the per-channel NIP-29 roster (39001/39002), and the UI treated the
`closed` metadata tag as invite-only.
But Buzz's write gate (buzz-relay `check_channel_membership`) is *member OR
visibility=="open"*: an open (non-`private`) channel accepts kind-9 from any
authenticated relay member with no per-channel join, and Buzz stamps `closed`
onto every channel — so `isClosed()` says nothing about who may post; only
`isPrivate()` reflects the write ACL.
Add `RelayGroupChannel.requiresMembershipToPost()` (Buzz open channels don't;
standard NIP-29 relays always do) + `canPost(pubkey)`, and route the composer,
threads FAB, top-bar Join button, and discovery Join button + invite-only badge
through it. Standard NIP-29 relays are unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reworks the Buzz relay's Community view to mirror Buzz's own sidebar
ordering instead of stacking the DM + Console entries on top of the
channels:
- Channels and Forums are now separate sections, split by the relay-signed
39000 `channel_type` (stream vs forum); DM-typed channels are excluded
from both (they belong to the DM section). Adds isBuzzForum() and the
forum/stream type constants alongside the existing DM reader.
- Direct Messages moves below the channels and renders inline: the most
recent conversations (avatar + name + last-activity time), a New-message
action in the section header, and a "See all N" row into the full inbox
when there are more than fit.
- Agent Console drops to a single footer card at the very bottom.
- Section headers get a consistent modern style (primary-colored labels
with optional trailing actions), replacing the old top-stacked action
cards. BuzzImportRow gains an onOpen tap target so a channel row opens
the chat while keeping its Add-to-list affordance.
Vanilla NIP-29 relays are unchanged (flat channel directory, no
forums/DMs/console).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
Bring four settings screens onto the SettingsSection card kit used by the
UI Preferences / Security Filters screens, and drop the last Save/Cancel
flow in favor of auto-save + back-button-only navigation.
- Privacy Options: remove SavingTopBar + TorDialogViewModel staging; bind
controls directly to TorSettingsFlow.tryEmit (the existing debounced
propertyWatchFlow already persists changes), so edits save on change and
the screen has only a back arrow. Rebuild the body from SettingsSection
cards: a segmented Tor-engine tile, an Orbot-port block, a live preset
picker row, and per-usage switch tiles.
- Profile UI & Home Tabs: reskin the ad-hoc rows into SettingsSection
cards with SettingsSwitchTile / SegmentedChoiceTile (Home Tabs keeps the
"can't disable the last tab" guard via the enabled flag).
- Calendar Reminders: swap the hand-rolled TopAppBar for
TopBarWithBackButton and reskin into a SettingsSection card with a
segmented lead-time selector, preserving the CalendarReminderPrefs writes
and WorkManager schedule/cancel side-effects.
- Shared kit: make SegmentedChoiceTile internal for reuse and add a compact
title-only SettingsSwitchTile overload with enabled support.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013JvrwLYbGVJQhabfZS7sBy
The Buzz relay gates its `#p=me` reads (44100 member-added, 30622 DM
visibility) and the 39002 group roster behind NIP-42. Two gaps kept
those empty even after joining a workspace:
- DM list and Agent Console used a plain paged fetch, which returns
empty on an `auth-required` CLOSED. Switch both to the warm-auth
`fetchAllWithHooks(pendingOnAuthRequired = true)` path the import
already used, so they authenticate on the CLOSED and retry.
- NIP-42 sends its AUTH challenge once, on connect. When the socket was
already open before the user joined (the relay is in their lists and
connected at startup), that challenge was spent while the relay was
still not first-party, leaving the persistent group-roster subscription
refused — so the channel showed a "Join" lock. A join makes the relay
first-party (AuthCoordinator.isFirstParty); force a reconnect on a new
join so the relay re-challenges and the connection authenticates,
unlocking the roster and every other #p=me read on the shared socket.
Also reworks the New Buzz DM recipient picker: instead of only accepting
a raw npub/hex, it now offers a typeahead user search
(LocalCache.findUsersStartingWith) that surfaces this workspace's channel
members first. Pasting an npub/hex still works as an escape hatch for
someone not yet in the local cache.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
Buzz DMs and the Agent Console are community-scoped (a DM is a t=dm relay group on
one community's relay; agents/telemetry live on community relays), so they don't
belong as global drawer entries. Remove the AGENT_CONSOLE + BUZZ_DMS nav items and
reach both from within a community instead: the Buzz relay's group screen now shows
"Direct Messages" and "Agent Console" cards scoped to that one relay.
- Route.BuzzDmList / BuzzNewDm / AgentConsole now carry a relayUrl.
- BuzzDmListViewModel / BuzzNewDmViewModel / AgentConsoleViewModel take a single-relay
scope (and mark it joined + pre-approve NIP-42 so the #p=me discovery authenticates).
- Removed the settings-catalog AgentConsole search entry (no longer global).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
A Concord community pinned to the bottom bar wouldn't load at all when its
private kind-13302 joined-communities list wasn't already cached: the tab and
its server screen stayed blank. The list often lives only on the community's
own relays (Armada/Vector publish it there, never to the user's outbox), and
the only fetch that looked beyond the outbox — importConcordCommunities — was
triggered solely from the Concord hub and never queried the community's relays.
Carry each pinned community's bootstrap relays on its BottomBarEntry.Concord
tab (captured from the joined-list entry at pin time) and:
- importConcordCommunities now takes extra relays and folds in the relays saved
on every pinned Concord tab, so the list is found where it actually lives;
- ConcordChannelPreload bootstraps app-wide: it fetches the list for any pinned
community we don't yet know, so the tab and server screen fill in without the
user ever opening the hub.
Once the list folds into the cache, ConcordChannelListState.liveCommunities
already surfaces it reactively (verified by a new late-arrival test) and the
plane preload picks the community up — so a late-arriving list with no local
backup now updates the tab and the Concord Channels screen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSiSXaHMEpuo3gcDuTZ24u
Two fixes for the "connected over clearnet, receiving 39000s, but nothing shows"
case on a Cloudflare-fronted relay (blocks the plain HTTP NIP-11 GET while serving
events over the socket):
- Relay group-list screen no longer hard-depends on NIP-11. The display gate
needs the relay's `self` key (from NIP-11) to show relay-signed groups; when the
NIP-11 fetch fails (FAIL_TO_REACH_SERVER — Cloudflare resets the GET), fall back
to the dominant 39000 signer on that relay as its de-facto signer, so its groups
render while a stray user-published 39000 (different author) stays filtered. Also
re-fetch NIP-11 when the relay is marked Trusted (moved to clearnet), busting the
cached over-Tor error (new Nip11CachedRetriever.invalidate).
- RelayProxyClientConnector now reconnects the transport-flipped relay immediately
when a relay-classification set changes (e.g. marking it Trusted → clearnet).
Previously only TorRelaySettings changes counted, so a newly-trusted relay sat
out its Tor-earned backoff before re-dialing. Scoped to onlyIfChanged (no
resetBackoff), so only the flipped relay skips its delay and the rest of the
pool's backoff is untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
Three cleanups collapsing parallel Buzz UI into the relay-group machinery a Buzz
workspace already is (a NIP-29 relay + its groups):
1. Tor→clearnet escape hatch: when a relay won't load over Tor (non-onion, not
already trusted) after a grace period, the relay screen offers "Use clearnet",
which adds it to the kind-10089 Trusted Relay List — connected over clearnet
even while Tor stays on. Targets Cloudflare-fronted relays that block Tor exits.
2. Merge "Import Buzz" into "Browse": one operation, two filters (public 39000
directory vs your 44100 memberships). The relay group-list screen now detects a
Buzz relay (dialect or NIP-11 software) and folds in your membership-scoped
channels with Add / Add all; the standalone import screen/route + the second
browse-screen button are gone. Adding still calls follow() → kind-10009.
3. Remove the redundant Buzz Workspaces tab: a workspace IS a relay group, so the
parallel list is gone. The Agent Console and Buzz DM inbox move to their own
drawer entries (NavBarItem.BUZZ→AGENT_CONSOLE + BUZZ_DMS); the invite screen's
post-browser button now opens the relay's group screen. Deletes
BuzzWorkspacesScreen/ViewModel + BuzzBrand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
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
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
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
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
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
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
- 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
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
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>
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
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
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
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
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
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>
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>
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
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
- private monitor, and correct the commit() KDoc
- replace atomics handshake with a single monitor
- flag userFinder re-entrancy assumption in commit() KDoc
`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.
- 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
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>
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
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
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
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
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
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
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
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
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
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
Adds a third Observer tab streaming ephemeral agent telemetry frames
(ObserverFrameEvent, kind:24200, p=owner) live from every Buzz-dialect
relay via subscribeAsFlow. Frames are decrypted (frame:telemetry only),
deduped across relays, sorted newest-first, and bounded to a 200-row ring.
The live REQ is opened only while the Observer tab is on screen (started
in a DisposableEffect, cancelled on dispose and in onCleared) because
observer frames are never persisted by relays or LocalCache.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
Read-only owner dashboard over the AI-agent fleet. AgentConsoleViewModel
fetches turn metrics (kind:44200, p=owner) and personas (kind:30175,
authored by owner) from the Buzz-dialect relays plus the owner outbox set,
decrypts the NIP-44 metric payloads with the owner signer (cached by event
id), and derives fleet + per-agent totals via the pure AgentFleetAggregator.
AgentConsoleScreen renders a Costs tab (fleet total, per-agent spend/tokens/
turns/sessions, estimated-total warning) and a Personas tab (display name,
model, runtime, provider, system prompt). Wired as Route.AgentConsole with a
settings-catalog entry under App settings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
Pure aggregation of decrypted NIP-AM turn metrics (kind:44200) into per-agent
and fleet token/cost totals — the data core of the agent-owner console.
Correctness that makes the numbers trustworthy: turns group by (agent,
sessionId); within a session `cumulative` is the monotonic running total, so the
max cumulative per field is the authoritative session total (robust to dropped
turns), falling back to summing per-turn deltas only where no cumulative exists —
per field, so a cumulative that omits costUsd still gets cost from deltas.
Delta-sums that include a deltaReliable=false turn flag the fleet estimate.
10 tests cover cumulative-not-summed, missing-turn recovery, delta fallback,
per-field mixing, multi-session/multi-agent rollup, sessionless singletons, and
the unreliable-estimate flag.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
Three-reviewer pass over the branch. Fixes, most-severe first:
HIGH — channel-instance swap froze open screens. The subclass-upgrade design
replaced a group's RelayGroupChannel with a Buzz-typed instance on dialect
discovery, but screens/feeds/composers capture the instance for life, so all of
them kept rendering the orphan (frozen feed; composer stuck on kind 9). Removed
BuzzWorkspaceChannel entirely; RelayGroupChannel is a single stable type again.
Buzz-only overlay state (edit + canvas) now lives in BuzzWorkspaceStates, a
registry keyed by the channel UUID — dialect discovery no longer touches object
identity. The swap also silently dropped all relay-signed state (name, members,
admin status, pins, threads) and demoted the confirmed host; gone with the swap.
HIGH — wrong thread-root got messages relay-rejected AFTER the draft was
destroyed. A reply to a direct reply derived root=parent (buzzThreadRoot null on
a collapsed reply), which Buzz's ancestry validator rejects. Fallback is now
buzzThreadRoot() ?: buzzThreadReply() ?: parent.id. Also: minichat (kind 1111)
replies in Buzz channels are relay-rejected, so they're gated off for Buzz relays.
HIGH — pre-create defeated stray-redirect and marked the dialect off unverified
input. consumeBuzzTimelineEvent no longer pre-creates the channel; attachment
goes through the shared NIP-29 path (with its stray protection), and the dialect
is marked only off a VERIFIED event (markBuzzIfVerified) — a hostile relay can no
longer flip what the composer sends.
MEDIUM — engram tombstone divergence: a memory body missing `value` decoded as a
null tombstone (a deletion) where Buzz rejects it. `value` is now required (no
default); added NIP-AE slug-grammar validation. Pinned by test.
MEDIUM — unbounded edit overlay: pruneOldMessagesChannel now prunes overlay
entries for reaped messages. Overlay is keyed by channel id so own offline edits
(null relay) apply too.
Also: 40002 inbound reply linkage in computeReplyTo (we emit thread markers we
couldn't read); registered-but-unhandled 40901/40902/48001 now consumed;
unconditional Buzz-kind widening (kills the history-cursor-skip); stream mention
dedup; persona empty-list omission for content-hash parity; thread-marker
positional guard; edit-note-loading blank-row fallback.
Full amethyst unit suite + affected quartz suites green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
Review feedback: the 40002 composer branch hand-rolled raw arrayOf tags. Now:
- New shared `buzz/threading` package: `buzzThread(root, parent)` builder verb
emitting Buzz's exact thread_tags wire form (["e",root,"","root"] +
["e",parent,"","reply"], collapsing when parent==root; the empty relay slot
is deliberate — MarkedETag.assemble's arrayOfNotNull would slide the marker
into the relay slot) and `buzzThreadRoot()/buzzThreadReply()` positional
readers. The forum verbs now delegate to it (streams and forum comments share
thread_tags in buzz-sdk), and the composer uses buzzThread + the typed
pTag(PTag(...)) verb instead of raw arrays.
- Dialect bootstrap fix: the single-group open-channel REQ now always includes
the Buzz timeline kinds. Without this, an undiscovered Buzz relay was a
chicken-and-egg: fleet subs only widen after BuzzRelayDialect marks the
relay, but the mark comes from consuming a Buzz kind no filter asked for.
Opening a channel is explicit one-group intent, so the wider ask is cheap and
matches nothing on vanilla relays; fleet-wide subs stay dialect-gated
(both behaviors pinned in BuzzTimelineKindsTest). The live relay's NIP-11
("Buzz Relay", supported_extensions=[nip-er,nip-pl]) is a future
connect-time marker.
- Live proof of the full workspace lifecycle against the running Buzz relay
(BuzzRelayLiveInteropTest, 3/3 green): discover the channel via its
relay-signed 39000 (queryable by #d), join with NIP-29 kind-9021 from a
second member, post a 40002 after joining, and leave with kind-9022 — the
exact Quartz events Amethyst's group UI sends.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
The README license badge label read "Apache-2.0" while LICENSE, PRIVACY.md
and every source header are MIT. Only the static label text was wrong (the
shields.io endpoint auto-detects), but it is the license on the front page.
SKILL.md had drifted from the codebase since the Kotlin DSL migration:
- All Gradle references pointed at Groovy `build.gradle` / `settings.gradle`;
the repo is `.gradle.kts` throughout. Converted the snippets to Kotlin DSL
and matched the repo's existing `getByName("release")` style.
- The plugins block listed `jetbrainsKotlinAndroid` (gone) and omitted
`serialization` and `googleKsp`.
- compileSdk is 37, not 35. Added a pointer to libs.versions.toml so the
number has a source of truth rather than drifting again.
- The client-tag section told readers to create
`nip01Core/tags/clientTag/TagArrayBuilderExt.kt` and edit both `build()`
functions in TextNoteEvent. That file already exists at
`nip89AppHandlers/clientTag/`, and the tag is now applied centrally by the
NostrSignerWithClientTag decorator — so rebranding is a one-constant edit
to CLIENT_TAG_NAME.
- Default relays pointed at `quartz/src/main/java/...`, a path that does not
exist in the KMP layout; they live in commons `defaults/`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the visible half of Buzz workspace support:
- Composer: BuzzWorkspaceChannel branch (checked before its RelayGroupChannel
parent) sends the native kind-40002 stream message with the group's h tag,
Buzz thread markers on replies (["e",root,"","root"] + ["e",parent,"","reply"],
collapsing when parent is the root — mirrors thread_tags in buzz-sdk) and a
p notify to the parent author, with the same hashtag/url/quote/emoji/imeta
enrichment as every other channel type.
- Rendering: kind-40099 system messages render as centered system lines (like
NIP-28 admin rows) from the relay-signed payload; kind-40003 edits render as
an overlay — the newest edit's content replaces the stale original with an
"(edited)" marker, recomposing via the channel's editUpdates flow.
- LocalCache correction pinned by test: edits are overlays, never timeline rows
(Buzz's own CHANNEL_TIMELINE_CONTENT_KINDS excludes 40003) — a 40003 is
stored and recorded in the channel's edit map but no longer attached to the
timeline, so it can't render as a duplicate message.
All Buzz unit tests green; app compiles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
Surfaces block/buzz workspace channels inside the existing NIP-29 relay-group
experience — one group model, dialect-aware rendering, zero impact on vanilla
groups (kind-filtered feeds can never receive Buzz kinds by accident).
- BuzzRelayDialect (commons): per-relay capability registry. Event-shape
detection — the first Buzz-only kind consumed from a relay marks it; vanilla
relays never serve those kinds, so no false positives. NIP-11 marking can be
layered on later.
- BuzzWorkspaceChannel (commons): sibling of RelayGroupChannel (now `open`)
holding Buzz-only channel state: the kind-40003 edit overlay (never render
superseded text as current) and the newest kind-40100 canvas. Kind-9 chat and
kind-40002 stream messages share ONE timeline per group so mixed-dialect
conversations stay whole.
- LocalCache: consumes every registered Buzz kind (previously all fell into the
"Event Not Supported" branch). Timeline kinds attach to the group's channel,
materialized dialect-aware with an in-place upgrade (note migration) when the
dialect is discovered after the channel was first created as plain NIP-29.
Addressables store replaceably; the rest store as queryable regular events;
ephemeral signals (typing 20002, observer 24200, huddle reaction 24810,
pairing 24134) mark the dialect but are deliberately not persisted.
- RelayGroupFilterBuilders: group-chat REQs widen their timeline kind set with
40002/40003/40008/40099 only for marked relays; vanilla NIP-29 REQs unchanged.
Tests cover dialect detection + materialization, the plain-channel upgrade with
timeline migration, newest-edit-wins overlay ordering, and that filter builders
extend kinds only on marked relays. Full amethyst unit suite green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
Adds an env-gated jvmTest (BUZZ_RELAY_WS / BUZZ_MEMBER_SK / BUZZ_OWNER_SK; CI
skips it) that drives the real block/buzz relay booted from its own compose +
cargo build, with members enrolled via buzz-admin. Verified green against the
running relay:
- NIP-42 member auth, and NIP-OA/NIP-AA agent auth: a brand-new un-enrolled
agent key authenticates using only our owner-signed `auth` tag — the relay's
NIP-OA membership fallback accepts the Quartz-produced attestation.
- Channel lifecycle: kind:9007 create via the existing NIP-29 CreateGroupEvent
(Buzz channels are NIP-29 groups), kind:40002 publish, REQ round-trip with a
byte-identical echo (same id), EOSE.
Interop lessons encoded in the test + README: tenancy is host-bound (connect
with the bound Host or the WS upgrade 404s), `h` values must parse as UUIDs,
and the channel row must exist before channel-scoped kinds are accepted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
Adds Quartz-native models for the entire block/buzz custom kind space, each as
an idiomatic per-NIP package (Event + KIND companion + tags/ + write-DSL +
read accessors, mirroring nip88Polls) and registered in EventFactory. Standard
NIP-29/34/51/42/43 kinds Buzz reuses are left to their existing Quartz classes.
Coverage:
- Agent identity: Persona 30175, Team 30176, Managed Agent 30177, Agent Profile 10100
- Agent telemetry (encrypted): Observer 24200, Engram 30174 (HMAC d-tag derivation
pinned to Buzz reference vectors), Turn Metric 44200 (prior commit)
- Workspace overlays: Reminders 30300, Push Lease 30350, DM Visibility 30622,
Workspace Profile 9033, Identity Archival 9035/9036/8002/8003/13535,
Channel Window 39005/39006, relay admin 9030-9032, moderation 9040-9044, 42000
- Messaging/collab: stream 40002-40100 (+sidecars), DMs 41001/41010-41012,
jobs 43001-43006, forum 45001-45003, workflow 30620/46001-46031,
notifications 44100/44101, presence 20001/20002, huddles 24810/48100-48106,
pairing 24134, audit 48001, media 49001, read-state (NIP-RS helpers on 30078)
All schemas confirmed against the authoritative Rust in a local block/buzz
checkout (buzz-core / buzz-sdk / buzz-relay), not the outdated prose NIPs.
Kinds only reserved-but-unbuilt in Buzz (jobs, workflow lifecycle, some
stream/sidecar/audit kinds) are modeled tolerantly and flagged in KDoc + README.
Kind conflicts with existing Amethyst classes are implemented but deliberately
NOT registered in EventFactory (incumbent keeps dispatch): 9041 (GoalEvent),
20001 (GeohashPresenceEvent), 39005 (GroupPinnedEvent), plus 49001 (Buzz marks
it non-wire) and 30078 read-state (reuses AppSpecificDataEvent).
156 Buzz tests pass; full quartz jvmTest green (no regressions).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
Addresses review feedback on the initial Buzz commit:
- Drop the central `BuzzKinds` registry (un-idiomatic). Each event now
declares its own `const val KIND` on its companion and is wired into
`EventFactory`, matching the nip88Polls-style per-NIP layout used across
Quartz (Event + tags/ + TagArrayBuilderExt write-DSL + TagArrayExt readers).
- Implement Agent Turn Metric (NIP-AM, kind:44200): an encrypted per-turn
token-usage/cost record published by an agent to its owner. content is a
NIP-44 v2 ciphertext of AgentTurnMetricPayload (camelCase), between the
agent (author + `agent` tag) and owner (`p` tag); either party decrypts.
- Verify against a vector generated by Buzz's OWN code rather than a
transcribed schema: a small generator on the real buzz-core emits a signed
44200 event with deterministic keys; AgentTurnMetricVectorTest dispatches it
through EventFactory, NIP-44-decrypts it, and asserts the payload — proving
end-to-end interop. Fixture + generator committed under jvmTest resources.
Schemas confirmed against buzz-core/src/agent_turn_metric.rs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
Introduces a top-level `buzz` package modelling the block/buzz protocol in
Quartz. Buzz is a NIP-29-family relay-group workspace (relay is the source of
truth, plaintext content, server-side membership) that layers an agent +
workspace vocabulary on top, so this package models only the Buzz-custom
extensions and reuses the existing NIP-29/34/51/42/43 classes for the standard
kinds.
- BuzzKinds: the full kind registry mirroring buzz-core `kind.rs`, annotated
with where each standard kind already lives in Quartz.
- Owner Attestation (NIP-OA): the owner-signed `auth` tag that lets agents act
as first-class members. Commitment = SHA-256("nostr:agent-auth:" + agent +
":" + conditions), BIP-340 Schnorr-signed by the owner; conditions grammar
(kind=/created_at</created_at>, canonical decimals, u16/u32 bounds) matches
buzz-sdk `nip_oa.rs` exactly.
- Tests pin Buzz's own published known-answer vector as a cross-implementation
compliance check: our preimage hash equals Buzz's SHA-256 and our verifier
accepts Buzz's reference signature.
Confirmed against the authoritative Rust (buzz-core/buzz-sdk), not the prose
NIP drafts, which lag the implementation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
2026-07-21 18:47:12 +00:00
1460 changed files with 97272 additions and 7291 deletions
@@ -212,9 +212,25 @@ Before presenting results, **scan the missing English strings** for two red-flag
Also **audit existing `<plurals>` resources** for two anti-patterns:
1.**`quantity="one"` items that hardcode the literal `1`** (instead of using a `%d` / `%1$d` placeholder) — broken for languages where the `one` CLDR category covers more than just `n=1` (Russian, Ukrainian, Croatian, etc.).
2.**`quantity="zero"` items in any locale that doesn't natively use the `zero` CLDR category** — i.e. **everything except Arabic (`ar`) and Welsh (`cy`)**. ICU/CLDR maps `count=0` to `other` for English and all the locales we ship to (cs, de, pt-BR, sv, etc.), so `<item quantity="zero">` is **dead code** there: `getQuantityString(id, 0)` will pick `other`, never the zero entry, and the visible runtime string ends up `"…0 items"` instead of the intended `"…no items"`.
2.**`quantity="zero"` items in any locale that doesn't natively use the `zero` CLDR category** — i.e. everything except **Arabic (`ar`)**, **Latvian (`lv`)** and **Welsh (`cy`)**. ICU/CLDR maps `count=0` to `other` for English and most of the locales we ship to (cs, de, pt-BR, sv, etc.), so `<item quantity="zero">` is **dead code** there: `getQuantityString(id, 0)` will pick `other`, never the zero entry, and the visible runtime string ends up `"…0 items"` instead of the intended `"…no items"`.
If a UX genuinely wants special "no items" wording at count=0, that has to be a call-site `if (count == 0)` branch to a separate `<string>`, **not** a`quantity="zero"` plural item.
> ⚠️ **Latvian is the trap here — do NOT strip its `zero` items** (we nearly did, 2026-07-22). `lv` has an integer-bearing `zero` category that covers far more than 0: `select(0)`, `select(10)` and `select(11)` all return `zero` (the rule is `n % 10 = 0` or `n % 100 = 11..19`). So a Latvian `<item quantity="zero">` is *live code on the majority of counts*, and it must read as a normal plural form ("%1$d minūšu"), **not** as "no items" wording. An earlier version of this skill claimed only `ar` and `cy` had `zero`, which flagged all ~40 correct Latvian entries as dead and would have deleted working translations.
If a UX genuinely wants special "no items" wording at count=0, that has to be a call-site `if (count == 0)` branch to a separate `<string>`, **not** a `quantity="zero"` plural item. (This is why `zero` is the wrong tool even where it exists: in `lv` it does not mean "zero".)
**Verify, don't recall.** Before asserting any locale's category set, check it against CLDR rather than memory:
for c in ['en','lv','ar','cy','cs','de','sv','pt_BR','ru','pl']:
r = Locale.parse(c).plural_form
print(c, sorted({r(n) for n in range(0,10001)}), 'select(0)=', r(0), 'select(10)=', r(10))
"
```
Across the 56 locale dirs this repo ships, **only `ar-rSA` and `lv-rLV`** have an integer-bearing `zero`.
Flag and offer to fix:
@@ -240,15 +256,16 @@ for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*
done
```
Then scan for dead `quantity="zero"` entries. CLDR's `zero` category is integer-bearing only in **Arabic (`ar`)** and **Welsh (`cy`)**. In every other locale, count=0 falls through to `other`, so a `<item quantity="zero">` entry is dead and likely a translator/author bug (or it silently never fires):
Then scan for dead `quantity="zero"` entries. CLDR's `zero` category is integer-bearing only in **Arabic (`ar`)**, **Latvian (`lv`)** and **Welsh (`cy`)** — those three are skipped below, so a hit is a genuine bug. In every other locale, count=0 falls through to `other`, so a `<item quantity="zero">` entry is dead and likely a translator/author bug (or it silently never fires):
```bash
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml \
- Latvian (`lv`): `zero`, `one`, `other` — its `zero` is **not** "no items"; it covers 0, 10, 11–19, 20, 30, …
- German / Swedish / Brazilian Portuguese: `one`, `other`
- When a missing string contains a count placeholder and is conceptually a singular/plural pair, **flag it before translating** — it may belong as a `<plurals>` resource rather than a single `<string>`. Surface this to the user before proposing translations.
- **Do not use `quantity="zero"` outside Arabic (`ar`) and Welsh (`cy`).** CLDR's `zero` category is integer-bearing only in those two languages. Android calls `PluralRules.select(0)` for the device locale; in English/German/Czech/Polish/Russian/Swedish/Portuguese/etc. it returns `other`, so the explicit `<item quantity="zero">` is never picked at runtime and the user sees `"…0 items"` instead of the intended wording. If the design calls for "no items" at count=0, model it as a separate `<string>` and an `if (count == 0)` branch at the call site:
- **Do not use `quantity="zero"` outside Arabic (`ar`), Latvian (`lv`) and Welsh (`cy`).** CLDR's `zero` category is integer-bearing only in those three languages. Android calls `PluralRules.select(0)` for the device locale; in English/German/Czech/Polish/Russian/Swedish/Portuguese/etc. it returns `other`, so the explicit `<item quantity="zero">` is never picked at runtime and the user sees `"…0 items"` instead of the intended wording. Conversely, **never delete an existing `zero` item from `ar`/`lv`/`cy`** — there it is live. If the design calls for "no items" at count=0, model it as a separate `<string>` and an `if (count == 0)` branch at the call site:
```kotlin
val label = if (count == 0) {
stringRes(R.string.foo_no_items, dateLabel)
@@ -377,4 +395,5 @@ When adding translated strings to locale files:
- **Inserting strings in a specific position** — always append at the bottom; ordering is handled separately
- **Hardcoding `"1"` in a `<plurals>` `quantity="one"` item** — always use the count placeholder; otherwise non-English `one` categories produce wrong text
- **Copying English's `one`/`other` set into every locale** — each language must include all CLDR plural categories it uses (e.g. Czech needs `one`, `few`, `many`, `other`)
- **Using `<item quantity="zero">` to special-case count=0** — outside Arabic and Welsh, this entry is unreachable: ICU/CLDR maps 0 → `other`, so the runtime never picks the zero item and the user sees `"…0 items"`. Special-case at the call site with a separate `<string>` instead.
- **Using `<item quantity="zero">` to special-case count=0** — outside Arabic, Latvian and Welsh, this entry is unreachable: ICU/CLDR maps 0 → `other`, so the runtime never picks the zero item and the user sees `"…0 items"`. Special-case at the call site with a separate `<string>` instead.
- **Reporting Latvian `quantity="zero"` entries as dead code** — `lv` has a real, integer-bearing `zero` category covering 0, 10, 11–19, 20, 30, … so those entries fire on *most* counts. An earlier version of this skill excluded only `ar`/`cy` from the zero audit and flagged all ~40 correct `values-lv-rLV` entries; acting on that would have deleted working translations. Confirm any locale's category set against CLDR (the babel snippet in Step 4) before calling a `zero` item dead.
echo "::warning::Apple signing/notary credentials are not configured; this DMG is unsigned and unnotarized. Gatekeeper will block it, and it is not eligible for the Homebrew cask."
exit 0
fi
if ! xcrun stapler validate "$DMG"; then
echo "::error::$DMG has no stapled notarization ticket -- notarizeReleaseDmg did not run or failed"
exit 1
fi
echo "notarization ticket stapled OK"
# jpackage pins libicu to the build host's version (libicu74 on
# ubuntu-24.04). Rewrite the .deb so it installs across Debian/Ubuntu.
5. **Stable vs prerelease** — a tag containing `-rc`, `-beta`, `-alpha`, `-dev`,
or `-snapshot` is auto-classified as prerelease. Stable tags trigger the
Homebrew + Winget bump workflows.
or `-snapshot` is auto-classified as prerelease. Only stable tags run the
Homebrew + Winget bump workflows (and those are no-ops until the one-time
bootstrap PRs land — see § Bootstrap).
### Dry-run (no tag push)
@@ -389,8 +403,8 @@ provided automatically; everything else you set yourself.)
| `MAC_NOTARY_APPLE_ID` | Apple ID email of the notarization account | Apple notarization (`notarytool`) |
| `MAC_NOTARY_PASSWORD` | **App-specific** password for that Apple ID (not the login password) | Same |
| `MAC_NOTARY_TEAM_ID` | 10-char Apple Developer **Team ID** | Same |
| `HOMEBREW_TOKEN` | PAT for `Homebrew/homebrew-cask` | Desktop cask bump (stable tags) |
| `WINGET_TOKEN` | PAT for `microsoft/winget-pkgs` | Desktop winget bump (stable tags) |
| ~~`HOMEBREW_TOKEN`~~ | *Not used.* The cask bump runs on a maintainer's machine — see § Homebrew cask | — |
| ~~`WINGET_TOKEN`~~ | *Not used.* The winget bump runs on a maintainer's machine — see § Winget | — |
| `CROWDIN_PERSONAL_TOKEN`, `CROWDIN_PROJECT_ID` | Crowdin API creds | Translation sync (separate workflow, not the release) |
Note the **three distinct signing identities** people often conflate:
@@ -473,11 +487,11 @@ distributes; the official Amethyst rollout for each is in
| Channel | How it ships | Push or pull |
|---|---|---|
| **GitHub Releases** | The release workflow builds + signs all assets and attaches them to the tag's Release | Automatic (CI) |
| **Maven Central** | Same workflow runs `publishAllPublicationsToMavenCentral` for `quartz` | Automatic (CI) |
| **Maven Central** | Same workflow runs `publishAllPublicationsToMavenCentral` for `quartz` — a *step* at the end of the `deploy-android` job, not a job of its own, so it does not appear in a job list | Automatic (CI) |
| **Google Play** | Download the signed `amethyst-googleplay-<version>.aab` from the GH Release and upload it in Play Console | **Manual push** |
| **F-Droid** | F-Droid's build server detects the new tag and **builds the `fdroid` flavor from source** per its recipe in the external [`fdroiddata`](https://gitlab.com/fdroid/fdroiddata) repo, then signs + publishes itself | **Pull (build-from-source)** |
| **Zapstore** | The [`zsp`](https://zapstore.dev/) CLI reads [`zapstore.yaml`](zapstore.yaml) and publishes a Nostr software-release event signed with the app's nsec | **Manual push (Nostr)** |
| **Homebrew + Winget** | `bump-homebrew.yml` / `bump-winget.yml` open version-bump PRs on stable tags | Automatic (CI) |
| **Homebrew + Winget** | `bump-homebrew.yml` / `bump-winget.yml` open version-bump PRs on stable tags — **currently no-ops**: neither package has been bootstrapped upstream yet (§ Bootstrap) | Automatic (CI), inactive |
Two channels need the build to stay split into product flavors (see
`amethyst/build.gradle.kts` → `productFlavors`):
@@ -498,20 +512,88 @@ reads an optional per-release changelog from
## Bootstrap runbook (one-time)
### Secrets to provision in GitHub repo settings
> **Status as of v1.13.1: neither Homebrew nor Winget has been bootstrapped.**
> `https://formulae.brew.sh/api/cask/amethyst-nostr.json` and
> `microsoft/winget-pkgs/manifests/v/VitorPamplona/Amethyst` both 404, so
> **Amethyst does not currently ship through either channel.** The bump
> workflows detect this and skip with a `::warning::` instead of failing, so a
> green release run does *not* mean Homebrew/Winget shipped. The two subsections
> below are the work that activates them; until then treat the desktop app as
> GitHub-Releases-only on macOS and Windows.
### Package-manager credentials (and why there are none)
The full secret inventory is in [§ Secrets the CI needs](#secrets-the-ci-needs).
The two that need the most setup care are the package-manager PATs, because of
their token type and scope:
Neither package-manager channel adds anything to it:
@@ -14,7 +14,7 @@ specific to shipping the official Amethyst artifacts.
## At a glance
A release is one tag push that fans out to five distribution channels:
A release is one tag push that fans out to four live distribution channels:
| Channel | Mechanism | Who pushes |
|---|---|---|
@@ -22,10 +22,11 @@ A release is one tag push that fans out to five distribution channels:
| **Google Play** | **Manual** — download the signed AAB from the GH Release, upload in Play Console | Maintainer |
| **F-Droid** | **Pull** — F-Droid's build server builds the `fdroid` flavor from source when it sees the new tag | F-Droid (we just maintain the recipe + metadata) |
| **Zapstore** | `zsp publish` reads `zapstore.yaml`, signs a Nostr release event with Amethyst's nsec | Maintainer |
| **Homebrew + Winget** | Automatic — `bump-homebrew.yml` / `bump-winget.yml` fire on stable tags | CI |
| **Homebrew + Winget** | ⚠️ **Not shipping.** The bump workflows run, but skip: neither package exists upstream yet | Nobody (see § 3) |
Maven Central (the `quartz` library) also publishes automatically from the same
workflow.
workflow — as a *step* at the end of the `deploy-android` job, not a job of its
own, so don't expect to find it in the run's job list.
---
@@ -61,8 +62,10 @@ workflow.
`scripts/translators.sh --seed` with `CROWDIN_PROJECT_ID` /
`CROWDIN_PERSONAL_TOKEN` set, which refreshes the file.)
3. **Publish the release-notes note on Nostr** with Amethyst's account and paste
its event id into `amethyst/build.gradle.kts`:
3. **Publish the release-notes note on Nostr** — *minor releases only.* In
practice this id has only ever been bumped on `x.y.0` (1.11.0, 1.12.0,
1.13.0); patch releases leave it pointing at their minor's note. Publish with
Amethyst's account and paste the event id into `amethyst/build.gradle.kts`:
Keep `wss://relay.zapstore.dev` in the list — that is the relay the Zapstore app
itself reads from.
### Homebrew + Winget — automatic
`bump-homebrew.yml` and `bump-winget.yml` fire on stable tags and open PRs
against `Homebrew/homebrew-cask` (cask `amethyst-nostr`) and
`microsoft/winget-pkgs` (`VitorPamplona.Amethyst`). No action unless one fails —
then see BUILDING.md § Bootstrap and § Incident response.
### Homebrew + Winget — ⚠️ not shipping yet
`bump-homebrew.yml` and `bump-winget.yml` are wired to open PRs against
`Homebrew/homebrew-cask` (cask `amethyst-nostr`) and `microsoft/winget-pkgs`
(`VitorPamplona.Amethyst`) — but **neither package has ever been submitted
upstream**, so both workflows detect that and skip with a `::warning::`. As of
**v1.13.1** these two channels deliver nothing; macOS and Windows users get the
desktop app from GitHub Releases only.
Two separate faults kept this invisible until v1.13.1, both now fixed:
1. **The workflows never ran at all** — for *any* release. They triggered on
`release: types: [released]`, and GitHub does not raise workflow-triggering
events for a release created by `GITHUB_TOKEN`, which is exactly how
`create-release.yml` creates it. They now trigger on `workflow_run` after
`Create Release Assets` succeeds, which also fixes a latent race — the old
event fired while assets were still uploading.
2. **Nothing exists upstream to bump.** `brew bump-cask-pr` and
`winget-releaser` can only *update* an existing package. The first
submission is a manual, human-reviewed PR: BUILDING.md § Homebrew cask
(one-time initial PR) and § Winget (one-time initial submission).
Until someone does that bootstrap, a green release run means the bump workflows
*skipped cleanly* — not that Homebrew/Winget shipped. Check the run's warnings
if you want to confirm which case you're in.
**Both bumps are half-manual by design.** CI does the bookkeeping with
`GITHUB_TOKEN` only — verifying the artifact, computing hashes, and opening an
in-repo PR syncing the reference packaging files. Pushing upstream needs
credentials that would be dangerous as CI secrets (a `repo`-scoped PAT is
readable by anyone with push access here), so a maintainer runs the last step:
```bash
# after merging the sync PRs
export HOMEBREW_GITHUB_API_TOKEN=ghp_... # classic PAT, `repo` scope
scripts/bump-homebrew-cask.sh v1.13.2
scripts/bump-winget.sh v1.13.2 # no token — uses your `gh` auth
```
Both scripts re-verify the published artifact's sha256 before submitting, and
the Homebrew one additionally refuses if the DMG is not notarized + stapled.
See BUILDING.md § Package-manager credentials.
All four in-repo sync workflows (`amy` formula, `geode` formula,
`amethyst-nostr` cask, winget manifests) open PRs against *this* repo on every
release. Merge them to keep the reference packaging files current.
---
@@ -211,7 +272,7 @@ ownership:
| `SIGNING_KEY`, `KEY_ALIAS`, `KEY_STORE_PASSWORD`, `KEY_PASSWORD` | The **Android upload keystore** — losing/leaking it is the worst case; Play app signing identity | Keep the keystore backed up offline; never rotate casually (Play upload key reset is a support process) |
| `SONATYPE_USERNAME`, `SONATYPE_PASSWORD` | Maven Central namespace `com.vitorpamplona` | On compromise |
| `SIGNING_PRIVATE_KEY`, `SIGNING_PASSWORD` | The **GPG key** signing Maven artifacts | Per GPG key expiry |
| *(none for Homebrew/Winget)* | — | Both bumps run on a maintainer's machine — `scripts/bump-homebrew-cask.sh` and `scripts/bump-winget.sh` — so neither channel's PAT ever becomes a CI secret. See BUILDING.md § Package-manager credentials |
| `CROWDIN_PERSONAL_TOKEN`, `CROWDIN_PROJECT_ID` | Translation sync | On compromise |
Owner assignments and rotation reminders live with the team (issue tracker).
@@ -222,13 +283,23 @@ Owner assignments and rotation reminders live with the team (issue tracker).
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.