Two-reviewer adversarial audit of the branch found no correctness/data-loss/
crash bugs. This closes one gap and applies three robustness fixes:
- Multi-filter search ordering: a REQ whose filters all carry a search term
(e.g. the client's search-across-kinds) was created_at-ordered via the union
path. Now relevance-ordered — unionSubqueriesIfNeeded(projectRank) projects
rank per branch, UNION ALL + GROUP BY row_id MIN(rank) dedups across branches
keeping the best score. Only when every branch is a search branch; mixed
search/non-search REQs and count/delete unions stay as before.
- prepareAuthorStreams/prepareTagStreams build cursors via buildStreams, which
closes already-prepared statements if a later prepare throws (was: stranded
checked-out, un-reset handles holding read locks in the pooled connection).
- Stream counts computed as Long so authors×kinds / values×kinds can't overflow
Int back into the eligible band and route a huge fan-out into the merge.
- Renamed CachedStatement.finalize() -> finalizeStatement(): a no-arg finalize()
is the JVM Object.finalize, risking a GC double-close of the native handle.
Tests: multi-filter search relevance + cross-branch dedup + count parity;
existing merge/cache/search suites green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqGuBuSUsRudGerPqDuoKA
From the branch audit: verifies the search-relevance path holds under a
two-tag-key filter (requires both tags, ranks by bm25, no duplicate rows),
that count(filter) matches query size under a limit smaller than the match set
(NIP-45), and that deleting a non-searchable event (no FTS row) fires the
contentless delete trigger against an absent rowid harmlessly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqGuBuSUsRudGerPqDuoKA
Any filter with a search term must be relevance-ranked (NIP-50), but only the
tag-free shape (makeSimpleSearch) was — search + a tag fell through to the
row-id-subquery path (prepareRowIDSubQueries/makeQueryIn), which ordered by
created_at.
prepareRowIDSubQueries gains projectRank: when a search filter joins event_fts,
it also projects the bm25 score as a `rank` column and cuts its LIMIT by rank
(most relevant, not newest); makeQueryIn(orderByRank) then presents the joined
result by that rank, created_at DESC as tie-break. Off by default, so
count/delete/union/negentropy (which must stay single-column and unranked) are
untouched. toSql wires it on whenever the filter carries a search term.
SearchRelevanceOrderTest adds a tag-scoped case (stronger-but-older outranks
weaker-but-newer, wrong-tag and non-matching excluded, limit cuts by score).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqGuBuSUsRudGerPqDuoKA
NIP-50: results are returned "in descending order by quality of search result
... not by the usual .created_at", with the limit applied after the score. The
store sorted search by created_at DESC (pre-existing), so it returned the
newest matches rather than the best ones.
makeSimpleSearch (the search [+ kinds/authors/since/until] + limit shape) now
orders by FTS5 bm25 (ORDER BY event_fts.rank, created_at DESC as a tie-break).
Verified bm25 rank works on the contentless table through the join, and that a
stronger-but-older match outranks a weaker-but-newer one. The rarer
search+specific-tag shape and the negentropy snapshot still sort by created_at
(the row-id subquery can't carry rank; negentropy is a sync set) — documented.
This is a correctness fix, not a scaling one: bm25 scores every match, so
search latency still grows with the match set. Tests: SearchRelevanceOrderTest,
Fts5CapabilityProbe.bm25RankWorksOnContentlessTableInAJoin; QueryAssemblerTest
search plans updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqGuBuSUsRudGerPqDuoKA
searchOrderByRowId ordered NIP-50 search by the FTS rowid (ingestion order) to
get O(limit) search, but NIP-01's limit requires the newest events by
created_at. Once ingestion diverges from created_at (any historical sync) that
returns the wrong events under a limit — a spec violation — so the flag, its
QueryBuilder branch, and its test are removed. Search stays created_at-ordered;
corpus-independent search is an external-engine job, not this index.
The contentless + rowid=row_id schema stays for the reasons that don't touch
ordering: the delete trigger now seeks by rowid (O(log n)) instead of scanning
by an FTS column (O(n)) — measured ~78× faster at 8k rows and widening, on a
path every deletion hits — and the index is smaller. Benchmark reframed around
the delete win and the honest (unchanged) search cost; plan doc updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqGuBuSUsRudGerPqDuoKA
The scale-curve report showed the SQLite store degrading with corpus size on
NIP-50 search (~18×) and the large-IN tag watcher, while point reads stayed
flat. Three read/size changes (write path and index set unchanged):
- FTS: rebuild event_fts as a contentless FTS5 table (content='',
contentless_delete=1) keyed by rowid = event_headers.row_id. Drops the
stored content copy (smaller index); external-content can't hold the
derived indexable text, so contentless is the correct primitive. Adds an
opt-in searchOrderByRowId strategy flag: ORDER BY event_fts.rowid DESC
early-terminates (O(limit), corpus-independent) at the cost of
ingestion-order results — flat ~0.22 ms vs created_at's 4.26 ms at 200k
(~19×). reindexAll ends with 'optimize'; the periodic optimize() folds in a
bounded segment 'merge'. DB version 4->5 with a drop-and-rebuild migration.
- MergeQueryExecutor: extend the k-way merge to the tag path (kinds + #e IN
[hundreds] + limit), one cursor per (value[,kind]) stream heap-merged to the
limit, deduping events that carry several queried values. O(limit + streams)
instead of collecting all matches and sorting.
- StatementCachingConnection: pool multiple handles per SQL so the merge's
many concurrent identical-SQL cursors all hit the cache (previously only the
first did) and repeated polls reuse their per-stream statements.
Tests: contentless migration (real v4 DB upgrade), rowid-order search, tag
merge correctness (incl. cross-stream dedup), statement pool, FTS5 capability
probe, and an FTS search-scaling benchmark. Plan in
quartz/plans/2026-07-21-sqlite-query-scaling.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqGuBuSUsRudGerPqDuoKA
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>
The remaining SmallReqFloorBenchmark waste, on the per-row replay, the
per-event live fanout, and the per-accepted-event index probe:
- LiveEventStore replay dedupe: a SeenIds holder with an inline lock
replaces the local-fn-plus-lambda that allocated one closure per
streamed row (and again per live delivery). Its HashSet is created
empty so the JVM defers the backing table to the first add — a 0-row
replay no longer allocates a 1024-slot table (was ~4 MB across the
benchmark's 1000 idle subs).
- Live fanout serializes the event body once and passes it through
onEachLive(event, body); RelaySession splices it into the per-sub
frame prefix. An event matching N live subscriptions paid N identical
Jackson passes before; now one. queryRaw's onEachLive signature gains
the body arg (EventSourceBackend default serializes inline, no
cross-sub memo, no regression). Measured: fanout 1->200 live subs
0.50 ms (2.5 us/sub).
- FilterIndex holds subscribers in one persistent map per dimension, so
candidatesFor (once per accepted ingest event) probes with the event's
own fields and allocates no IdKey/AuthorKey/KindKey/TagKey wrappers;
BucketKey now lives only in the rare register/unregister bookkeeping.
SmallReqFloorBenchmark grows a fanout stage (200 live subs, one submit)
to anchor the fanout number; it drives `live` directly and guards the
await with withTimeout so a future fanout regression fails fast.
Verified: quartz relay.server + FilterIndex suites (110 tests),
SmallReqFloorBenchmark, geode suite (126 tests).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
Three hot-path allocation cuts the SmallReqFloorBenchmark stages flagged:
- strippingSearchExtensions: index-loop guard returns the same list with
zero allocation when no filter carries a search term (every non-search
REQ/COUNT/snapshot, the overwhelming majority).
- EoseMessage/OkMessage: direct-buildString wire form on the escape-free
fast path (EOSE per REQ, OK per publish), skipping the generic
serializer's node tree; exotic subIds/reasons fall back. Shared
isEscapeFreeAscii helper in WireJson.kt, mirroring NegMsgMessage.
Verified: quartz relay.server + message-frame suites (110 tests).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
AccountSettings declares 25 backup* fields and saves each on change, but two
were never wired into LocalPreferences — neither written to nor read back from
the encrypted prefs:
- backupKeyPackageRelayList (MIP-00, Marmot/MLS)
- backupFavoriteAlgoFeedsList (kind 10090)
Both only ever existed for the lifetime of the process. Their consumers already
implement the restore-from-backup path — KeyPackageRelayListState's
normalizeKeyPackageRelayListWithBackup falls back to the field, and
FavoriteAlgoFeedsListState's init seeds the cache from it — so that code was
dead after every cold boot and the value read as empty until relays answered.
For the key-package list that matters beyond latency: it feeds
Account.publishRelaysFor(), which decides where this account's key packages are
published so others can add it to groups, and Account.updateKeyPackageRelays()
reads it as the *previous* list when computing an update.
Found by auditing all 25 backup* fields against their five LocalPreferences
wiring sites after the same gap turned up for the Concord community list; the
other 23, NIP-29's relay-group list included, are correctly wired.
Verified on device with a persist-then-cold-boot pair: the key-package list is
absent on the first boot and restores 1.6 s after the second. The favorite algo
feeds list could not be exercised on this account (it has no kind 10090, so
there is nothing to persist); it is wired identically and its type arguments
are compiler-checked, but it is not verified end to end.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AccountSettings has held backupConcordList and saved it on change since the
feature landed, but the field was never wired into LocalPreferences: it was
neither written to nor read back from the encrypted prefs. So it only ever
existed for the lifetime of the process, concordList() returned null on every
cold boot, and the kind-13302 joined-communities list had to be refetched from
relays before a single Concord plane could be subscribed.
Every sibling list — channel, community, hashtag, geohash, ephemeral chat,
relay group, trust provider — is persisted this way; Concord was the one that
was missed. That made it the only chat type whose rooms could not appear until
the network answered, which is the bulk of the cold-boot delay: the joined list
gates the control-plane REQ, the control plane gates the fold, and the fold
gates the channels.
Measured on device, boot -> first Concord plane wrap:
- without the backup: liveCommunities sat empty for ~56 s waiting on the
13302 fetch (first arrival from nostr.mom), first wrap at +45 s
- with the backup restored: list decoded 1.6 s after boot (30 ms), first
wrap at +6 s
Wired the same five sites the other lists use (pref key, save, read, parse,
restore). Prefs are encrypted and backupCashuWallet already sets the precedent
for persisting a secret-bearing event, so the community roots in the 13302
content are stored no differently than the wallet's.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The additive feed path re-filters the existing list whenever an incoming batch
contains a kind-5, dropping notes whose event has been deleted. Event-less
notes fell into the else branch and returned false, so they were dropped too.
An event-less row is a placeholder the filter synthesizes for a room with no
message yet — a just-joined Concord channel, NIP-29 group, Marmot group or
geohash cell. It carries no event, so it cannot have been deleted. Dropping it
removed every such row from Messages the moment ANY unrelated deletion landed,
and because this is the additive path the rows stayed gone until the next full
rebuild. A community whose channels are all quiet looked like it had never
loaded at all.
Verified on device: surviving Concord placeholders in sort() went 0 -> 14, and
a community that had been absent from Messages entirely now renders all of its
channels. Not Concord-specific — the same placeholderNote() pattern backs
NIP-29, Marmot and geohash rooms.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cold boot bumped the session revision ~292 times for 3 communities, driving 22
Messages rebuilds and re-deriving every plane subscription each time. Three
compounding causes, all measured on device:
1. Every refold republished state even when the fold was identical.
ConcordCommunityState and its components were plain classes, so StateFlow
conflation never applied and a prior-epoch wrap that didn't move the
anti-rollback floor still counted as a change. Make the fold result compare
by value (AuthorityResolver holds only immutable value fields; a data class
with a private constructor is fine).
2. A control wrap bumped twice — once from ingest() returning STRUCTURAL and
once from the per-session state watcher reacting to the same refold. Add
ConcordIngestOutcome.STRUCTURAL_FOLD for the two control-plane branches so
the manager leaves those to the watcher, which (given 1) now fires only on
genuine change. Guestbook and base-rekey keep STRUCTURAL: they mutate
members/the rekey buffer, not state, so no watcher covers them.
3. refold() and controlFloorsLocked() re-opened the WHOLE wrap buffer on every
control wrap, and opening a wrap is a NIP-44 decrypt + parse — making a
backfill quadratic in decryptions (~8.6k opens to ingest 93 wraps for one
community). Memoize editions by wrap id: one open per wrap, ingest() stays
synchronous and results are unchanged.
Measured over one cold boot: revision bumps 292 -> 87, Messages rebuilds
22 -> 7, and time from first fold to all 17 channels 43s -> 7.5s.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A Concord control-plane fold is what first reveals a community's channels and
makes ConcordCommunitySession.state non-null, without which
ChatroomListKnownFeedFilter emits nothing at all for that community. None of it
flows through LocalCache.newEventBundles, so the additive feed path could not
see it: a folded channel only reached the Messages tab if a message for it
happened to arrive afterwards.
Cold boot therefore showed a subset of a community's channels, or omitted a
quiet community entirely, until some unrelated invalidation fired. Measured on
device: the Concord hub reported 3 communities / 17 channels folded in memory
while Messages rendered 3 rows and omitted one community completely.
AccountFeedContentStates already forces a rebuild for the Marmot, NIP-29,
geohash, view-mode and pin flows for exactly this reason; Concord was the one
missing collector. Add it, sampled the same way Account.kt samples this flow to
drive refreshConcordChannelIndex.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FilterIndex registration runs on every REQ open/close but built each
new snapshot by copying both full maps — O(S) work and allocation per
REQ with S live subscriptions. Persistent (HAMT) maps keep the
wait-free single-load reads and CAS write loop while making a write
O(keys x log S) with structural sharing.
SmallReqFloorBenchmark grows a B@1k stage (1000 idle parked
subscriptions) to make the population cost visible, and its B stage
now enters queryRaw undispatched like production does: @1000 subs the
per-REQ cost drops 0.225 -> 0.151 ms and the measured population
penalty falls below run noise (was +0.011 ms per REQ).
With this and the undispatched replay, the in-process floor above the
raw store query is ~0.11 ms (was ~0.66 ms as first measured): A 0.120,
B 0.203, C 0.239 ms on a quiet machine.
Verified: FilterIndex tests, quartz relay.server suite.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
SmallReqFloorBenchmark showed the per-REQ floor on small results is
dominated by pipeline, not the store (raw query 0.125 ms vs 0.785 ms
session REQ->EOSE in-process). Half of the dispatch slice was the
scheduler hop between handleReq's launch and the query coroutine:
starting the job with CoroutineStart.UNDISPATCHED runs the stored
replay and EOSE inline on the receiving coroutine (the reader-pool
acquire doesn't suspend when a connection is free), parking only at
the live tail. Measured: dispatch+frames slice 0.397 -> 0.207 ms.
Commands on a connection are processed sequentially, so nothing can
target the subscription before the job lands in the registry at the
first suspension point.
Verified: quartz relay.server suite, SmallReqFloorBenchmark, geode
full test suite.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
TagAuthorIndexBenchmark at 1M events settles the flag: the DM-room
shape (kinds + authors + #p, 65 client assembler call sites) drops
14.2 ms -> 0.66 ms (~21x, growing with corpus size) while batch-insert
cost stays inside run noise (49.0 vs 47.4 us/event). Existing relay
DBs build the index on next open via ensureOptionalIndexes.
Also refreshes the docs the numbers made stale: IndexingStrategy KDoc
now records the 200k and 1M measurements instead of a TODO,
MergeQueryExecutor's tag-merge note points at the new relayBench
reactions-watch scenario, FsQueryPlanner/FsDriverSelectionBenchmark
reflect the landed cost-based pick (149 ms -> 4.0 ms at 30k events),
and RELAY.md documents that strategy flag flips materialize indexes on
the next open.
Verified: quartz jvmTest store suites, geode test (126), desktopApp
LocalRelayStore tests (5, incl. reopening a default-strategy DB with
the new pubkey-alone flag), relayBench compiles.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
Acts on the measured gaps from TagAuthorIndexBenchmark and
FsDriverSelectionBenchmark:
- FsQueryPlanner: replace the fixed tags -> kinds -> authors driver
order with a cost-based pick. Every legal driver (each tagsAll value,
each tags key's value union, the kind set, the author set) opens a
lazy directory iterator; all are drained in lockstep and the first to
exhaust (the smallest listing) drives, so a giant idx/kind tree is
never read past ~the smallest candidate's size. Fixes the 149 ms vs
3.4 ms (~44x at 30k events) authors+kinds+limit regression.
- EventIndexesModule.ensureOptionalIndexes + SQLiteEventStore: flag-
gated indexes are runtime config, not schema. An idempotent
CREATE INDEX IF NOT EXISTS pass now runs on every open, so flipping
an IndexingStrategy flag on an existing DB builds the index without
a user_version bump.
- Desktop LocalRelayStore: enable indexEventsByPubkeyAlone. Shared
ViewModels (Nip65RelayList, PrivateOutboxRelayList, VanishRequests)
replay authors-only filters that full-scanned without
(pubkey, created_at); existing DBs pick the index up on next open.
- relayBench Scenarios: add "conversation" (tag ∩ author ∩ kind, the
DM-room shape, 65 client assembler call sites) and "reactions-watch"
(kind 7 + #e IN 150 hottest notes) so the uncovered archetypes get
head-to-head numbers vs strfry.
- quartz build: forward tagBenchScale/fsBenchScale to the test JVM.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
Real numbers from the two new benchmarks, so the trade-offs are on the
decision points instead of in a chat log:
- IndexingStrategy.indexTagsWithKindAndPubkey: the KDoc called the
kinds+authors+tags shape "rarely used", but the client assembler
survey found 65 call sites. TagAuthorIndexBenchmark @ 200k events:
DM-room query 9.4 ms -> 0.6 ms (~15x) with the flag on, insert cost
+14% (41.5 -> 47.3 us/event). TODO: re-evaluate defaults (geode).
- MergeQueryExecutor: tag-path analogue of the follow-feed collect-all
sort (kinds + #e IN [hundreds] + limit never merges). Measured
12.8 ms cold / 6.0 ms since-bounded at 200k events; revisit if
relayBench shows it at relay scale.
- FsQueryPlanner: fixed driver order sends authors+kinds+limit (the
most common CLI shape) through the kind tree. FsDriverSelection-
Benchmark @ 30k events: 149 ms -> 3.4 ms (~44x) driving from the
author tree; TODO: cost-based pick by directory entry counts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
The 2026-07 client filter-assembler survey mapped 551 Filter
constructions to ~12 query archetypes. Two hot shapes had no benchmark
coverage in prodbench or relayBench, and the FS store had none at all:
- TagAuthorIndexBenchmark: the DM-room shape (kinds + authors + #p,
65 assembler call sites) with indexTagsWithKindAndPubkey off vs on,
including the insert-cost delta of the extra index; plus the
reactions watcher (kinds=[7], #e IN 300, limit) cold and
since-bounded, which has no tag-side k-way merge today.
- FsDriverSelectionBenchmark: FsQueryPlanner's fixed driver order
(tags → kinds → authors) on authors+kinds+limit — the most common
CLI shape — comparing the current kind-tree driver against an
author-tree driver with kind post-filter.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
the files are now pure-ASCII and visually unambiguous:
- BlossomPaymentSafetyTest: raw U+202E/U+202C test payload -> escapes
- BlossomPaymentRequired: BIDI_OVERRIDES char array -> escapes
- Sanitizer: RTL_OVERRIDES and ZERO_WIDTH regex classes -> escapes
Verified by mutation: with BIDI_OVERRIDES stripping disabled,
reasonBidiOverridesAreRemoved fails, proving the escaped payload still
carries a real U+202E. Emoji ZWJ sequences in RichTextParserTest are
intentionally untouched (functional joiners, not bidi controls).
test(share): pin naddr encoding for addressable notes in QR payloads
feat(share): add strings for the QR share screen
feat(share): add fixed-height note card for the QR screen
feat(share): add display-only QR screen for notes
feat(share): register the ShareNoteAsQr destination
feat(share): add Share as QR to the note share sheet
fix(share): gate NSFW thumbnail with ContentWarningGate, prefer article title
fix(share): size the QR from available width, add a11y description
fix(share): add QR a11y strings, document QR row in ShareActionRows KDoc
fix(share): compact permanently-covered NSFW thumbnail, fix inert QR width cap
fix(share-qr): close sensitivity gate leaks and text/thumbnail bugs on the share-as-QR card
fix(share-qr): fix stale payload, unreachable controls, missing back button, and screen-wake handling
fix(share-qr): show image thumbnail for image-only notes, hide raw media URL
Merges nostr proposal 2ca4f8ae into main:
- feat(cli): full NIP-34 git collaboration parity for `amy git`
- feat(cli): `amy git grasp list|set` (NIP-34 GRASP server list, kind 10317)
- feat(cli): `amy git browse|cat|log` — read git objects over smart-HTTP
- feat(cli): `amy git init` — bootstrap a repo from the local git checkout
- feat(cli): `amy git label` (NIP-32) and `amy git apply` (patch -> working tree)
- fix(quartz): NIP-34 wire-format interop with ngit (clone/web, issue p, plain r)
- test(cli): live interop check against the real ngit-published amethyst repo
- fix(cli): shallow-clone euc, read truncation, process deadlock
- fix(cli): read routing, status perf, publish-ack + robustness
- fix: complete PR clone multi-value (kinds 1618/1620) + CLI robustness
- revert(amethyst): drop GitStatusIndex auth change; defer to separate proposal
clone/web now serialize as single multi-value tags per NIP-34 (ngit drops
repeated ones) for kinds 30617, 1618 and 1620; readers stay tolerant of the
legacy repeated form.
The GitStatusIndex status-authority guard was reverted before merge: it
authorized against the status event's own `a` tag, which the author controls,
so a forged ["a","30617:<attacker>:x"] defeated it — and unverifiable statuses
were dropped permanently (no re-reduce when the 30617 announcement arrives),
rendering closed issues as open. Refiling as its own proposal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The isAuthoritative guard added to GitStatusIndex read the repository owner
from the status event's own `a` tag (GitStatusEvent.repositoryAddress() ->
first a tag, no kind filter, no cross-check against the target's repo). That
value is attacker-controlled: a forged kind:1632 carrying
`["a", "30617:<attacker-pubkey>:anything"]` makes `status.pubKey ==
repoAddress.pubKeyHex` pass, so the spoof it meant to block still succeeds.
It also regressed reads: reduceLatestByTarget only re-runs on a new
kind 1630-1633, so a status dropped while the 30617 was uncached stayed
dropped, leaving genuinely-closed items in the Open tab with wrong counts.
Keep this series focused on the quartz + cli NIP-34 parity work. The Android
status-authorization hardening (resolve the repo from the target item, load
the cached 30617, authorize against repo.pubKey + maintainers() + the target
author, and re-reduce when a 30617 arrives) will land as its own proposal.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
Addresses review findings from a merge-time audit.
- **Complete the headline multi-value `clone` fix for PRs** (was applied only to
kind:30617). GitPullRequestEvent (1618) and GitPullRequestUpdateEvent (1619)
carry `clone` with the same spec shape but still emitted repeated single-value
tags and read only the first value — so the exact interop bug this branch set
out to kill was still live for PRs, both directions (ngit keeps only the last
repeated tag; we lost every URL after the first from ngit's multi-value tag).
Now both emit one multi-value `["clone", …]` tag and read both forms. Verified
on the wire + GitNip34InteropTest + CLI harness (40 checks).
- **Android git-status spoofing (GitStatusIndex)**: newest-status-wins with no
author check meant anyone could publish a kind-1632 and make someone else's
issue render closed. Now filter statuses to the repository owner (from the
status's own `a` tag), declared maintainers (from the cached announcement), or
the target item's author — matching NIP-34 and the CLI's derivation. Pre-existing
on main; this branch made the CLI/Android divergence visible.
- **CLI robustness**: `git comment`/`git patch` no longer block forever reading
stdin on an interactive TTY (amy is non-interactive — error instead). The local
`git` subprocesses in `git init`/`git apply` now drain stdout on a side thread
under a bounded `waitFor` + `destroyForcibly`, so a wedged git can't hang the
CLI.
Left as a follow-up (cosmetic): GitBrowseCommands.candidateUrls duplicates
GitRepositoryBrowserViewModel's — worth lifting to shared code, not worth the
cross-module coupling here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
Findings from a second review round (two independent reviewers), with fixes:
Read path (git issues/patches/prs/thread):
- **Reads ignored the repo's own relays** (correctness). They queried only the
account outbox/bootstrap (general relays); NIP-34 events live on the repo
announcement's advertised relays (often GRASP/git-specific), which general
relays don't mirror — so `amy git issues <repo>` with no --relay could return
empty. Now fetch the announcement once and read from queryTargets ∪ its
advertised `relays`. Verified live: `git issues`/`git prs` on the amethyst
repo now return real events (and derive `closed`) with NO --relay.
- **O(items × statuses) status rescan** with un-memoized `rootEventId()` reparse
→ pre-group statuses by root id once (O(1) lookup per item).
- **Status query could truncate / exceed relay caps**: statuses are now paged
(`drainAllPages`) and the `#e` id set is chunked to 50 (under the common
~100-value relay filter cap).
- **Latency regression**: capped the list `drainAllPages` idle timeout to 12s
(was the 30s default; `drain` had been 8s).
- **Nondeterministic status on same-second ties** → deterministic id tie-break.
- Reuse the fetched repo for the maintainer set (removes a redundant round-trip).
Write path:
- **`git init` silently reported success when the 30618 state publish failed**
— its ack was dropped. Now surfaced as `state_published_to`/`state_rejected_by`
with a stderr warning on total rejection.
- **`git apply`** feeds stdin as UTF-8 (was JVM default charset — corrupted
non-ASCII patches) and joins the stdin thread in `finally` (no leak on error).
- **`normalizeCloneUrl`** drops the port from `ssh://git@host:port/…` (it was
carried into the https URL, making it unreachable).
- **Delivery fallback** to the account outbox (repo unresolved / no advertised
relays) now warns to stderr instead of reporting silent success.
Known limitation (documented, not fixed): patch-revision-chain status derivation
follows only the root item, and `git thread` shows first-level replies only
(nested trees and 1619 PR-updates are out of scope). 38/38 harness green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
Findings from a review pass over the git-parity branch, with fixes:
- **`git init` announced a WRONG earliest-unique-commit on shallow clones**
(interop-critical). `git rev-list --max-parents=0 HEAD` returns the shallow
boundary commits, not the true root, so the repo would be announced under a
different cross-fork identity than ngit computes. Now: detect shallow clones
and omit the euc with a warning to pass `--earliest-commit`; on full clones
derive the deterministic `--first-parent` mainline root instead of an
arbitrary `tail -1`.
- **`git issues|patches|prs` silently truncated and mis-derived status** on
active repos: one single-page `drain` pulled items AND status events under a
shared cap, so status events (newer, more numerous) could crowd items out of
the window and the close-status that determines an item's state could fall
outside it → a closed item read as open. Now paginate the items
(`drainAllPages`) and fetch exactly the statuses that `e`-reference them.
Verified on the live amethyst repo: 51 PRs paginated, 19 correctly closed.
- **Pipe-buffer deadlocks** (latent): `GitInitCommand.git()` discards stderr to
the OS (a chatty command can no longer fill its stderr pipe and hang the
stdout read); `GitApplyCommand.runGit()` writes stdin on a background thread
while draining stdout, so a patch larger than the pipe buffer can't deadlock.
- Minor: `git cat` binary detection uses an index loop instead of boxing 8000
bytes; `GitRepositoryEvent.clones()/webs()` dedupe.
The harness `git init` test now runs against a fresh full checkout (this repo's
CI checkout is shallow) and adds a shallow-clone case asserting the euc is
omitted. 38/38.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
Adds a `--live` block that reads the actual amethyst repository ngit publishes
to relay.ngit.dev and asserts our reader parses ngit's real multi-value `clone`
tag (currently 4 URLs) plus its published issues. This is the real-world proof
of the multi-value interop fix: the pre-fix reader would have surfaced only the
first clone URL. Opt-in (needs network + the live relay), skipped by default.
Verified manually end-to-end against the live repo: repo announcement (4 clone
URLs), issues (1621), patches (1617), pull requests (1618, with a real `closed`
status derived from ngit's status event), and a NIP-22 comment via `git thread`
all read correctly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
Verified Amethyst's NIP-34 events byte-for-byte against the ngit reference
implementation (DanConwayDev/ngit-cli) and the spec, and fixed three real
interop divergences in quartz — so ngit/gitworkshop and Amethyst read each
other's git repos, issues, patches, and PRs without losing data.
- Repository announcement `clone`/`web` were emitted as REPEATED single-value
tags (`["clone", a]`, `["clone", b]`). The spec and ngit use ONE multi-value
tag (`["clone", a, b]`), and ngit's parser keeps only the LAST of repeated
known tags — so multi-URL repos silently lost every URL but one in both
directions. Now emitted as a single multi-value tag; `clones()`/`webs()` read
BOTH the spec form and the legacy repeated form, so old events still parse.
(`relays`/`maintainers` were already correct multi-value tags.)
- Issues (kind 1621) were missing the `["p", <repo-owner>]` tag that patches and
PRs already include — a maintainer watching `#p` wouldn't see them. The
builder now adds it (fixes both the CLI and the Android issue-creation path,
which both passed an empty notify list).
- Patch / PR / PR-update `r` tags carried the `"euc"` marker
(`["r", commit, "euc"]`). Per the spec and ngit that marker belongs only on
the kind-30617 announcement; other `r` tags are plain `["r", commit]`. A `#r`
filter matches either shape, so this is a spec-compliance/byte-parity fix.
`alt` (NIP-31) tags are intentionally still omitted — quartz treats the generic
alt client-hint as deprecated, and ngit/gitworkshop parse the structured tags,
so it isn't required for interop.
Adds `GitNip34InteropTest` (5 cases: multi-value write, tolerant read of both
forms, issue p-tag, plain patch r-tag) and 4 wire-format assertions to the CLI
git harness (37 offline). No regressions in the nip34 or Search suites.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
Close two more ngit/nak parity gaps:
- `git label TARGET LABEL[,LABEL]` — attach NIP-32 kind:1985 labels to an
issue/patch/PR (the `ngit pr label` / `issue label` surface), over quartz's
existing `LabelEvent`. Namespace defaults to `ugc`; `--namespace` overrides.
- `git apply PATCH_ID` — fetch a kind:1617 patch and apply it to the local
working tree via `git am` (the `nak git patch apply` / `ngit pr apply`
surface); `--check` dry-runs `git apply --check`, `--print` emits the patch.
Shells out to `git` like `git init`, since it operates on the local checkout.
Verified end-to-end: a patch published to a relay, fetched, and `git am`'d as a
real commit into a scratch repo; labels land as kind 1985. The harness gains 5
assertions (label + a full publish→apply round-trip), now 33 offline.
Remaining out-of-scope items are documented: git-packfile push (needs a git
write layer quartz lacks) and NIP-34 cover notes (kind 1624, no quartz builder).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
Match `ngit init` / `nak git init`: read the local git repository and publish a
NIP-34 repository announcement, deriving the fields instead of making the user
type them. Shells out to `git` to determine the name (top-level dir), clone URL
(origin remote, ssh→https normalized), earliest-unique-commit (root commit),
and — for the accompanying kind:30618 state — the branch/tag tips and HEAD.
Publishes the 30617 announcement and (unless `--no-state`) the 30618 state in
one shot. Every derived value is overridable with a flag; outside a git repo
the derivation is skipped and `--name`/`--clone` are supplied manually.
This is the one `amy git` verb that shells out to `git`, since it is inherently
about the local working tree — exactly like the tools it mirrors.
Verified against the amethyst checkout itself (derives name=amethyst, the origin
clone URL, the root commit as EUC, and a 30618 with the live branches + HEAD).
The harness gains 4 assertions driving `git init` against its own checkout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
Give `amy git` the git-object read side of `nak git download` / a shallow
clone. `browse` lists a repo's tree, `cat` prints (or `--out` writes) a file at
a ref, and `log` shows recent commit history — all over the git smart-HTTP v2
protocol via quartz's `GitHttpClient` (the same shallow-clone path the Android
repo browser uses). REPO may be a NIP-34 coordinate/naddr (whose announcement
supplies the clone URL) or a raw http(s) clone URL; `--clone` and `--ref`
override the URL and branch/tag.
Read-only: pushing git objects back to clone/GRASP servers stays out of scope.
Verified live against a public repo (octocat/Hello-World) — browse/cat/log all
return correct trees, blobs, and history. The harness gains a `--live` block
(28 assertions with `--live`, 24 in the default offline run) exercising these
against `$LIVE_REPO`, skipped by default since it needs a reachable git host.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
Declare/read a user's preferred GRASP (Git-over-Nostr hosting) servers in
preference order — the NIP-65-style list `ngit`/`nak git` consult to decide
where PR tip branches (`refs/nostr/<pr-id>`) get pushed. `set` publishes a
kind:10317 to the outbox; `list` reads it back cache-first (anonymous-capable).
Thin assembly over quartz `UserGraspListEvent`. The git push itself stays out
of scope, as with the rest of the packfile transport.
Extends the git NIP-34 harness with a grasp round-trip (24 assertions) and
updates the README/ROADMAP/help tables.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
Extend `amy git` from repo announce/list/show/issue to the complete
pure-Nostr surface of `ngit` and `nak git`, so every NIP-34 collaboration
flow is scriptable without a GUI.
New sub-verbs (all thin assembly over quartz's `nip34Git` builders):
- `git state` — kind:30618 repository state (branch/tag tips + HEAD)
- `git patch` — kind:1617 patch from `git format-patch` (--file or stdin),
with --root/--root-revision, --commit, --parent-commit,
and --in-reply-to for revision chains
- `git pr` / `git pr-update` — kind:1618 pull request + kind:1619 tip update
- `git comment` — NIP-22 kind:1111 reply on an issue/patch/PR/repo (the
modern replacement for the deprecated kind:1622 git reply)
- `git open|applied|close|draft` — kind:1630/1631/1632/1633 status events
(aliases `merged`/`resolved` for applied); applied carries
--merge-commit / --commit / --patch
- `git issues|patches|prs` — list a repo's items with status derived from the
newest authoritative (owner/maintainer/author) status event,
with --open/--applied/--closed/--draft/--status filters
- `git thread` — one item plus its status timeline and comments
Shared parsing/fetch/routing glue lives in `GitSupport`; the existing
announce/list/show/issue verbs now reuse it. The git *packfile* transport
(clone/fetch/push of real objects to clone/GRASP servers) stays out of
scope — it needs a git plumbing layer, not an event builder — and is
documented as such.
Adds `cli/tests/git/git-nip34-headless.sh` (21 assertions, drives the whole
flow against `amy serve` and checks the status-deriving reads) and updates
the README/ROADMAP command tables.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
Bring the notifications-pagination feature up to date with main.
Conflict resolution — the two live-notification managers:
main independently fixed the "notifications capped at a week" bug by a
different route: it dropped the oneWeekAgo() floor and now runs an all-time
`#p`+`limit` query gated by the lastNoteCreatedAtIfFilled() paging boundary
(kept together with its lastNoteCreatedAtWhenFullyLoaded collector job). That
updateFilter + newSub pair is one self-consistent unit, so this merge takes
main's complete version of AccountNotificationsEoseFrom{Inbox,Random}
RelaysManager and keeps the branch's dedicated `until`+`limit` history pager as
an additive layer on top (Account.notificationHistory, the history manager,
NotificationHistoryPaging.kt, the markers/retry UI, filter builders, tests).
Net: the feed gets main's all-time live query plus the branch's unbounded
backward pager. Note the two now overlap for users under the relay limit — the
pager's remaining unique value is scrolling past that limit; worth a review
pass, not a merge blocker.
Verified: :amethyst:compilePlayDebugKotlin, spotlessApply (clean), and the
notification unit tests (FilterNotificationsHistoryTest,
NotificationKindsContractTest) all green on the merged tree.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
CardFeedView had grown a ~90-line paging block plus an auto-retry loop,
constants and helpers that aren't about rendering cards. Move all of it into
a dedicated NotificationHistoryPaging.kt:
- rememberNotificationHistoryPaging(): the look-ahead buffer driver (with the
per-burst cap), the stalled-relay auto-retry loop, cursor building, and the
per-relay sentinels — returns the List<RelayReachCursor> the feed draws.
- BootstrapNotificationHistoryWhenEmpty(): the empty-feed hunt.
- The five tuning constants and the reachState / relayShortName helpers.
CardFeedView.FeedLoaded now just fetches the pager, calls the helper for the
cursors, and renders the detail dialog; the in-gap RelayReachMarkers stay
inline (they're per-row). No behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
Address the two audit findings on the infinite-scroll driver.
- Bound the eager fill (#1): the buffer targets 100 rows but pages are pulled
in events and notifications collapse heavily into cards, so on a dense
account a fill could keep pulling until it downloaded the whole history to
reach the row target. Cap consecutive pages pulled WITHOUT scrolling
(NOTIFICATION_MAX_PAGES_PER_BURST); scrolling resets the budget, so paging
resumes as the buffer is consumed. Appended older cards don't move
firstVisibleItemIndex, so the from-top preload still fills the full
look-ahead on open — only a dense whale is bounded.
- Only the active feed drives (#2): add drivesPaging (default true); the split
screen passes page == pagerState.currentPage so an off-screen tab composed
during a swipe no longer drives the shared account pager, and its buffer
driver / auto-retry loop / sentinels stay idle. Single screen and side panel
keep driving.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
Improve the notifications history UX around slow/unreachable relays, keeping
the per-relay markers (they let users notice their own bad relays) but making
recovery automatic and the tap-through actionable.
- Auto-retry stalled relays with backoff (~3s→30s): once the buffer driver
stops (every relay done-or-stalled) but some are merely stalled, keep
re-advancing them so recovery no longer depends on the user scrolling to the
marker or reopening the screen. One non-restarting effect so the backoff
survives the transient in-flight blips each retry causes; cancels on leave.
- Add a "Try Again" action to RelayReachDetailDialog (shared): when a caller
passes onRetry and a relay is stalled, the tapped marker's detail popup
offers an active retry and drops the now-inaccurate "retries on reopen" hint.
Notifications wire it to advanceAll; DM callers pass nothing (unchanged).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
The buffer-only driver stopped paging once every relay was done-or-stalled
(exhausted), so a transient all-relays blip halted history until re-navigation
— bad for faulty relays with different datasets, exactly when we'd miss data.
Restore the per-relay RelayReachSentinels alongside the look-ahead buffer:
- the buffer driver (advanceAll) keeps the runway full from healthy relays;
- the sentinels retry an individual relay when its frontier marker scrolls
into view — the recovery path once the buffer can't keep the frontier ahead
(relays stalled/exhausted), naturally rate-limited by scrolling.
The buffer keeps the frontier ~a screen below the fold, so the sentinels stay
quiet during normal scrolling and only fire on stall/end. This also makes the
kept per-relay markers functional again (they drive the retry) instead of
purely decorative.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
Switch the notifications feed from marker-visibility paging (load only when
the bottom marker is on screen) to infinite scroll: keep ~100 already-loaded
rows below the viewport so the user practically never reaches the end.
- Replace RelayReachSentinels with a buffer-depth driver: when fewer than
NOTIFICATION_LOOKAHEAD_BUFFER (100) rows remain ahead of the last visible
one, step every not-done relay one older page (advanceAll). It re-fires as
each page settles until the buffer refills or all relays run dry — the
wallet's lastVisibleIndex >= totalItems - N pattern with a large N.
- Keep the per-relay BackwardRelayPager engine, cursors and filters unchanged.
- Keep the in-feed per-relay progress markers + tap-through detail dialog;
they are now purely visual (loading is driven by the buffer, not by them).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
Merges nostr proposal b07eb505 into main:
- feat(nip05): add Nip05Id.parseLenient for mention/text rendering
- feat(compose): wire NIP-05 popover mentions to nostr:nprofile1…
Also closes duplicate proposal 4b90b41f, which pointed at the same commits.
Beyond the feature, this replaces the unvalidated `Nip05Id("_", prefix)` raw
constructor in UserSuggestionState with `Nip05Id.parseLenient(prefix)`, closing
a hole where a typed mention such as `evil.com#x.bit` produced a GET to an
arbitrary host via `toUserUrl()`'s bare interpolation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes the privilege escalation: any BAN holder could ban the authorities above
them — including the owner — because the Banlist gate checked only the BAN bit.
Once banned, a member loses all authority (`hasPermission` is `!isBanned && ..`)
and honest clients drop their events, so a single edition from the most junior
moderator permanently silenced every admin above them.
CORD-04 §3 requires the rank half: "One hard rule binds every action: the actor
must hold the required bit and strictly outrank its target — equal cannot act on
equal (an admin cannot ban a peer admin)", restated as §5 step 3. Only §4, which
defines the Banlist, states the bit half alone — which is why both this client
and Armada shipped the same rank-blind gate.
§3 is stated per TARGET while the Banlist is one whole-list document, so it is
enforced as a DELTA rule: an edition may only add or remove npubs its signer
strictly outranks, judged against the roster settled behind it; the owner is
never a valid target (position 0 is "supreme and unremovable"); and entries the
signer may not act on are IGNORED rather than rejecting the edition, so one bad
entry cannot discard the bulk-ban §4 recommends as the collision remedy, and a
rogue cannot grief the list by forcing rejections.
ConcordModeration.currentBanned now reads the honored banlist through the
resolver instead of decoding the raw head. Besides picking up the fork healing
it was missing, this closes a laundering path: our own next ban/unban would
otherwise re-publish an entry our fold refuses, under our signature.
BREAKING (consensus): Armada has not shipped this rule, so banlists can differ
between clients until it does — we now ignore a ban Armada honors whenever the
signer did not outrank the target. Shipping the spec-conformant behaviour was
judged better than continuing to honor an escalation. Write-up to send upstream
is docs/concord-banlist-rank-conformance.md.
The three tests added in 0ae6bc6698 as @Ignore-d documentation now pass and are
un-ignored; two companions (a moderator still bans a plain member, the owner
still bans anyone) passed throughout and pin what the fix had to preserve.
Full :quartz:jvmTest and :commons:jvmTest suites green.
Still open and documented, not addressed here: a banned BAN holder can lift
their own ban (a fixpoint-ordering question that needs a spec ruling), and a
forked ban survives an unban that does not chain onto it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Correcting the previous commit's reasoning. It concluded from the two
implementations that CORD-04 does not rank-gate the Banlist and that enforcing
it would be a unilateral divergence. Reading the actual spec
(github.com/concord-protocol/concord, not in the Armada repo) shows the
opposite: §3 is normative and binds "every action" — "the actor must hold the
required bit and strictly outrank its target — equal cannot act on equal (an
admin cannot ban a peer admin)" — and §5 step 3 restates it. Banning is the
example the rule itself picks.
Only §4, the section defining the Banlist, states the bit half alone. Both
independent implementations read §4 in isolation and made the same mistake,
which is evidence about the section rather than about the readers.
So the fold fix is spec-mandated. It remains consensus-affecting (we would
ignore bans Armada honors until they ship), so it wants coordination rather
than a race, and the fold is still unchanged here.
Adds docs/concord-banlist-rank-conformance.md to share upstream: verbatim spec
citations, both implementations' gates, a delta-based rule that makes the
per-target requirement expressible against a whole-list entity, and two further
reproduced findings — a banned BAN-holder can lift their own ban (so bans do
not stick against any BAN holder), and a forked ban survives an unban that does
not chain onto it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ban/Remove were offered to any BAN holder against any non-owner, ignoring rank
— unlike the role picker, which routes through `canActOn`. Both the Members
roster and the message-level path (`Account.concordBanTarget`, the chokepoint
for the quick-action menu, the note dropdown, the note action sections and the
chat action sheet) now require `canActOn(me, target, BAN)`.
This is NOT the no-op it first looked like. The premise that the fold would
drop such a ban is wrong, and a test proves it: BANLIST is a single whole-list
entity, so `authorizedHeads`/`banGate` gate on the author's BAN bit alone and
never rank-check the list's *contents*. A rank-5 moderator's ban of a rank-1
admin is therefore ACCEPTED by every client, and the admin then loses every
permission, since `hasPermission` is `!isBanned && ..`. It is privilege
escalation, not a silent no-op.
The fold is deliberately left alone. Armada has the identical gap — its
`banlistGate` calls the rank-blind `isAuthorized(.., Permissions.BAN)` while
its role path uses the rank-aware `canActOnPosition` — so rank-gating our fold
would make us ignore bans every other client honors, splitting the banlist
across clients. Closing it needs a spec change, like CORD-05. Refusing to
AUTHOR such a ban restricts only what we write, never what we accept, so it
cannot diverge consensus.
Three `@Ignore`-d tests in AuthorityResolverTest state the fold-level invariant
and currently fail by design; two companions assert the gate does not
over-correct (a moderator still bans a plain member; the owner still bans
anyone). Un-ignore the first three when the spec closes the gap.
The owner short-circuits the check rather than going through `canActOn`, which
begins at `hasPermission` and is false while banned — since a rogue BAN holder
*can* currently banlist the owner, routing them through it would let them be
locked out of moderating their own community.
Device-verified on Amethyst QA Concord as Dr. Edo (QA Lead, rank 2): Bob
(Admin, rank 1) now offers only the disabled "Roles… / You don't outrank this
member" where Ban and Remove used to be enabled, while the Helper (rank 5)
still offers Roles…, Ban and Remove.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Notifications were pinned to the recent week with no way to scroll further
back. Mirror the NIP-04 / gift-wrap DM approach: a backward, per-relay
until+limit pager driven by in-feed window-limit markers that pull the next
older page only while visible.
- Add Account.notificationHistory (RelayLoadingCursors) and
AccountNotificationsHistoryEoseManager, a BackwardRelayPager over the
inbox + NIP-29 group-host relays, registered always-on in
AccountFilterAssembler. It parks until a marker advances a relay.
- Add filterNotificationsHistoryToPubkey / filterGroupNotificationsHistoryToPubkey
and AllNotificationKinds: one combined-kinds filter per relay so the single
per-relay cursor stays gap-proof (empty page + EOSE = nothing older).
- Wire RelayReachMarkers + RelayReachSentinels into the notifications card
feed (CardFeedView), with an empty-feed bootstrap, so scrolling a relay's
marker into view loads more.
- Fix the two live notification loaders to a fixed one-week tail (drop the
fullness-driven `since` drift), letting the marker-driven pager cleanly own
everything older — the same live-tail/history split DMs use.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
Adds the Notifications and Concord role-grant rows to the coverage table,
retires the "grantConcordRole is unreachable" finding, and records two new
ones: the Members roster offering Ban/Remove on members the viewer doesn't
outrank (silently dropped on fold), and notification cards whose target note
isn't cached rendering as placeholders.
Also adds the pattern worth carrying forward: a paging boundary gated on a
full page deadlocks against a narrow query window, and the empty state is
self-sustaining.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`Account.grantConcordRole` had been implemented with zero callers, so role
grants were unreachable from the app while the changelog claimed they ship.
This adds the missing surface: a "Roles…" item beside "Make admin" opening a
multi-select over the roles the viewer may hand out.
Both rank rules are enforced by delegating to `AuthorityResolver` rather than
reimplementing them:
- assignable roles are `roles().filter { myRank < it.position }` — the fold
drops a grant whose granter does not strictly outrank every assigned role, so
offering one at or above our own position would publish an edition that every
client then silently discards;
- reachable members are `authority.canActOn(me, target, MANAGE_ROLES)`, which
already folds the whole rule (hold the bit, not banned, target isn't the
owner, strictly outrank) and makes self-promotion fall out for free.
Out-of-reach members show the item disabled *with a reason* instead of omitting
it, so there is no silently no-op control.
The grant REPLACES a member's role set rather than merging into it, so the
dialog preselects their current roles. That preselection is provably complete:
a member's rank is the lowest position they hold, and the dialog only opens
when we strictly outrank that rank, so every role they hold sits strictly below
us and is therefore rendered — no held role can be silently stripped.
`amy concord roles` also gained a `grants:` section reading the post-fixpoint
`authority.roleHolders()`. It previously printed role *definitions* but never
the *grants*, which made the fold outcome unverifiable from the CLI; a
rank-violating grant now shows up as visibly absent rather than as if it landed.
Device-verified on a test community (Admin/QA Lead/Helper/Greeter): the picker
hides roles above the viewer, disables on members who outrank them, preselects
correctly, and a saved grant survived the fold and a fresh relay drain read
back from a second client.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The notifications query pinned `since` to `oneWeekAgo()` whenever it had no
EOSE timestamp. Combined with two other facts that produced a deadlock:
- the EOSE `since` map is in-memory only (`SincePerRelayMap = MutableMap<..>`),
so EVERY cold start re-pinned the window to 7 days; and
- the backward-paging escape hatch (`lastNoteCreatedAtIfFilled()`) only arms
once the feed holds a FULL page (`notes.size >= localFilter.limit()`).
The feed could not fill a page because the query only asked for a week, and the
query could not widen because the feed never filled. Any account whose last
inbound mention was older than 7 days saw a near-empty Notifications tab
forever — including a fresh install of a long-established account.
Worse, the floor was applied to `filterSummaryNotificationsToPubkey` (kinds
1/7/6/9735 — the overwhelming bulk of notifications), which was not even wired
to the paging fallback that the secondary per-key kinds received.
Home is the precedent and does not do this: `filterHomePostsByAllFollows`
passes `since ?: boundary`, i.e. plain null on a cold start, relying on the
relay-side `limit` to bound the response. Notifications was the outlier. These
filters are `#p`-scoped to the user's own key and carry a `limit`
(2000/500/200/20), so an all-time query is one index scan returning at most
`limit` events newest-first — same cost, strictly more useful.
Measured on the test account before the fix: 163 events p-tagging it exist on
relays and are fetchable (100 kind-1 and 100 kind-7, both hitting the query
cap), 77 of which clear the follow gate — yet the tab rendered ~3, because the
account's most recent inbound activity was a month old. After the fix the tab
scrolls back through Feb 2025.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 30 fixes from this session each carry their reasoning in their own
commit message, and the regression tests are in the repo. What none of
that captures is the shape of the testing itself: what was actually
exercised, what was not, what we knowingly left broken, and how to
reproduce the setups. That lived only in one conversation.
Records four things:
- **Coverage**, honestly split three ways: exercised on device; fixed but
only unit-verified (roughly half the session's work, including the
entire Concord authority chain); and never opened at all (Nests,
Marmot, the whole Desktop app, payments, notifications). Plus the
platform gaps — one tablet, one flavour, one ABI, Android 14 only while
targetSdk is 37, and no real `release` build.
- **Open findings** we chose not to fix, so they are decisions rather
than oversights: the unbumped versionCode, the Firebase scheduling
failure that may cost Crashlytics, Tor settings needing a restart, an
unreachable relay reported as empty, WebView profiles surviving logout,
control-plane edits still dropping unknown keys, and the CORD-05
root-binding gap that Armada shares and therefore needs a spec
conversation.
- **Setup recipes** that took real time to work out: isolating `amy` by
`$HOME`, which relays actually accept a NIP-29 create, deep-linking
past a 1000-entry directory, and proving "no network before consent" by
running a local relay over `adb reverse` and counting events either
side of the tap.
- **Patterns** that recurred often enough to be process problems: tests
that assert the bug, implemented-but-unreachable capabilities, Gradle
serving a stale up-to-date test run and reporting success for a
deliberately broken build, and four confident diagnoses that
measurement overturned.
Written for whoever picks this up next, including the parts that reflect
badly on the testing rather than only the parts that reflect well.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two StrictMode disk violations on startup and on IPC, fixed differently
because they are different problems.
**Favicon storage was writing on the IPC handler thread**, i.e. the main
looper: `BrowserIconRegistry.record` did `File.writeBytes` inline, and
`init` scanned the directory with `listFiles`. Both now run on an IO
scope. The directory is still published synchronously so `iconModelFor`
and `record` work immediately — only the scan and the write are deferred.
`keys` updates after the bytes are actually on disk, so a reader is never
told an icon exists before the file backing it does, and until the scan
lands an icon renders its placeholder for a frame and then recomposes,
which is what the StateFlow is for.
**The settings read was left synchronous on purpose, and stays that
way.** `notificationServiceEnabled` reads SharedPreferences inside a lazy
initialiser; the surrounding comments record why it is not hydrated
asynchronously — an async hydrate reopens a window where a late disk read
clobbers a user's toggle. Converting it would have looked like a
StrictMode cleanup while resurrecting a settings-corruption bug. Instead
the prefs file is warmed on an IO thread at startup, so the first
synchronous read hits SharedPreferences' in-memory cache. Best-effort by
design: if a main-thread reader wins the race it pays the disk hit once,
exactly as before, and correctness is unchanged either way.
Verified on device: `notificationServiceEnabled` StrictMode hits went
from 9 to 0 on a cold start, no icon storage failures, 884 ms cold start,
no crashes — and both pinned web-app icons (ditto.pub and Brainstorm)
still render, which is the thing making the write async could plausibly
have broken.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A pinned web app showed a generic placeholder instead of its icon —
ditto.pub failed while brainstorm.nosfabrica.com worked. The
discriminator is what each site declares:
brainstorm: favicon.svg AND favicon.png <- the PNG is why it works
ditto: logo.svg only (+ an apple-touch-icon nobody read)
Icons were captured solely through `WebChromeClient.onReceivedIcon`,
which hands back a rasterized Bitmap. Android WebView does not decode SVG
favicons into that callback, so a site offering a raster alternative gets
picked up and an SVG-only site never fires it at all: nothing is
recorded, `iconModelFor()` returns null forever, and it fails silently —
no error, no log line, just a permanently generic icon. SVG-only
declarations are increasingly the norm, so this was set to affect more
apps over time.
Adds a sniffer that, after a main-frame load, ranks the page's declared
icons (raster `rel="icon"` → SVG `rel="icon"` → apple-touch-icon →
/favicon.ico) and fetches the best one IN PAGE CONTEXT, falling through
on failure. The bytes are size-bounded and magic-byte validated before
being relayed down the existing record path; anything unrecognised is
dropped.
Fetching in page context is the point, not an implementation detail. The
registry captures from the WebView deliberately — "an alternative to the
main app fetching host/favicon.ico itself, which would bypass Tor and
leak the request". Ditto's /favicon.ico exists and returns a valid icon,
so fetching it from the app would have been the easy fix and the wrong
one. Every byte still comes through the sandbox WebView's own network
path.
No rasterisation needed: coil3's SVG decoder is registered on the
singleton loader and sniffs by content rather than extension, so stored
SVG bytes decode even under the registry's .png filename. Only a leading
BOM/whitespace trim was required so byte 0 is the '<' the sniffer wants.
Also fixes a second gap found while diagnosing: `NappletBrowserService` —
the EMBEDDED path backing pinned bottom-nav tabs — had no icon capture at
all, so a pinned app's icon depended on having once opened it full
screen. It now gets both the missing raster callback and the sniff.
`NappletHostService` deliberately left alone: it serves verified blobs
over a synthetic internal host, and applet icons already come from the
manifest, so capture there would be dead code.
The sniff runs after a delay and skips when the raster callback already
claimed the host, so sites that worked before are untouched.
Verified on device: ditto.pub now shows its real icon on the favorite
card, the pinned sidebar tab and the recents row, via both the embedded
and full-screen paths; Brainstorm is unchanged; example.com (no usable
icon) degrades to the placeholder with zero record calls and no hang; no
crashes.
Pre-existing, not fixed: `BrowserIconRegistry.record` writes the file on
the IPC handler thread, tripping StrictMode. It did so before this change
— which now simply gives it more occasions. Moving that write off-main is
a cheap follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Changing an app to PARANOID did not stop it signing. `sessionAllows` is
consulted BEFORE `signerLedger.decide()`, so a live "allow for this
session" grant short-circuits exactly the check the stricter policy would
have failed. The user picks "I'm a bit paranoid", the UI updates, and the
app carries on signing on the strength of a grant made under the old
policy — until every applet surface closes.
The trust level is a decision about how an app is treated from now on, so
the policy change now drops what that app is currently holding, the same
way revoking and forgetting already do. It fires on any change rather
than only on tightening: loosening is the user's call too, and a stale
grant surviving a deliberate re-decision is surprising in either
direction.
Completes the revocation work — the three paths that change what an app
may do (forget, per-op revoke, trust level) now all clear its live
session grants.
Not automatically tested: this is a Compose click handler and `amethyst`
has no Robolectric. The underlying `revokeSessionGrants` is covered by
`revokingAnAppDropsItsLiveSessionSignerGrants`, which was verified to
fail before its namespacing fix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three defects, each of which made revocation look like it worked.
**`revokeSessionGrants` had no callers.** It was added with the KDoc "so
revoking an app takes effect immediately instead of lingering until this
broker instance dies" and then never wired, so revoking an app in
Connected Apps left its in-memory session grants active. The user
revokes; the app keeps signing.
**And it was broken as written.** `sessionAllows` keys are the
account-namespaced `napplet:<signer>:<coordinate>|<op>`, but every revoke
call site holds the BARE coordinate, so the prefix match found nothing.
Wiring it naively would have looked correct and silently done nothing. It
now namespaces before matching, and also clears the post-Cancel re-prompt
cooldown so a revoked app prompts on next use instead of being quietly
dropped.
**Worse: there were three ledgers.** `NappletBrokerService`,
`ConnectedAppsScreen` and `ConnectedAppDetailScreen` each constructed
their own `NappletPermissionLedger`, while ALLOW_SESSION grants are
per-instance in-memory state. So "Forget" cleared the screen's own
always-empty session map while the grants the broker actually consults
lived on. The KDoc described a process-wide singleton; it wasn't one.
Promoted to a real singleton in AppModules alongside the existing
permission store, and shared by all three.
The screens are plain composables with no binder to the broker service,
so rather than invent an IPC path the cached broker moved to the
service's companion under a lock — matching the sibling main-process
registries in that package. Both revoke paths call it: the Forget button
and the per-op revoke.
Also gives `NappletPermissionLedger.endSession()` its first caller, which
promoting the ledger made necessary: it used to die with the service, so
session grants had a natural bound. Now that it outlives the service,
`onDestroy` restores exactly the lifetime ALLOW_SESSION already implied.
The boundary is safe — the service is bind-only and is destroyed only
once every applet and browser surface has unbound, so switching between
two open applets never drops grants mid-use. Deliberately NOT wired to
account switch (already handled by account-keying) or to backgrounding
(would re-prompt mid-use).
Test notes, kept honest: the revoke test was verified to fail before the
namespacing fix. The `endSession` test PASSES without the change —
`endSession` itself was always correct, the bug was that nobody called
it — so it is characterization for the new lifetime contract, not a
regression test. The `onDestroy` wiring and the composable click handlers
have no automated coverage; `amethyst` has no Robolectric and no harness
was invented for them.
Known gap, left alone deliberately: changing an app's trust level to
PARANOID does not drop its live session grants, because `sessionAllows`
is consulted before the signer ledger. That is a revoke-shaped action and
belongs in the same fix, but it is a behaviour change and was out of
scope tonight.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An unauthorized control edition in the middle of an entity's chain
permanently froze that entity. Observed on device for a member's GRANT:
v0 owner (grant mods)
v1 owner (grant admins) <- fold stopped here, forever
v2 MIDTIER (escalation, correctly rejected)
v3,v4,v5 owner orphaned, unreachable
`AuthorityResolver` filtered unauthorized editions out BEFORE calling
`EditionFold.foldEntity`, and the walk only advances when the next
version cites the current head's hash. Removing v2 severed the chain, so
every honest edition above it was lost. Any member could permanently
freeze any member's role assignment — including the owner's ability to
change it — with a single event, recoverable only by a Refounding. It
predates the recent rank gates (verified with a zero-role identity); the
gates only widen which editions can poison.
Armada does not have this bug, and its approach settles the design.
Reading its control-plane fold (read for semantics only — Armada is
AGPLv3, Amethyst is MIT, no code taken): the chain walk runs over the
UNFILTERED set, producing an ordered candidate list — chain-verified head
first, then every remaining edition version-descending — and authority is
applied AFTERWARDS, per candidate, picking the first admissible one. A
rejected edition is skipped during the ascending admissibility walk
without truncating it. For the chain above, Armada picks v5.
So the fix is not to filter later but to gate later: `EditionFold` gains
candidate-based gated folding, and the resolver and community state now
gate per candidate instead of pre-filtering the pool. Authority checks
themselves are unchanged — only WHEN they run moved. Applied to ROLE,
GRANT, BANLIST, CHANNEL, METADATA and the authorized-head map.
The writer had to be fixed too, for a sharper reason than expected. With
an ungated `headOf`, a rogue banlist edition at the tip is read as
current state, so the owner's next ban REPUBLISHES THE ROGUE'S CONTENT
UNDER THE OWNER'S SIGNATURE — an unauthorized empty banlist laundered
into an owner-signed one the moment the owner bans anyone else. Tolerant
reading cannot heal that, because the resulting edition is genuinely
authorized. `ConcordModeration.headOf` now folds the authority-gated
heads, and `owner` is a REQUIRED parameter rather than defaulted, since a
silently-wrong default here is a consensus footgun.
Banlist healing is preserved with one necessary change: the ancestry walk
now runs over the full pool rather than the authorized subset. Ancestry is
structural — walking only authorized editions stops at the rejected one
and misreads genuine ancestors as concurrent forks, resurrecting bans an
unban had cleared.
Six regression tests, each verified to fail without the fix. Two process
notes worth recording: the first "without the fix" run reported BUILD
SUCCESSFUL because Gradle served a stale up-to-date `jvmTest` — trusting
it would have meant concluding the tests were worthless. And the
forged-edition test initially passed both ways because the forgery's
content coincided with the honest outcome; it was rewritten so the
mid-chain arm genuinely discriminates.
The rank-gate, rogue-higher-version, floor and rollback tests all pass
unchanged.
Known gap: `headOf` gates through the per-kind permission map, which is
coarser than the resolver's rank gates, so the writer can still pick a
head the reader rejects when an in-permission but out-of-rank edition
sits at the tip. Tolerant reading makes that benign, but it is not an
exact reader/writer match; tightening it needs the resolver to expose
per-entity heads.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`Account.leaveConcordCommunity` has existed since the feature landed and
had ZERO callers anywhere in the repo — so joining a Concord community
was one-way. It stayed in the account's kind-13302 list and in Messages
permanently, with no affordance on the community screen, the Members
screen, or Messages.
Found because a test account joined a probe community whose only relay
then went away: stuck in the list, channels unrecoverable, nothing to
tap. Third capability found this session that is fully implemented and
unreachable, after `ConcordInviteBundle.isExpired` (called only from a
test, so invite expiry was decorative) and
`NappletPermissionLedger.endSession` (never called, so session grants
outlived every revoke). Each made a feature look complete to anyone
reading the model.
Adds "Leave community" to the community screen's top-bar overflow,
mirroring how NIP-29 relay groups already place membership-destroying
actions, behind a confirmation. It renders whether or not the Control
Plane ever folded, which is the case that matters — a dead-relay
community never folds.
The copy is deliberately narrow about what leaving does: it removes the
community from THIS account's list and stops syncing, it does NOT notify
the community or remove anyone from a roster, and returning needs a new
invite.
Owner leaving is allowed, with an extra warning. Blocking it would make
the actual stuck case unfixable, since the motivating community was one
the account created; and it is the user's own private list to edit.
But it is irreversible in a way worth stating: `ownerSalt` lives only in
that entry, so discarding it retires the community rather than
transferring it. Ownership is read from the stored entry rather than the
folded authority, because a dead-relay community has no folded authority.
Works offline by construction: the underlying call rewrites the local
list (falling back to the on-disk backup when nothing folded) and
publishes fire-and-forget to the user's OWN outbox — never the
community's relays — so the UI does not wait on a relay that cannot
answer. Both paths that could resurrect a left entry were checked:
stranded recovery iterates live communities only, and the list import
takes the newest 13302, which is ours.
Tests cover the real logic behind the button — `unfollow` was previously
untested — driven through the offline-backup path with no cached relay
event: drops only the named community, preserves other memberships'
secrets, empties cleanly on the last one, no publish for a community
never joined, and the rewritten list stays self-encrypted.
Not verified on device: building an APK would have replaced the build a
concurrent Concord authority test was running against. The composable
itself has no automated coverage — `amethyst` has no Robolectric.
Two follow-ups noted, not fixed: leaving does not unpin a community from
the bottom bar, so a pinned one leaves a dead tab; and
`grantConcordRole` is another zero-caller capability — the general
CORD-04 role-grant path is unreachable, with only the narrower
make/remove-admin wired up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A joined NIP-29 group showed its raw hex id instead of its name — in the
header, the Messages list and the browse directory — offered "Join" to an
account the relay already listed as an ADMIN, and hid the entire admin
surface. Members, Edit group, Invite people, the Threads "+" FAB,
pin/unpin, share and the member count all sit behind the same
`isMember()` gate, so none of them were reachable. Posting was blocked
too.
The cause is not membership modelling, warmup skipping, or the local
kind-10009 list. **The relay rejected the subscription outright.** The app
asked for all five group-state kinds in one filter, captured on the wire:
SEND ["REQ",…,{"kinds":[39000,39001,39002,39003,39005],"#d":[…]}]
RECV ["CLOSED",…,"blocked: it's not allowed to mix metadata kinds
with others"]
The whole REQ is dropped, so zero 39000/39001/39002 reach LocalCache.
Membership is derived from those events, so it fell back to NONE and
every gated control disappeared. Chat messages arrived on a separate `#h`
REQ, which is why the group looked half-alive rather than broken.
relay29/khatru29 evaluates that rule PER FILTER, confirmed by probe:
39000-39003 + 39005 in one filter -> CLOSED, 0 events
the same kinds as two filters -> 4 events, EOSE
39000-39003 alone -> 4 events
So the fix is to split the kinds into two filters in the same REQ — no
extra subscription, no new assembler. This repairs every joined group on
any relay29-family relay, not just the one that surfaced it: the always-on
joined-state subscription was hitting the identical rejection everywhere.
Two hypotheses were tested and disproved rather than assumed. The
join-tap/warmup theory was wrong — instrumentation showed the filter was
built correctly with the right scope and only failed on the wire. The
`since`-on-replaceable theory was also wrong here: `since` logged null on
every call, since the map is per-subscription and reset on disconnect
rather than a persisted floor. Both left unchanged.
Regression tests fail on the old single-filter behaviour and pass on the
new one, verified by reverting the behaviour while keeping the constants
so the tests genuinely run rather than fail to compile.
Verified on device: header now reads "Amethyst QA 1.13" with an Admin
badge and member count 2, composer enabled, overflow menu showing
Members / Edit group / Invite people / Leave — all consistent with the
relay's roster.
Not fixed here: a stale "Requested" state is client-side only and is
never reconciled against an arriving roster. With this fix a genuine
pending→member transition now resolves, but a rejected 9021 still shows
"Requested" until restart. Separate change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Browsing a relay's groups (Relay Groups → Find groups → pick a relay)
crashed the app to the launcher:
java.lang.IllegalArgumentException: Comparison method violates its
general contract!
at java.util.TimSort.mergeLo
...
at RelayGroupChannelListScreen$allChannels$3$1$1.emit
`sortedBy { it.toBestDisplayName().lowercase() }` re-evaluates its key on
every comparison, and the key comes from mutable shared state — the
channel's display name, which a kind-39000 directory event can change
while the sort is running. TimSort detects the inconsistency and throws.
The failure scales with directory size and needs no user action: a relay
hosting 1237 groups tripped it on the first browse, while a relay with 18
never did — which is why an earlier sweep of this same screen missed it.
v1.12.6 already fixed this class elsewhere ("snapshot live-stream status
order before sorting"); these sites were not covered.
Adds `sortedBySnapshot`, which computes each key ONCE before any
comparison runs, and applies it to all four sites that sorted live
objects by their display name: the relay group directory (both the
initial value and the observer), the parent-group picker, and the
name-ordered search results. The picker's site also had to materialise
its Sequence first, since sorting lazily would have re-introduced the
same window.
Verified on device: the exact tap that crashed now loads the screen with
zero FATAL EXCEPTIONs and the app stays in the foreground.
Found while setting up a NIP-29 group to test the admin surface, which is
also how the directory got large enough to expose it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The map picker opened blank, showed "Tessalit, ML" — the middle of the
Sahara — as if it were chosen, and had Confirm/Teleport ENABLED, so a user
could join a geohash cell they never selected. Three of those are one bug
and the fourth follows from it. Notably there is no tile, network or Tor
problem: tiles were downloading and rendering the whole time.
**The defect.** `onCenterChanged` treated any map movement beyond
`SELECT_MOVE_EPS` (0.0005°) as the user's first pick. osmdroid emits a
scroll when the MapView is first laid out, reporting a pixel-quantized
centre — and at the opening zoom one pixel is about 0.4°, roughly 800x the
epsilon. The phantom scroll therefore always cleared the threshold and the
picker selected a location before the user touched anything. No epsilon
can fix that: the quantization error is unbounded at low zoom. Replaced
with a `mapTouched` gate, so only finger-driven movement can become a
selection.
**Why it looked blank.** That phantom selection made the level effect zoom
to 12 over empty desert, and the night tile filter darkened featureless
Sahara tiles to near-black. Tiles were present; there was nothing to see.
Tapping "Region" re-zoomed and a detailed map appeared instantly, which is
what ruled out a loading failure.
**Why Tessalit.** The self-selection encoded the default centre, and
`WORLD_CENTER_LAT/LON = 20.0/0.0` is inland Mali — not "mid-Atlantic" as
the comment claimed. Moved to -30.0 longitude, genuinely unnamed ocean and
off the prime meridian; two nearby hardcoded 20.0/0.0 literals now use the
constants.
**The label mismatch was mostly a misread, with a real bug behind it.**
20N/0E sits exactly on the prime meridian, so a sub-kilometre pan flips the
geohash between the `e…` and `s…` halves of the world — both cells really
were Tessalit, so the name was right. The genuine bug is narrower: the name
came from a 450 ms-debounced cell, so mid-pan the previous cell's name
rendered beside the new geohash. The name now renders only when the
debounced cell matches the live one, and an unresolvable cell shows no
label rather than echoing its geohash.
Side benefit: the seeded composer path no longer has its exact seed
coordinates overwritten by osmdroid's quantized initial scroll.
Verified on device before and after, on both the Teleport screen and the
composer dialog: tiles on open, no bogus place, no selection until a real
touch, Confirm disabled until then; then pan → cell with no label at sea,
pan → "Oriximiná, BR", search → "Lisboa, PT", composer pick → confirmed.
Pre-existing, not fixed: in the modal dialog only, the picker header is
overdrawn by the MapView and invisible. Proven to predate this change by
A/B against an unmodified build. Back-dismiss works; cosmetic.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Day separators in group chat did not match the messages under them: a
"Jul 1, 2025" header sat directly above a Sep 23 bubble, and a "Sep 23"
header above a Sep 25 one — while LATER separators in the same
conversation were correct.
The obvious explanation — that a bubble group spanning midnight takes its
header from the group's first message — is wrong. `ChatGroupPosition`
already refuses to group two messages whose formatted dates differ, so a
group cannot cross a day boundary at all.
The real cause is an off-by-one from a false assumption about
`reverseLayout`, stated in the comment that used to sit here: that later
content "draws just above the message". `reverseLayout` reverses the
order of the LIST'S ITEMS, not the content inside one item — each item's
Column still lays out top to bottom. Verified with a throwaway headless
Compose layout probe rather than by argument: with `reverseLayout = true`
and two items each holding a message and a divisor, item 1 (older) sits
above item 0 (newer), but within each item the divisor sits BELOW its own
message.
So the divisor for item i was labelled with date(i) and gated on
date(i+1) != date(i) — the arithmetic was right — but drawn between item
i and item i-1, heading the NEXT, newer message while showing the
previous one's date. That also explains the detail the grouping theory
could not: whenever two consecutive messages fall on the same day the
mislabelled header happens to read correctly, which is why only some
separators looked wrong.
Moving the divisor to the top of the item Column fixes it. Its arguments
were already correct, so nothing else changed — no formatting change, no
grouping change, and one fewer list lookup since it now reuses the
already-computed older note. Subject headers had the same inversion and
are fixed with it.
One call site serves every chat surface — NIP-29 relay groups, NIP-28
public chats, ephemeral chats, live-activity chats, Concord channels,
private DMs, geohash chats and Marmot groups — so all were wrong and all
are fixed.
`markersInGap` immediately below had the identical bug from the identical
assumption: it is handed the bounds for the gap toward the OLDER message
but, composed last, rendered in the gap toward the newer one. Moved for
the same reason.
Not unit-tested, deliberately: the date arithmetic was always correct, so
a pure-function test passes both before and after. Catching this needs a
Compose layout assertion, and `:amethyst` has no Robolectric or JVM
Compose harness — only instrumented tests. The layout probe above is what
established the behaviour; it was deleted afterwards.
Not verified on-device: the visual result is inferred from that probe
plus the reported symptoms.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The episode renderer shows two blocks: the `description` tag via
`episodeDescription()`, and the event content as markdown. Most feeds put
the SAME text in both, and the thread view renders both (`makeItShort` is
false there), so the whole description appeared twice inside one card —
one author header, one player, two identical bodies, each with its own
"Show More" pill.
The existing guard only suppressed the markdown block when the content
was blank. It now also suppresses it when the content merely repeats the
description, compared on collapsed whitespace so a copy differing only in
wrapping still counts as a duplicate. Feeds were unaffected because they
pass `makeItShort = true` and never rendered the second block, which is
why this only showed on the episode screen.
Found by a device sweep of the untested feature areas.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two bugs found by a device sweep of the Messages list.
**Raw NIP-04 ciphertext rendered as the message preview.** Rows in New
Requests showed base64 blobs like `0tyoSVovKSK9uDKLUVMs137TD0b+vz…`.
Cause: every decryption branch in `Account.cachedDecryptContent` and
`decryptContent` is gated on `isWriteable()`, and the non-writeable path
fell through to `event.content` verbatim — which for a kind:4 IS the
NIP-04 blob. A read-only (npub-only) login therefore hit this on every
legacy DM room.
Both functions now return null instead of the ciphertext, which closes
the leak on every surface reading them — including the open chatroom
body, which had the identical fallthrough. A new pure classifier backs
the preview and never reads `event.content` for an encrypted kind, so a
future raw fallback cannot resurface there.
Pending and undecryptable are now distinguished on facts the UI actually
has, rather than collapsed into one message: no key at all, or a kind:4
between two other people, is "could not decrypt"; encrypted with our key
a party but plaintext not yet back is "Decrypting…", which resolves
itself when the signer answers. The old code showed the not-found string
for the pending case.
**Group DM titles stuck on npubs while the facepile beside them showed
real names** — and this one is not a display bug at all. Both already
observe metadata through the same flow; the fault is in `User`:
fun metadata() = metadata ?: UserMetadataCache().also { metadata = it }
Non-atomic lazy init on a plain field, called from BOTH the Compose main
thread (every `observeUserInfo` composition) and the relay/IO threads
(`updateUserInfo`). Two threads can each read null, each allocate, and
one instance is orphaned. A composable collecting the ORPHANED cache
never receives the metadata, so it sits on its pubkey fallback forever
while a sibling that got the surviving instance renders the name — which
is exactly "npubs in the title, names in the facepile, same row", and
why it never recovers.
All six per-user lazy caches are now `@Volatile` with double-checked
locking under one process-wide lock, held only for the allocation. The
store holds tens of thousands of users, so a lock per user would be
worse than the bug.
This likely explains a broader class of "some names resolve and others
never do" symptoms, not just the row that surfaced it.
Not fixed: for a read-only account the open chatroom body now renders
nothing for a kind:4 rather than ciphertext — better, but it deserves the
same "could not decrypt" placeholder the preview row got.
Unverified: that the account which showed the ciphertext was in fact
read-only. Every other route to ciphertext was traced and returns null,
so the non-writeable fallthrough is the only reachable source, but the
device state itself was not captured.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two problems in the remote signer, both about a client getting something
without the user meaningfully agreeing to it.
**`get_public_key` and `get_relays` answered anyone.** Every other method
runs through `ifAuthorized`; these did not, and nothing required a prior
successful `connect`. The service decrypts and dispatches any well-formed
kind-24133 envelope, so anyone holding the `bunker://` URI — pasted into a
malicious app, posted for support, leaked in a screenshot — could ask it
which account it belongs to, without the secret and without connecting.
`get_relays` additionally handed over the inbox relay set. That defeated
the transport/identity split, which otherwise works: the relay-visible
traffic really is anonymous, since the p-tag and author are a transport
key and the payload is NIP-44.
Both now require the client to be paired. The authorizer interface gains
`isPaired` with NO default, so a future authorizer has to state its own
rule rather than silently inheriting "everyone is paired".
`ping` is deliberately left open. It reveals nothing the caller does not
already have — a signer is alive at a pubkey they hold — and first-party
behaviour could be confirmed but third-party clients that ping before
connecting could not be ruled out. Breaking a legitimate handshake to
close a minor oracle is a bad trade. The choice is pinned by a test that
also asserts the pairing check is never consulted, so it stays deliberate
rather than drifting back by accident.
**Decrypt consent showed nothing at all.** The bridge populated the
content preview and raw data only for signing requests, so a decrypt
request produced an empty preview block — no ciphertext, no counterparty,
not even the "Show event" toggle — leaving "AppName wants to read your
private messages" with *Allow always* as the primary button. Meanwhile
the coordinator documented the opposite: "Amethyst decrypts first, then
asks permission to expose." That was never implemented.
Now:
- The counterparty is resolved and shown, so the prompt reads "…read your
private messages **with Alice**". It never degrades to nothing —
cached name, else a shortened npub. Knowing *whose* messages is a
categorically different decision.
- The message is decrypted BEFORE prompting and the plaintext is the
preview, as documented. It is a local operation and nothing is exposed
until approval. Failure, blank and hang all collapse to an explanatory
string under a timeout, so the dialog is never empty and cannot stall.
- A narrower grant is offered ALONGSIDE the broad one, not instead of it:
`DecryptFrom(counterparty)` keyed `decrypt:<hex>` next to `Decrypt`.
The dialog's primary button becomes "Always allow for Alice" with the
broad option demoted. Because the ledger stores an opaque op key, no
persisted decision migrates and the storage format is untouched.
Scoping decrypt per counterparty *instead* would have been worse than
the bug: a DM client would prompt once per conversation, training users
to approve everything. A narrow option beside the broad one gives
granularity without the prompt explosion.
Also fixes a latent bug found on the way: `AllowForSession` recorded the
*requested* op rather than the *granted* one, which would have widened a
narrow session grant back to broad.
Verified by three sabotage passes; the tests that stayed green under them
are the intended negative guards. One existing test asserted the buggy
behaviour outright ("public reads are never gated") and was rewritten.
Not done: the batched consent sheet still records the broad op for
"remember" — offering the narrow choice per row there is a UX design
question, not a mechanical change.
Needs a device check before release: the decrypt preview runs the account
signer before consent. That is free for a local key, but an account backed
by an external NIP-55 signer (Amber) may show Amber's own prompt ahead of
Amethyst's.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Four defects in the pay-to-upload path, all exploitable by a hostile or
merely broken media server.
**No amount cap, and the shown amount was not the paid amount.**
`amountSats()` was display-only — `pay()` never read it, so a server
asking 10,000,000 sats was handled exactly like one asking 10. `pay()`
now takes the amount the dialog showed, re-derives it from the invoice,
and refuses on over-cap, on mismatch with the dialog, and on an
amountless invoice (a payee-chosen amount must never be paid
unattended). The cap is 10,000 sats: real BUD-07 per-blob fees are
single-digit to low-hundreds, so this is one to two orders of magnitude
above legitimate use and caps one prompt's damage without blocking
anyone.
**Unbounded re-prompting.** The post-payment retry caught generic
`Exception` — including a second `BlossomPaymentException` — marked the
target missing, then re-entered the mirror path, so a server that
pocketed the preimage and replied 402 again got an indefinite pay-prompt
cycle. A new prompt ledger allows one prompt per (blob, target) per
user-initiated action; the user's own tap resets it, the automatic
continuation does not.
**Double-spend window.** `withTimeoutOrNull(90_000)` abandoned the wait
without cancelling the in-flight NWC request, so a payment settling at
second 91 was reported as failure and could be paid again. NIP-47
`sendZapPaymentRequestFor` is fire-and-forget with no cancellation and
does not return a request id, so cancelling is not reachable. Instead the
invoice is claimed before sending and released only on a definitive
wallet answer — the timeout path deliberately does not release it, so it
can never be sent twice. Known limit: the claim is process-lifetime, so a
timed-out invoice becomes payable again after a restart; persisting it
needs a real store.
**Unsanitised server text.** The server-controlled `X-Reason` header was
rendered verbatim directly above the pay button, letting a hostile server
assert its own amount ("Pay 1 sat") in what looked like Amethyst's voice.
It is now stripped of control characters and Unicode bidi overrides,
whitespace-collapsed, clamped to 200 chars, and rendered in a distinct
style as `<host> says: "…"` so it cannot be mistaken for our wording.
The two policies are small standalone classes so they are testable
without an Account graph. Verified by disabling all four checks: 12 of 15
tests fail, covering every defect; the 3 that stay green are the intended
negative controls.
Not covered: the background mirror sweep does not go through this handler
and cannot prompt, so it was never exposed to the cap or re-prompt
defects, and it inherits the invoice claim and sanitisation for free.
`pay()` itself is still not end-to-end tested — that needs Account
mocking well beyond this fix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`MintHttpClient` only trimmed trailing slashes — no scheme check, no host
check — and `token.mint` comes verbatim from any pasted or posted Cashu
token. Tapping Redeem on a token in someone's note therefore made the
device issue HTTP requests to an arbitrary URL: `http://127.0.0.1:<port>`,
LAN addresses, `169.254.169.254` (cloud metadata), any scheme at all —
plus it disclosed the user's IP to whoever controlled the URL.
Validation now runs in the constructor, so no caller can issue a request
before it. The rule: `https://` to a public host, or `http://` to a
`.onion` host, and nothing else. Onion mints matter — a blanket
"https only" rule would have silently broken every Tor mint.
Rejected hosts cover the private/loopback/link-local/unique-local ranges
plus CGNAT, multicast, reserved and `0/8`: none is a public unicast host,
so allowing them buys nothing and leaks reachability.
The bypasses are what make this worth care, and each has a test:
IPv4-mapped and IPv4-compatible IPv6 (`::ffff:127.0.0.1`, `::127.0.0.1`),
the full `inet_aton` spellings (`2130706433`, `0177.0.0.1`, `0x7f000001`,
`127.1`), trailing-dot hosts, and userinfo disguise
(`https://mint.example.com@127.0.0.1/`) — handled by splitting on the
LAST `@`. The host parse is hand-rolled rather than delegated to
`java.net.URI`/`HttpUrl` precisely because those normalise these forms
inconsistently.
A mint the user added to their own wallet is exempt from the host and
https rules — a self-hosted mint on a LAN is a legitimate setup, and the
threat here is a *pasted, untrusted* token pointing inward, not a mint
the user chose. The exemption never relaxes the scheme check. It is
threaded properly rather than TODO'd: the melt path passes the wallet's
known mints and marks the mint user-configured only on a match; the
wallet ops and CLI pass it directly, since those URLs are the user's own.
Refusal gets its own message rather than reusing the mint-error string,
whose wording would have misattributed our own refusal to the mint.
DNS rebinding is out of scope and noted in a comment — the check runs
pre-resolution and cannot defend against a host that resolves differently
on the second lookup.
Verified by disabling the scheme and host checks: 11 of 20 tests fail,
every rejection case among them, and every allow case still passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`renameConcordChannel` built `ChannelEntity(name = ...)` from scratch
without reading the standing definition. `private`, `voice` and every
other flag default to false, so the published edition declared the
channel PUBLIC and TEXT. Renaming a private channel silently reclassified
it; renaming a voice channel silently converted it.
The messages themselves stay encrypted — a private channel is
independently keyed and a rename does not rotate that key — but the
Control Plane, which is what every client reads to decide how to present
and gate a channel, now says the wrong thing about it. A moderator
fixing a typo in a channel name should not change who a client believes
may read it.
`deleteConcordChannel` had the same shape: its tombstone also reset the
flags, so retiring a channel reclassified it on the way out.
Both now carry the standing definition forward and change only what they
mean to change.
Found while auditing which write paths re-serialize a decoded entity
rather than re-wrapping it — the same audit that produced the community
list fix. This one is worse than the unknown-key loss it was found
beside, because it discards KNOWN fields with a privacy meaning rather
than fields we merely failed to model.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The private community list (kind 13302) is documented as wire-compatible
with Armada's `communityList.ts`, whose entry type ends in
`[k: string]: unknown` — unknown keys are part of the contract, and
`ConcordJson`'s own KDoc says shapes are "deliberately client-extensible
(CORD-03/04)". But `ignoreUnknownKeys = true` plus closed `@Serializable`
DTOs meant decode dropped every unmodelled key and encode never restored
it, so **every Amethyst write of a user's list silently stripped fields
another client had written**, across every community in it.
Already proven, not hypothetical: `JoinMaterialWire` declared a
`refounder` field that nothing in the repo reads, so it was parsed and
destroyed on the first write. We only avoided destroying Armada's
`invite_ref`/`excluded_at_epoch` because those were modelled hours ago,
for stranded recovery — the anchor recovery depends on would otherwise
have been deleted on every write.
Each wire DTO's compiler-generated serializer is now wrapped in a shared
`JsonTransformingSerializer` that lifts unknown keys into a bag on
decode and merges them back on encode, with declared fields winning on
conflict. The known-key set is read from the descriptor rather than
hand-listed, so it cannot drift from the DTO. Preserved at the document
root, each entry, the `current` join material, each channel, each
held_root, each tombstone, and everything nested inside `seed`.
`refounder`'s typed field is removed so it round-trips generically.
Two further data-loss bugs surfaced while doing it, both fixed here:
- **`seed` was overwritten with `current` on every write**, destroying
the immutable join anchor. It is now kept and re-emitted verbatim as a
raw JsonObject — we never hydrate from it while `current` exists, so we
have no business rewriting it, and keeping it raw preserves everything
nested inside for free.
- **`tombstones` were re-encoded as an empty list**, which did not just
lose their unknown keys: it RESURRECTED communities another client had
deliberately removed. They are now carried verbatim.
Verified by four separate sabotage passes (no-op the transform,
re-mint `seed`, restore the empty-tombstone write, flip the merge order);
each new test fails under at least one, and every mechanism is covered.
Control-plane re-serialization was audited too and is NOT fixed here:
`compactControlPlane` is safe (it re-wraps the original seal verbatim),
but the user-facing *edit* paths — `editConcordMetadata`, `grant`, and
the channel edits — construct fresh typed entities and re-encode, so they
drop extensions the same way. Fixing those means merging into the head
edition's raw JsonObject on each edit path, which is a larger change than
this should carry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A rotation carries only (newRoot, newEpoch, rotator) — no recipient list
— so a member simply left out of the recipient set receives nothing and
is stranded on the dead epoch forever while everyone else moves on. It
applies to any member, the owner included, and cannot be prevented on the
receive side: there is nothing to check.
Armada does not prevent it either; it recovers, and this follows the same
approach. The invite link a membership was joined through is stored as
the anchor, and when that link later resolves to a HIGHER epoch, the
membership merges forward.
Uses Armada's exact wire names so the kind-13302 list stays compatible in
both directions: `invite_ref` (the link in bare `<naddr>#<fragment>`
form, host-stripped so a link minted by a different front end reduces to
the same anchor) and `excluded_at_epoch`, both at the entry level.
Details that decide whether this works at all:
- `merge()` lets a higher-epoch winner inherit the loser's `invite_ref`
when it has none. Without it a two-device merge silently discards the
only anchor recovery has, disarming it permanently.
- `adoptConcordRoot` carries `invite_ref`/`excluded_at_epoch` through a
rotation; it rebuilt the entry field-by-field and would have dropped
them at exactly the moment they matter.
- Merging forward preserves `heldRoots`, so prior-epoch history the
member legitimately holds is not lost by recovering.
- Recovery requires a strictly higher epoch and a matching community id,
so it is monotonic and cannot be steered by an unrelated bundle.
Hooked onto the existing Concord revision tick immediately after
`drainConcordRekeys`, because the two are halves of one problem: a
rotation you were included in arrives as a rekey to drain, one you were
excluded from produces no message at all and can only be found by polling
the link. Rate-limited to 15 minutes per community; an idle tick costs a
map lookup. Only a Live bundle recovers — an expired or revoked link is
not a missed rotation.
Verified by mutating the production code eight ways (dropping the anchor,
dropping heldRoots, dropping the epoch comparison, renaming the wire
keys, removing the merge inheritance, breaking bare-form parsing) and
confirming each produced exactly the expected failures.
Two things this surfaced, both left for their own change:
- Our parser does NOT round-trip unknown JSON keys — `ignoreUnknownKeys`
plus closed DTOs — while Armada's format ends in `[k: string]:
unknown`. So every Amethyst write of the community list silently strips
fields Armada added that we do not model; it already discards the
`refounder` field we parse but never re-emit. That is live interop data
loss, caused by us, independent of this work.
- `mergeForward` keeps the entry's existing private-channel grants rather
than adopting the bundle's, matching what `joinConcordViaInvite`
already does. If a recovered member should pick up new-epoch grants,
both paths need it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`ConcordRefounding.compactControlPlane` re-wraps one edition per entity
when a community rotates epoch, and the ROTATOR chooses which one
survives. The receiving side had no memory: `refold()` folds only the
wraps at the current epoch's Control-Plane address and discards the prior
epoch's buffer, and `EditionFold` accepts whatever it is handed (its
no-genesis fallback anchors at the lowest version present).
So a rotator could publish only version 1 of a chain and omit version 2 —
restoring a revoked role, clearing a banlist, reverting metadata. Every
signature is genuine; this is rollback by omission, not forgery.
Adds a per-entity floor: the version AND hash last successfully folded.
- **No floor (fresh joiner)** — unchanged: genesis anchor, else the
lowest-version edition as the legitimate compaction bootstrap.
- **With a floor** — the walk is anchored AT the floor: the offered set
must contain the exact edition already folded (version and hash; a
same-version sibling is a fork, not our chain), then walks up. A head
below the floor is structurally unreachable.
- **Gap** (the floor edition is absent) — refuse, and keep the known
head. Refusing by *retaining* matters here: this fold is recomputed
from scratch each time, so letting an entity vanish would itself be a
rollback — a dropped banlist is an unban.
The floor needs no new persistence. It is derived from `heldRoots`, the
rotated-out access roots already persisted in the NIP-44 self-encrypted
kind-13302 list: the session derives each prior epoch's Control-Plane
address from them, folds oldest-first, and takes the resulting heads as
the floor. That survives both a process restart and the session rebuild
`ConcordSessionRegistry.sync` performs at exactly the moment of a
Refounding — which would have destroyed any in-session floor. If the old
planes are not served, there is no floor and behaviour is as before.
Floors are built from AUTHORITY-GATED heads, not raw ones. Without that,
any ex-member still holding a rotated-out root could mint a high-version
edition on the old plane and freeze the entity for every honest client —
a denial of service this change would otherwise have introduced. Covered
by a test.
Verified by disabling both enforcement points: 7 of 12 quartz tests and
the end-to-end commons test fail, and the ones that still pass are
exactly the non-regression cases (fresh joiner, honest compaction,
pass-through without floors).
Known limit: `AuthorityResolver.resolve` folds authorized SUBSETS of the
edition pool and does not carry floors itself; gating happens at the pool
level before the resolver sees anything. Sound, but connectivity checked
on the full set is a weaker precondition than on each subset — passing
floors into the resolver's three folds is worth a follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Refounding recipient set was `Guestbook joins ∪ roster ∪ owner ∪ self`
— it never consulted `observedAuthors`, the members seen publishing to a
channel. `ConcordCommunitySession.allMembers()` already unions them in
(and already subtracts the banned); the rotation path just didn't use it.
Amethyst announces a Guestbook Join, but nothing in the protocol requires
one, so in practice most members of a cross-client community have never
sent one. Every member who had only ever *posted* — no role, no Join —
therefore received no rekey blob and was silently expelled by the next
rotation. Removing a single spammer would quietly strand most of the
community, and it fell hardest on Armada members, who are the bulk of the
roster in the communities we interop with.
Now uses `allMembers()`. Still a floor rather than a census, as its KDoc
says: a member who joined silently, holds no role, and has never posted
leaves no trace to re-key, and nothing here can find them. Stranded
recovery is what brings those back.
Also corrects an overclaim on `ConcordInviteBundle.validate`. Its KDoc
said self-certification stops a bundle smuggling "a false owner or a fake
key for a real community". Only the first half is true: `community_id`
commits to (owner, salt) — both public in any invite — and NOT to
`community_root`, so an attacker can mint a bundle carrying a real
community's id, owner and salt beside a root of their own. A joiner
adopts that root, believes they are in the real community, and posts into
planes the attacker can read.
That is a CORD-05 limitation, not an implementation gap: Armada's
`validateBundle` checks exactly the same thing and also leaves the root
unbound, so a stricter unilateral rule would break interop while
protecting nobody. Verifying the adopted root's Control Plane does not
close it either — sealed editions carry the owner's own signature, so an
attacker can re-wrap genuine owner editions into their plane, which is
what a legitimate compaction does. Closing it needs a spec change
(commit the root into the self-certifying id, or require the bundle to be
signed by a roster-authorized member); the KDoc now says so instead of
claiming a guarantee the code does not provide.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reviewed the previous commit's authority changes against Armada
(gitlab.com/soapbox-pub/armada), the reference Concord client we interop
with. Read for its rules only — Armada is AGPLv3 and Amethyst is MIT, so
no code was taken from it.
It confirmed the role rank gate (Armada checks both directions too: the
author must outrank the position being minted AND the standing position
being replaced) and the banlist check on rotation. It also showed one of
our rules was wrong and one gate was missing.
**Removes the owner's refusal of foreign rotations.** The previous commit
made the owner ignore any rotation it did not author, reasoning that a
BAN-holder could otherwise carry the owner onto a root of their choosing.
Armada does the opposite on purpose — "authority is the roster, never key
possession" — and it is right: an admin legitimately rotating to remove a
spammer would leave the owner alone on the dead epoch, self-inflicting
the strand the rule was meant to prevent, and diverging from the
reference implementation forks communities across clients. The threat is
better answered by the rank gate: with role editions gated, nobody can
escalate themselves to BAN, so BAN-holders are people the owner
deliberately trusted.
**Adds the missing rank gate on grants.** A grant was authorized if the
granter outranked every role it handed out — but a REVOKE carries no role
ids, and `all {}` over an empty list is vacuously true. So any
MANAGE_ROLES holder could strip anyone's roles, the owner's admins
included: promotion was gated, demotion was free. Armada treats a grant
as an action ON the member and requires outranking the target's standing
rank; this now does the same.
Both new tests were verified to fail with the corresponding check
disabled, and each has a companion asserting the legitimate case still
works (an admin can still revoke a moderator beneath it).
Also records what Armada does about exclusion, since we cannot prevent it
receiver-side: it does not try to. A rotation carries no recipient list
there either, so a stranded member instead re-resolves the invite link
they joined through and merges forward to the higher epoch ("stranded
recovery"). Amethyst has no equivalent, so a stranded member stays
stranded — noted at the call site as follow-up work.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two authority holes, plus the limit that remains.
**Role editions had no rank gate.** Grant editions are correctly gated —
a granter must hold MANAGE_ROLES *and* strictly outrank every role it
hands out. Role editions checked only that the author held MANAGE_ROLES.
Nothing stopped an authorized signer editing a role at or above its own
rank, including the role it holds itself.
So a moderator at position 5 with MANAGE_ROLES could publish one edition
on their own role's chain claiming position 1 and every permission bit,
then a second demoting the real admins beneath them — reaching full
authority over everyone but the owner in two editions. The
rogue-higher-version defence added earlier does not cover this: it drops
editions from *unauthorized* signers, and this signer is authorized.
Role editions are now gated in both directions: an author may not claim a
position at or above its own rank, may not touch a role that already sits
at or above it, and may not hand a role permission bits it does not hold
itself. Deleting keeps only the second rule, so retiring a role beneath
you still works. The owner is unaffected.
**A banned moderator could still rotate the community.** The rekey
receive path authorized the rotator with `effectivePermissions(...)`,
which ignores the banlist, rather than `hasPermission(...)`, which
excludes banned members. Now uses the latter.
**The owner no longer adopts a root someone else minted.** A rotation
replaces the community root, so a rotator who *includes* the owner as a
recipient hands themselves the keys to the owner's own community — the
owner would follow them onto an attacker-chosen epoch. The owner changes
epoch only by rotating themselves.
**Known limit, documented at the call site.** A rotation carries only
(newRoot, newEpoch, rotator) — no recipient list — so a receiver cannot
tell who was omitted. A BAN-holder can therefore still evict the owner by
leaving them out: everyone else adopts, the owner is stranded on the old
epoch. That is not fixable in the receive path; it needs a protocol
change (a recipient commitment the receiver can check, or owner
co-signing). Tracked for CORD-06.
The escalation test was verified to fail with the gate disabled, and a
companion test asserts an admin can still edit a role beneath it, so the
gate is not merely blocking everything.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An audit of the v1.13.0 notes against the implementation found several
claims that are wrong or overstated. These are user-facing release notes,
so a false claim changes what people believe about their own security.
Corrected:
- **Privacy Lock.** The notes said it gates Messages "behind a password or
biometric" on "Android and Desktop", with inactivity auto-lock and
optional preview redaction. It does not exist on Android at all — no
code, no settings entry — and there is no biometric implementation on
any platform (`CredentialPrompter` has no implementations). Preview
redaction persists a setting nothing reads, and the auto-lock is a fixed
timer, since the idle-reset modifier is never applied. It is also a
screen gate rather than encryption at rest: the account stays live and
messages keep syncing while locked. Now described as what ships — a
password gate on the Desktop Messages and Wallet columns.
- **"Every signature, payment, or data read needs your explicit
approval."** Only payments require per-use consent; everything else can
be granted once and reused, and the default trust level auto-signs notes,
reactions and encryption after a single tap.
- **Web of Trust (GrapeRank)** was listed under app features, but crawling
and scoring exist only in the `amy` CLI — no app module references it.
The app consumes NIP-85 cards published by an operator, which is what the
entry now says.
- **"One-tap trust for your follows' relays"** described bulk-granting
relays used by people you follow. What exists is category rules evaluated
per challenge.
- **Pinned web apps "show the app's own icon"** — they render a generic
placeholder.
- **PoW** contradicted itself: "all cores" in one entry, "half the device's
cores" in another. The latter matches `PoWPolicy.minerWorkers`.
- **Onion-Location "through every HTTP client"** — the Android app's
clients only; the desktop, CLI, geode and sandbox blob clients don't
install it.
- **Relay hardening** claimed REQ refusals stop immediately and failures
evict "on the first strike"; both take repeated failures, and the
first-strike eviction applies only to crawls.
- **`bunker://` links cannot be pasted in** — Amethyst only emits them.
- **Git code browser** needs a repository with an http(s) clone URL.
- **Geohash anonymous identity** — the per-area identity is unlinkable, but
the optional nickname is one global handle, so setting it links your
posts across areas.
- **Concord ban** is read-time enforcement for everyone else; the banned
member keeps the keys until a Refounding.
Adds an Upgrading section for the user-visible effects of the per-account
isolation work: sites signed out once, permissions re-asked per account,
and relay logins now prompting under the default remote-signer policy.
Contributor and translator credits are left as they are — several entries
are unresolved npubs and Crowdin-generated usernames, but correcting
attribution is not a call to make from the code.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Requests resolved their signer through `sessionManager.loggedInAccount()`
— whichever account is active *right now* — with no binding to the
surface that asked. A full-screen host is a separate activity that an
account switch does not tear down, so:
- Open a site full-screen as A and log in via NIP-07: the page shows A.
- Switch to B in the main app.
- Return to that still-open surface and request a signature: the broker
handed it **B's** key.
Confirmed on device before the fix. Worse than a mismatched prompt: B's
session was then written into **A's** WebView storage jar, so afterwards
even the embedded tab — which rebuilds correctly and had been verified
correct — displayed the wrong account. Per-account isolation held only
until a full-screen surface wrote a foreign session into a jar. And it
happened silently, because the ledger is per-account+origin and B had
already granted "always allow" for that origin from an earlier session.
`NappletLaunchRegistry.Session` now carries the account that minted the
token, and the broker resolves *that* account out of the cache. This
needs no new machinery to satisfy both halves of the rule: embedded
surfaces are torn down and re-minted on a switch, so they follow the
active account, while a full-screen surface keeps the account it was
opened with. It also extends an argument the code already made — the
sandbox can only act as the napplet it was launched as, because it holds
only its own token; now the same is true of the account.
Fails closed: if the launch account is no longer loaded, the request is
refused rather than falling back to whoever is signed in now.
The same live-account resolution existed on two adjacent paths, fixed
here too:
- Relay subscriptions took the account from a global supplier, so a
full-screen surface's REQs would target the newly-active account's
relays while its signatures came from the old one. The account is now
passed per-open from the launch token.
- `identity.changed` streamed the app's active account, so a page bound
to A could be told it had become B while signatures still returned A —
the same desync inverted. It is now bound to the surface's own account
and reports only that account going away.
Verified on device: with B active, a fresh identity read from a
full-screen surface launched as A returns **A**; the embedded tab still
follows B; both surfaces ran simultaneously under different accounts with
no cross-writes between jars.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three fixes to the Concord invite and moderation paths.
**Invite deep links redeemed with zero consent.** `ConcordInviteScreen`
called `joinConcordViaInvite` from a `LaunchedEffect` on open, and the
manifest registers `https://amethyst.social/invite/` as BROWSABLE. So a
link on any web page — or a QR code, or a push — silently caused a
connection to up to three ATTACKER-CHOSEN relay URLs decoded from the URL
fragment (disclosing the user's IP to a third party), a Guestbook JOIN
signed by the user's identity published to those relays, and a write to
their private community list. No tap, no preview.
The screen now opens in an awaiting-consent state and only joins from an
explicit Join button. The preview is built entirely from the link itself
— base64url and NIP-19 decoding, both pure in-memory — and touches the
network for nothing: no relay connection, no signing, no publishing. It
shows the relays it would contact so the user can see whom they'd be
talking to. The community name lives inside a bundle only those relays
can serve, so it is honestly reported as unknown until joining rather
than fetched.
**Invite expiry was decorative.** `ConcordInviteBundle.isExpired` had no
production callers at all — the only ones were in a test — so an expired
invite redeemed forever. Expiry is now enforced at `classify`, the choke
point every redeem path funnels through, with its own result and message
so the user knows to ask for a fresh link.
**Moderation read the wrong edition.** `ConcordModeration` used
`firstOrNull` over `controlEditions()`, which is in wrap-ARRIVAL order,
not the folded head. Once an entity had two or more editions the next one
chained off a stale predecessor, forking the chain at an already-used
version, and `EditionFold` then resolved the fork by `minByOrNull` on the
rumor id — a coin flip. Bans were masked by a down-only healing union;
UNBANS and role revocations were not, so they could silently fail to
apply. Both call sites now fold to the true head.
Regression tests assert the fold-head behaviour under two arrival orders
— a single order accidentally puts the head first and passes against the
buggy code.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`REASONABLE` — the default policy on connect — auto-approved kind 22242
(NIP-42 relay auth). The in-code justification was that the event is
ephemeral and bound to one relay and challenge, so it cannot be replayed
elsewhere. That is true and beside the point: the requesting app supplies
the `relay` and `challenge` tags verbatim, so it never needs to replay —
it just asks for a FRESH signature naming any relay it likes.
A paired app could therefore, with no prompt, open its own socket to any
NIP-42 relay, take the challenge, get 22242 signed, and authenticate to
that relay AS THE USER. That yields read access to whatever the relay
gates behind AUTH — notably the kind-1059 giftwrap inbox and its full DM
metadata (who, when, how many) — and burns quota on paid relays, which
bill whoever authenticates.
Amethyst auto-signing AUTH for relays the USER configured is not the same
as letting a third party name the relay; the comment conflated them.
22242 now falls through to ASK. The existing test asserted the vulnerable
behaviour with the same flawed reasoning, so it is inverted here rather
than merely extended.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`computeShares` paid each fee recipient `totalMilliSats * split / 100`
with no upper bound on `split`, and `split` comes verbatim from a
kind-30054 episode event that anyone can publish (`ValueTag.parse` is a
bare `fromJson` with no validation). A single `fee:true, split:1000`
recipient was therefore paid TEN TIMES the amount the user chose. The
`remainder` clamp looked like a safety net but only zeroed the honest
recipients; it never touched the fee recipients themselves.
Proven by test before fixing: `split:1000` pays 10x, and two fee
recipients at 60% each pay 1,200,000 millisats for a 1,000,000 zap.
This was reachable in the worst possible place. Streaming V4V pays every
minute, automatically, so a modest multiplier stays under a typical NWC
budget and simply runs — and the on-screen running total tracks the
INTENDED amount, so it reads "100 sats" while 1,000 left the wallet,
while the streaming error handler suppresses the toast. The ordinary zap
button reroutes through the same path for any note carrying a value
block, so it was not limited to the streaming toggle.
Fees are a percentage off the top, so each is now clamped to 100% and the
cumulative total to the remaining budget: the payout can never exceed
what the user chose.
The existing test asserted the right invariant (`sum <= total`) but only
ever ran it on well-formed input, which is why this survived.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A WebView's storage profile is fixed at construction, so per-account jars
mean each pinned tab has to be rebuilt on an account switch. Nothing did
that, and the result was a tab that went permanently black: the
logged-in subtree is wrapped in `key(account.pubKey)`, so a switch
disposes and rebuilds it, which closes each sandbox session — but
`EmbeddedTabHost` is process-scoped, so the warm controllers survived.
The rebuilt layer then re-attached fresh views to controllers whose
adapter had already been consumed, and a `SandboxedSdkView` with no
adapter paints only its background, forever.
Three fixes:
- Tabs are torn down and re-armed against the new account's profile when
it changes, including tabs that were never opened, so none survives
bound to the previous account's jar.
- `attachView` now recovers instead of silently doing nothing when a
second view attaches after the adapter was spent, minting a FRESH
session id — reusing the id let the disposed view's late `close()`
reap the replacement.
- The re-warm kickoff moved off `LaunchedEffect`. It dispatches through
the composition's scope, and an account switch floods the main thread:
measured on a slow device, the sweep started 943-1157 ms after the
teardown, and the account watcher itself fired 3-4 s late. Running it
synchronously in the same apply phase drops that to 10-23 ms. The
suspending work stays in a coroutine; only the kickoff is immediate.
Also gates the load overlay on the tab id rather than a live controller,
so a tab that is re-arming shows the existing spinner instead of a bare
surface — previously the one moment it most needed a cover was the one
moment it had none.
Verified on device across both switch directions, and with profile
detection force-disabled to confirm the recovery path alone prevents the
black.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Embedded web content — browser tabs, napplets, nSites — runs in WebViews
in the `:napplet` process, and their cookies, localStorage, IndexedDB and
service workers were SHARED across every Nostr account on the device.
Nothing in the repo ever cleared them: no `CookieManager`, no
`WebStorage` call anywhere.
So a web app stayed logged in as the previous account after a switch, and
a Nostr web client's localStorage — which routinely holds decrypted DMs,
drafts and follow caches — was readable by whichever account came next.
For a user keeping a pseudonymous npub apart from a real one, the app
could correlate the two itself.
Uses the androidx.webkit multi-profile API (already a dependency) to give
each account its own profile: cookies, storage, geolocation grants and
service workers are all partitioned per `Profile`. Switching accounts
moves to that account's jar and switching back restores the session
intact — isolation rather than deletion, so nothing is lost.
The sandbox never learns which account it is serving. The main process
derives an opaque, domain-separated SHA-256 of the account pubkey,
truncated to 32 hex chars, and passes only that; `:napplet` validates the
shape before use, so a compromised sandbox cannot mint a name for another
account's jar. Both re-arm paths read the current profile at send time,
so a re-created session can never resurrect the previous account's jar.
Where MULTI_PROFILE is unsupported (older WebView), isolation degrades to
lossy-but-safe: cookies and web storage are wiped when the account behind
the WebViews changes, rather than silently shared.
Known gap, documented at the logout hook: a removed account's profile is
not deleted. It cannot be done from the main process — WebView profiles
live in the `:napplet` data directory, and booting WebView here would
collide on it — so it needs a broker message that has the sandbox call
`ProfileStore.deleteProfile`, and that must refuse a profile still bound
to a live WebView.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The dialog rendered `kind` plus a 160-char content preview and nothing
else. For the kinds whose payload lives entirely in the TAGS, that is
technically true and practically useless — the user saw "publish an event
of kind 3" while approving a replacement of their whole social graph.
kind 10002 redirects every future read and write to attacker relays;
kind 5 deletes notes.
Now, for a replaceable list, it diffs the proposed tags against the copy
already cached on the account and reports what actually changes, rather
than a raw total that hides the dangerous case (a list that silently
drops 130 follows). A single-account edit — by far the common one — names
and pictures that account, so the user can recognize who it is at a
glance. Republishing an identical list says so plainly instead of raising
a false alarm, and a missing baseline falls back to the total and admits
it could not compare. Mute lists diff people only, so the string points
at "Show Event" for muted words and hashtags.
Adds a "Show Event" raw-event toggle mirroring the NIP-46 dialog, which
already had one; the napplet dialog had no way to inspect the full event.
Also fixes an amountless-invoice display bug in the same family:
`LnInvoiceUtil.getAmountInSats` returns ZERO (not null, not a throw) for
a BOLT11 with no amount, so both this dialog and the Blossom pay dialog
affirmatively rendered "0 sats" — telling the user a payment was free
when the amount is in fact unspecified and chosen by the payee.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Accepting the "Connect to Nostr" dialog bulk-grants every declared
non-payment capability as ALLOW_ALWAYS — RELAY, IDENTITY, STORAGE,
RESOURCE, UPLOAD, NOTIFY, KEYS — unless the user picks PARANOID. The
dialog never showed that: `buildConnectInfo` did not receive `declared`
at all, so the user approved a set on the strength of a title and an
icon.
`SignerConnectInfo.requestedPermissions` already existed for exactly this
("shown so the user gives INFORMED consent before those ops are
pre-granted") and is populated by the NIP-46 nostrconnect path. The
napplet path simply never filled it in. This threads `declared` through
`NostrConnectPrompt` to `buildConnectInfo`, which lists the capabilities
that actually get pre-granted — SHELL/THEME never prompt and VALUE is
per-use, so listing those would overstate what accepting hands over.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every napplet/web-app grant was keyed by applet coordinate alone
(`<appAuthor>:<identifier>`), which carries no account. The stores and
ledgers are process-wide singletons shared by all accounts, so grants
leaked in two directions:
- **Across apps.** `NappletBroker.sessionAllows` held a bare `op.key`
("sign:1"), and the check ran *before* the per-app ledger lookup. One
app's "Allow for this session" therefore authorized that op for every
other applet and browser origin, silently, for the broker's lifetime.
- **Across accounts.** A grant made under one npub authorized the same
applet under every other npub on the device. For a user keeping a
pseudonymous account separate from a real one, an app authorized by
one could sign as the other with no prompt — defeating the point of
separate accounts.
NIP-46 already solved this shape correctly: `Nip46PermissionAuthorizer`
namespaces by account (`nip46:<signer>:<client>`) and keys session grants
by `(coordinate, op)`. Its comment even claims it "mirrors the napplet
broker's sessionAllows" — the mirror was backwards. This adopts the same
pattern on the napplet side:
- `sessionAllows` and `NappletPermissionLedger.session` are keyed by
account + coordinate + op.
- Napplet storage and the capability store namespace keys by account,
read at call time so a switch moves reads/writes with no rebuild.
- The signer ledger is deliberately NOT account-scoped at the store: it
is shared with NIP-46, whose sessions run for a specific account rather
than the active one, so scoping it there would break a background
bunker. The napplet path namespaces its own coordinate instead.
Also here, found while scoping:
- `identity.watch` consulted only the manifest declaration and never the
ledger, bypassing a standing DENY — it short-circuits before
`NappletBroker.handle`, where the "a standing denial always wins" rule
lives. It now applies that rule itself.
- `DataStoreNappletStorage.keys()` filtered on a space separator while
keys are written with NUL, so it silently matched nothing and always
returned an empty list.
Existing grants live under the old un-namespaced keys and are not
migrated: users are re-prompted once. Migrating would attribute grants
made under the broken model to whichever account is active, preserving
the bug.
Regression tests cover both leak directions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add docs/changelog/v1.13.00.md — the v1.13.0 "Web Apps, Communities & Git"
release notes — and link it at the top of the changelog index.
Covers the full v1.12.6..HEAD range: the in-app Browser and NIP-5D/5A web
clients, Concord communities, NIP-34 git collaboration, Location Channels, the
NIP-46 Remote Signer and Privacy Lock, NIP-42 relay login permissions, NIP-29
Groups, the redesigned Messages inbox, Blossom file sync, Podcasting 2.0,
proof-of-work (NIP-13), negentropy (NIP-77), GrapeRank, accent theming, and the
Desktop, amy CLI, Quartz, and Geode work.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrPNt4FchqArpMtAHHfqGi
- "Use my location" could spin forever after a permission denial: the reset was
in an isGranted-keyed effect that never re-ran on false→false. Use
rememberPermissionState's result callback, which fires on grant AND denial, so
the spinner always clears.
- Forward-geocode search ran the blocking Geocoder on the UI thread on Android
< 13 (ANR risk). Run the search on Dispatchers.IO.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
The geo-post composer showed two locations: the static external-id marker at the
top and the new "Posting to · Change" row below the message. Drop the duplicate
and, for the geohash case, render the interactive channel control (with retarget)
in the marker's place. Non-geohash external ids still use DisplayExternalId.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
1) Follow this location from the feed: when a top-nav Geohash filter is active
(e.g. after teleporting), the filter header shows a bookmark toggle to
follow/unfollow that place (kind 10081), so a teleported spot can be saved to
your locations — the "add to my interests" step. Respects read-only accounts.
2) Retarget a geo-post: the geohash comment composer now shows "Posting to <place>"
with a Change action that opens the map picker and re-scopes the post's NIP-73
geohash channel (CommentPostViewModel.geohashScope/setGeohashScope), instead of
being locked to the feed's channel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
Add a "Teleport to a place…" entry to every top-nav feed filter that offers
"Around Me" (home, video, discover, notifications, products, music, git,
podcasts, relay-group discovery, …). Selecting it opens the shared map picker;
on confirm the chosen place becomes this screen's TopFilter.Geohash feed.
Implementation is centralized in FeedFilterSpinner (no per-top-bar changes): a
UI-only TopFilter.TeleportPicker sentinel is intercepted there to open the
picker, then forwarded through the normal onSelect path as a TopFilter.Geohash —
which each screen's existing changeDefault*FollowList already handles. The
spinner header falls back to the raw selection so a teleported (unfollowed)
geohash still shows its place name.
Follow-up (not in this commit): a "follow this location" action so a teleported
place can be saved to the kind-10081 geohash list from the feed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
If the picker opens with no seed and location permission is already granted, fly
to the user's current position instead of the neutral world view. Runs once and
never prompts — it only consumes already-granted permission; on denial or no fix
it stays at the world view as before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
- Mute the busy MAPNIK tiles in light mode (desaturate + gentle lighten), the
light-mode counterpart to the existing dark tile filter. Applied to both the
interactive picker and the preview thumbnail.
- Outline the selected geohash cell on the map so the user sees exactly which
region a post/filter covers; the rectangle follows the pin and resizes with the
area-size level.
- When the area-size level changes, animate the zoom so the new cell is framed.
LocationPickerMap gains zoomTo/highlight/highlightColor params (all guarded to
rebuild only on change, preserving the per-frame-cheap update path).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
Adversarial audit of the PR's own changes (8 finder angles, verified
before fixing). Quartz core:
- fetchAll-family drains get a wall-clock ceiling (maxTotalMs, default
10x the idle window, delay()-watchdog: cancellable and virtual-time
testable). The pure idle window was unbounded when a relay trickled
events forever — sandboxed napplet queries, set -e fetches, and
marmot await stuck inside one drain. Streaming relays still finish.
- The suspending onEvent hook no longer runs inside a cancellable
timeout scope (an expiring window could cancel verifyAndStore
mid-write and silently drop a received event); the timeout is armed
only when the channels are dry (no per-message timeout-job churn).
- fetchAll is a projection over fetchAllWithHooks: fixes its
unsynchronized events/seenIds mutation from concurrent socket
threads and deletes the duplicate loop + per-event activity channel.
- publishAndConfirmDetailed regains its only-responders contract
(synthetic no-response entries no longer render as 'relay rejected
your message' in app callers); results built by pure associateWith;
shared failure-reason constants + PublishResult.isTransportFailure.
- NIP-65 mutations: split read+write r-tags for the same URL now merge
to BOTH instead of last-wins dropping a facet (+ test).
- TcpProber's 128-thread pool drains after 60s idle.
CLI:
- publishGuard: all-transport failure exits 124 as timeout; rejected/1
is reserved for an actual OK-false answer.
- --help anywhere in argv is hoisted centrally; 'amy notes post "x"
--help' prints usage instead of publishing.
- rejectUnknown false-reject traps fixed: geochat --no-fetch behind an
early return, and 13 elvis-alias short-circuit sites read eagerly.
- Aliases load once per Context and only match name-shaped inputs (no
shadowing a real npub/NIP-05/hex); stderr color requires a
positively-known terminal (TERM sniff polluted captured logs).
- Relay-CSV strictness unified on RawEventSupport.relayFlag (post,
graperank publish/followers/register no longer silently drop
malformed URLs); Args.timeoutMs(+OrNull) replaces 27 hand-rolled
conversions, all strict; offer/debit --timeout > 3600 rejected with
a 'looks like milliseconds' hint; NPub.create idiom; stale jq .id in
the marmot reactions harness; printUsage drift (offer pay --with,
profile --clink-offer, search --kind).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
- Open the seeded picker at a zoom that fits the geohash cell's precision
(region opens wide, building opens tight) instead of a fixed zoom.
- Hide the search field's rectangular outline so its square corners no longer
poke through the rounded pill (border colors → transparent).
- Drop the 48dp minimum touch-target on the area-size chips so wrapped rows sit
8dp apart (matching the horizontal gap) instead of ~24dp.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
- The interactive picker map didn't tell ancestor views to stop intercepting
touches, so a horizontal drag on the Teleport map got stolen (opening the nav
drawer) instead of panning. Add the same requestDisallowInterceptTouchEvent
touch listener LocationPreviewMap already uses.
- The precision chips were a single horizontally-scrolling Row; make them a
FlowRow so "Region · ~1250 km … Building · ~38 m" wraps onto multiple lines.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
Bugs:
- "Use my location" could spin forever when a fix never arrives (location off,
indoors, permission granted but unavailable). Wait with a 20s timeout and stop
on any terminal outcome so the button always resets.
- With no seed, an initial osmdroid layout-scroll at the opening center could be
mistaken for a pick, auto-selecting the mid-Atlantic and enabling Confirm before
the user moved. Gate selection behind a real pan/search/GPS/tap (hasSelection).
Performance:
- The map's AndroidView update ran setColorFilter + overlays.removeAll +
invalidate() on every scroll-driven recomposition. Move the dark-mode tile
filter to a theme-keyed effect, and rebuild the marker + invalidate only when
the marker point actually changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
The fetchAll/fetchAllWithHooks accessories wrapped their whole
collection loop in one withTimeoutOrNull, so a relay actively
streaming a large backlog was cropped mid-delivery the moment the
absolute deadline hit — even though the loop already has proper
terminal conditions (per-relay EOSE / CLOSED / cannot-connect) and the
timeout's only real job is stall detection.
timeoutMs now measures the delta since the LAST message: every event
or terminal signal resets the window (fetchAll gains a conflated
activity ping so event progress is visible to its wait loop), and only
a full window of silence ends the fetch early. fetchFirst/count keep
absolute waits (single-response — idle and absolute coincide), and
subscribe's duration timeout stays absolute by design (a live stream
has no terminal state).
Since the pages/pool helpers delegate to fetchAll, pagination inherits
the semantics. This also changes app-side callers of these accessories
— in their favor: the timeout only ever fired on slow relays, exactly
when cropping loses data.
New commonTest suite pins the behavior: a relay emitting every 200ms
under a 300ms window streams to completion (10/10 events); a stall
ends one window after the last message, not after the start; EOSE
still returns immediately. CLI docs reworded (--timeout = idle window).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
With no external consumers yet, converge the --json surface to its
ideal shape in one pass:
- quartz gains publishAndCollectResults: the NIP-01 OK message, connect
errors, and silent timeouts now survive as PublishResult(accepted,
message) per relay instead of dying in a debug log. The existing
boolean APIs delegate unchanged. Silent relays are reported as
'no response within timeout' rather than omitted.
- Context.publish returns the rich map; the new
RawEventSupport.ackFields(ack) is the one canonical projection every
publisher emits: published_to (urls) + rejected_by as
[{relay, reason}] — 'why didn't it post' now answers itself, in
partial failures and in the rejected error alike.
- author/pubkey rule enforced module-wide: 'author' is the key that
signed an event (feed/search/dm/message list items), 'pubkey' an
identity being described; profile show and outbox add the bech32
npub beside the hex when the user is the primary subject.
- Event-list items converge on event_id/author/created_at/content
(dm, feed, search, marmot message, geochat, concord).
- Byte counts standardize on *_bytes keys (blossom/nsite size ->
size_bytes); the text renderer drops the fragile bare-'size'
heuristic and colors stderr progress independently of a piped
stdout.
- Error details are sentences everywhere (not bare gids); dead Result
class removed from the quartz publish accessory.
Docs updated (DEVELOPMENT output conventions, README rejected example).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
Lift pickedGeoHash into the shared ILocationGrabber interface and add a shared
GeoHashPostSection composable (GPS default + "pick on map"), then wire it into
every remaining composer: long-form, classifieds, private DMs, public messages,
NIP-22 comments, channel messages, and nest messages. Each ViewModel now honors
the map-picked geohash over GPS at build time and round-trips it through drafts;
each screen renders the shared section in place of the GPS-only LocationAsHash.
The short-note composer is refactored onto the same shared section (its local
copy removed). The geohash-chat "New location channel" screen already reaches the
picker via its Teleport card, which now uses the shared, polished picker.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
The 1500-line GrapeRankCommand (15 sub-verbs in one object, 7.5x the
module's 200-line smell threshold) becomes a 165-line dispatch that
delegates to graperank/{Crawl,Score,Publish,Operator,Support}. The TCP
reachability pre-probe with its dedicated 128-thread dispatcher is
transport infrastructure, not command code — it moves to quartz
nip66RelayMonitor/reachability (TcpProber, jvmAndroid). Pure move, no
behavior change; cli tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
The docs had drifted badly behind the code: geochat was documented
nowhere, concord/zap/search/podcast20/nsite-publish were README-
invisible, the ROADMAP matrix contradicted its own nak table on six
shipped features, and DEVELOPMENT described the legacy FS event store
as the default when SQLite is.
- README: sections for search, zap (incl. --with auto-pay), podcast20,
nsite/napplet (all four sub-verbs), concord (13 verbs), geochat, and
a 'Which chat system?' comparison table; output section rewritten for
the new contract (exit-code derivation, rejected, unknown-flag
errors, -- terminator, per-command --help); layout diagram fixed for
the SQLite default + operator/ + concord.json; the bunker nak-interop
claim reworded honestly; RECIPES.md linked.
- ROADMAP: stale new-item rows flipped (follow, outbox, Blossom,
bunker, search; zap partial), rows added for relaygroup/geochat/
concord/nsite/napplet/podcast20/CLINK/fof, orphaned thread note
fixed, test-suite section updated.
- DEVELOPMENT: canonical error-code list pinned, exit-code rule
documented, no-prompts carve-outs, refreshed architecture tree +
command template (USAGE/route(help=)/rejectUnknown/publishGuard),
SQLite store section, testing table covers the new JVM suites.
- tests/README: all ten suite dirs listed, JVM contract suite noted,
mis-spliced marmot row repaired.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
The composer could only attach the current GPS location (fixed ~5km), which
fails when GPS is denied/unavailable and can't tag a post with a different
place. Add a "pick a place on the map" action that opens the shared
GeohashLocationPickerDialog and stores the result in
ShortNotePostViewModel.pickedGeoHash, which overrides the GPS fix at build time
and round-trips through drafts. Picking a place also skips the GPS permission
prompt. The existing "use my location" GPS flow is unchanged; this is additive.
Scoped to the short-note composer for now; the other ILocationGrabber composers
(long-form, classifieds, DMs, comments, channel/nest messages) can adopt the
same pickedGeoHash override + section in a follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
Context.drain/drainAllPages/requestResponse re-implemented the
subscription state machine quartz already ships in
relay/client/accessories — the CLI-specific needs (per-event
verify-and-store hook, dead-relay collection, pending-on-auth) now live
in an option-rich fetchAll variant there, and Context keeps thin
adapters. The per-domain sections bolted onto Context (Cashu seed
warming/snapshot/restore counters; the Concord stream-key AUTH
registry) move to CashuContext/ConcordAuth, with the NUT-09 restore
counter rule shared via commons CashuWalletOps so the CLI and Android
can't drift. Context.kt: 1246 -> 950 lines; behavior unchanged
(cli tests green).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
Extract the picker body into GeohashLocationPickerContent (chrome-less: map +
search + use-my-location + precision + readout + confirm), leaving
GeohashLocationPickerDialog as a thin dialog wrapper around it.
Rewrite GeohashTeleportScreen to host that shared content inside its own scaffold,
passing a teleport-specific confirm label and action (follow the cell + open the
chat with the teleport flag). This dedupes the map/precision/city-name code that
Teleport previously copied, and gives Teleport place search, "use my location",
dark-mode tiles, area-size chips, and the animated pin for free.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
Regenerates the shipped native library from the preceding lib.rs change. Kept
as its own commit so the binary diff is isolated and can be audited against the
source change independently.
Built with the canonical reproducible-build path (/tmp/amethyst-arti-build) and
the pinned 1.94.1 toolchain; ARTI_VERSION and Cargo.lock are unchanged, so
lib.rs is the only input that moved. Note that the build path is embedded in
the output, so overriding ARTI_REPRO_DIR produces different bytes — the
artifact committed here comes from the canonical path.
verify-reproducible.sh passes: two clean builds produced identical bytes.
arm64-v8a 3293f9fd1663f9481972f4b705fe80feb9650271b5a3b4258420501d6375bb3e
x86_64 a5a5864741a09708cd0b6e44ef3ba28298b4862872084d98e172487d6b12720b
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The JNI wrapper answered every failed client.connect() with SOCKS reply 0x05,
so a domain that no longer exists, an exit that timed out, and a genuinely
refused port were indistinguishable. Java renders 0x05 as
SocketException("SOCKS: Connection refused"), so the whole failure taxonomy
collapsed into one opaque string and callers could only apply their most
generic retry policy. On a cold start with the default settings — torType
INTERNAL and newRelaysViaTor true, so the entire outbox fan-out is routed
through Tor — 639 of roughly 768 relay failures arrived this way.
This is why the DNS classification added earlier was effectively dead code for
default users: name resolution happens at the exit, so UnknownHostException is
never raised locally.
socks_reply_for() maps ErrorKind onto the codes Java surfaces with distinct
messages, so a caller can tell "this relay is gone" from "this circuit had a
bad minute". No new dependency — arti-client re-exports ErrorKind and HasKind.
RemoteHostResolutionFailed is mapped to 0x04 even though Arti documents it as
retryable, because an exit's resolver failing is not proof the name is dead.
The caller's response to 0x04 is a bounded backoff rather than permanent
condemnation, which is a retry, just a slower one; probing the relays that
produced this error found 17 of 21 to be NXDOMAIN from an ordinary resolver, so
the conservative reading costs far more than it saves. Changing that arm to
0x01 restores Arti's reading if that tradeoff is ever judged wrong.
Verified on device: Java now reports "SOCKS: TTL expired", "SOCKS: Host
unreachable", "SOCKS: Connection not allowed by ruleset" and friends where
everything was previously "SOCKS: Connection refused".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Nothing could answer "what did every relay in the pool actually do, and why did
the ones that failed, fail". RelayStats has counters but no dump path and no
failure taxonomy; RelaySpeedLogger counts events per second, not connection
outcomes; RelayLogger prints one line per event, which at the few-hundred-relay
cardinality of the outbox model is thousands of lines to grep rather than a
table to read.
BootRelayDiagnostics buckets connection failures by cause (separating a Tor
SOCKS refusal, which says nothing about the relay, from DNS, TLS, an HTTP
upgrade rejection, a timeout, or a genuine refusal), counts REQ/EOSE/CLOSED per
relay with CLOSED split by NIP-01 machine-readable prefix, and records
time-to-first-open and time-to-first-EOSE. It dumps a rollup plus two tables at
20/45/90s: the relays that cost dials and returned nothing, and the relays
actually carrying the boot — the latter so a suppression change can be checked
for coverage loss instead of only counting CLOSED reduction.
Debug builds only, attached like the other loggers in AppModules.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A relay whose domain no longer exists (lapsed registration, decommissioned
host) was treated like a busy relay: the backoff doubled from 1s and spent
about ten dials climbing to the ceiling it was always going to reach. An HTTP
upgrade rejection already jumps straight there; a name that does not resolve
deserves the same.
Matching is on the exception type rather than the message because the message
is localized and platform-specific — Android says `Unable to resolve host "x"`,
JVM on macOS says `nodename nor servname provided, or not known` — while the
class name is stable. That is why onCannotConnect appends it in the first
place. Neither message ends with "Host unreachable", so the existing check
never caught DNS failures.
Being this eager is only safe because the verdict is cheap to revisit: a DNS
answer is a property of the network, not of the relay (a captive portal or a
filtering resolver forges NXDOMAIN), and both a network-identity change and a
transport change now clear the backoff outright. The test pins that round trip,
not just the classification.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A relay's reconnect backoff was process-global and network-blind: the delay
earned on one network was still being served out on the next. The only reset
signal was OkHttpClient reference identity, which is rebuilt off the metered
bit, so it fired for wifi<->cellular and nothing else. Wifi A -> wifi B, a VPN
coming up, a captive portal clearing, and metered-wifi -> cellular all left
every relay parked on a penalty earned against a network the device had left —
up to five minutes of silence on a network that might reach the relay instantly.
The transient Off that would have reset things is swallowed by the 200ms
debounce in ConnectivityFlow, so it never rescued those cases.
Key the decision on ConnectivityStatus.Active.networkId instead, which is the
same signal SurgeDns already uses to stale its cache, and treat a genuine
network change as a full pool rebuild: every socket is bound to an interface
that no longer carries traffic, and needsToReconnect() cannot see that because
it only compares the proxy and the timeouts.
Also treat a Tor policy flip as a transport change. Flipping a Tor toggle while
Tor is already up leaves both OkHttpClient references identical, so a relay
whose transport just changed kept waiting out a backoff earned on the other
transport. Only TorRelaySettings is compared, not the relay sets that
TorRelayEvaluation also carries — those churn while an account's relay lists
load (observed firing three times in one cold start), and forgiving the whole
pool every time any list updates is far more damage than it repairs.
Adds IRelayClient.resetBackoff() (default no-op, so the existing fakes and
BleNostrClient are unaffected) rather than reusing ignoreRetryDelays, which
only skips the gate for a single attempt and still doubles the stored delay —
a relay that failed that one dial came back worse off than before.
INostrClient.resetBackoff() is deliberately separate from reconnect(): the
latter debounces, so folding this into a coalescing command would let a later
request silently drop the reset.
The decision table moves into RelayProxyClientConnector.apply() so it can be
exercised directly, without a debounce and a shared StateFlow in the way.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Refinements after the initial map picker:
- Dark mode: apply the night tile filter to the interactive LocationPickerMap
too, so the picker matches the app theme instead of showing a bright map.
- Search results: the place search now shows a tappable list of candidates
(with human place names) instead of silently jumping to the first hit.
- Precision chips now show approximate area sizes (e.g. "City · ~5 km").
- The center pin lifts with an animated shadow while the target moves.
Generalization: the picker's own strings are renamed from relay_group_location_*
to neutral location_picker_* keys so any caller can reuse
GeohashLocationPickerDialog, not just the NIP-29 group form. The group-form card
strings stay namespaced. LocationPreviewMap gains a configurable aspectRatio.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
Seven end-to-end recipes (own relay + NIP-86 admin, NIP-46 bunker both
directions, Marmot group chat, NIP-60/61 Cashu wallet, NIP-5A nsite
publishing, GrapeRank provider pipeline, scripting patterns) so the
docs teach jobs, not just verbs. Reflects the new contract: per-command
--help, alias resolution in user slots, published_to/rejected errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
Reorder the App Settings section in AllSettingsScreen to: Privacy Options,
UI Preferences, Home, Messages, Notifications, Compose Settings, Reaction
Row, Bottom Navigation Bar, Profile UI, then the remaining rows.
Rename the "Home Tabs" row to "Home" (reusing route_home) and switch the
Messages row icon to MaterialSymbols.Mail to match the Messages screen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYck7FKoLzoaomnjjLFLzi
The NIP-29 group create/edit form asked for the group's location as a raw
geohash text field (placeholder "u0nd") — unusable, since nobody knows their
geohash, so the discovery geo-filter it feeds stayed empty.
Replace it with a first-class, map-first location experience, reusing the
pieces that already power the Geohash-chat Teleport screen:
- New GeohashLocationPickerDialog: a full-screen picker where the user pans a
map under a fixed center pin, searches for a place by name, or taps "use my
location" (device GPS). Precision chips (GeohashChannelLevel) control the
area size; the resolved place name is shown via LoadCityName. Never surfaces
a raw geohash.
- New ForwardGeolocation service (Geocoder.getFromLocationName) powering search,
mirroring the existing ReverseGeolocation.
- Extend LocationPickerMap with backward-compatible recenter/recenterZoom and
onCenterChanged hooks for the center-pin interaction; extend LocationPreviewMap
with a configurable aspectRatio for the form's map thumbnail.
- Rework the form's location field: an inviting empty-state card, a filled card
with a themed map thumbnail + place name + geohash + clear, and a collapsed
"enter manually" field so power users can still paste a known geohash.
The ViewModel's geohash state stays the single source of truth, so the publish
path (parseGeohashes -> kind-9002 EditMetadataEvent) is unchanged. Adds the
MyLocation glyph and regenerates the Material Symbols subset font.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
The kind:10002 facet-merge rules (adding a write marker to a read-only
relay promotes it to BOTH; removing one facet of BOTH demotes to the
other; removing the last facet drops the relay) lived as private
helpers in the CLI's RelayCommands. Any frontend that edits a NIP-65
list needs them, so they now live in quartz nip65RelayList as
AdvertisedRelayListMutations (applyFacet/addFacet/removeFacet/setFacet)
with commonTest coverage. Behavior unchanged; the CLI rewires to the
shared functions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
Context.syncIncoming carried real protocol policy — the NIP-59 gift-wrap
since-cursor rule (2-day lookback, advance only when events arrive),
per-group cursor bookkeeping, and the MIP-00 consumed-KeyPackage
rotation — that the Android app implements separately. Divergence there
silently drops DMs, so the policy now lives once in
commons/marmot/MarmotSyncPolicy with Cursors/Relays/drain/publish
injected, and the CLI Context wires itself in as a thin adapter.
Behavior is unchanged (the body moved verbatim, comments included).
Also resolves aliases from the per-account aliases.json in
Context.requireUserHex, so 'amy dm send bob ...' works with a local
alias — previously aliases.json was written but never read.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
Same contract as the A-D sweep, applied to the E-M command families:
- rejectUnknown() everywhere Args is constructed (typo'd flags -> bad_args)
- publishGuard on single-event publishes (total rejection -> non-zero)
- USAGE constants + route(help=...) / --help fast-paths; graperank's
full sub-verb set (including the previously undocumented 'followers')
and 'key validate' + the --pw alias are now documented in-binary
- geochat --relay is a strict comma-list (bare positional relays kept);
geochat listen default limit 500 -> 50
- graperank update/probe aliases now print deprecation notes
- git --identifier accepted as alias of --d
- marmot message list defaults to --limit 50 (0 = everything)
- dm-style guards for marmot message send/react/delete and group edits
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
Applies the new CLI contract across the A-D command families:
- rejectUnknown() after flag parsing, so typo'd flags fail with bad_args
instead of silently no-oping
- publishGuard on single-event publishes: total relay rejection now
exits non-zero ('rejected') instead of 0
- per-group USAGE constants wired into route(help=...) and --help
fast-paths on flat commands; previously invisible sub-verbs
(blossom media/report, the concord moderation set, the whole cashu
surface, NIP-86 method list) are now documented in-binary
- dm send/send-file parse flags before positionals (flags may appear
anywhere); dm list/await accept a positional USER as alternative to
--peer; dm list caps at 50 by default
- concord create: --relay is canonical (--relays kept as alias)
- cashu receive resume: deprecation warning pointing at 'complete'
- offer/debit: --timeout is seconds (was raw milliseconds)
- Identity.fromBunkerUri now delegates to quartz NostrConnectURI
parseBunker instead of duplicating the URI parsing
BREAKING (--json): unknown flags and malformed numeric/relay/author
flag values now exit 2 where they were previously ignored.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
- Output.error now derives the exit code from the error code
(bad_args -> 2, timeout -> 124, else 1), so every
'return Output.error(...)' site honours the documented contract.
Previously ~225 bad_args sites exited 1 while the docs promised 2,
and two timeout paths (nostrconnect wait, namecoin lookup) exited 1
instead of 124.
- Args: literal '--' ends flag parsing (escape hatch for values that
start with '--'); intFlag/longFlag reject non-numeric values instead
of silently using the default; requireFlag/positional no longer
double-print to stderr; new rejectUnknown() turns typo'd flags into
bad_args failures; new 'help' detection.
- route() understands --help/-h/help (prints group usage, exit 0) and
names the expected verbs on an unknown sub-verb.
- Unknown or missing top-level subcommand now emits a proper bad_args
error (JSON-aware under --json) plus a one-screen verb list instead
of dumping the full 400-line usage.
- RawEventSupport: --relay/--kind/--author/--id/--since/--until/--limit
entries that do not parse are now bad_args errors; previously an
unresolvable --author was silently DROPPED and the query ran with a
weaker filter than requested. New shared publishGuard() reports
'rejected' (exit 1) when every relay refuses an event.
- runCli() seam extracted from main() plus an 'amy.home' system-property
override of DEFAULT_ROOT so the new JVM test suite can drive the CLI
in-process; first contract tests: ArgsTest, ExitCodeContractTest,
JsonContractTest (NIP-19 vector goldens).
BREAKING (--json): error code pow_timeout is now timeout; exit codes
for bad-argument failures move from 1 to 2 as documented.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
The Threads tab loaded kind-11/1111 with a single #h since-filter and no
limit, so a group with more threads than the relay's default result cap
silently lost the older ones. Mirror the chat history stack for threads:
- RelayGroupChannel.threadsHistory: separate RelayLoadingCursors so paging
the forum doesn't move the chat's cursor.
- buildRelayGroupThreadsHistoryFilters: per-armed-relay #h + kind-11/1111
until+limit page, the forum analog of buildRelayGroupHistoryFilters.
- RelayGroupOpenThreadsHistoryFilterAssembler: the on-demand BackwardRelayPager
("relayGroup.threads.history"), bound to the open group's threadsHistory
cursors, landing on the normal ingest path (kind-11 -> addThread).
- Threads screen: mount the history subscription, eagerly backfill to a
window on open, page older content as the list nears its end, and show a
quiet loading/caught-up footer.
Tests: RelayGroupFilterBuildersTest gains the threads-history filter shape;
RelayGroupFilterServingRelayTest gains a geode backward #h + thread-kinds
walk proving every thread is covered exactly once and the walk terminates.
The screen wiring is device-untested (flagged with the other Tier-D items).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
A group-scoped content event (kind-9 chat, poll, kind-11 thread) is keyed
to its RelayGroupChannel by the relay that served it, because a NIP-29
event doesn't carry its host relay. That's correct for the group's own
host-pinned subscriptions, but a message resolved from a NON-host relay --
e.g. a quoted kind-9 fetched by id during missing-event resolution -- was
filed under GroupId(groupId, strangerRelay), a channel the group's screens
never read, so the message silently vanished (the serving-relay hazard).
LocalCache.attachToRelayGroupIfScoped / attachThreadToRelayGroupIfScoped
now, when no channel is keyed to the serving relay, redirect the stray to
the group's single confirmed host channel via redirectStrayRelayGroupContent,
keyed off RelayGroupChannel.hasRelaySignedState(). A phantom channel never
has relay-signed state, so the redirect can only ever land on a real host,
never on another phantom -- the fix is strictly safe and the common
host-pinned arrival stays an untouched O(1) fast path (the scan runs only
on the rare no-channel-for-serving-relay miss).
Also add the cache-prune gap-fill can't-miss test: drive the production
RelayLoadingCursors down a real relay, rewindTo below the window, and
confirm the pruned band re-loads with no gap.
Tests: RelayGroupContentRoutingTest (pure router + the channel signal);
RelayGroupHistoryPagingRelayTest gains the rewind reload case. The
LocalCache wiring is unit-covered at the router level but still device-
untested end-to-end (flagged in the test plan).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
Round out the branch's test plan across the headless-runnable tiers.
Tier B (filter shapes) — now every assembler:
- reconnect stability: a since-only bump on the state/tail filters is not a
resend (no full replay on reconnect), while a history until step is; via
FiltersChanged.needsToResendRequest.
- directory: extract buildRelayGroupDirectoryFilter into RelayGroupFilterBuilders
(kinds 39000-39003, no d/h scope, limit 500) and point the RelayGroupsOnRelay
assembler at it, with a shape test.
- ChannelPublic relay-group branch (filterRelayGroupState): state + pinned-id
backfill and crucially NO message window.
- group notifications (filterGroupNotificationsToPubkey): #p+#h scope, kind set,
empty-guards.
- discovery #p roster augmentation (filterRelayGroupsByAuthors): the author,
#p-roster and #d-backfill filter shapes.
Tier C (serves-the-shape, against the in-process geode relay): state #d,
batched preview tail, threads, a pinned message reachable by id below the tail
window, notification #p+#h scoping, and the relay directory.
Tier C3/E1 (can't-miss + resilience): drive the production RelayLoadingCursors
backward over the wire to the bottom, and pin that a short page is not
exhaustion (only an empty page + EOSE ends the walk).
The remaining hostile-relay faults (echo-newest, no-EOSE, auth/stall) are
already covered generically by RelayLoadingCursorsTest, WindowLoadTrackerIdleTest
and BackwardRelayPagerTest; Tier D (device) and E2 (real third-party relay) are
not headless-runnable and stay flagged for a human in the test plan.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
Primal iOS writes a bare subtype in the imeta MIME tag (`m jpeg`) instead of a
full type (`m image/jpeg`). `createMediaContent` classified media only by the
MIME `startsWith` prefixes when a MIME was present, so a bare subtype matched
neither image/video/pdf and the whole imeta was dropped: the URL rendered as a
plain link. That path is doubly bad — it discards the imeta `dim`/blurhash, so
the loading placeholder cannot reserve the image's height and the feed jumps
when the bitmap finally arrives, and it forces a URL-preview network round-trip
just to rediscover the type the imeta already declared.
Fall back to file-extension detection whenever the type is still unknown after
the MIME/data: checks. This recovers the `.jpg` (or `.mp4`, …) classification
and keeps the imeta metadata, so the image renders through the fast media path
with its dimensions reserved up front. `data:` URIs keep their type in the
prefix and are left unprobed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQ9F7xnRvRgBmVNbo8hRKc
Extract the pure REQ-filter construction out of the NIP-29 group-chat
assemblers into RelayGroupFilterBuilders so the exact filter each screen
puts on the wire can be unit-tested without an Account or relay client,
and point every assembler at the shared builders (dropping the duplicated
per-file kind lists).
Add two test suites from the branch's test plan:
- RelayGroupFilterBuildersTest (Tier B): pins the kinds, #d/#h scope,
per-host-relay batching, since/until/limit and all-authors shape of the
state, joined chat-tail, open chat-tail, history-pager, threads and
card-warmup joined-skip filters.
- RelayGroupHistoryPagingRelayTest (Tier C3/E1): the can't-miss-messages
property against the in-process geode relay -- a backward #h + kind-9
walk delivers every group message exactly once and stops on an empty
page, and an #h-scoped walk isolates one group from another on the same
relay even when their createdAt ranges fully overlap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
The friendly-geode tiers test the client against a compliant relay; they neither
harden it against buggy relays nor surface bugs in the third-party NIP-29 relays
users connect to. Tier E adds both:
- E1: a deterministic fault-injecting mock relay (ignore since/until, short pages,
echo-newest, out-of-order, EOSE-early/none, AUTH-CLOSE, mid-stream drop, caps)
asserting the tail/pager still converge with no miss / loop / hang. Notes that
UntilLimitPagingRelayTest + BackwardRelayPagerTest already cover several cases
for the generic pager, to be extended to the NIP-29 #h shapes.
- E2: a conformance run against a real containerized NIP-29 relay via relayBench's
RelayUnderTest adapter (AUTH gating, relay-signed 39xxx, previous-tag rejection,
since/until semantics + caps) — a failure is a relay bug, doubling as a
conformance report for the operator.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
A validation plan an AI (or human) on this branch runs before trusting the
state-vs-content refactor. Organized by screen and covering every NIP-29
assembler (JoinedState, JoinedChatTail, OpenChatTail, OpenChatHistory,
OpenThreads, CardWarmup, RelayGroupsOnRelay, RelayGroupsDiscovery, the
ChannelPublic relay-group metadata branch, and the notifications path).
Tiers: (A) build + JVM suite; (B) new amethyst unit tests for each assembler's
filter shape + reconnect stability; (C) amy + geode/`amy serve` integration for
the framework, ingest and the can't-miss-messages paging property (C3); (D)
Android per-screen scenarios with logcat pass criteria, including the original
join-mid-session bug (D1) and reconnect no-replay (D8). Flags what is verifiable
headless vs. needs a device, and the known-failing serving-relay hazard row.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
isEnabled() reads plain SharedPreferences (a synchronous disk read, by
design) and scans the accounts cache. Since onReceive runs on the main
thread, this triggered a StrictMode DiskReadViolation at boot. Wrap the
work in goAsync() + a background thread so it runs off the main thread
while keeping the receiver alive until the check completes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P4tx1QSE27uoDpnxZZ7msT
Continues the scope+role naming pass over the group-chat datasource layer:
- RelayGroupThreadFeed -> RelayGroupOpenThreads: it's the open group's threads
tab, so it now sits in the "Open" family beside RelayGroupOpenChatTail/History.
- filterMetadataToRelayGroup -> filterRelayGroupState: matches the "state"
concept (39000-39005 + pinned-id back-fill) the always-on state sub is named for.
- makeRelayGroupsDiscoveryFilter -> filterRelayGroupsDiscovery: consistent
`filter…` verb with the rest of the relay-group filter builders.
The RelayGroupsOnRelay / RelayGroupsDiscovery assemblers keep their PLURAL prefix
on purpose: it marks the "browse/discover many groups" surfaces apart from the
singular in-a-specific-group ones (RelayGroupJoined*/Open*/Card*). The already-
consistent filterRelayGroupsBy* discovery builders are left as-is.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
Two clarity cleanups on the group-chat subscription split:
1. RelayGroupCardWarmup (ex-Warmup) now skips a group the user has already joined.
Joined groups are kept fully warm app-wide by the always-on state + chat-tail
subs; warmup only needs to cover groups shown as cards that those don't — above
all NON-joined groups (discovery, a relay's channel list, member/metadata/parent
screens). Removes the last joined-group double-fetch. (A joined group appearing
in the discovery "My Groups" tab now draws its card activity from the recent-tail
cache instead of a fixed newest-50 fetch.)
2. Rename the assemblers so their scope and role read at a glance, side by side:
RelayGroupState -> RelayGroupJoinedState (joined · state, always-on)
RelayGroupPreviewTail -> RelayGroupJoinedChatTail (joined · recent chat, always-on)
RelayGroupChatTail -> RelayGroupOpenChatTail (open group · recent chat)
RelayGroupChatHistory -> RelayGroupOpenChatHistory (open group · older chat)
RelayGroupWarmup -> RelayGroupCardWarmup (on-screen card, non-joined)
The family now reads Joined{State,ChatTail} · Open{ChatTail,ChatHistory} · CardWarmup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
Audit fixes for the Blossom client:
- BlossomBlobManagerViewModel: use StateFlow.update{} for the presence
matrix so the Main-thread sync collector and IO-thread delete/mirror
actions can't lose each other's writes; add refreshJob de-dup so two
quick refreshes can't interleave; rethrow CancellationException; bound
the /list HEAD-probe backfill with a Semaphore(8).
- BlossomClient.has(): rethrow CancellationException instead of
swallowing it as 'not found'.
- BlossomSyncForegroundService: drop the stale 'running' de-dup guard so
a fresh sweep always gets foreground protection.
- CLI mirror: strip query/fragment before extracting the sha256.
- DisplayBlossomSyncProgress: retain the last state so the slide-out exit
animation still has content to draw after the state clears.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
The sweep was strictly sequential (one mirror at a time). Now it parallelizes
ACROSS servers but stays serial WITHIN each server, so every server gets a steady
one-at-a-time stream (no rate-limit storms) while all of the user's servers work at
once — wall-clock drops to roughly the slowest single server's queue instead of the
sum of everything.
- BlossomMirrorQueue: group work by target server, run one coroutine per server, and
update the shared progress atomically via MutableStateFlow.update from the parallel
workers. Extracted mirrorOne() for the per-task call.
- Dropped the now-ambiguous currentHost from BlossomSyncState (and the banner /
notification text) since multiple servers are in flight at once.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
Generalizes the shared foreground-service scaffolding (channel setup, start/stop-
when-idle over a StateFlow, the ProgressStyle notification skeleton, tap + cancel
intents, onTimeout) into an abstract FlowProgressForegroundService<T>. Kept as a
base — NOT one mega-service — because Android 14+ binds foregroundServiceType to
behavior, so each workload keeps its own subclass with the correct type.
- FlowProgressForegroundService: the shared base.
- PowMiningForegroundService: refactored onto the base (still shortService); mining
state maps to the card via render(); behavior preserved.
- BlossomSyncForegroundService: new, typed dataSync (the right type for a multi-file
sync that exceeds shortService's ~3-min budget), bound to blossomMirrorQueue.state.
Started (from the foreground, as dataSync requires) when a "Sync all" sweep begins,
so it survives backgrounding; stops itself when done.
- BlossomMirrorQueue: publishes the active state and fires onActive() synchronously so
the FGS starts with live state; wired in AppModules.
- Manifest: FOREGROUND_SERVICE_DATA_SYNC permission + the service declaration.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
Consolidates the six overlapping NIP-29 group-chat REQ assemblers into the
state-vs-content shape the DM and Concord chat stacks already use, reusing the
BackwardRelayPager / RelayLoadingCursors / WindowLoadTracker framework. See
amethyst/plans/2026-07-18-nip29-group-chat-subscriptions.md.
State (always-on, account-keyed), mounted at LoggedInPage:
- RelayGroupStateFilterAssembler — one #d filter per host relay carrying every
joined group's 39000/39001/39002/39003/39005. Keeps name/roster/roles/pins
current app-wide, so no screen re-queries metadata. Promotes (and replaces) the
old RelayGroupMyJoinedGroups roster path from "while a groups screen is up" to
genuinely always-on.
- RelayGroupPreviewTailFilterAssembler — one #h filter per host relay across all
joined group ids, since=recentBoundary(), NO per-group limit (a time floor
batches it and makes it reconnect-safe). Drives the Messages-list previews.
Replaces the old fixed-window content path whose shared per-relay `since` gated
a newly-joined group's backfill (the reported slow-first-load bug).
Content (per open group), mounted on the chat screen:
- RelayGroupChatTailFilterAssembler — the open group's recent chat live (covers a
non-joined group opened by link, which the joined-only preview tail misses).
- RelayGroupChatHistoryFilterAssembler — on-demand backward pager (until+limit on
the host relay, all authors), cursors on RelayGroupChannel.history, driven by
the feed's viewport markers with an eager backfill-to-window on open. Gap-proof
via RelayLoadingCursors.rewindTo, so deep scroll never misses older messages —
and being all-authors it re-materializes my own history too.
Retires the duplicated paths:
- RelayGroupMyJoinedGroups{FilterAssembler,Subscription} deleted (state + preview
subs cover it); unmounted from Messages panes, discovery, bottom-bar preloader.
- ChannelPublicFilterSubAssembler RelayGroupChannel branch drops the content
window, keeps only metadata + pinned-id backfill (for a non-joined open group).
- ChannelFromUserFilterSubAssembler RelayGroupChannel branch removed (redundant).
- FilterMessagesToRelayGroup / FilterMyMessagesToRelayGroup deleted (orphaned).
Warmup (non-joined cards), OnRelay (directory), Discovery, ThreadFeed and the
always-on notifications path are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
Design doc for consolidating the six overlapping NIP-29 group-chat REQ assemblers
into the same state-vs-content shape the DM and Concord chat stacks already use:
an always-on account subscription for the small replaceable state (metadata /
roster / roles / pins), and a live tail + on-demand backward history pager
(reusing BackwardRelayPager / RelayLoadingCursors / WindowLoadTracker) for the
high-volume chat content. Includes the full message-delivery coverage proof
(can't-miss-messages), the file-by-file change list, and the additive-first
rollout order.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
Sync-all now runs like the PoW miner — an app-level job with a floating progress
banner — instead of a screen-bound loop, so it keeps going as the user navigates:
- BlossomMirrorQueue (app-level, on applicationIOScope via Amethyst.instance): runs
the BUD-04 fan-out, exposes an aggregate BlossomSyncState for the banner and a
results SharedFlow so an open manager flips each pill green/grey live. Servers that
need payment are skipped (pay those per-row).
- DisplayBlossomSyncProgress: a bottom floating banner mounted at the navigation root
(sibling of the mining/broadcast banners) with a determinate bar, "x / N · host",
failed count, and cancel/dismiss.
- Manager: a "Sync all" banner appears when any file has gaps; tapping it enqueues the
sweep and optimistically spins the affected pills.
Also, per request: the screen is renamed "My Blossom Data" and moved into the left
drawer's "You" section (just before My Emoji Packs); the standalone Settings-catalog
entry is removed and the Media Servers shortcut relabeled.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
Addresses three UX gaps on the "Manage stored files" screen:
- Loading was a full-screen spinner until every server × every blob HEAD probe
finished, sequentially — and it even HEAD-probed servers that had already
returned a /list. Now: /list runs across all servers in parallel and renders
immediately; only servers that don't implement /list are HEAD-probed, and those
run in parallel too. A server that listed its blobs needs no probe. Result is
shown as soon as the lists land.
- Mirror-to-missing gave no feedback. Presence is now a per-server state
(present/missing/pending); the target pill shows a spinner while mirroring and
turns green on success or back to grey on failure — updated in place, no full
reload. Delete spins the pill then greys it (and drops the row when it's gone
everywhere).
- The refresh control was a FAB mislabeled "Retry". It's now a proper refresh
action in the top bar (a spinner while loading); the contextual "Retry" stays
only on the error state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
Two regressions from the mirror-on-upload change:
1. Progress state stuck. verifyHeader() ends by calling finish() (state=Finished,
progress=1.0), but mirrorToOtherServers then ran updateState(0.95,
ServerProcessing) and never restored Finished — so the on-screen progress ended
pinned at "processing / 95%" and isUploading never cleared. Mirroring is now
fire-and-forget on account.scope AFTER the upload finishes: it never touches the
progress state, never delays the post, and can never turn a completed upload into
a failure.
2. Upload could fail outright. The upload token was BUD-11 server-scoped; some
servers reject an upload whose auth carries a `server` tag, surfacing as a bare
"Uploading error:" with no detail. Upload-token replay isn't the threat scoping
guards against (delete tokens are — those stay scoped), so upload/media tokens
are no longer scoped. Also: blank exception messages now fall back to the class
name so the dialog is never empty.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
Brings the "Manage stored files" screen up to the design language main just
applied to the Media Servers screen:
- Rounded surfaceContainer cards instead of flat Cards.
- Image thumbnail, or a file-type glyph in a rounded secondaryContainer box
for non-images.
- Monospace, truncated-both-ends hash; type · size subtitle.
- Per-server presence as pills with a status dot — allGoodColor tint for
present, muted for missing — replacing the ambiguous same-shaped chips.
- A single MoreVert overflow (copy link / open / report / per-server delete)
with leading icons, plus a full-width tonal "Mirror to missing" CTA.
- Icon-in-rounded-box empty/error states and an ExtendedFloatingActionButton
refresh.
- BUD-07 pay dialog restored with a Bolt icon and tonal confirm; payment
message now names the server.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
Adds cli/tests/blossom/blossom-live.sh, an end-to-end harness that drives the
full amy blossom lifecycle against a REAL public Blossom server: upload → HEAD
check → download-and-verify-hash → list → cross-server mirror → delete. Verified
green (6/6) against files.sovbit.host with a mirror to blossom.primal.net, which
exercises the whole quartz/commons/CLI Blossom stack over the wire.
Server-side write rejections (whitelists, rate limits, payment) record as SKIP,
not FAIL — only a broken client contract (bad descriptor, hash mismatch,
unparseable list) fails — since public servers gate anonymous writes very
differently. Documented in cli/tests/README.md; state dir gitignored.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
Every channel composer screen (NIP-28 public chat, NIP-29 relay group, NIP-53
live activity, ephemeral and geohash chats) calls `init(accountViewModel)`
directly from its composable body, so it ran on the main thread on every
recomposition of that body — re-allocating UserSuggestionState,
EmojiSuggestionState and a fresh ChatFileUploadState each time. Besides the
wasted main-thread allocations, blindly re-initializing `uploadState` would
reset an in-progress upload.
Guard the setup so it only (re)runs when the account actually changes, matching
the pattern the sibling ConcordNewMessageViewModel already uses. Repeated calls
with the same account are now cheap no-ops, so leaving the call in the composable
body stays correct while dropping the per-recomposition work.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
The Messages-list warmup (RelayGroupMyJoinedGroupsSubAssembler) is keyed by
account, so its `since` is one per-relay EOSE map shared across every joined
group on that relay — not per-group. Feeding that shared `since` into the
bounded per-group content backfill gated any group joined (or first surfaced)
after that relay's `since` advanced — or after a live message on a sibling group
pushed it forward: the group only ever fetched events newer than that timestamp,
so its history never landed in the Channel's (strong-ref) notes cache. The
Messages row showed the newest message while opening the group waited on a full
`limit=200` relay round-trip — "the last message is there but the group loads
slowly even though it should be cached."
Dropping `since` entirely would be just as wrong: the pool re-sends every REQ on
each reconnect (FiltersChanged treats a brand-new connection's empty prior state
as a change), and reconnects happen constantly, so a since-less filter would
replay the whole newest-50 page for every group on every reconnect.
Instead, gate the shared `since` per group on whether we already hold a full page
(>= LIMIT) of that group's preview content in the notes cache:
- < LIMIT cached -> cold / newly joined / thinly scattered: fetch the full
page with no `since`; once it lands it flips to incremental
on its own.
- >= LIMIT cached -> already backfilled: apply the shared `since` so reconnects
fetch only the tail (the group already has its history, so
sharing the relay `since` only bounds incremental top-ups).
A group with genuinely fewer than LIMIT total events re-pulls its (sub-page,
cheap) content on reconnect — an acceptable cost for guaranteeing the backfill.
Roster stays on the shared per-relay `since`: those are a few small replaceable
events per group, so a reconnect just re-confirms them rather than replaying chat.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
Adds a confirm-then-pay flow so a 402 from a paid Blossom server can be settled
from the app instead of only being reported.
- quartz: BlossomPaymentProof (settled Cashu token / lightning preimage) with the
X-Cashu / X-Lightning retry headers; BlossomClient.mirror accepts a proof.
- BlossomPaymentHandler (Android): pays the challenge's BOLT-11 invoice via the
account's existing NIP-47 (NWC) wallet and returns the preimage — it never
handles keys or funds itself, only drives the connected wallet. Decodes the
invoice amount for display.
- Blob manager: a mirror that hits 402 now raises a payment prompt; a dialog shows
the amount and, on confirm, pays and retries the mirror, then continues with the
remaining servers. Cancel leaves the blob unmirrored.
Cashu-only servers and the composer upload path still surface a clear message;
auto-settlement there can reuse this handler next. Not yet validated against a
live paid server.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
- Blob manager rows now show an image thumbnail (image/* blobs) and Copy-link /
Open controls, alongside the existing mirror-to-missing / delete / report.
- BUD-07: BlossomUploader now raises a typed BlossomPaymentException on a 402, and
UploadOrchestrator surfaces a specific "server requires payment" message instead
of a generic upload failure (auto-settlement via the NIP-60 wallet is still TBD).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
Audit follow-ups on the Messages unread dot:
- concordCommunityHasUnreadFlow captured the community session once, but a
Refounding rebuilds a still-joined community's session in place (same id, new
object). Because the row's flow is remember(communityId)-scoped it was never
recreated, so the fan-in kept watching the dead session and the dot froze.
Re-resolve the session on every concordSessions.revision tick instead — the
same signal ConcordServerRoomCompose already uses to refresh the row's
name/icon — which also covers channel-set changes from folds.
- distinctUntilChanged() on both aggregated server flows so a new message that
doesn't flip the has-unread boolean no longer pushes a redundant value into
composition.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ZkjdHANMKyu2EbM7eEM8C
Adds first-class Blossom upload controls to the Media Servers screen and rounds
out the blob manager:
- New persisted account settings (LocalPreferences + AccountSettings):
`mirrorUploadsToAllServers` (BUD-04, default on) and `optimizeMediaOnUpload`
(BUD-05, default off), each with a change fn.
- UploadOrchestrator honours both: uploads via /media with a t=media token when
optimize is on, and only mirrors when the mirror toggle is on.
- BlossomUploader gains a useMediaEndpoint flag to target /media vs /upload.
- "Upload behaviour" section on the Media Servers screen: mirror toggle,
optimize-via-/media toggle, and a shortcut into the blob manager.
- Blob manager gains a refresh FAB.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
Both the mirror-on-upload fan-out and the blob-manager matrix read the raw
kind-10063 list (BlossomServerListState.flow) instead of hostNameFlow, which
injects the 10 public DEFAULT_MEDIA_SERVERS when the user has none set. This
avoids fanning uploads out to — and listing/HEAD-probing across — public
servers the user never opted into.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
Move the All Settings search field from a separate row below the top bar
into the app bar itself, replacing the static "Settings" title. The back
arrow is still shown only when the back stack can pop (deep-link entry),
so bottom-nav entries stay chromeless.
Adds SettingsSearchTopBar with a compact pill search field sized to fit
the app bar and leaves the shared TopBarWithBackButton untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RBSLyPYssVu8QdBrvCM7wk
The crowdin/github-action runs inside a Docker container as root, so files
and directories it downloads are root-owned. When Crowdin creates a brand-new
locale folder (e.g. values-en-rGB/ under commons), the unprivileged runner
user in the peter-evans/create-pull-request step can't unlink files inside it,
so the branch checkout aborts with 'unable to unlink ... Permission denied'.
Add a chown step that reclaims ownership of the whole working tree after the
Crowdin action and before any git manipulation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0179E3AfsnB1NP87YmtAMZNX
Extends Blossom support toward a full client on both the CLI and the mobile app.
Quartz (protocol):
- BlossomAuthorizationEvent: add t=media auth (BUD-05) and optional BUD-11
`server` domain scoping on every factory (stops replayable upload/delete tokens)
- BlossomServerUrl: mirror/media/list/report path builders, BUD-06 preflight and
BUD-07 payment header constants, and a lowercase bare-domain helper
- BlossomUploadResult: parse `ox` (BUD-05 original hash) and `nip94` (BUD-08)
- BlossomPaymentRequired: BUD-07 402 challenge model (Cashu/Lightning)
- BlossomReport: BUD-09 kind-1984 blob report reusing NIP-56 tag builders
Commons (shared JVM client, now in jvmAndroid so Android shares it too):
- BlossomClient gains mirror (BUD-04), list/delete (BUD-02), media (BUD-05),
preflight/has (BUD-06/01), report (BUD-09) and typed 402 handling
- BlossomAuth: media/list/delete passthroughs with server scoping
CLI (first-class):
- amy blossom now routes all HTTP through the shared client and adds `media`
and `report` verbs; auth tokens are scoped to --server
Android (first-class):
- uploads mirror to the user's other Blossom servers (BUD-04) best-effort
- new "Manage stored files" screen: per-server presence matrix (BUD-02 list +
BUD-01 HEAD), delete, mirror-to-missing, and report actions
Tests: quartz URL/auth/descriptor/payment parsing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
The Messages list showed the purple "new messages" dot (NewItemsBubble) for
DMs, NIP-28 public chats, ephemeral chats, geohash chats and Marmot groups,
but hardcoded hasNewMessages = false for the four NIP-29 / Concord rows, so
those never indicated unread activity.
Wire real unread detection into all four, reusing the last-read markers their
open-chat screens already advance:
- Individual NIP-29 group row and Concord channel row compare the newest
message against the persisted last-read timestamp, exactly like the Marmot /
public-chat rows do.
- The collapsed "grouped by relay" (RelayGroupServerRoomNote) and "grouped by
community" (ConcordServerRoomNote) rows light the dot when ANY child group /
channel has unread, via new reactive fan-in flows that follow the joined list
/ community session so joins and folds re-subscribe.
Extract the relay-group last-read route into relayGroupChannelLastReadRoute so
the read side (row) and write side (RelayGroupChannelView) can't drift, mirroring
marmotGroupLastReadRoute / concordChannelLastReadRoute.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ZkjdHANMKyu2EbM7eEM8C
FollowerCrawler is in commonMain but used `Dispatchers.IO` without importing
the multiplatform `kotlinx.coroutines.IO` extension. On JVM `Dispatchers.IO`
resolves to the JVM member (compiled fine locally), but on Kotlin/Native that
member is internal, so `:quartz:compileKotlinIosSimulatorArm64` failed with
"Cannot access 'val IO': it is internal". Add the explicit import — the same
idiom the sibling GrapeRankCrawler already uses across all targets.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4
The first "Upload priority" header stacked FeedPadding's top inset, an
extra 8dp, and SectionLabel's 20dp inter-section top padding — ~38dp of
dead space under the top bar. Make SectionLabel's top padding a parameter
and shrink it for the first section.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ77Hm5L7fXEbPWbUds1iA
Addresses review feedback on the redesign:
- Auto-save: drop the Save button and replace the cancel "X" with a
standard back arrow (TopBarWithBackButton). Every add/remove persists
immediately; a reorder persists once the drag gesture ends, so dragging
no longer publishes a kind-10063 event on every intermediate swap.
- On-device cache card now uses the app's filled surfaceContainer card
style (no outline), matching the main Settings screens.
- Drop the redundant intro sentence at the top of the screen.
- Rework the "Add a server" block: recommended servers come first as
one-tap chips, followed by the "or paste a server address" field.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ77Hm5L7fXEbPWbUds1iA
The Crowdin action now downloads the commons module's Compose-resource
translations too, but the create-pull-request step's add-paths still only
covered amethyst/ and docs/. New untracked commons files created by the
Crowdin action (run as root in its container) were therefore left out of
the commit and shelved by git stash --include-untracked, which failed with
'Permission denied' when trying to remove the root-owned files.
Add commons/src/commonMain/composeResources/**/strings.xml to add-paths so
those translations are committed into the PR instead of being stashed.
Health probe was hitting the bare server root with HEAD. CDN-fronted hosts
like cdn.satellite.earth never answer `/` (verified: HEAD/GET `/` time out,
while HEAD `/upload` returns 404 in ~0.5s), so a fast, working server was
wrongly shown Offline. Probe the BUD-01 `/upload` endpoint instead — the
path that actually matters for an upload target.
Visual polish:
- Server rows are now outlined cards; the primary target (#1) gets an accent
border, a faint accent tint, and a "Primary" badge.
- Rank and monogram merge into one avatar (rank as a small corner badge),
removing the separate rank tile and decluttering the row.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ77Hm5L7fXEbPWbUds1iA
`amy graperank followers <observer>` (no --relay) assembles the relay universe
via allKnownRelays, which read the reachability cache through ctx.reachability —
and that lazily derives the monitor key from the operator master, forcing a
passphrase prompt (or failing on a fresh machine) purely to READ kind:30166
records. The follower crawl signs nothing, so this defeats its anonymous path.
Read the reachability snapshot with a throwaway signer instead (snapshot() only
reads; the signer is used solely for writes) — the same trick `graperank status`
already uses to stay side-effect-free.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4
Replaces the Servers/Local-cache segmented tabs with a single cohesive
scroll, and redesigns every zone of the screen:
- Upload priority: draggable, ranked server rows now carry a colored
monogram (identity) and a "Primary" badge on #1, alongside the health dot.
- Add a server: the old inline URL field + a second 10-row recommended
list are replaced by one inline add area — the URL field plus the
recommended servers as a horizontal strip of add-chips that read as
"done" once added (matched by host so URL normalization can't hide it).
- On-device cache: the old bare switch rows become a self-contained card
with a storage icon, a detection-status dot, and the profile-pics-only
sub-toggle nested under it.
Removes the segmented tabs and their now-dead strings. Same staged
Save/Cancel model, kind-10063 publish, drag utility, and health probe.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ77Hm5L7fXEbPWbUds1iA
Audit follow-up. These renderers sit in scrolling feeds; each recomputed
event parses / string builds directly in the composable body instead of
caching them against the (immutable) event — the CLAUDE.md rule-#4 pattern.
The cards are already skippable (immutable event params + strong skipping),
so this is work redone per composition (each item scrolling into view), not
per frame; still worth removing. These patterns pre-existed in the Android
originals and were carried over faithfully — this cleans them up now that the
code is shared.
- RelayDiscoveryCard: the heaviest — 6 `joinToString` + a `.sorted()` ran in
the body. Now each display string (network / relay-type / requirements /
supported-NIPs / accepted-kinds / geohashes, the requirements lock flag, and
the relay-URL displayUrl()) is `remember`ed off its parsed list; the row
visibility checks still key off the original lists so behavior is identical.
- CalendarRsvpCard: `status` / `calendarEventAddress` / `freebusy` parses now
`remember(event)` instead of re-scanning tags every composition.
- CalendarCollectionCard: `title()` and `calendarEventAddresses().size` (which
allocated a whole List just to read size) now `remember(event)`.
- PodcastValueSplits: the `recipients.filter{}` + `totalSplit()` now
`remember(value)`.
Behavior is unchanged (same keys, conditions, and outputs). Verified:
:commons:compileKotlinJvm + :amethyst:compileFdroidDebugKotlin pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
commons ColorScheme.isLight tested `primary.luminance() < 0.5f`. With the
default purple accent the primary is a deep purple in the light theme (lum
0.09) AND a light purple in the dark theme (lum 0.35) — both < 0.5 — so it
reported "light" in BOTH modes. The Android app decides the same thing from
the background (`background != Color.Black`); `background.luminance() > 0.5f`
is the multiplatform-safe equivalent (light bg ≈ 0.98, dark bg = 0.0).
Surfaced while reviewing the note-ui extraction: the new commons theme helpers
that branch on isLight — subtleBorder / replyModifier (card hairline borders)
and allGoodColor / warningColor (RelayDiscovery latency chips) — were rendering
their light-theme variant in dark mode, a regression vs the Android originals
which use the app's background-based isLight. This restores parity.
Also corrects two pre-existing consumers that had the same latent bug and now
behave correctly in dark mode (worth a dark-mode glance in review):
- UserAvatar → CachedRobohash light/dark variant selection;
- ChatTheme.chatBubbleBackground alpha.
Verified: :commons:compileKotlinJvm passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
Amends commons/plans/2026-07-16-note-ui-commons-extraction.md with a §0.1
"Progress & findings" section after landing 13 event kinds:
- a batch table (what moved, which seam mechanic each proved) and the shared
commons theme now in place;
- Finding A: commons i18n was an unscoped prerequisite — wired commons into
Crowdin so migrated strings/plurals keep every locale;
- Finding B: gate #1 (amethyst reads commons Res) is the real Tier-1 unblock,
since most renderers share strings with a still-native screen;
- Finding C: "unused seam" (declares accountViewModel/nav but never calls
them) is the cleanest Tier-1 signal;
- Finding D: commonMain bans Jackson (blocks MedicalData/MiniFhir);
- Finding E: platform leaves become opaque or typed @Composable slots (PS1
bitmap icon, Roadstr map) and it works cleanly.
Also flips front-matter status to in-progress and annotates the Tier 0 / Tier
1 lists in §5 with what's done and what's left. The design (§1–§8) is
unchanged; §0.1 is the amendment.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
Twelfth batch of the note-ui-commons extraction — the calendar family, and
a fuller exercise of gate #1: strings shared with three native call sites
move to commons with no duplication.
- commons/ui/note/CalendarCollectionCard.kt: the calendar collection card
(kind 31924) — title, description, event count.
- commons/ui/note/CalendarRsvpCard.kt: the RSVP card (kind 31925) — the
going/maybe/not-going status, note, and target address.
Both are pure value-in; the entries' unused accountViewModel/nav are dropped.
- Strings/plural migrated to commons: calendar_rsvp_going/maybe/not_going and
the calendar_collection_count plural. Their native co-users now read them
from commons Res instead of the Android res tree — CalendarRsvpRow (the
interactive RSVP row), CalendarEventDetailScreen, and CalendarCollectionsView
— so each key lives in exactly one place.
- amethyst keeps the thin RenderCalendarCollectionEvent / RenderCalendarRSVPEvent
dispatcher entries.
Verified: :commons:compileKotlinJvm and :amethyst:compileFdroidDebugKotlin
both pass; every touched strings.xml is well-formed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
Eleventh batch of the note-ui-commons extraction, and the first to exercise
gate #1 (app-side reads of commons Res) for a string shared with a native
screen — no duplication.
- commons/ui/note/RelayDiscoveryCard.kt: the NIP-66 relay discovery/monitor
card (relay URL, latency health chips, network/relay-type/requirements/
NIPs/kinds/topics/geohashes). Pure value-in — takes the quartz event; the
entry's unused accountViewModel/nav are dropped.
- commons/ui/theme: adds ColorScheme.allGoodColor / warningColor (the
green/amber status colors), mirroring the Android values.
- Migrated the 10 relay_monitor_* / relay_discovery_* strings the card uses
into commons. Seven of them are shared with the native RelayInformationScreen,
which now reads them from commons Res instead — so the keys live in exactly
one place. relay_monitor_reports (screen-only) stays app-side.
- amethyst keeps the thin RenderRelayDiscovery(Note, …) dispatcher entry.
Verified: :commons:compileKotlinJvm and :amethyst:compileFdroidDebugKotlin
both pass; every touched strings.xml is well-formed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
Enables app-side (still-native) screens to reference commons' generated
`Res` directly, so a string shared between a commons renderer and an
Android screen no longer has to be duplicated across the two resource
trees. commons already exposes `Res` publicly (publicResClass = true); the
only missing piece was the Compose-resources runtime on amethyst's
classpath — the JetBrains compose artifacts are already there transitively
via :commons, this just adds components-resources so
`org.jetbrains.compose.resources.*` resolves.
Proof + cleanup: the V4V split editor now reads
podcast_value_split_percent from commons `Res` instead of the Android res
tree, and that key is removed from amethyst — collapsing the duplication
introduced when PodcastValueSplits moved to commons. (podcast_value_for_value
stays app-side: it feeds an Android-int ResourceToastMsg, which has no
Compose-resource form.)
This unblocks the next wave of renderer extractions whose strings are
shared with native screens (relay discovery, calendar, …) — those screens
can now switch to commons `Res` rather than forcing a duplicate.
Verified: :amethyst:compileFdroidDebugKotlin and :commons:compileKotlinJvm
both pass; touched strings.xml well-formed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
Ninth batch of the note-ui-commons extraction, completing the podcast
display family (badge/link/soundbite atoms landed earlier).
- commons/ui/note/PodcastValueSplits.kt: the Podcasting-2.0 value-for-value
split breakdown card (header, split hint, one row per recipient with its
percentage). Pure value-in — takes a quartz PodcastValue.
- commons/ui/theme: adds ColorScheme.grayText (onSurface @52%); reuses the
Size5dp added with the ActivityCard batch.
- Consumers (PodcastEpisode, PodcastMetadata) re-point to commons.
Strings: podcast_value_zap_split_hint is renderer-only and moves fully to
commons. podcast_value_for_value and podcast_value_split_percent are also
used by the native V4V split editor — for_value via an Android-int
ResourceToastMsg toast that has no Compose-resource equivalent, and the
editor can't reference commons' generated Res until amethyst gains the
compose-resources dependency. So those two keys are intentionally duplicated
(kept in amethyst for the editor, copied to commons for the shared card).
The duplication collapses once amethyst can read commons Res or the editor
itself moves; both sources stay in Crowdin meanwhile.
Verified: :commons:compileKotlinJvm and :amethyst:compileFdroidDebugKotlin
both pass; every touched strings.xml is well-formed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
Eighth batch of the note-ui-commons extraction. The two Roadstr renderers
(report kind 1315, confirmation kind 1316) are self-contained except for the
map hero, which is a platform tile view.
- commons/ui/note/RoadEventCard.kt: RoadEventReportCard(event, map) and
RoadEventConfirmationCard(event, map). Commons owns all the logic — the
category emoji/color/label palette, the geohash→point resolution, the
freshness-based pin opacity, the floating category pill, and the layout.
The map arrives as a typed RoadEventMap slot
(lat, lon, pinColor, pinEmoji, pinAlpha) so the native LocationPreviewMap
stays in the app. Labels resolve via commons Res.
- amethyst keeps thin RenderRoadEventReport(Note) /
RenderRoadEventConfirmation(Note) entries that decode the event and pass
LocationPreviewMap as the slot.
- Migrated all 18 road_event_* strings with their locale translations into
commons/composeResources; deleted the amethyst copies.
Verified: :commons:compileKotlinJvm and :amethyst:compileFdroidDebugKotlin
both pass; every touched strings.xml is well-formed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
Seventh batch of the note-ui-commons extraction. The activity-card slot
composables — the gradient frame, kind badge, header row, amount row, and
pill shared by the reaction / zap / nutzap renderings — are pure
value/slot-in with no strings, so they move as-is.
- commons/ui/note/ActivityCard.kt: ActivityCardFrame, ActivityBadge,
ActivityPill, ActivityHeaderRow, ActivityAmountRow, and the LikeTint color.
- commons/ui/theme/Sizes.kt: shared Size16Modifier (and Size5dp for the next
batch), mirroring the Android ui/theme values.
- Consumers (Reaction, ZapEvent, Nutzap) re-point their imports to commons.
Verified: :commons:compileKotlinJvm and :amethyst:compileFdroidDebugKotlin
both pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
Sixth batch of the note-ui-commons extraction. These are the shared
Podcasting-2.0 display atoms the show/episode renderers compose from; they
already take plain values + onClick/onPlayFrom lambdas, so they move as-is.
- commons/ui/note/PodcastChips.kt: PodcastBadge + PodcastLinkChip (now
public), pure value/lambda-in pills.
- commons/ui/note/PodcastSoundbites.kt: the soundbite "jump to the good
part" chip row; the play label resolves via commons Res, playback stays
controller-agnostic through the onPlayFrom callback.
- Migrated podcast_play_soundbite with its locale translations into
commons/composeResources; deleted the amethyst copy.
- Consumers (PodcastEpisode, PodcastMetadata, PodcastEpisodeAudioPlayer,
PodcastChaptersView) re-point their imports to commons.
PodcastValueSplits is intentionally left for a follow-up: two of its strings
are shared with the native V4V split editor, so it needs the editor
re-pointed in the same change.
Verified: :commons:compileKotlinJvm and :amethyst:compileFdroidDebugKotlin
both pass; every touched strings.xml is well-formed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
Fifth batch of the note-ui-commons extraction, and the first to use the
platform-specific @Composable slot pattern the plan calls for (§3e/§4).
The PS1 save card (kind 38192) is pure value-in except for its animated
16×16 icon, which is decoded from raw memory-card pixels via Android Bitmap
APIs (createBitmap/setPixels/asImageBitmap) that can't move to commonMain.
- commons/ui/note/Ps1SaveCard.kt: the card takes decoded primitives (title,
filename, region, block number, blank flag, hex preview) plus an
`icon: (@Composable () -> Unit)?` slot; a null icon falls back to the
floppy-disk emoji prefix. Labels resolve via commons Res.
- amethyst keeps the thin RenderPs1Save(Note) entry, which decodes the event
and supplies the native Ps1SaveIconImage (the bitmap/frame-clock animation)
as the icon slot.
- Migrated ps1_save_title/ps1_save_block/ps1_save_empty_slot with all locale
translations into commons/composeResources; deleted the amethyst copies.
Verified: :commons:compileKotlinJvm and :amethyst:compileFdroidDebugKotlin
both pass; every touched strings.xml is well-formed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
Fourth batch of the note-ui-commons extraction. The two Birdstar cards
(Birdex kind 12473, bird detection kind 2473) are pure value-in renderers:
they only read decoded tag values and open one Wikidata link, with no
AccountViewModel/INav.
- commons/ui/note/BirdexCard.kt: BirdexCard(BirdexEvent) and
BirdDetectionCard(BirdDetectionEvent). The scientific-name link reuses the
commons ClickableUrl atom; labels resolve via commons Res.
- amethyst keeps thin RenderBirdex(Note) / RenderBirdDetection(Note) entries
that decode the event and call the shared cards.
- Migrated bird_detection_title (string) plus birdex_species_count and
birdex_species_preview_more (plurals) with all locale translations from
amethyst/res into commons/composeResources; deleted the amethyst copies.
Verified: :commons:compileKotlinJvm and :amethyst:compileFdroidDebugKotlin
both pass; every touched strings.xml is well-formed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
Third batch of the note-ui-commons extraction. GitDiffView is a pure
presentation composable — it takes a quartz ParsedPatch + a Modifier, with
no AccountViewModel/INav/Note — and its syntax-highlighting helper
(CodeHighlighter) already lives in commons, so it moves wholesale.
- commons/ui/note/GitDiffView.kt: the GitHub-style file-by-file diff view
(stat summary, per-file collapsible cards, +/- line coloring, intraline
emphasis, syntax highlighting). Labels resolve via commons Res.
- Migrated git_diff_binary (string) and git_diff_files_changed (plural,
all quantity forms across 11 locales) from amethyst/res into
commons/composeResources; deleted the amethyst copies. This exercises the
now-wired commons Crowdin pipeline for a plural resource.
- Callers (Git.kt, GitPullRequestChanges.kt, GitCommitLog.kt) re-point the
import to commons; the call sites are unchanged.
Verified: :commons:compileKotlinJvm (plural accessor generation) and
:amethyst:compileFdroidDebugKotlin both pass; every touched strings.xml is
well-formed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
Second batch of the note-ui-commons extraction, and the load-bearing
setup for every string-bearing renderer that follows.
Commons had no translation pipeline — crowdin.yml synced only the Android
app's strings.xml — so moving an already-translated string to commons
Res.string would drop its translations and remove it from future syncs.
This commit fixes that first:
- crowdin.yml: add commons/composeResources as a second Android source
with the same language mapping, so shared keys keep flowing through
Crowdin.
- Migrate the four git_status_* pill labels (open/merged/closed/draft)
and all 56 existing locale translations from amethyst/res into
commons/composeResources, deleting the amethyst copies. No language
regresses.
Then the renderer split (NIP-34 git status pill):
- commons/ui/note/GitStatusPill.kt: the StatusKind enum + the pure
GitStatusPill(kind, modifier) presentation composable, resolving labels
via commons Res.string. Shared by Android and Desktop.
- amethyst keeps the thin GitStatusPill(targetIdHex, …) entry that reads
the account-bound GitStatusIndex and delegates to the commons pill.
- Callers (GitItemListRow, Git.kt) re-point StatusKind to commons.
Verified: :commons:compileKotlinJvm (accessor generation + all 56 locale
qualifiers accepted) and :amethyst:compileFdroidDebugKotlin both pass;
every modified/added strings.xml is well-formed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
First pilot of the note-ui-commons extraction plan
(commons/plans/2026-07-16-note-ui-commons-extraction.md, Tier 0). These
two event-kind renderers take plain quartz events with no AccountViewModel,
INav, or R.string, so they move to commons/ui/note/ almost verbatim:
- EcashMintCard: RenderCashuMint / RenderFedimint / RenderMintRecommendation
move wholesale (already past the seam — the NoteCompose dispatcher hands
them a decoded event). NoteCompose + ThreadFeedView imports re-pointed.
- CodeSnippetCard: the feed preview and thread header layouts move to
commons; the Android app keeps a thin RenderCodeSnippetEvent(Note) entry
that decodes the Note and calls the shared card.
- NoteBorders: shared QuoteBorder/SmallBorder/StdHorzSpacer/StdVertSpacer/
subtleBorder/replyModifier theme primitives in commons so the migrated
cards share one visual vocabulary. Mirrors the Android ui/theme values;
the Android copies are deleted once every renderer that uses them moves.
Desktop now renders the identical cards, so the two front ends can't drift.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
crawl and score used Context.open, which requires an account solely to default
the observer to the logged-in user — but neither uses account keys: crawl never
signs, and score signs cards with the machine-level operator key (~/.amy/operator/,
independent of any account). Switch both to Context.openOrAnonymous and require
an explicit OBSERVER when running anonymously, matching `graperank followers`.
A machine acting as a GrapeRank provider for arbitrary observers no longer needs
a personal account (the operator key still needs its SecretStore passphrase, as
before — that's the reachability monitor + card signer, a real secret).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4
Address gallery layout feedback for the "accepted by relays" line:
- Move the relay line to the bottom of the reaction detail gallery.
- Use NotificationIconModifier for the Dns icon box so it lines up
horizontally with the zap/like/nutzap category icons (which carry the
same 5dp end padding).
- Show the reaction-gallery expand button whenever the note has relay
URLs, not only when it has reactions/zaps/boosts, so the always-present
relay line is reachable. Gate it with a lightweight boolean relay
observer (no list allocation, no sampling) since it runs per feed note.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVPYTTnMbxwi5hUAagsKVc
The backward "load older" pager REQ'd only the current epoch's Chat Plane, so
deep scroll stopped at the last Refounding and showed "All caught up" while
older messages sat under prior-epoch planes.
Widen the history REQ authors to the union of the channel's plane pubkeys
across every held epoch (ConcordCommunitySession.channelPlaneAddressesAllEpochs).
The relay serves them interleaved by created_at, so one backward `until` sweep
walks the whole cross-Refounding timeline and `exhausted` (the "All caught up"
signal) now means every epoch is drained, not just the current one. The pager
is unchanged — it only tracks until/limit per relay and forwards createdAt; the
prior-epoch wraps decrypt on the normal ingest path (already epoch-aware).
Test: ConcordCommunitySessionTest asserts channelPlaneAddressesAllEpochs returns
current + prior planes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A CORD-06 Refounding rotates the community_root and bumps the epoch, so each
channel's pre-refounding messages live under a different derived Chat Plane
per epoch. The client only ever subscribed to the current epoch's plane, so
older history was invisible and the feed said "All caught up" while months of
messages sat on the same relays under prior-epoch stream keys.
The account already persists each rotated-out root in
ConcordCommunityListEntry.heldRoots; this consumes them on the read side:
- ConcordActions.historicalChannelPlanes() re-derives each folded channel's
plane at every held epoch (bounded by MAX_BACKFILL_EPOCHS = 8; 0 disables).
- ConcordCommunitySession keeps a historicalChannelKeysByAddress map (derived
in refold, since channels are known only after a fold) and folds it into
channelAddresses() (subscribe), streamKeys() (NIP-42 AUTH), and ingest()
(decrypt with the matching epoch, isBoundTo per epoch). Channel ids are
epoch-invariant, so historical messages merge into the same channel feed.
- ConcordSubscriptionPlanner.channelPlaneSubs appends the historical planes,
so the existing filter assembler subscribes to them unchanged.
Writes / moderation / rekey stay strictly on the current epoch. Cross-validated
against amy: the app now subscribes to the exact prior-epoch plane pubkeys
`amy concord read --epoch 0` proved hold the older Soapbox #nostrhub messages.
Tests: ConcordCommunitySessionTest.ingestsPriorEpochWrapsFromAHeldRoot,
ConcordSubscriptionPlannerTest.channelSubsAlsoCoverPriorEpochPlanesForHeldRoots.
Follow-up (plan step 3): BackwardRelayPager epoch-stepping so deep "load older"
scroll crosses epochs to the true start.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Web-based highlighter clients publish kind:9802 highlights with W3C Web
Annotation selectors (textquoteselector / textpositionselector /
rangeselector) instead of a NIP-84 `context` tag. These were previously
ignored, so the highlight rendered without its surrounding paragraph and
the "jump to page" link couldn't disambiguate repeated quotes.
- Parse the W3C textquoteselector into TextQuoteSelectorTag (exact/prefix/
suffix; a "-" or empty exact is treated as a placeholder since the quote
lives in .content).
- HighlightEvent.contextOrReconstructed() prefers an explicit `context`
tag and otherwise rebuilds the paragraph from prefix + content + suffix,
so the in-context bolding still works.
- Build a disambiguated Text Fragment URL (`#:~:text=prefix-,exact,-suffix`)
from the selector's prefix/suffix so the source link scrolls to the
correct occurrence.
The position/range selectors are left unparsed; they only matter for an
in-app live-page re-highlighter, which we don't have.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Di4UurD9SQGrpScX7uy2Kh
Follow-up on the audit of the Media Servers redesign:
- Add a process-wide TTL cache (60s) to MediaServerHealthProbe so probe
results survive the screen's ViewModel being recreated on each open;
the ViewModel reuses fresh cached status instead of re-hitting the
network, and skips launching a probe when one is already in flight.
- Prune the _health map on refresh/remove so it can't grow unbounded as
servers are added and removed within a session.
- Persist the selected tab across configuration changes (rememberSaveable).
- Restore the screen's intro caption, reusing the previously orphaned
set_preferred_media_servers string.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ77Hm5L7fXEbPWbUds1iA
FollowerCrawler set the reverse-lookup filter's `limit` to the page size, but
fetchAllPages treats a filter `limit` as the TOTAL cap across all pages and
stops paging once it's reached — so the crawl silently capped at ~500 followers
per relay (verified live: relay.damus.io returned exactly 500 for a
many-thousand-follower observer).
Leave the filter limit null by default so pagination walks the whole result set
(the same observer now returns 12,823 followers from damus alone); Config gains
`maxPerRelay` and the CLI a `--max N` flag to opt back into a bounded spot check.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4
Replace the Jackson-based FHIR resource parser with kotlinx.serialization.
The `resourceType` polymorphism is now handled by a JsonContentPolymorphicSerializer
that dispatches to the modeled types (Practitioner, Patient, Bundle,
VisionPrescription) and falls back to a new UnknownResource for anything else,
so a Bundle mixing known and unknown resources still parses.
The reader is lenient (ignoreUnknownKeys, isLenient, explicitNulls=false,
coerceInputValues) so we parse what we can and tolerate the missing or extra
fields that many FHIR implementations add.
Adds MiniFhirTest covering the vision-prescription bundle, extra/unknown field
tolerance, unknown-resourceType fallback, mixed bundles, and garbage input.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUeR8YFNHvBELCaLRDTpCn
The relay favicons rendered at 17dp while the zap/boost/reaction gallery
rows use 35dp author pictures, so the "accepted by relays" line looked
out of scale. Parameterize RenderRelay with a box size and icon modifier
(defaults unchanged for the existing compact relay lists) and render the
gallery relays at 35dp with MediumRelayIconModifier to match.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVPYTTnMbxwi5hUAagsKVc
NostrSignerRemote extended NostrSigner(signer.pubKey), where `signer` is the
ephemeral NIP-46 transport keypair — so `pubKey` returned the transport key,
not the user's identity. Every self-encryption / self-authorship site keys off
`signer.pubKey`, so for bunker accounts this silently broke:
- private NIP-51 lists (private bookmarks / mute / follows / hashtags) and
NIP-37 drafts — an `if (signer.pubKey != event.pubKey)` guard short-circuits
(desktop: private bookmarks always empty);
- NIP-44 self-encrypted data (Concord list, Cashu) sealed to / read against
the wrong peer key.
Android is unaffected (no bunker path); desktop and CLI were affected.
Make `NostrSigner.pubKey` open and have `NostrSignerRemote` return the
bunker-resolved user key: `getPublicKey()` now caches it, and `bindUserPubkey()`
sets it eagerly for a reloaded account / stored identity. Internal transport
(the response-subscription `p` filter, request addressing) keeps using the
transport keypair explicitly, so it is unchanged. No-op for local/external
signers, where signer.pubKey already equals the account key.
Wired: desktop AccountManager.loadBunkerAccount binds the resolved pubkey; CLI
Context binds identity.pubKeyHex. amy's Concord-list decrypt workaround is
dropped — `newest.decrypt(ctx.signer)` now works for a bunker. Verified live:
`amy concord import` over a bunker account decrypts the kind-13302 list and
recovers Soapbox heldRoots [0,1].
Plan: quartz/plans/2026-07-17-nip46-remote-signer-self-pubkey.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
NostrSignerRemote.pubKey returns the ephemeral NIP-46 transport key, not
the verified user identity, so every self-encryption / self-authorship site
that uses signer.pubKey as "myself" breaks for bunker accounts. Verified
impact: Android unaffected (no bunker path); desktop private NIP-51 lists
(private bookmarks/mute/follows) and NIP-37 drafts silently empty, Cashu
self-encryption sealed to the wrong peer; CLI the same incl. `concord list`.
Records the two failure modes, the affected call sites, and the fix
direction (resolve pubKey to the user key via get_public_key while pinning
transport uses to the transport keypair).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A refounded Concord community (CORD-06 rotates community_root + bumps the
epoch) keeps its pre-refounding messages under the prior epoch's derived
Chat Plane stream key. The client only ever fetches the current epoch, so
older history is invisible and the feed says "All caught up".
Add the diagnostics to reach it:
- `amy concord import` — fetch this account's own kind-13302 list, decrypt
it, and upsert every community WITH its heldRoots (the prior-epoch access
roots Amethyst persists across Refoundings). Decrypts against the account
identity, not signer.pubKey (which for a bunker is the ephemeral transport
key, not the self-encryption peer).
- `amy concord read <community> <channel> --epoch <n> [--root <hex>]` — read
a prior epoch's Chat Plane; the root auto-resolves from the stored
heldRoots when --root is omitted. Output includes the epoch + derived plane.
- StoredCommunity.heldRoots persistence.
Verified live against Soapbox #nostrhub: epoch 0 (a held root) returns 7
messages the app never shows; epoch 2 (current) returns 2 — reproducing the
gap and confirming heldRoots-walking recovers the history.
Design for the in-app fix (walk heldRoots on the read side) lives in
commons/plans/2026-07-17-concord-epoch-walking-backfill.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`amy login bunker://<pubkey>?…` was persisting the pubkey embedded in the
bunker URI as the account identity. That pubkey is the remote-signer /
connection key (Amber, nsec.app, nak all mint a dedicated one), not the
user's identity key — so every "my events" fetch queried the wrong author
(no kind-10002/10050/13302 found, empty timelines).
After saving a provisional identity, connect the bunker and call the
NIP-46 `get_public_key` RPC (already implemented on NostrSignerRemote),
persisting the returned user pubkey as the account identity while keeping
the bunker's remote key in Identity.bunker for RPC addressing. Best-effort:
falls back to the URI pubkey if the bunker can't answer. Mirrors the app's
NostrConnectLoginUseCase, which already stores the verified pubkey.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Restore a visibility guard on the reaction detail gallery so a note with
neither relays nor reactions no longer paints an empty padded row when
the gallery is expanded. The relay flow is now collected once via
observeNoteRelays and reused both for the guard and to render the
"accepted by relays" line, avoiding a second subscription.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVPYTTnMbxwi5hUAagsKVc
ConcordInviteScreen auto-redeems and forwards to Route.ConcordServer, but it did
so with nav.newStack(). Since the community route isn't in the back stack yet,
newStack's popUpTo(target) matches nothing and the invite screen stays underneath.
Pressing Back from the community then reveals the invite screen, whose plain
remember(link) state resets to Working, re-runs joinConcordViaInvite, and forwards
right back — trapping the user in a Back→forward loop.
Forward with popUpTo(ConcordServer, ConcordInvite::class) instead, matching the
sibling group-creation screens (CreateGroupScreen, RelayGroupMetadataScreen), so
the invite screen is removed and Back returns to wherever the invite was opened.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeJJCDLdvi4KQEuozcMMBh
Replace the radio-row inline/grouped pickers with the same bordered PolicyCard
selection cards the Relay Authentication "When to authenticate" picker uses:
an accent-colored icon + label + description, a check when selected, and an 8dp
stack. Inline uses the list icon, grouped uses the folder icon (both already in
the Material Symbols subset).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RbzL9cZk1h88kE7ErgjG5
The outbox model can't find an observer's followers — you don't know a
follower exists until you've seen their kind:3, so you can't route to their
outbox first. FollowerCrawler casts a wide net instead: it asks as many relays
as possible for kind:3 lists that #p-tag the observer, paging each relay past
its per-REQ cap via fetchAllPagesFromPool, verifies with ParallelEventVerifier,
keeps only lists that genuinely tag the observer, dedups by id, and
group-commits to the store.
Each follower's list is a full contact list, so persisting it also enriches the
graph a later `graperank score` builds — every follower becomes a FOLLOW edge
into the observer.
CLI: `amy graperank followers [OBSERVER]` assembles "all possible relays" from
the reachability-cache live set + every kind:10002/30166 relay in the store +
the index/aggregator relays, skipping proven-dead relays. Runs anonymously (no
signing) when given an explicit observer. Tunable via
--relay/--page-limit/--timeout/--relay-concurrency/--insert-batch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4
Each kind:30382 GrapeRank card now carries two more public tags alongside
`rank`:
- `followers` — the number of the target's followers whose own score clears a
threshold (`--followers-threshold`, default 0.02), mirroring Brainstorm's
trusted-follower cutoff.
- `hops` — the shortest follow-graph distance from the observer (1 = a direct
follow), matching the `hops` field on Brainstorm's ScoreCard.
New `HopsTag` (the `followers`/`FollowerCountTag` already existed) is wired
through the ContactCardEvent tag accessors/builders. TrustGraph gains
`hopsFrom` (a follow-only BFS over the compact int-CSR) and
`trustedFollowerCounts`; the out-CSR now packs the relation code so a forward
walk can filter FOLLOW edges. The publisher's `reconcileLocal` takes a richer
`ScoredCard` and diffs the full (rank, followers, hops) triple, so a card
re-signs when any of them moves and older cards migrate onto the new tags once.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4
Redesigns the Media Servers screen around the fact that Blossom uploads
mirror in list order, plus a lightweight reachability check per server.
- Split the screen into "Servers" / "Local cache" segmented tabs, moving
the local-cache switches into their own tab to declutter the list.
- Make the server list drag-to-reorder using the shared RelayDragState
utility; list order is the upload/fallback priority (row #1 first).
- Add a rank badge per row (accent-filled for the primary target) and a
reorder hint.
- Add a per-server health dot (Online / Slow / Offline / Checking) backed
by a one-shot HEAD probe through the account's Tor-aware preview client.
- Add moveServer() + health StateFlow to BlossomServersViewModel; probe on
load and when a server is added.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ77Hm5L7fXEbPWbUds1iA
When redeeming a Concord invite link (`…/invite/<naddr>#<fragment>`) whose
resolved community is already in the joined list, return
`ConcordInviteResult.Joined` immediately instead of re-following and
re-announcing a Guestbook JOIN (kind 3306). The invite screen already forwards
to `Route.ConcordServer(communityId)` on `Joined`, so reopening an old invite
for a community you're already in simply takes you to it rather than spamming
the community relays with a fresh join every time.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeJJCDLdvi4KQEuozcMMBh
Complete UI Mode used to paint the "seen on" relay pills below the
author avatar (BadgeBox / RelayBadges), hidden from every other mode.
As part of retiring Complete UI Mode, surface those relay icons for
everyone instead: they now appear as an "Accepted by relays" line at
the top of the expanded reaction gallery.
- Add AcceptedByRelaysGallery: a reaction-gallery line (Dns icon +
FlowRow of relay favicons) mirroring the OnchainZap/Nutzap gallery
pattern, subscribing to the note's sampled relay flow.
- Wire it as the first line of ReactionDetailGallery and drop the
hasReactions gate so the line shows whenever the gallery is opened
(a note almost always has at least one relay).
- Remove the complete-mode-only BadgeBox and the now-dead RelayBadges
composable and the relayBadges slot in NoteComposeLayout (reindexing
the single-pass measure/place logic).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVPYTTnMbxwi5hUAagsKVc
Relays that can't serve a request (a NIP-50 search-only relay pulled into
the feed, a write-only relay, a relay whose filter shape is rejected) were
being hammered with the same doomed REQs. Observed on a 40s cold start:
search.nos.today CLOSED 13-14x ("error: search filter is required") across
6 subscriptions, plus repeated "restricted: does not accept REQs" and
"unsupported: too many filters". The reconnect path replayed refused REQs on
every reconnect, and many different subscriptions kept hitting the same
capability wall.
Two complementary, purely-quartz mechanisms (so the app and Amy both benefit
with zero wiring):
- Per-subscription refusal memory (RequestSubscriptionState + PoolRequests):
a filter a relay CLOSES is not replayed to that relay across reconnects
until it meaningfully changes or a REQ succeeds (EOSE/event). Never applies
to auth-required (the auth subsystem re-signs and replays) or rate-limited
(the adaptive limiter spaces it out).
- Per-relay capability block (RelayReqRefusals): after 2 refusals, classify a
relay SEARCH_ONLY (suppress only non-search filters; genuine search REQs
still flow) or NO_READS (suppress all), from narrow substring markers that
deliberately avoid auth-conditional messages. A fully-blocked relay is
dropped from PoolRequests.desiredRelays so the pool disconnects it, closing
the idle socket rather than keeping it open with every REQ suppressed. A
SEARCH_ONLY relay stays connected while any subscription carries a search
filter for it, so the separate search path is unaffected.
Adds UNSUPPORTED to MachineReadablePrefix (relays send "unsupported:"; parse()
returned null on it before).
Device-verified: search.nos.today now Connecting -> OnOpen -> 2x Closed ->
Disconnected; sendit.nosflare.com (NO_READS) -> 3x Closed -> Disconnected;
feed event volume unchanged (kind-1 from 17 relays) - no coverage loss.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a "Conversations to load" section to Settings › Messages that lets users
choose which chat protocols the inbox loads: NIP-04, NIP-17, NIP-28, NIP-29,
Marmot (MLS), Concord, Geolocation (geohash) and Ephemeral chats.
Disabling a type both hides its rows from the inbox and drops its kinds/
assemblers from the always-on downloading routes:
- New ChatFeedType enum (commons) with stable persisted codes.
- AccountSettings.enabledChatFeeds (defaults to all-on); persisted per-device
in LocalPreferences as the disabled set, so absence = everything on and any
future type defaults enabled.
- ChatroomListKnown/NewFeedFilter gate each section (NIP-04 vs NIP-17 split by
event type) in both the full build and the incremental update paths.
- Each downloading route (rooms-list NIP-04/28/ephemeral/geohash, account gift
wraps + Marmot, NIP-29 joined groups, Concord) returns no filters when its
type is off and re-arms via a shared launchChatFeedToggleObserver.
- AccountFeedContentStates rebuilds both tabs when a toggle flips.
- Modernized MessagesSettingsScreen: colorful accent switch cards per type; the
NIP-29/Concord display-mode options now only appear while their type is on.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RbzL9cZk1h88kE7ErgjG5
Document this session's CLI additions in the living NIP-46 device checklist
(no separate plan doc for two flags — they're covered by `amy --help`):
- a "CLI interop driver (amy)" section covering amy on both sides — client
(login bunker:// / --nostrconnect [--perms]) and signer (bunker --perms /
--interactive) — as the reproducible interop harness.
- a repro command on the NostrConnect informed-consent item: `amy login
--nostrconnect --perms sign_event:1,nip44_encrypt` emits a perms-carrying
offer that drives the app's consent sheet.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Until now the CLI bunker auto-approved every request (it hosts the
operator's own key, so the pairing secret was the only gate). That left
two NIP-46 signer behaviors the harness couldn't exercise: a signer that
*rejects* disallowed ops, and a signer that asks a human live.
Add two opt-in gates to both `amy bunker` and `amy bunker connect`:
- `--perms sign_event:1,nip44_encrypt,…` restricts the signer to the
listed ops; anything else is rejected. Fully scriptable/headless. This
is the server-side mirror of the client's `--nostrconnect --perms`, so
an interop run can now test a client against a rejecting signer.
- `--interactive` keeps the bunker listening and prompts `y/N` on the
terminal for any op the policy doesn't already allow, so the operator
approves/rejects each request live. TTY-guarded (errors on a piped
stdin), default-deny, prompts serialized by a mutex because the service
dispatches requests concurrently. Composes with `--perms` (auto-allow
the safe ops, prompt for the rest) — mirroring Amethyst's Reasonable
policy.
Neither flag → unchanged auto-approve behavior. Thin assembly: the perms
parsing and request→op mapping are reused from commons
(`Nip46PermissionAuthorizer.parsePerms` / `toSignerOp`), both already
unit-tested there.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
The Amethyst signer now honors a nostrconnect offer's `perms=` for
informed consent, but the CLI client had no way to emit one — so the new
consent flow couldn't be driven from `amy`, the project's interop-test
harness.
Add a `--perms` flag to `amy login --nostrconnect`, threaded into the
offer through quartz's NostrConnectURI (which already builds/parses the
param). Also keep the parsed perms on NostrConnect.Offer and surface a
client's requested perms in `amy bunker connect`, so an operator can see
what an app-side signer would be asked to pre-grant (the CLI bunker still
auto-approves the operator's own key — perms is not a gate there).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Closes the one real gap from the "first-class" review. The nostrconnect flow
treated the paste/scan as blanket consent and, having no dialog, had to DROP the
app's declared sensitive perms to avoid a silent grant.
Now first contact via nostrconnect shows a connect sheet with the app's identity,
the account it would act as, the exact permissions it declared (rendered
human-readably — "Sign notes (kind 1)", "Encrypt messages", …), and a trust
picker — before anything is published or granted. Approving connects and
pre-grants exactly those declared ops (including sensitive ones the user just
reviewed) unless they pick Paranoid; Cancel/Block declines and nothing is
registered. A re-pair of a known app skips the sheet and keeps the user's prior
trust/per-op decisions.
Adds SignerConnectInfo.requestedPermissions + rendering, a
Nip46ConsentBridge.requestNostrConnectConsent path, and a ConnectResult.Declined.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
The UI login path (AccountSessionManager) and the background preloaders
(RegisterAccounts, EventNotificationConsumer, loadAllWritableAccounts) race
loadAccount's check-then-create on cold start and after account switches.
Both saw an empty cache and both built an Account: the loser was never
cancelled, so its Nip46SignerState kept answering bunker requests with a
NostrSignerExternal no Activity ever registers an Amber launcher on — every
sign failed 'No activity to launch from' while the twin raced error replies
to NIP-46 clients and doubled every consent prompt. Reproduced on-device
(f8ff11c7 constructed twice 10ms apart); gone after the lock.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two follow-ups on the batched-consent sheet:
One account per sheet. The consent coordinator is process-wide, so a batch could
bundle requests for different logged-in accounts. Instead of mixing them, the
Activity now renders only the oldest-pending account's group; when that clears,
the next account's requests render as their own sheet. The account moves to a
header (avatar + "signing as <name>") since every row in a sheet now shares it.
Dismissing denies only that account's group.
Expandable per-item preview. Each batch row is collapsed to app · operation +
a one-line excerpt; tapping it expands the full detail so the user can inspect
exactly what they're signing/encrypting/decrypting — the unsigned event rendered
as a NoteCompose (with the JSON toggle), or the encrypt/decrypt plaintext.
Extracted that rich content block into a shared SignerConsentPreview reused by
the single-request dialog and each expanded row.
Removed the now-unused denyAllPending (dismissal is per-group / per-request).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
The consent coordinator is process-wide, so a batched sheet can bundle requests
for different logged-in accounts. The rows showed only the app + operation, not
which identity would sign — against the "make it clear which account is signing"
rule the single-request dialog already follows. Each row now carries the signing
account's avatar + name, so a mixed-account batch is unambiguous.
(The batch stays a compact triage list — it does not render a full NoteCompose
preview per item the way the single-request dialog does.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
An independent review of the concurrent-servicing changes found two real bugs
(the quartz/authorizer concurrency core reviewed clean):
- Notification TOCTOU. SignerConsentCoordinator did a non-atomic
"if pending empty → cancel notification" in the resolving request's finally.
A request arriving concurrently could post the shared full-screen-intent
notification between another request's empty-check and its cancel, wiping the
new request's only surface while backgrounded — it then sat unseen until the
120s timeout denied it. Add/show and remove/empty-check/cancel now run under
one surfaceLock, so a live request's notification can't be cancelled out.
- Batched selection re-seeded to all-selected on any pending-set change
(fail-open). Because requests are serviced concurrently, the pending set
changes under an open sheet; re-seeding silently re-checked deselected items
and auto-checked newly-arrived requests, so "Allow selected" could grant ops
the user deselected or never saw. Now seed once and reconcile incrementally
(selected ∩ tokens): deselections survive and a new request is never
auto-selected. Also default the batch "Remember" toggle off.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Self-review of this session's changes surfaced four issues:
- Perms re-seeded on every re-pair. connectViaNostrConnect pre-granted the
offer's declared ops outside the first-contact guard, so re-pairing an app
overwrote per-op decisions the user had since changed (e.g. an op set to DENY
came back as ALLOW). Now only on first contact.
- Perms could silently grant sensitive ops. The nostrconnect flow shows no
dialog (scan = consent), so the declared perms are never surfaced — yet seeding
pre-granted everything except decrypt/deletion, which would silently allow
config-overwrite (kinds 0/3) and other sensitive kinds. Tightened to only the
ops REASONABLE already auto-allows, so pairing never exceeds the default policy;
sensitive kinds still prompt on first use.
- Batched-consent deny-all race. SignerConsentActivity.onDestroy denied every
pending request when finishing; a request arriving as the sheet closed is owned
by a freshly-launched instance, so it was wrongly denied. Removed — each
dialog's onDismissRequest already fails closed, and the 120s bridge timeout
backs it up.
- Redundant work: batched-selection state keyed on list size (a new request in a
same-size swap was unselectable) → key on the token set; connected-apps loader
re-read loadPolicy per app when allPolicies() already carried it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Third refinement from the Primal comparison — and the one that needed an
architecture change, not just UI.
Quartz: NostrConnectSignerService now fans each request into a child coroutine
under a Semaphore(maxConcurrentHandles=16) instead of handling them inline, so a
request awaiting a consent prompt no longer blocks other clients' auto-allowed
traffic and several prompts can be pending at once. Intake (dedup, staleness,
rate-limit, seen-id persistence) stays on the single consumer. Two guards keep
it safe: BunkerRequestProcessor serializes the actual crypto with a Mutex
(authorization — the prompt — runs unlocked, only sign/encrypt/decrypt holds the
lock) so an external NIP-55 signer never sees concurrent IPC ops; and
Nip46PermissionAuthorizer serializes first-connect consent so two connects can't
stack dialogs. Covered by BunkerRequestProcessorConcurrencyTest (crypto never
overlaps; a blocked prompt doesn't stall another client's signing).
Amethyst: SignerConsentCoordinator is now a shared pending StateFlow; one
SignerConsentActivity observes it and shows the rich single-request dialog (1
pending) or a batched checkbox list with select-all + a Remember toggle +
Allow/Deny selected (>1). Dismissing the sheet denies every still-open request
(fail closed).
Needs on-device validation (burst batching, no concurrent external-signer IPC,
fail-closed on dismiss) — see the device checklist.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Two of the three refinements from the Primal comparison:
Trust picker as security icons: unify the connect dialog and the app-detail
picker on one open→shield→locked glyph set — LockOpen (Full trust, never asks),
Shield (Reasonable), Lock (Paranoid) — replacing the connect dialog's emoji and
the detail's heart so both surfaces read the same and the icon carries the
guard-level at a glance.
Reconnect affordance: when an app's relays show Offline (from the live status
added last commit), offer a one-tap Reconnect — on the connected-apps row and in
the detail's Relays section — that forces the relay pool to re-dial now, ignoring
backoff. Shared Nip46ReconnectPill.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Two gaps found comparing against Primal's NIP-46 signer:
Honor the offer's `perms`: we already parsed the `nostrconnect://?perms=` list
but ignored it. Now the declared ops are pre-granted at pairing (the deliberate
pair is the user's consent for what the app openly asked for), so a client that
declares its needs runs without prompting on first use. The two highest-risk
classes stay gated even when declared — decryption (private content) and
deletion (kind 5) still prompt on first use with full context. Adds
Nip46PermissionAuthorizer.parsePerms + tests.
Per-app live relay status: the connected-apps list shows a Connected/Offline dot
per app (judged on its own nostrconnect relays, or the inbox relays for a
bunker-flow app), and the detail Relays section shows a live dot per relay — so
"which relays is this costing me and are they up right now" is answerable at a
glance. Shared Nip46StatusDot/Nip46LiveStatus/nip46AppOnline helpers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
The list row's "N relays" count wasn't inspectable, so a user debugging why the
signer holds a background relay connection couldn't see which relays an app
uses. Add a Relays section to the (already tap-through) detail screen listing
each relay URL, with a note that Amethyst keeps a background connection to each
while the app stays connected. Apps that brought no relays of their own (the
bunker flow) show that they ride the account's inbox relays and add no extra
connection.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
A NIP-46 client's connect metadata can include an image (its app/site icon).
Render it — in a circle, with the Key glyph as the placeholder/error fallback —
for both the connected-app list row and the detail header, via a shared
Nip46AppIcon. We only draw an icon the app itself advertised; we never fetch a
site favicon from the main app, which would bypass Tor and leak the user's IP
(the same reason BrowserIconRegistry captures favicons in the sandbox instead).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
The dedicated NIP-46 apps list always rendered the client's npub, while the
detail header showed the app's self-declared website (url) when it had one — so
an app that advertised a website looked like a bare pubkey in the list but a
website once opened. Extract one nip46ClientSubtitle(url, clientPubKey) helper
(website host when declared, npub otherwise) and use it in both places so a row
and the screen it opens never disagree.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Move the Nostr Signer out of Settings into the left drawer's "You" section,
directly under Wallet (and available as a bottom-bar favorite). Removed the
Settings catalog entry.
Give NIP-46 remote-signer clients their own management screen, separate from
the napplet/nsite/browser Connected Apps screen — unlike those, each NIP-46 app
can carry its own relays that the signer keeps subscribed in the background, so
they need distinct visibility (name, npub, relay count, last-used, trust level)
and pruning. The shared Connected Apps screen no longer lists NIP-46 apps.
Auto-forget apps idle for 7+ days on signer start (Nip46PermissionAuthorizer.
pruneIdle), so an app paired once and abandoned stops leaking a background relay
subscription forever. last-used is stamped on connect and every serviced op, so
an app still in use is never pruned.
Surface the consent dialog when Amethyst is backgrounded: a bare startActivity
from the app context is silently dropped by Android 12+ background-activity-launch
restrictions, so the dialog never appeared and the request timed out. Add a
full-screen-intent notification fallback (the same mechanism CallNotifier uses
for incoming calls) on a high-importance channel; it no-ops when the app is
already in the foreground so there's no redundant heads-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
- Enlarge the bunker QR to fill the card width (responsive, easier to scan)
instead of a fixed 232dp.
- Drop the raw bunker:// URI text under the QR — the QR + Copy button convey
the address; the long hex string was just noise.
- Remove the "Signing as npub1…" line — on your own signer settings screen the
account is already implied (that clarity belongs on the consent dialogs,
where it now lives).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
allPolicies() enumerates the datastore directory and reads each file (blocking
disk IO) but is invoked from Compose LaunchedEffects on the main dispatcher,
tripping StrictMode's DiskReadViolation. Wrap the File listing + reads in
withContext(Dispatchers.IO). Fixes both the NIP-46 signer screen and the
Connected Apps screen callers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
The per-op consent dialog now renders a sign_event/publish request as a real
NoteCompose preview — what the note will actually look like once signed
(text + media + mentions, authored by the signing account) — instead of only
a quoted content snippet. The "Show event" JSON toggle stays as a fallback for
anyone who wants the raw payload.
Reuses the live AccountViewModel via CallSessionBridge (the same handle
CallActivity uses to render app UI from a standalone Activity), builds a
transient unsigned Note with RumorAssembler.assembleRumor + createTempDraftNote
(never persisted/verified — the same path the composer uses to preview an
unsent post), and passes EmptyNav so taps don't navigate out. SignerConsentInfo
carries the EventTemplate; both the NIP-46 bridge and the napplet
buildSignerConsentInfo populate it for Publish/SignEvent.
Falls back to the content quote + JSON when the AccountViewModel isn't
available (main Activity gone) or the op has no event (encrypt/decrypt).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Both signer dialogs now render the account's avatar + display name instead of
a raw pubkey / coordinate hex, so it's clear which logged-in identity is
approving, signing, encrypting, or decrypting:
- Connect dialog: replaces the client transport-pubkey line with the account
being connected to (avatar + name).
- Per-op dialog: replaces the meaningless coordinate hex with the account that
would sign/encrypt/decrypt.
The account is resolved from the coordinate's signer pubkey
(Nip46PermissionAuthorizer.signerPubKeyOf) via LocalCache, and rendered with a
shared ConnectedAccountRow (RobohashFallbackAsyncImage + name, robohash
fallback). SignerConnectInfo/SignerConsentInfo carry the account
name/picture/pubkey; the napplet/browser paths leave them null and keep their
existing domain line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Reject sign/encrypt/decrypt when the identity signer is not writeable — the
account was logged out, is read-only, or lost its external (NIP-55) signer —
returning an `account unavailable` error instead of prompting the user or
hanging on a key that can't be used. Checked before authorization, so no
dialog is raised for a key we can't sign with. Public reads (get_public_key,
ping, get_relays) stay ungated.
Test: a non-writeable signer refuses a sign request without invoking the
signer or the authorizer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
The 30s window shrank the restart re-sign problem but couldn't close it: a
relay replays stored ephemeral requests on re-subscribe, and the in-memory
dedup set is wiped on restart, so anything within the window came back.
Persist the recently-serviced kind-24133 event ids (bounded to 128) and seed
the service's dedup set from them on start, so a replay after an app restart
is dropped by EXACT event id. Chosen over a created_at high-water mark on
purpose: a global timestamp floor would wrongly drop a second connected app
whose clock lags behind another's, whereas id-matching is immune to client
clock skew. The `since` filter still bounds how far back relays replay.
- AccountSettings.nip46SeenRequestIds (persisted via putStringSet) + host-side
bounded LinkedHashSet, fed to NostrConnectSignerService.initialSeen and
advanced through onHandledId.
- Tests: a fresh request whose id was serviced last session is not repeated;
the serviced id is reported for persistence.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
An app restart re-subscribes with `since = now - window`, so relays replay
(and the signer re-signs) anything created within the window. 120s was wide
enough that a request made a minute before restart still came back; 30s keeps
that replay window small while still tolerating normal NTP clock skew.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Users on multiple relays were being asked to sign the same request repeatedly,
some minutes old. Root cause: kind-24133 is ephemeral, but many relays store
and REPLAY it every time the signer re-subscribes (a relay-set change,
reconnect, toggle, or rotation), and the in-memory dedup set is scoped to one
run() call so it's wiped on restart — the old requests then get signed again.
Gate requests by created_at (default 120s window):
- a `since` on the subscription filter so compliant relays never replay old
stored events, and
- a receive-side staleness drop for relays that ignore `since`.
The window must exceed realistic client/relay clock skew so a genuinely fresh
request is never dropped. Also corrected the seenCap KDoc, which called the
event-id dedup set a "request-id" set.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Runs the exact payload (kind 1 + `client` tag + fixed created_at) through the
processor/authorizer with a REASONABLE policy and asserts: it signs with no
prompt (kind 1 is auto-allowed), created_at/content/tags are preserved, the
event is authored by the identity key (not the transport key), and the
signature + id verify.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Independent review confirmed the first-connect wedge (fixed) and flagged the
inline-consent head-of-line blocking and relay-restart cancellation as
architectural limitations to address with a device-tested subscription/
concurrency redesign, plus three low-severity items. Documented so they are
not lost.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Audit fixes:
- requestConnect had no timeout while requestOp did. Since authorize()/
onConnect() run inline in the signer service's single-consumer loop, an
ignored first-connect dialog blocked every other client's requests forever.
Both consent prompts now fail closed on the shared 120s timeout (per-op →
deny-once, connect → declined) so a stuck dialog can't hold the loop hostage.
- Remove the now-unused Nip46ActivityLog.forClient() (the detail screen filters
the collected flow instead).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
The per-operation consent dialog and the first-connect trust picker are not
napplet-specific — napplets, sandboxed browser origins, and now the NIP-46
remote signer all prompt through them. Rename and relocate them out of the
napplet package into a neutral home so the shared plumbing reads honestly:
napplet/NappletSignerConsent{Info,Coordinator,Activity,Dialog}
→ connectedApps/consent/SignerConsent{Info,Coordinator,Activity,Dialog}
napplet/NappletConnect{Info,Coordinator,Activity,Screen}
→ connectedApps/consent/SignerConnect{Info,Coordinator,Activity,Screen}
The genuinely napplet-specific pieces stay put: the capability-consent flow
(NappletConsent*), and the napplet→info builders (buildSignerConsentInfo /
buildConnectInfo, which resolve a napplet manifest identity) now return the
relocated generic render models. Manifest activity names and all references
updated; no behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
- Nip46ConsentIntegrationTest: end-to-end through the real dispatch path
(BunkerRequestProcessor → Nip46PermissionAuthorizer → opConsent/connectConsent)
with a real NostrSignerInternal — proves an ASK sign prompts and returns a
signed event on allow, "unauthorized" on deny, and that a FULL_TRUST app
signs even a dangerous kind (0) without prompting.
- Device checklist (amethyst/plans/) for the interactive/background/interop
behavior JVM tests can't cover: pairing paths, consent variants, rotation,
activity feed, relay health, boot restart, and the reference-client matrix.
Notification polish was deliberately skipped: the always-on notification is
shared with the relay/DM service, and consent uses its own dialog Activity, so
neither retitling nor notification actions are warranted. Documented in the
checklist.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
The live status card now reflects whether the signer's listening relays are
actually connected, so silently-missed requests become visible: it reads the
client's connectedRelaysFlow(), intersects with the signer's listening set,
and shows "Listening on N relays, all connected" or "X of N relays connected"
when some are down.
Boot-restart needs no change: the existing BootCompletedReceiver already
restarts the foreground service whenever isEnabled() is true, and that check
honors nip46SignerEnabled — so an enabled signer resurrects after a reboot or
app update.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
The shared Connected-App detail screen mis-parsed a nip46:<signer>:<client>
coordinate and rendered it through the napplet manifest path (showing a
truncated coordinate). Add a NIP-46 branch that:
- heads the screen with the client's self-declared name + url (from the stored
Nip46ClientInfo) and a key badge, and titles the top bar with the app name;
- shows that client's recent serviced-request history (reusing the shared
Nip46ActivityCard, extracted so the signer screen and this screen share it).
The existing trust-level picker, per-op overrides, and NIP-46-aware Forget
action render below unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Give the user visibility into what the signer is doing:
- Nip46ActivityLog: a bounded, newest-first, in-memory feed of serviced
requests (method + kind + client + ok/denied), fed from the service's
onServiced hook (enriched to pass the full BunkerRequest so the event kind
is available). Survives service restarts; not persisted (it's a live feed).
- The signer screen shows a "Recent activity" card (last 8, friendly labels
like "Signed an event (kind 1)", green/red status dot, relative time) and a
"Signing as npub1…" line so it's clear which account is the bunker.
onServiced now hands callers the BunkerRequest instead of just the method
string (CLI updated to match).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Wire the NIP-46 remote signer into the same interactive consent surface the
napplet/browser signer path uses, so requests that aren't pre-granted prompt
instead of silently failing.
The ledger already returns ASK for the risky operations (profile 0, contacts
3, deletion 5, decryption, DMs are excluded from REASONABLE_SIGN_KINDS; a
PARANOID app asks for everything) — the only reason it didn't work was that
authorize() treated ASK as "unauthorized". Now:
- authorize(): ALLOW proceeds, DENY refused, ASK consults an in-memory session
grant then calls opConsent (the shared per-op dialog). The returned
SignerOpGrant is recorded via a new NostrSignerPermissionLedger.record()
helper (allow-for-op / until / all / deny-for-op persisted; once/session not),
mirroring the broker. No opConsent wired → ASK fails closed (CLI/tests).
- onConnect(): first contact asks connectConsent for the trust level
(AppConnectResult) instead of silently granting REASONABLE; Blocked/Cancelled
reject the connection. Falls back to defaultPolicyOnConnect when no prompt.
- forget() also clears the client's in-memory session grants.
Nip46ConsentBridge (amethyst) implements the two prompts by reusing the
existing NappletConnect/NappletSignerConsent coordinators + dialogs + ledger,
building the render info from the bunker request (op label, event JSON
preview, client metadata/icon). A 120s timeout fails a stuck per-op prompt
closed so it can't wedge the signer's single-consumer loop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Add a VIEW/BROWSABLE intent filter for the `nostrconnect` scheme on
MainActivity so a website's "Connect with Amethyst" link (or any app
firing a nostrconnect:// URI) hands off into the same pairing flow as a
scanned QR. The incoming intent.data already flows through uriToRoute,
which now maps a nostrconnect:// offer to Route.Nip46Signer(connectUri);
both the cold-start and onNewIntent paths navigate there and pair on open.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Two UX gaps made "scan an app's code to connect it to my signer" hard to
reach:
- The signer screen's Connect section (Scan a code / paste) lived behind
the enabled gate, so a first-time user saw only the "Turn on signer"
hero and couldn't scan until they had enabled. Since a successful
nostrconnect pairing already calls setEnabled(true), the gate was
pointless. The Connect section now shows in both states; the success
toast spells out the consequence ("Amethyst now signs for it in the
background").
- The app's general QR scanner (uriToRoute) only understood NIP-19
entities, so scanning a nostrconnect:// code did nothing. It now routes
a nostrconnect:// offer to the signer screen, which pairs the app on
open. Route.Nip46Signer carries an optional connectUri for this.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Make the "regenerate" action mint a brand-new transport keypair (plus a
fresh pairing secret) instead of only rotating the secret, so a spammed
user can burn down the old bunker:// address. Anyone holding the old
address — a spammer included — can no longer reach the signer, and every
app talking to the old transport pubkey is dropped. Legit apps re-pair by
re-scanning; their trust survives because the Connected-Apps coordinate
keys off the stable identity pubkey, not the transport key.
For rotation to actually take effect, the cached `by lazy` transportSigner
is replaced with a per-call rebuild from the persisted key, and the
service-restart trigger now includes AccountSettings.nip46TransportKey so
the running NostrConnectSignerService re-subscribes under the new key.
setEnabled() settles the secret and transport key before flipping the flag
to avoid a throwaway double-start on first enable.
The UI gates rotation behind a confirmation dialog (it disconnects every
connected app) and relabels the action "New address".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
NIP-46 lets the remote-signer (transport) key differ from the user's identity
key. Previously the bunker advertised and wrapped everything with the identity
key, so anyone watching the inbox relays could see kind-24133 traffic addressed
to the user's real npub and infer "this npub runs a bunker".
Now each account gets a dedicated, persisted transport keypair:
- NostrConnectSignerService wraps/unwraps the kind-24133 envelope with a
`transportSigner`; BunkerRequestProcessor keeps the identity signer for the
actual sign/encrypt/decrypt and answers get_public_key with the real npub
(disclosed only to a connected client, over the encrypted channel).
- The host mints + persists the transport key lazily (accounts that never
enable the signer mint nothing), advertises it in bunker:// and the
nostrconnect ack, and listens p-tagged to it.
- AccountSettings/LocalPreferences persist nip46TransportKey so the advertised
address stays stable across restarts.
Bonus: because the envelope is now wrapped with a LOCAL key, external NIP-55
(Amber) accounts no longer round-trip the external app for envelope crypto —
only the genuine signing request does. A new test asserts get_public_key
returns the identity, never the transport key. Unreleased feature, so no
migration needed. The CLI bunker keeps using the operator's own key (dev tool).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Clearing a connected client on logout had two gaps:
- The user-facing "Forget this app" button only revoked the permission ledger;
it never cleared the NIP-46 client store, so a forgotten app's metadata and
relays lingered and were re-recovered on the next restart. Route NIP-46
coordinates through the host's new forgetClient() so the store is cleared too.
- Neither logout path stopped the RUNNING session from listening on the app's
relays — only the next restart picked up the change. extraRelays is now a live
projection of the client store (recomputed on connect, on start, and on
disconnect via a new onDisconnected hook), so a forgotten app's relays are
dropped immediately.
onLogout and the UI Forget now share one authorizer.forget() path (revoke grant
+ clear store + clear throttle entry + signal the host), so client-initiated and
user-initiated disconnects behave identically. Adds tests for both.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
The per-app signing-authorization plumbing was named/located under `napplet/`
for historical reasons, but it is not napplet-specific — it already gates
napplets, the sandboxed browser, and (now) NIP-46 remote clients through one
shared ledger. The package name mislabelled what the code is, so:
- commons: `napplet/signers/` (generic) → `connectedApps/signers/`
(AppSignerPolicy, NostrOpDecision, NostrSignerOp, NostrSignerConsentPrompt,
NostrSignerPermissionLedger/Store). The NIP-46-specific bridge moves to
`connectedApps/nip46/` (Nip46PermissionAuthorizer, Nip46ClientStore), so the
feature is no longer split across unrelated packages.
- The `NappletRequest.toSignerOp()` extension — napplet protocol leaking into
the generic layer — moves back to `napplet/protocol/`.
- amethyst: `napplet/DataStoreNostrSignerPermissionStore` → `connectedApps/`,
`napplet/DataStoreNip46ClientStore` → `connectedApps/nip46/`.
Pure move + repackage: all 27 import sites updated, no behaviour change.
Napplet-specific code (broker, capabilities, consent, :nappletHost) and the
Connected Apps UI folder are untouched — those really are napplet/UI.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Follow-ups to the audit:
- Abuse protection: the signer service now bounds its event queue
(DROP_LATEST) and rate-limits per author BEFORE decrypting — decryption can
be an external-signer (NIP-55) IPC round-trip, so a looping or hostile client
can no longer force one per event or grow the queue without limit. Fixed
window (default 40 requests / 10s per author, oldest authors evicted). The
limiter is touched only by the single consumer coroutine, so it needs no
locking. Covered by a headless test.
- logout now clears the client's persisted metadata/relays too (not just the
ledger grant), so a disconnected app stops being listened for after restart.
Not changed: get_public_key/ping stay ungated — gating them behind a prior
connect risks breaking clients that discover the pubkey at connect time, and
the pubkey is already public, so the enumeration leak is negligible.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Findings from an audit of the signer, all verified against the code:
- Data race: NostrConnectSignerService deduped request ids inside onEvent,
which the relay pool invokes CONCURRENTLY from each relay's socket thread
(PoolRequests dispatches listeners outside its lock). Two relays delivering
the same subscription could mutate the LinkedHashSet at once → race / CME.
Move dedup into the single consumer coroutine; onEvent now only does the
thread-safe channel send.
- Swallowed cancellation: broad `catch (Exception)` around suspend calls in the
processor, the service's decrypt + publish, and connectViaNostrConnect caught
CancellationException too, breaking structured cancellation when the service
restarts. Rethrow it first (matching the AccountCacheState convention).
- Write amplification: the ledger wrote last-used to that client's DataStore
file on EVERY authorized request (unthrottled, unlike the relay-auth store).
Coalesce to at most one write per client per 60s in the authorizer.
- Redundant resubscribe: the enable/relays collector lacked distinctUntilChanged,
so a duplicate inbox-relay emission tore the subscription down and re-opened
it on every relay for nothing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
The signer's relay subscription lives in the account scope and needs the shared
NostrClient connected and the process alive to keep working while Amethyst is
backgrounded or closed. Hook it into the existing always-on foreground service
(and its five restart layers) instead of building a new one:
- AlwaysOnNotificationServiceManager now starts/stops the layers when EITHER the
notification service or nip46SignerEnabled is on (combined flow).
- NotificationRelayService.isEnabled (the auto-restart guard) honors the signer
flag too, so START_STICKY / watchdog / boot restart keep the signer up.
- The signer screen notes that a background connection (ongoing notification)
is what lets it answer requests while closed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Add a Nip46ClientStore (commons interface + InMemory + a single-file Android
DataStore) keyed by the same signer-namespaced coordinate as the permission
ledger, holding each connected client's self-declared name/url/image and the
relays it reaches us on.
- The host persists metadata on connect (bunker + nostrconnect) and, for the
nostrconnect flow, the app's own relays. On startup it re-adds those relays
to the listen set, so a nostrconnect-paired app stays reachable across app
restarts instead of silently going dark until it re-pairs.
- Connected Apps now shows the app's real name (falling back to the generic
label + npub) for remote-signer clients.
- Wired the store through AppModules → AccountCacheState → Account → host.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
The Connected Apps signer store is app-global, so a remote client keyed only by
its own pubkey would share one trust level across every local account. Namespace
the coordinate as `nip46:<signerPubKey>:<clientPubKey>` so the same client paired
with two accounts on one device gets independent grants.
- Nip46PermissionAuthorizer takes the user's signerPubKey; coordinateFor/belongsTo
encode + match the namespace; clientPubKeyOf reads the trailing segment.
- onLogout now revokes the client's grant (wired through the new quartz hook).
- Connected Apps lists only the active account's remote clients (napplet/browser
grants stay app-global); the signer screen counts the same way.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
- NostrConnectSignerService now bounds its request-id dedup set (LinkedHashSet
with an evicting cap) so a long-lived bunker can't leak memory on the ids it
has seen.
- Add NIP-46 `logout`: the processor recognises the method, acks it, and calls
a new Nip46RequestAuthorizer.onLogout hook (default no-op) so a host can
revoke the app's grant when it disconnects.
- New NostrConnectSignerServiceTest drives full request→reply round trips
(connect/sign/logout + drop-if-not-addressed) through the service over a fake
relay client with passthrough signers — headless proof of the subscribe →
decrypt → dispatch → publish wiring.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Rework the signer settings screen from a plain toggle list into a purpose-
built pairing surface:
- A disabled-state hero (key medallion, headline, one big "Turn on signer"
call to action) that reads as a feature intro rather than a setting.
- A live status card with an animated pulsing dot summarising "signing for N
apps · listening on M relays".
- A QR hero: the bunker:// address rendered as a large scannable QR
(QrCodeDrawer) on a white surface — pairing is scan-first, with copy and
new-secret as tonal actions and the raw string kept as a caption.
- Scan-to-connect: a primary "Scan a code" button opening the QR scanner for
nostrconnect:// offers, with paste-a-link as a revealable fallback.
- A connected-apps row showing the live count and linking into Connected Apps.
Reuses the existing QrCodeDrawer / SimpleQrCodeScanner composables.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Adds the "Nostr Signer" feature: Amethyst can now be a remote signer (a
"bunker") for other apps, listening on the user's inbox relays and signing
through whatever signer the account uses — a local key or a NIP-55 external
app — gated by the shared Connected Apps trust ledger.
- Nip46SignerState: account-scoped host that runs the quartz
NostrConnectSignerService on the inbox relays whenever the feature is on,
restarting on relay/toggle changes. Builds the bunker:// advertisement,
handles nostrconnect:// paste pairing, and manages the pairing secret.
Requests are authorized through Nip46PermissionAuthorizer (the Connected
Apps ledger), so a remote client is a connected app under nip46:<pubkey>.
- AccountSettings: persisted nip46SignerEnabled toggle + nip46BunkerSecret,
wired through LocalPreferences.
- Account/AccountCacheState/AppModules: build the signer ledger from the
app-global Connected Apps store and construct the host per account.
- UI: a Nostr Signer settings screen (enable toggle, listening status,
copyable bunker address with secret regeneration, nostrconnect:// connect
box, link to Connected Apps) + settings-catalog entry + route. Connected
Apps renders nip46: clients as remote-signer cards with their trust chip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Nip46PermissionAuthorizer implements the quartz Nip46RequestAuthorizer by
routing every remote-signer request through the shared Connected Apps
permission ledger (NostrSignerPermissionLedger). A NIP-46 client becomes a
connected app under the coordinate `nip46:<clientPubKey>`, so it reuses the
same per-app trust levels and per-op overrides as napplets and web origins:
- sign/encrypt/decrypt requests map to NostrSignerOp and are allowed only when
the ledger's standing decision is ALLOW (ASK/DENY are refused — a background
signer cannot prompt, so access is granted ahead of time in the UI).
- connect validates the pairing secret, then registers the app at a default
REASONABLE policy (never downgrading a level the user already set) and echoes
the secret back.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Replace BunkerCommand's hand-rolled subscribe/decrypt/dispatch/publish loop
with NostrConnectSignerService + BunkerRequestProcessor, and route bunker://
/ nostrconnect:// URI parsing+building through NostrConnectURI. Behaviour is
unchanged (the CLI bunker still hosts the operator's own key and auto-approves
every request via a small CliAuthorizer that only checks the pairing secret);
this removes the duplicated protocol logic now that it lives in quartz.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Adds the bunker/signer half of NIP-46 as reusable, signer-agnostic quartz
components so Amethyst can act as a remote signer for other apps:
- BunkerRequestProcessor: turns a decrypted BunkerRequest into the
BunkerResponse the client expects, performing the work through whatever
NostrSigner the account uses (local keypair or NIP-55 external app).
Signing/encryption/decryption are gated through a Nip46RequestAuthorizer;
public reads (get_public_key/ping/get_relays) are not.
- Nip46RequestAuthorizer: the permission boundary the host app plugs its
own trust model into (connect validation + per-op authorization).
- NostrConnectSignerService: subscribes to kind-24133 requests on a relay
set, decrypts, dispatches to the processor, and publishes the reply.
- NostrConnectURI: KMP-safe parse/build for bunker:// and nostrconnect://
pairing URIs (percent-encoded), shared by CLI/desktop/Android.
Unit tests cover the dispatch/authorization matrix with a fake signer (no
crypto) and the URI round-trips.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Replace the Material 3 DockedSearchBar with a persistent, pill-shaped
search field that filters the settings list in place instead of opening
a results dropdown. The docked component is aimed at a search surface
with its own results view (and the tablet/desktop form factor); an
in-page filter — as in the Android system Settings app — is a better fit
here.
- Rounded, tonal search field pinned above the list (Material 3 look).
- Typing narrows the categorized list directly; a blank query shows all.
- Clear (X) resets the query; "no results" state when nothing matches.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQYnKXff1esD1bdPGNzyHp
With onPrimary contrast-picked, the default filled Button/FAB/Switch already
reads correctly on both themes, so the dedicated deep-fill path is redundant.
Remove it everywhere for one uniform look:
- Revert the Save/Post/Send/Create top-bar button and standalone SaveButton to
plain Button (no colors override).
- Revert all 27 AmethystSwitch call sites back to Switch and delete the
AmethystSwitch wrapper + amethystSwitchColors.
- Drop the filledAccent / onFilledAccent ColorScheme getters.
- Update the color-pairs preview: primary/onPrimary now covers the whole
filled-control surface.
The entire filled-control fix is now the single onPrimary = onAccent(primary)
line in the scheme builders.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVwY2Qzth2EREWg8qLhyzF
Drop the old-vs-new normalization comparison now that the color direction is
settled; keep ThemeColorPairsPreview (fg/bg pairs by ColorScheme role, dark |
light) and bump it to heightDp=980 so all rows render. Revert darkColors/
lightColors back to private (the removed preview was the only external caller).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVwY2Qzth2EREWg8qLhyzF
A live relayop.xyz kind-33301 invite bundle (vsk=6) decrypted correctly but
failed to open: its JSON diverged from quartz's CommunityInvite model on two
CORD-05 wire details, so decodeOrNull returned null and the invite reported
Unreadable ("This invite link can't be opened...") instead of joining.
- InviteChannel.key was required; a public channel (e.g. an unencrypted
`general`) carries no delivered grant key and some reference clients omit
the field. Default it to "" so a keyless channel no longer rejects the
whole bundle.
- icon was modeled strictly as an ImagePointer object; relayop emits a bare
public URL string for an unencrypted icon. Add LenientImagePointerSerializer
(a JsonTransformingSerializer) that lifts a string into ImagePointer(url=...)
on read while still serializing the canonical object form on write.
Adds a regression test driving the real live bundle + its fragment token,
asserting it now classifies as Live. This is a distinct interop case from the
existing vsk=8 mis-posted-registry test: here the sub-kind, token, and crypto
are all correct — only the JSON schema was too strict.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iLfo68yVtG2ALkKB8tpAa
Revert onPrimary from a hardcoded white back to onAccent(primary) in both
schemes. On the dark theme primary is a light pastel, so white content on it
was only ~2.6:1 — every filled Button and FAB (which default to
primary/onPrimary) rendered washed out. onAccent picks by contrast: black on
the dark theme's light accents (~7.9:1), white on the light theme's deep
primary (unchanged). One theme-level change fixes all ~90 filled-control sites
without touching a single element. Save button + switches keep their dedicated
filledAccent fill, left as an on-screen A/B against the lighter default look.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVwY2Qzth2EREWg8qLhyzF
Switch the Settings search from a custom expand-into-the-top-bar icon to
the Material 3 DockedSearchBar component, matching the modern in-page
search convention (e.g. Android Settings).
- A persistent search pill sits below the "Settings" title bar.
- Tapping it expands the docked results dropdown, listing the filtered
settings (a blank query lists everything, narrowing as the user types).
- The back arrow, the system back gesture, and picking a result all
collapse the bar and clear the query.
- The full categorized settings list shows below the pill while collapsed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQYnKXff1esD1bdPGNzyHp
A live HLS stream shared as a plain kind:1 note (a FAST/IPTV channel `.m3u8`)
played its first ~60 s window and then broke or looped. Root cause: the
isLiveStream flag is derived from the Nostr event kind — only kind:30311 live
activities set it — so a live `.m3u8` in a note arrived flagged non-live and was
routed to the caching data source. Caching a live playlist makes ExoPlayer reload
a stale, non-advancing manifest and throw PlaylistStuckException, after which
playback loops replaying the frozen window.
Caching: learn liveness from ExoPlayer instead of the URL or event kind.
HlsLivenessRecorder records the playlist's live/on-demand verdict into
HlsLivenessCache once the media playlist resolves; CustomMediaSourceFactory routes
the next play by it (shouldBypassCache, pure/tested). A live stream is never cached
(the unclassified first play bypasses too), while immutable multi-rendition NIP-71
VOD is cached from its second view. The verdict is asymmetric on purpose — a wrong
"live" only forgoes caching, a wrong "on-demand" breaks playback — so live is
recorded eagerly while on-demand is recorded only from a resolved static window at
STATE_READY. That keeps a live stream's early/placeholder timeline, and a
geo-blocked stream that serves a VOD-shaped placeholder and then 403/404s before it
plays, from being mislearned as cacheable.
Error recovery: recover only ERROR_CODE_BEHIND_LIVE_WINDOW (seek to live edge +
re-prepare) for genuine live-edge drift. An earlier broad "recover any live I/O
error" thrashed on a stream whose segments fail to parse — it re-prepared, briefly
reached READY, hit the same bad segment, and reset its cap on READY, so it looped
forever. I/O and decode errors are now terminal (RenderPlaybackError's overlay),
and the recovery budget refills only after real forward progress past the error.
Restore ThemeColorPairsPreview (every fg/bg pair listed by ColorScheme role
name, current theme, dark | light) next to the by-component old-vs-new table.
The role view answers "what is primaryContainer right now"; the comparison
answers "how did this surface change". Both read the live scheme.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVwY2Qzth2EREWg8qLhyzF
The Settings screen previously showed a permanent search text field below
the top bar. Replace it with a search icon in the top bar's actions slot
that expands into an inline text field when pressed.
- Collapsed: shows the "Settings" title plus a search icon action.
- Expanded: the title becomes an auto-focused inline search field, the
back arrow (and system back) collapses it and clears the query, and a
clear (X) action appears while there is text.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQYnKXff1esD1bdPGNzyHp
Replace the single-theme pair preview with a side-by-side comparison: each app
surface (Save button, checked/unchecked switch, settings tile, card, links,
error) rendered Old·Dark | New·Dark | Old·Light | New·Light with live contrast
and hexes. "Old" reconstructs the pre-normalization scheme (purple primary +
teal secondary, Material's violet-tinted surfaces/containers, deep default
onPrimary); "new" uses the real production schemes, so editing Theme.kt
re-renders both sides. Makes darkColors/lightColors internal so the preview
builds the actual current scheme rather than a copy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVwY2Qzth2EREWg8qLhyzF
The modernized UI Preferences screen (merged from main) added a raw Switch,
which would show the old pale checked colors. Point it at AmethystSwitch like
every other toggle so it picks up the deep filledAccent fill.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVwY2Qzth2EREWg8qLhyzF
The 1dp outlineVariant outline around every SettingsSection card read boxy.
Replace it with a surface-tone step: fill the card with surfaceContainer
instead of surfaceContainerLow. The neutral surface ramp puts the page at
background #FDFDFD, where …Low (#F7F7F7) was nearly invisible; …Container
(#F2F2F2) reads as a soft card in light and #252525 stands clear of black in
dark — the Material way to separate a card, without an outline.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cy9tooutEdFQV5CwMhaP3j
Caught by the first successful build after the Gradle distribution became
seedable in the web sandbox:
- BooleanSwitchTile's toggle inferred as (Boolean) -> Boolean because
MutableStateFlow.tryEmit returns Boolean; annotate it (Boolean) -> Unit so
it fits Switch.onCheckedChange and SettingsControlRow.onClick.
- Font tile referenced MaterialSymbols.Article, which lives under the nested
AutoMirrored object; use the top-level MaterialSymbols.Description glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cy9tooutEdFQV5CwMhaP3j
A living reference composable that renders each foreground/background pair
the mobile theme uses (filledAccent, primary, containers, surfaces, the
unchecked-switch pair, …) with the real AmethystTheme colors — no
approximation — so editing Theme.kt re-renders it. Left column dark, right
column light, via the existing ThemeComparisonRow. Each row shows the live
WCAG contrast, the two hexes, and the concrete in-app surfaces that use it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVwY2Qzth2EREWg8qLhyzF
Upgrade the preview to render the real screen — top bar plus the redesigned
content — side by side in dark and light (ThemeComparisonRow), so both
themes and the card-hairline contrast can be checked at a glance instead of
the content-only, chrome-less preview.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cy9tooutEdFQV5CwMhaP3j
Applies the audit findings on the reworked screen:
- Cards get a 1dp outlineVariant hairline so they separate from the page in
light mode, where surfaceContainerLow (#F7F7F7) barely differs from the
background (#FDFDFD). Applies to every SettingsSection, keeping settings
screens consistent.
- Segmented buttons drop the default selected check icon (the fill already
signals selection) and use compact labels in the tight 3-4-up rows
(Wi-Fi, Full/Simple/Fast, System/Sans/Serif/Mono) so labels stop
ellipsizing to "Unmet…", "Simpl…", "System D…".
- Language becomes a disclosure row (icon + title + current language +
chevron, whole row opens the existing picker dialog) instead of a lone
text-field dropdown, matching the app's other "opens a picker" rows.
- Font-size preview uses a gentler 13sp base so the row no longer lurches
taller for "Huge"; still previews small-to-huge.
- Accent swatches use FlowRow so all six wrap into view instead of scrolling
off-edge with no affordance.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cy9tooutEdFQV5CwMhaP3j
The font family and font size segmented rows now render each option's label
in the very typeface / at the very scale it selects — Sans Serif in sans,
Monospace in mono, Small small and Huge huge — so the control demonstrates
the choices instead of only naming them.
Adds an optional per-option text-style hook to SegmentedChoiceTile, reusing
the existing FontFamilyType.toFontFamily() mapping and FontSizeType.scale.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cy9tooutEdFQV5CwMhaP3j
Replace the flat list of dropdown (TextSpinner) rows on the UI Preferences
screen with the card-based settings design already used by the Compose and
security settings: contextual SettingsSection cards holding in-screen
SingleChoiceSegmentedButtonRow controls and Switch tiles, so every option
is visible and one tap away instead of hidden behind a spinner.
Group the settings by context:
- Appearance: theme, accent color, font, font size
- Media & Data: image preview, video playback, autoplay, URL preview,
profile pictures
- General: language, UI mode, immersive scrolling
The two boolean (Always/Never) settings — autoplay videos and immersive
scrolling — become proper switches. Language keeps a dropdown (too many
locales for segmented buttons) but restyled into the new card. The
externally-consumed SettingsRow overloads and language helpers are kept.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cy9tooutEdFQV5CwMhaP3j
With the Gradle distribution now pre-seeded in the web sandbox
(session-start.sh) and dependencies resolving through the proxy, spotlessApply
no longer fails for infra reasons. Remove the network-error skip branch so any
spotlessApply failure blocks the push: a failure now means a real
formatting/compile error, and the narrow regex can no longer mask one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A7bocRTSPyXwz6zVMzDbKR
Claude Code Web routes github.com through a git-only proxy, so the Gradle
wrapper's distributionUrl (services.gradle.org, which 307-redirects to a
github.com release asset) can't bootstrap — it 403s even at Full network
access, and `./gradlew` fails before running any task.
Seed the pinned distribution in the existing web-only SessionStart hook,
reusing the same idempotent curl-download pattern already used there for the
Android SDK and Kotlin/Native deps: skip if already installed, else fetch
Gradle's OFFICIAL sha256 (served from services.gradle.org, reachable here),
download the zip from a mirror, and verify before extracting so a tampered or
wrong mirror file is rejected and never executed. The wrapper cache dir is
derived as base36(md5(distributionUrl)) so it survives version bumps.
Also pin distributionSha256Sum in gradle-wrapper.properties as defense in
depth: Gradle then verifies any distribution it installs (mirror-seeded, CI,
or local) against the known-good hash.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A7bocRTSPyXwz6zVMzDbKR
Correctness:
- GeoRelayDirectory.relays is now @Volatile; on the process-wide `shared`
directory the CSV refresh was written by one thread and read by others with
no memory barrier, so readers could keep using the FALLBACK list forever and
never route to the correct rendezvous relays.
- sendPostSync bails before cancel() when a geohash cell has no resolvable
relays, so the composer text + draft are preserved instead of the message
being silently dropped with its draft deleted.
- Teleport detection compares on the common geohash prefix; a cell finer than
the fixed 8-char device fix could never be a startsWith prefix, so the user
was wrongly marked teleported even when physically present.
Performance:
- GeoRelayDirectory.closest precomputes each relay's great-circle distance
once instead of recomputing the trig inside the sort comparator (was
O(n log n) haversine calls over the ~370-relay directory).
- GeohashChatChannel.relays() memoizes the derived set, invalidated by a new
directory version token, instead of re-sorting the whole directory (and
allocating a fresh Set) on every call.
- filterFollowingGeohashChats groups cells by relay into one filter each
(g = [cells]) rather than one REQ per (cell, relay).
Leak/thread-safety:
- FollowingGeohashChatSubAssembler.userJobMap is a ConcurrentHashMap and
endSub now removes the entry (it previously cancelled the jobs but left the
stale entry behind).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
The color standardization set the dark theme's `primary` to Purple200
(#BB86FC), a light pastel tuned for accent text/icons on black. Filled
controls (the top-bar Save/Post/Send/Create button and checked switches)
inherit `primary` + `onPrimary`, so they rendered as a washed-out pastel
fill under white content — low contrast on the black background.
`primary` can't simply be deepened: it's read by ~470 accent text/link/
icon sites that need a light tint on black, and `onPrimary` drives ~40 FAB
glyphs and chip labels. So instead of bending a shared role, add a
dedicated fill role:
- `ColorScheme.filledAccent` / `onFilledAccent`: on dark, deepen `primary`
halfway toward the accent's deep variant (carried in the scheme as
`inversePrimary`) for a rich, saturated fill; content picks black/white
by WCAG contrast. Light themes already use the deep variant, unchanged.
- Consumed only by the top-bar action button, the standalone SaveButton,
and a new `AmethystSwitch` wrapper (`amethystSwitchColors()`) that every
Switch call site now routes through.
Derives per-accent, so Blue/Green/Orange/Red/Pink get the same treatment.
`primary`/`onPrimary` are untouched, so links, NIP-05, chat bubbles,
unread dots and FAB glyphs are unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVwY2Qzth2EREWg8qLhyzF
The location-chat nickname was in-memory only (ChannelNewMessageViewModel
.geohashNickname), so it reset to empty on process death. It can't be
recovered from relays either: the anonymous per-cell key publishes no kind-0
profile, and kind-20000 messages are ephemeral, so the nickname (a per-message
`n` tag) has no durable home on the network.
Persist it on-device instead, as a single global handle:
- GeohashChatIdentityState gains nickname()/setNickname(), stored in the
account's encrypted storage next to the per-cell device seed (survives
restarts, switches with the account).
- The chat screen restores it into the composer on room open, and the
nickname dialog writes it back on Save.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
- Center the composer identity avatar vertically instead of bottom-aligning
it (the "post as me" picture was sinking to the bottom of the edit row).
- Halve the gap between the avatar and the text field (drop the avatar's end
padding and nudge the field left into its fixed inset).
- Drop the divider line between the feed and the composer — the other chat
screens don't use one, and the rounded field is separation enough.
- In a location room, suppress the room's own geohash in each message's
footer/time row (every message repeats the room cell, so showing it is
noise). Done via a new LocalChatSuppressGeohash composition-local, factored
into both the footer-has-metadata gate and the footer render; any other
geohash still shows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
The dark-theme primary is a pale pastel, so the follow shield read washed out
versus the original dark #7F2EFF. Use the accent's deep tone (its light-theme
primary) in both themes — carried by inversePrimary in the dark scheme — so the
shield stays a dark, saturated accent with a white figure, matching the
original badge's depth while still following the selected accent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1F9jXNwRGTP8qmsV3Wd69
Tying the badge to the tonal-button palette (secondaryContainer) made it read
too dark against profile photos. Match the original badge look instead — a
vivid shield with a white figure — recoloured to the accent: shield = primary,
figure = onPrimary.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1F9jXNwRGTP8qmsV3Wd69
Adds a compact caption beneath the anonymous composer avatar so the nickname
control is discoverable without knowing the long-press gesture: it shows the
chosen nickname, or a primary-tinted "+ name" prompt when blank. Tapping it
opens the same nickname editor as long-pressing the avatar.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
Audit of the rich-text convergence found a behavior regression: the Android
RichTextSegmentRenderer.Payment ignored canPreview and always rendered the
interactive MayBeInvoicePreview / MayBeWithdrawal UI. The original
RenderWordWithoutPreview switchboard deliberately showed plain text for
invoice and withdraw segments when previews are suppressed ("Don't offer to
pay invoices" / "Don't offer to withdraw"). Branch on canPreview for those two;
Cashu and Clink stay unconditional (they decode locally, network only on tap),
matching the original.
Verified: :amethyst play debug compiles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
Replaces the always-on nickname field + "post as me" chip in the location-chat
composer with a single leading identity avatar, next to the text field:
- Shows the account's profile picture when posting as yourself, or an
incognito avatar (person-off) when posting under the anonymous per-cell key.
- Tap toggles anonymous ↔ me (routing through the existing "post as your real
account?" warning when switching to your real identity).
- Long-press opens a small dialog to set/edit the per-cell nickname, so the
nickname field no longer has to sit in the chat all the time.
The read-only "✈ Teleported" indicator moves to its own line above the
composer, shown only when the app has determined you're not in the cell.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
The pre-push spotless gate already skips itself (warn + exit 0) when
`./gradlew spotlessApply` fails for infra reasons, so a restricted sandbox
can't strand a push over formatting. But its detection regex only covered
dependency-resolution/network errors, not the case where the Gradle wrapper
can't download the pinned distribution itself — e.g. an egress-proxy 40x on
`gradle-<ver>-bin.zip`. That surfaced as a hard BLOCK on an XML-only change
even though CI's spotlessCheck would pass.
Add the distribution-bootstrap markers (the `gradle-<ver>-bin.zip` URL, the
Java `Server returned HTTP response code` download exception, and "could not
install gradle") to the infra-skip regex. These strings only appear when
gradlew failed before running any task, so they can't mask a real
formatting/compile failure (verified: a spotlessKotlinCheck violation still
blocks).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157AWJNEzybTuegstbWiQ6B
concord_invite_failed_incompatible is shown for the Unreadable status,
which fires both for a genuinely newer bundle format AND for the far more
common case where the fragment's unlock token does not decrypt the
published kind-33301 bundle (a stale link, or a coordinate re-posted under
a new token). The old copy asserted "created with a newer version of the
app", which is usually wrong, and suggested "try again after updating"
even though this state is non-retryable.
Reword to lead with the likely cause (outdated/replaced link), keep the
newer-version case as a secondary possibility, and point the user at
getting a fresh invite link.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157AWJNEzybTuegstbWiQ6B
Per review, the follow badge now uses the same two colours as the tonal
buttons (Show more, profile actions): the shield is secondaryContainer and the
inner figure is onSecondaryContainer. The vector is a following(shield, figure)
builder cached per colour at the call site, so the badge follows the accent and
stays visually consistent with the tonal buttons in both themes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1F9jXNwRGTP8qmsV3Wd69
Addresses several UX papercuts in the location-chat screens:
- Teleport is no longer a manual composer toggle (which was confusing and
gameable). The app now determines it: when the device's location is known
it compares the current cell to the channel cell (objective); otherwise it
falls back to how the user arrived (map picker = teleported, near-me /
manual = not). The composer shows a read-only "✈ Teleported" indicator only
when you're not in the cell. Reading the permission state never prompts.
- The chat top bar now leads with the city name instead of the raw geohash
code (which users don't care about); the "#geohash · N relays" line is
secondary and tappable, opening a dialog that lists exactly which relays
the cell broadcasts to.
- The home "live near you" bubble resolves the cell to a city name too,
falling back to the #geohash while the reverse-geocode is in flight.
- Own ("purple") messages now show my name/nickname via a new
LocalChatShowSelfAuthorName composition-local — because a geohash chat can
post under two identities (anonymous per-cell key or the real account), the
bubble should say which one each message went out under. Off everywhere
else, so normal chats keep hiding the redundant self name.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
Two more on-device fixes:
- Container roles were a faint 0.12/0.16 tint over the surface, so filled
shapes like the Settings icon boxes nearly vanished (accent icon on almost no
background) and tonal buttons looked washed. Retune the container tones to a
moderately saturated fill (mirroring Material's baseline containers, recoloured
to the accent) with near-white content in dark mode, so the settings icon
boxes and profile-header tonal buttons read as white-on-accent again.
- The Following badge keeps its original deep purple (#7F2EFF) instead of
following the accent — it reads as a brand identity mark, and the accent-tinted
version looked flat. Reverted to the fixed two-tone vector.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1F9jXNwRGTP8qmsV3Wd69
Three fixes from on-device review:
- Secondary/tertiary CONTAINER roles now derive from the accent (primary)
instead of the purple theme's teal secondary. FilledTonalButton (profile
follow/edit/message, "Show more"/"Show anyway", tonal chips) was neutral
before and had turned teal-on-teal; it now reads as an accent tonal button.
The solid secondary/tertiary roles stay teal (unchanged, as before).
- onPrimary is white again. Deriving it by max contrast made it black on the
light accents used in dark mode, flipping FAB glyphs from white to black;
white reads better on the accent.
- The Following badge is two-tone again: the shield follows the accent while
the inner figure stays white, instead of a flat single-colour shield. The
vector is now a following(accent) builder cached per accent at the call site.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1F9jXNwRGTP8qmsV3Wd69
Amethyst's ClickableUrl now delegates plain http(s) links to the shared
commons ClickableUrl (one LocalUriHandler-based implementation for both front
ends); only the Android-specific blossom: case (opening a Blossom media URI via
an Intent) stays local. The public signature is unchanged, so all 12 call sites
are untouched.
Verified: :amethyst play debug compiles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
Brings geohash location channels to full parity with Public Chats (NIP-28),
Relay Groups (NIP-29) and Concord: a top-level list screen reached from the
drawer's "Feeds" section (and pinnable as a bottom-bar tab), rather than the
joined-cells list living inside the "New location channel" builder.
- Route.GeohashChats (plural) + AppNavigation wiring
- GeohashChatsScreen: DisappearingScaffold + AppBottomBar + FAB, a gradient
hero banner, rich per-cell cards (gradient location pin, "#cell", precision
level + resolved city, overflow menu with Open/Leave), and an inviting
empty state
- GEOHASH_CHATS catalog entry now resolves to the list screen; the "+" FAB
opens the builder (near me / manual / teleport)
- GEOHASH_CHATS added to the drawer's Feeds section alongside the other
chat-group types
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
Desktop's ClickableLink and Amethyst's ClickableUrl were near-identical: the only
real differences are mouse-first styling (Desktop underlines + shows a hand
cursor) and the open mechanism. But LocalUriHandler.openUri opens the browser on
Android AND Desktop (and the mail client for mailto:), so the "open a link"
logic never needed to be platform-specific.
Add ClickableUrl/ClickableEmail to commons/ui/components on LocalUriHandler, with
an `underline` flag so each front end keeps its exact look. Desktop's rich-text
renderer now reuses them (underline = true) for url/email/link-preview/withdraw
and its bespoke ClickableLink is deleted. Amethyst keeps its own blossom-intent-
aware ClickableUrl (blossom never applies to plain links, so this path is
equivalent); Phone stays platform-specific (Android dials, Desktop has no dialer).
Verified: :commons JVM and :desktopApp compile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
Closes the last rich-text fidelity residual. url/email/phone were rendered
generically by the shared core via RichTextInteractions callbacks, losing each
front end's per-type styling and open behavior. They now go through the
RichTextSegmentRenderer strategy:
- Add Url(url, displayText)/Email(address)/Phone(number) to the contract (with
plain-text defaults).
- Core routes LinkSegment(no-preview)/SchemelessUrl -> Url, Email -> Email,
Phone -> Phone; drop the in-core ClickableSpan.
- Amethyst renders them with ClickableUrl/ClickableEmail/ClickablePhone (blossom
intent + dial preserved); Desktop with ClickableLink / underlined mailto / plain
phone text.
- RichTextInteractions now carries only onClickHashtag (the one segment the core
draws itself, with shared icons); the onOpen* callbacks are gone.
Verified: :commons JVM, :desktopApp, :amethyst play debug compile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
Adds a "Location Channels" category to the bottom-bar settings picker so a
user can pin their joined geohash cells as avatars alongside PublicChat,
RelayGroup and Concord groups. Each pinned cell renders a location-pin
robohash keyed by the geohash, labelled "#<cell>", opening the location
chat when tapped.
- BottomBarEntry.Geohash sealed variant (+ stable key)
- NavBarItem.GEOHASH_CHATS catalog entry in the chats category
- rememberGeohashEntryDisplay resolves the anonymous avatar/route (no
metadata REQ — the cell is anonymous by design)
- picker/settings and live-bar group branches route geohash through the
shared GroupEntryDisplay path
- no feed preloader (the cell's message subscription opens from the chat
screen itself)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
The NIP-05 identifier colour and its verified badge were a fixed purple, and
the image hash-verification "passed" seal was a fixed purple drawable — none
followed the selected accent. Redefine the nip05 token to the theme primary
(covering the identifier text, badge and the other nip05 call sites) and tint
the nip_05 and original drawables with the accent at render.
Also switch the hash-verification "failed" icon from a raw Color.Red to
colorScheme.error, matching the themed error red used everywhere else. Removes
the now-unused Nip05EmailColor light/dark constants.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1F9jXNwRGTP8qmsV3Wd69
Replaces the two forked cached parsers (amethyst CachedRichTextParser on
android.util.LruCache + desktop DesktopCachedRichTextParser on ConcurrentLruCache
with a naive isMarkdown) with one shared object in commons/jvmAndroid/richtext,
built on quartz ConcurrentLruCache and keeping amethyst's CommonMark-aware
computeIsMarkdown and content-addressed key (content+tags+callbackUri+authorPubKey).
- Add ConcurrentLruCache.trimToSize(maxItems) (+ tests) for the onTrimMemory path.
- Repoint all amethyst callers (incl. the markdown unit test) and both desktop
callers; delete both forks.
Verified: :commons JVM, :desktopApp, :amethyst play debug + unit tests compile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
The previous change made every commons action icon follow the accent, but the
liked heart (red) and reposted check (green) are semantic status colours, not
brand purple. Restore their original baked colours and Color.Unspecified
rendering. Only the Following badge — which was brand purple — keeps following
the accent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1F9jXNwRGTP8qmsV3Wd69
Implements the cross-platform RichTextSegmentRenderer contract on Desktop
(mouse-first) and converges Desktop onto the shared commons RichTextViewer:
- DesktopRichTextSegmentRenderer draws each divergent segment with the existing
Desktop leaves (AsyncImage media + onImageClick, RenderInvoiceCard/RenderCashuCard,
QuotedNoteEmbed, RenderBechSegment, RenderPdfCard/RenderNowhereLinkCard,
RenderSecretEmoji, relay copy).
- DesktopRichText replaces the hand-rolled DesktopRichTextViewer switchboard: it
parses with DesktopCachedRichTextParser, keeps markdown on RenderMarkdown, and
drives the shared core via the two CompositionLocals. NoteCard repointed.
- Delete the old DesktopRichTextViewer + RenderSegment + the duplicate
RenderCustomEmojiSegment (the core now renders emoji); rename the file.
- Add a NowhereLink method to the contract so both platforms keep their
nowhere.ink card (the core no longer flattens it to a plain link); Android
adapter implements it via NowhereLinkCard.
Verified: :commons JVM, :desktopApp, and :amethyst play debug all compile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
Give the location-chat entry points a modern, consistent look instead of plain
Material rows:
- New-channel builder: card-based sections with tinted location-pin chips,
icon'd section headers (Your channels / Near me / Enter a geohash), tappable
elevated cards with city + geohash + chevron, and a prominent "Teleport" hero
card. Near-me levels (region → building) render as location cards.
- Teleport screen: the bottom controls become a rounded, elevated sheet floating
over the map, with a location-pin chip, city + #geohash, and a bold
"✈ Teleport here" CTA.
Only existing subset-font glyphs are used (LocationOn, Explore, TravelExplore,
Public, Groups, ChevronRight), so no font regeneration is needed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
The Following, Liked and Reposted vectors baked their own colours (a purple
shield, a red heart, a green check) and were rendered with Color.Unspecified,
so they ignored the theme and stayed those fixed hues under every accent.
Strip the baked colours to a neutral tintable black and tint them at the icon
wrappers with the theme primary, so the follow badge, liked heart and reposted
check now follow the user's selected accent. Call sites that relied on the old
baked colour via Color.Unspecified are pointed at the accent (or keep their
explicit tint, e.g. white-on-coloured-background). The already-monochrome
vectors (Bookmark, Like, Reply, Repost, Search, Share, Zap, ZapSplit) were
already tinted by their callers and are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1F9jXNwRGTP8qmsV3Wd69
Closes the fidelity gap and flips Amethyst's rich text onto the shared core:
- Move the hashtag-icon table (HashtagIcon + checkForHashtagWithIcon) into
commons/ui/richtext (the icons were already in commons); amethyst re-exports
it for existing call sites.
- Add a commons custom-emoji renderer (RenderCustomEmoji + InLineIconRenderer)
built on quartz CustomEmoji.assembleAnnotatedList, mirroring CreateTextWithEmoji.
- Wire both into the commons RichTextViewer core (hashtags now show shared inline
icons; emoji render inline).
- Point amethyst's RichTextViewer at CommonsBackedRichTextViewer (the shared core
is now the production plain-text path; ~55 call sites unchanged) and delete the
now-dead private RenderRegular switchboard. Markdown stays native.
Verified: :commons JVM and :amethyst play debug compile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
Swap the plain geohash composer for the real one — EditFieldRow on a
geohash-aware ChannelNewMessageViewModel. Geohash messages now get the full
compose experience like every other chat: @-mention autocomplete + tagging,
:custom emoji: suggestions and NIP-30 tags, hashtags, quotes, URL references,
image uploads, drafts, and the reply preview — all via the shared parsers
(NewMessageTagger + findHashtags/URLs/NostrUris + findEmojiTags).
ChannelNewMessageViewModel gains a GeohashChatChannel branch in createTemplate
(builds a kind-20000 with the enrichment + g/n/t + reply e-tag) and a geohash
send path that signs with the anonymous per-cell identity (or the account when
posting as self) and mines the fixed 8-bit Bitchat PoW — instead of the account
signer + PUBLIC_CHAT PoW category. Three geohash fields (nickname, teleported,
postAsSelf) drive it; all other channel types are untouched (branch-gated).
GeohashChatViewModel shrinks to the identity/reaction concerns the shared
composer can't express: myPubKeys (acting identity) and the anonymous react
toggle. Teleport / post-as-self / nickname / reply now live on the composer VM.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
Replace the plain text-dropdown accent picker with a row of tappable colour
swatches, each filled with the accent's real primary for the current light/dark
mode so the choice previews the actual result. The selected swatch is marked
with a ring and a check. Adds two small public helpers in the theme
(previewColor, contentColorOnAccent) that reuse the existing accent resolution.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1F9jXNwRGTP8qmsV3Wd69
Implements the commons RichTextSegmentRenderer with Amethyst's existing
touch-first leaves (ZoomableContentView, ImageGallery, LatexEquation,
BechLink/TagLink, MayBe{Invoice,Withdrawal}/CashuPreview/ClinkOffer,
LoadUrlPreview, relay/concord chips, DisplaySecretEmoji), closing over
AccountViewModel/INav/backgroundColor/callbackUri/canPreview.
Adds CommonsBackedRichTextViewer, a parallel entry that parses with the
existing CachedRichTextParser, keeps markdown native, and drives the
shared commons RichTextViewer core via the two CompositionLocals. Left
parallel to the existing RichTextViewer so the core is exercised against
real leaves without touching the ~55 call sites; flipping the entry over
(and deleting the duplicated switchboard) is the reviewed follow-up.
Verified: :commons JVM target and :amethyst play debug both compile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
Two cleanup passes on the mobile theme:
- Remove definition-only colour constants from Color.kt that had no references
anywhere (the Primary50..80 purple ramp, DEFAULT_PRIMARY, LIGHT_PURPLE,
Purple700, FollowsFollow, NIP05Verified, the base Nip05EmailColor, DarkerGreen
and LighterRedColor).
- Point the "new item" FloatingActionButton glyphs at onPrimary instead of a
hardcoded Color.White. With a light accent in the dark theme the container is
a pale pastel, so a white glyph was low-contrast; onPrimary resolves to the
correct high-contrast on-colour in both themes. Only FAB glyphs on a primary
container were changed; white icons on dark overlays were left as-is.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1F9jXNwRGTP8qmsV3Wd69
- The author line shows a ✈ marker for teleported senders (not physically in
the cell), folded into the shared display-name resolver so it needs no extra
renderer seam.
- Anonymous reactions now toggle instead of being add-only: if our per-cell key
already reacted with a given content, tapping again retracts it via a NIP-09
deletion signed by the same per-cell key; otherwise it adds the reaction.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
Status telemetry (broadcast result, relay latency ping, memory heap gauge)
hardcoded its own greens/ambers/reds instead of using the app's existing
semantic tokens, so the same "success"/"warning" meaning drifted across a
handful of near-identical shades.
Route them onto the tokens already used everywhere else for that meaning:
green -> allGoodColor, amber -> warningColor, the fixed critical red ->
colorScheme.error. Also point OtsSettingsSection's re-hardcoded #F7931A at the
shared BitcoinOrange constant. No new colors introduced.
Brand/categorical palettes (git diff, crypto brands, road-event categories,
chess, call UI, live-red indicators, medals) are intentionally left as-is —
they are meaning-carrying and unique by design.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1F9jXNwRGTP8qmsV3Wd69
Introduces commons/ui/richtext: one cross-platform RichTextViewer that
both the touch (Amethyst Android) and mouse-first (Desktop) front ends
can drive, to replace the two current forks (amethyst RichTextViewer +
DesktopRichTextViewer).
The shared core owns the universal parts (paragraph/RTL/word layout,
plain text, inline custom emoji, hashtags) and delegates the segments
whose *presentation and* call-to-action diverge by platform (media,
equation, quoted event, mention, payment, link preview, relay/invite,
secret message) to a RichTextSegmentRenderer strategy provided via
LocalRichTextSegmentRenderer -- the same CompositionLocal idiom the
codebase already uses for LocalInlineQuoteRenderer. Universal actions
(open url/email/phone, hashtag) go through a small RichTextInteractions
callback bag. A PlainTextSegmentRenderer default keeps the core usable
from previews/tests/headless callers.
Contract + skeleton only; compiles in :commons. No consumer wired yet.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
The dark/light color builders left the Material3 neutral roles (background,
surface, the surfaceContainer ramp, surfaceVariant, outline and the neutral
on-colors) at Material's baseline, which is violet-tinted. That produced a
faint purple wash on backgrounds, cards, dividers and secondary text that did
not follow the user-selected accent.
Replace them with hue-free greys that keep Material's lightness, so contrast is
unchanged while the chrome reads as neutral. Accent-derived roles
(primary/secondary/tertiary and their containers) are untouched and still drive
the themed surfaces.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1F9jXNwRGTP8qmsV3Wd69
The new-item unread dot and the selected-reaction box were built from
static Modifier vals whose fill was baked from the purple ColorPalette at
class load, so they stayed purple under every accent. Split the color-free
geometry into pre-built vals and apply the live scheme role (primary for
the dot, secondaryContainer for the reaction box) in the getters, so both
now track the selected accent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1F9jXNwRGTP8qmsV3Wd69
Stop hand-rolling a parallel bubble/feed and run the geohash room through the
exact renderer every other chat uses — RefreshingChatroomFeedView →
ChatMessageCompose, inside a DisappearingScaffold + top bar. Grouping, time
rules, delivery ticks, reply previews (+ suppression), rich content, colors,
long-press, swipe-to-reply, highlight/scroll — all shared, and stay shared as
those components evolve. The custom GeohashBubble/list/grouping is deleted.
The geohash-specific identity is injected without a second AccountViewModel,
via three composition-locals read by the shared renderer (default null = every
other chat unchanged):
- LocalChatActingIdentities — own messages align/highlight/tick under the
per-cell key (and the account, for "post as self").
- LocalChatReactOverride — reactions sign with the per-cell key (stay anonymous).
- LocalChatDisplayNameResolver — the author line shows the Bitchat `n` nickname,
since throwaway keys have no kind-0 profile.
This replaces the earlier per-component onReact/myIdentities params (now removed
from ChatReactionChips/ChatMessageActionSheet) with the cohesive local-based
seam. The composer stays geohash-specific (throwaway signing, PoW, teleport /
post-as-self) since the shared composer hard-codes non-geohash kinds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
TranslatableRichTextViewer can't move (ML-Kit translation is Android),
but the stack is already layered: translation wraps ExpandableRichText
wraps the 1031-LOC RichTextViewer core. TranslationConfig state is
already in commons and the core's real AccountViewModel surface is 5
members (3 cache reads -> ICacheProvider, toast -> callback, nav ->
callbacks). Move the core to commons/ui/text behind the cache port +
callbacks with a renderEmbeddedNote slot for the NoteCompose recursion;
keep the translation wrapper native and thin. This gates Tier 2 so
nested rich text becomes a direct commons call, not a per-renderer slot.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
Merged origin/main into the branch; a formatting violation in
LocalPreferences.kt (globalSettingsPrefs, added on main) was surfacing in
the PR merge-ref lint check. spotlessApply collapses it to one line
(whitespace only, no behavior change) so CI's lint job passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ
Account already injects `val cache: LocalCache` and LocalCache implements
the commons `ICacheProvider`/`ILocalCache` read ports, but AccountViewModel
lookups bypass account.cache and hit the object singleton directly, and
IAccount doesn't expose the cache. Revise the extraction seam: reads cross
via `IAccount.cache: ICacheProvider` (add it) rather than app-supplied
loader lambdas; reserve lambdas for nav + write/signer actions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
Study of com.vitorpamplona.amethyst.ui.note (214 files / ~48.7k LOC,
types/ = 89 files) for moving the rendering half into commons via a
Render (entry, stays native) -> Display (pure, moves to commons) split.
Documents the canonical entry signature, the AccountViewModel/INav/
R.string/leaf-toolkit dependency surface, the @Composable-slot seam for
the flavor-specific rich-text viewer, a tiered categorization of the 89
type renderers, and a per-event sequencing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
Addresses the four findings from the branch audit:
- Make the kotlinx codec (LimitsKSerializer, the iOS/native incoming path)
as lenient as the Jackson path: mistyped fields degrade to null, non-int
array elements are skipped, and a payload-less ["LIMITS"] frame yields an
empty message instead of throwing. Guard the payload access in
MessageKSerializer and MessageDeserializer likewise.
- Make the Jackson reads (LimitsDeserializer) type-checked so an explicit
JSON null or wrong-typed value stays null ("unspecified — keep previous")
instead of coercing to false/0. Both codecs now behave identically.
- LimitsMessage -> data class, so StateFlow.distinctUntilChanged in
RelayLimitsTracker suppresses no-op emissions when a relay re-advertises
identical limits, and tests get value equality.
- Drop the stale "NIP-22" labels (LIMITS is nostr-protocol/nips#1434, not
NIP-22) from LimitsKSerializer, MessageKSerializer and MessageSerializer.
Tests: added mistyped/null-field and payload-less coverage on both the
Jackson and kotlinx paths, plus a value-equality check. quartz:jvmTest
(RelayWireErgonomicsTest 11, KotlinSerializationMapperTest 57,
RelayLimitsTrackerTest 5) and amethyst play compile green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ
Redesigns the always-on notification control into two switches:
- A global master ("Background notification service") — a persisted, battery-saver
"airplane mode" stored in global preferences that overrides every account. The
Quick Settings tile toggles this; it survives restarts and crashes until re-enabled.
- Per-account participation ("Keep this account active in the background") — the
existing per-account setting, now surfaced as a per-write-account list under the
master in the Notification Settings screen.
The always-on service runs while the master is on AND at least one writable account
participates; turning the master off tears down every layer for all accounts. The
manager reacts to both the master flow and each loaded account's participation flag,
and the restart layers (watchdog/boot/worker) honor the same rule.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKLqvcC1vobUYs3PxL2zWU
Captures what's done (parse, RelayLimitsTracker, cleanup), the audit fixes
to fold in (kotlinx parse leniency to match Jackson on iOS, drop stale
NIP-22 labels, make LimitsMessage a data class), and the remaining roadmap
(client apply helpers, server-side emit, NIP-11 bridge, amy tooling).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ
Cleanup after adding LimitsMessage + the client-side cache:
- Remove the unused experimental LIMITS prototype
(experimental/limits/Limits.kt + LimitProcessor.kt). Its @Serializable
model duplicated LimitsMessage (minus auth_for_read/auth_for_write and
the Message wiring); the processor's clamp/reject logic is superseded by
the server-side LimitsPolicy and will be reincarnated as pure helpers on
LimitsMessage. Both were prototypes with no references (preserved in git
history).
- Rename the client accessory RelayLimits -> RelayLimitsTracker so it no
longer collides on simple name with the relay-server-side
nip01Core.relay.server.policies.RelayLimits (the operator-configured
limits a relay enforces and advertises). Updates AppModules and the test.
No behavior change. quartz:jvmTest (RelayLimitsTrackerTest 5/5) and
amethyst play-flavor compile are green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ
ChatReactionChips picked "my" reactions by comparing reactors to the logged-in
account, so an anonymous geohash reaction never showed as selected. Add an
optional myIdentities override (default null = account, unchanged for all other
callers); the geohash bubble passes its own pubkey set (per-cell key + account),
so reactions we made anonymously now render highlighted.
Kept as a surgical hook rather than a per-cell Account/AccountViewModel: the
shared chat components stay on the real account for all identity-agnostic work
(rendering, media, nav, cache) and only consult the injected identity for the
"is this mine" decision.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
The accent-color setting only overrode primary/secondary/tertiary, leaving
every Material3 *Container role (primaryContainer, secondaryContainer,
tertiaryContainer and their on-colors), inversePrimary and surfaceTint at
Material's baseline violet. Container-backed surfaces — settings icon boxes,
selected reaction chips, zap-amount chips, calendar selection, relay-group
top bars — therefore stayed purple no matter which accent was chosen.
Complete the dark/light schemes so those roles are derived from the accent:
containers are a faint accent tint composited over the base surface, on-colors
are the accent (dark) or a deepened accent (light), and on-accent fills pick
black/white by WCAG contrast (also fixing white-on-teal for the purple theme's
bright secondary). Mobile app only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1F9jXNwRGTP8qmsV3Wd69
Adds RelayLimits, a passive connection-listener accessory (modeled on
RelayAuthenticator) that caches the latest LIMITS each relay advertises
and publishes it as a Compose-stable StateFlow, so consumers can read a
relay's current rights/limits instead of only observing the raw message.
- RelayLimits: caches LimitsMessage per NormalizedRelayUrl, exposes
limitsFlow (StateFlow), get(url) and snapshot(); connection-scoped
(entry dropped on disconnect so stale limits don't leak).
- LimitsMessage marked @Immutable for Compose stability in the flow map.
- Wired into AppModules next to relayStats (Amethyst.instance.relayLimits).
- Tests: per-relay caching, later-replaces-earlier, independent relays,
drop-on-disconnect, and ignore non-LIMITS messages.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ
Adds support for the relay-to-client LIMITS frame, which advertises the
connection's current rights and limits (can_read/can_write, auth
requirements, max_message_length, max_subscriptions, max_filters,
max_limit, POW, rate limits, required tags, etc.). Relays such as
wss://pipe.imwald.eu/ send it on connect and whenever rights change,
and Amethyst previously logged it as an unsupported message.
- New LimitsMessage model in quartz commonMain with every optional field.
- Jackson decode/encode (LimitsDeserializer + MessageSerializer) and the
kotlinx-serialization path (LimitsKSerializer + MessageKSerializer).
- Logs a summary line in RelayLogger.
- Tests: production payload parse, array/tag fields, empty object, and
round-trip parity between the Jackson and kotlinx codecs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01464jkunWPtYhTReoc3fUQQ
Two fixes so reactions/replies behave like the rest of the app without
leaking the user's identity in anonymous mode:
- Reactions now follow the composer identity. GeohashChatViewModel.react()
signs the kind-7 with the anonymous per-cell key by default (so reacting no
longer deanonymizes you), or with the real account when "post as me" is on
(full account react/undo). Anonymous reactions are add-only and de-duped.
ChatReactionChips and ChatMessageActionSheet gained an optional onReact /
onReactOverride hook (default null = unchanged for every other caller) so the
chips, the quick-reaction row, and double-tap all route through it. Zaps stay
real-account by design.
- Reply previews now render on the bubble via the shared RenderReplyRow, which
carries the standard suppression rules (hidden when the parent is the message
directly above, pinned in a thread, or already cited inline). Geohash reply
e-tags are linked into note.replyTo by consumeRegularEvent, so this works with
no geohash-specific reply plumbing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
Authorization hardening:
- LocalCache only applies a kind 39000-39005 addressable (group metadata,
admins, members, roles, pins) to a group's state when the event is signed by
the relay's own NIP-11 `self` key. Previously any author's 39001 served for a
group id was applied (newest wins), so a stray/malicious user-published admin
list on a lax relay could inject itself as admin in the client's view and
unlock moderation UI. The guard only blocks when `self` is known and differs,
so legitimate groups (and relays whose NIP-11 hasn't loaded) are unaffected.
Moderation events (9000-9010) were already stored-but-not-applied — the client
relies on the relay-republished 39001/39002 — so they need no change.
Invite links:
- The invite dialog now produces the spec's single `naddr1…?invite=<code>`
link for closed groups and copies just that, so one tap joins. It previously
copied the naddr and code as two separate lines, which the new parser (that
reads `?invite=`) could never round-trip.
spotless clean; amethyst compiles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Qst2JsmNYMvXitv2vxo4S
On cold boot an AUTH-required kind:10050 DM-inbox relay usually sends its
AUTH challenge before the account's own kind:10050 list has been fetched.
AuthApprovalPolicy.classify reads the trusted (self-approved) relay set
exactly once, so it classifies the user's OWN inbox relay as tier-2 and
surfaces a manual `[Once][Always][Never]` banner. Nothing re-evaluates
that pending decision when the kind:10050 list finally loads, so the user
gets a spurious AUTH prompt for a relay that should have auto-signed.
Extract the pending-approval set into a platform-agnostic
commons/AuthApprovalRequests (add/resolve/cancelAll) and add
autoApproveNowTrusted(): when the DM-inbox set updates, retroactively
settle every pending prompt whose relay is now tier-1 with ONCE — sign
this session, do not persist (trusted by identity, not an explicit grant).
DesktopAuthCoordinator now delegates its pending set to AuthApprovalRequests
and exposes onSelfApprovedRelaysChanged(); Main.kt drives it from
DesktopAccountRelays.dmRelayList (the account's kind:10050 StateFlow).
AuthApprovalRequestsTest reproduces the cold-boot race (red before the fix)
and covers the resolve / cancelAll paths.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bugs:
- Metadata edit could re-root a subgroup or drop its children on a load race:
the edit ViewModel snapshotted parent/children at prefill and overrode the
Account-level live-read defaults. Children are no longer snapshotted (Account
reads the live child list at save time), and the parent is only overridden
when the user actually re-parents (parentTouched) — a plain rename can't
re-root or orphan children anymore, even if metadata hadn't loaded yet.
- Parent selector card cached a null channel via remember() and never
refreshed, so the parent's name/picture never loaded and the warm-up never
mounted. Now get-or-create + warm + observe the metadata flow.
- previousEventRefs could let a note with an unresolved author slip past the
self-exclusion and reference the sender's own event. Now requires a resolved
author.
- Assigning a relay-defined role replaced the member's whole role set while the
menu implied additive; now keeps existing roles (entry.roles + role.name).
- GroupNAddrInvite now also accepts a bare `invite=<code>` remainder if the `?`
is stripped upstream (+ test).
Performance:
- Subgroups bar mounted a full warm-up (metadata + content) subscription per
child chip — up to ~21 relay subscriptions per open group. Replaced with one
relay-directory subscription; chips read from cache.
- Parent picker recomputed the whole candidate scan on every recomposition
(each search keystroke) via a produceState initial-value argument; the scan
now lives only in the producer with a cheap empty initial.
spotless clean; quartz tests green; amethyst compiles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Qst2JsmNYMvXitv2vxo4S
Lets an admin nest a group under a parent (or promote it back to top-level)
from the create/edit metadata form, completing the subgroup feature's authoring
side (viewing/navigation already shipped).
- RelayGroupMetadataViewModel: editable parentGroupId + preserved children,
seeded from the group's metadata and passed through on save. NIP-29 requires a
kind-9002 to re-list every child, so children are carried untouched.
- Account.createRelayGroup: optional parent so a group can be created nested.
- RelayGroupParentPicker: a hero selector card (gradient badge, parent avatar or
a top-level/home glyph) that opens a modal bottom-sheet picker — search field,
a "no parent (top-level)" choice, and the relay's genuine groups with avatars,
member counts and an animated selection mark. Excludes the group itself and its
own descendants so a cycle can't be formed, and streams the relay directory
while open so candidates fill in live.
spotless clean; amethyst compiles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Qst2JsmNYMvXitv2vxo4S
Wire the shared chat affordances into the geohash bubble so location-chat
messages behave like every other chat surface:
- reactionsRow renders ChatReactionChips, which observes reactions (kind 7)
and zap receipts (kind 9735) for each message. The reaction/zap REQ follows
the note's own relay set — a geohash message's relays are the cell's
geo-relays — so reactions and Lightning zaps are subscribed and displayed on
the geo-relays with no extra datasource.
- double-tap sends the default reaction; the long-press ChatMessageActionSheet
provides react / zap (Lightning, on-chain, nutzap) / reply / copy / report /
share. Its own isLoggedUser/isDraft gating keeps own-only actions off
anonymous authors' messages.
- reply adds an ["e", id, "", "reply"] tag via the composer, with a reply
context bar above the input.
Enabled for all authors regardless of key type (per request). Lightning zaps
still require the target to expose an lnaddress; cashu nutzaps to anonymous
per-cell identities need the identity's own wallet, handled in a follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
Follows up the subgroup protocol work with four NIP-29 compliance/UX gaps.
previous timeline references (spec §Timeline references):
- RelayGroupChannel.previousEventRefs(): first-8-char id prefixes of the most
recent events seen from the host relay, excluding the sender's own, capped at
the spec's 50-event window. Only draws from events actually received in the
channel so the host relay is known to have them.
- Populate the `previous` tag on all outgoing group events: kind-9 chat and
replies, kind-1111 minichat comments, kind-11 threads, and group replies.
Custom roles (kind 39003):
- Route SupportedRolesEvent onto the channel (LocalCache.consume + a
RelayGroupChannel.supportedRoles field) instead of only storing it.
- Members screen: when the relay advertises a role set, offer those roles when
assigning (admins), and show each member's real relay-assigned role label
instead of collapsing everything to admin/moderator. Falls back to the
built-in admin/moderator shortcuts when no 39003 is published.
Subgroup navigation (spec §Subgroups):
- RelayGroupSubgroupsBar: a self-hiding bar under the pinned bar showing a
breadcrumb up to the parent group and chips for the child subgroups (in the
relay's `child` order), each opening that group on the same host relay.
naddr invite codes (spec §Group identifier):
- GroupNAddrInvite parses the `naddr1…?invite=<code>` suffix; both the tap
handler (ClickableRoute) and the deep-link handler (MainActivity) now feed it
into the kind-9021 join request so a shared invite naddr auto-joins.
Tests for the naddr invite parser. spotless clean; quartz tests green;
amethyst compiles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Qst2JsmNYMvXitv2vxo4S
isMine matched only the anonymous per-geohash identity, so a message sent
with "post as me" (signed by the real account key) rendered left-aligned as
if it were someone else's. Track both the anonymous identity and the account
pubkey as "mine" so own messages align right whichever identity signed them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
Replace the standalone GeohashChatIdentity object with an account-owned
GeohashChatIdentityState (account.geohashIdentity), so the throwaway
per-geohash identities are scoped to a single account and cached per cell.
This also fixes a cross-account privacy leak: the old DeviceSeed fallback
(used by bunker / external signers that can't reach a raw key) stored one
seed under a single global preference key, so every account on the device
shared it — producing identical throwaway identities and linking a user's
alt accounts together in every cell. The seed now lives in the account's own
encrypted store (keyed by pubkey), so different accounts get different seeds.
Local-key accounts are unchanged: their identity is still derived from the
account private key and stays stable across devices.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
GalleryListEvent (kind 10011, @Deprecated "Replaced by NIP-68") was
unreachable: EventFactory routes kind 10011 to ExternalIdentitiesEvent,
and no other code referenced the class. It also shadowed NIP-51's
"Favorite follow sets" kind. Removing the dead class; the live profile
gallery entry (ProfileGalleryEntryEvent) and its builders are untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017giudm3gXumsxZmd3uMQc8
The in-room geohash chat now uses the same data path as every other chat
(public/ephemeral/live/relay-group) instead of a hand-rolled subscription:
- GeohashChatChannel.relays() resolves the cell's geographically-nearest
relays from a process-wide GeoRelayDirectory.shared (populated by
GeohashRelays.ensureLoaded), so the subscription layer can reach a cell
before its first message arrives.
- filterMessagesToGeohashChat wires kind-20000 into ChannelPublicFilterSubAssembler,
so ChannelFilterAssembler assembles the geohash subscription like any channel.
Own messages carry the same g tag, so no separate from-user filter is needed.
- GeohashChatScreen loads the feed through ChannelFeedViewModel (LocalCache-backed,
with mute-filtering for free) + ChannelFilterAssemblerSubscription, mirroring
LoadEphemeralChatChannel/EphemeralChatChannelView. GeohashChatViewModel drops its
bespoke client.subscribe and keeps only the geohash-specific composer bits
(relay resolution for sending, anonymous identity, teleport / post-as-self, PoW).
Rendering stays custom so bitchat nicknames (n tag), the teleport marker, and
anonymous own-message alignment survive — the profile-based shared renderer
can't express those for throwaway per-cell identities.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
NIP-51's kind:10000 mute list defines four entry types — `p` (pubkeys),
`word`, `e` (threads) and `t` (hashtags). Quartz parsed only the first
three, so `t` hashtag mutes written by other clients were silently
dropped: uncounted, invisible, and never applied to filtering.
Quartz:
- Add HashtagTag (`"t"`) implementing the MuteTag sealed interface, and
register it in MuteTag.parse/isTagged so it round-trips like the other
entry types.
- Add mutedHashtags()/mutedHashtagIds() TagArray helpers.
Filtering (commons):
- Add hiddenHashtags to LiveHiddenUsers plus isHashtagHidden(), and hide
notes carrying a muted hashtag in Note.isHiddenFor() (exact, case-
insensitive `t`-tag match — distinct from the existing substring word
scan).
Amethyst:
- Aggregate HashtagTag entries from the mute/block lists in
HiddenUsersState.
- MuteListState.hideHashtag/showHashtag + Account and AccountViewModel
wrappers, and observeUserIsMutingHashtag.
- Surface a Mute/Unmute hashtag action in the hashtag screen's options
overflow menu.
Tests: HashtagTagTest (parse/round-trip/MuteTag dispatch) and
NoteIsHiddenForTest cases for muted-hashtag hiding.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017giudm3gXumsxZmd3uMQc8
Implements the NIP-29 Subgroups feature merged upstream: groups can now be
organized into a parent/child tree, scoped per host relay.
Quartz:
- Add `parent`/`child` tag classes and TagArray (builder) helpers.
- GroupMetadataEvent (39000): parent()/children()/isRoot() accessors and
build params.
- EditMetadataEvent (9002): parent()/children() accessors and build params
(a 9002 re-carries the full child list, per spec, or the relay rejects it).
- SubgroupTree: assembles a relay's flat 39000 set into the hierarchy —
structure follows each group's parent tag, sibling order follows the
parent's child-tag order, orphans surface as roots, and malformed cycles
are broken rather than looping.
- NIP-11: advertise/detect subgroup support via `nip29: { subgroups: true }`,
with a `subgroups()` builder DSL helper.
- Tests for tag round-trips, tree assembly, ordering, orphans and cycles,
plus NIP-11 serialization.
Amethyst:
- RelayGroupChannel: parentGroupId()/childGroupIds()/isSubgroup() reading the
latest metadata.
- Account.editRelayGroupMetadata: preserve the group's current parent and full
children list on a plain metadata edit so an admin renaming a subgroup no
longer detaches it (or gets rejected for dropping children).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Qst2JsmNYMvXitv2vxo4S
The previous regression test drove PreferencesAuthApprovalStore against
the real java.util.prefs.Preferences.userRoot() backing store, which
threw IllegalArgumentException on the macOS CI runner (its preferences
backend rejects operations the Linux one accepts).
Extract the key derivation into a pure `authApprovalPreferenceKey`
function and assert the invariant that actually matters — every derived
key stays within Preferences.MAX_KEY_LENGTH, which is exactly the
condition put() enforces. No OS I/O, so it runs deterministically on
every platform.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015aJPf1AsPTdzdMYR6pZ4e2
The desktop AUTH banner ("<relay> requires authentication to deliver
this message") kept re-appearing on every challenge even after the user
clicked Always or Never.
Root cause: PreferencesAuthApprovalStore used the raw relay URL as the
java.util.prefs.Preferences key. Preferences caps keys at
MAX_KEY_LENGTH (80 chars) and throws IllegalArgumentException from put()
for anything longer. Outbox-proxy relay URLs that embed an npub and a
query string routinely exceed that (e.g.
wss://filter.nostr.wine/npub1...?broadcast=true is 102 chars), so
setScope threw. RelayAuthenticator swallows the exception, so the
ALWAYS/BLOCKED grant was silently never persisted and the relay
re-prompted on the next AUTH challenge.
Fold any relay URL over MAX_KEY_LENGTH into a bounded sha256:-prefixed
64-char hex key. Short URLs are still stored verbatim so existing grants
keep working.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015aJPf1AsPTdzdMYR6pZ4e2
Adds a per-session composer toggle (off by default) to post to a location channel
under the user's real Nostr identity instead of the anonymous per-geohash key —
trading location privacy for profile, reputation, and zaps. Enabling it requires
confirming a dialog that spells out the location-exposure trade-off. When on, the
message is signed with the account signer (and PoW mined for that pubkey); when
off, the anonymous identity is used as before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
Follow-up making the invite redeemer match the CORD-05 §2 spec for the
addressable invite coordinate (33301, link_signer, d=""):
- vsk=6 → live bundle (open with the link token)
- vsk=9 → revocation tombstone: the newest event wins, so a tombstone buries
even a stale, still-openable copy on another relay ("a fetcher finds the
grave instead of keys"). Amethyst previously never checked for this, so a
revoked link failed generically.
- anything else present (e.g. a mis-posted registry vsk=8, the shape of the
relayop.xyz link that hung) → unreadable
- nothing on any relay → absent
New pure `ConcordInviteBundle.classify(wraps, token): InviteBundleStatus` in
quartz (next to parse/validate), wrapped by `ConcordActions.classifyInvite`,
and mapped by `Account.joinConcordViaInvite` to the `ConcordInviteResult`
cases — including a new `Revoked` outcome with its own message and no futile
retry. Crypto is unchanged and already matches the spec
(hkdf(token,'concord/invite-key') → NIP-44 → snake_case CommunityInvite).
Adds ConcordInviteClassifyTest covering live / revoked (order-independent) /
unreadable / absent, plus the real relayop.xyz vsk=8 event → Unreadable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KngFNwrQDLYa9QW1f5RRD
- LocationPickerMap: an interactive osmdroid map (tap/long-press to pick a
coordinate) built on the existing display-only map's tile/lifecycle setup.
- GeohashTeleportScreen (Route.GeohashTeleport): tap a spot, pick a precision
level (region -> building), see the resolved cell + place name, and "Teleport
here" -> follow the cell (kind 10081) + open its chat with teleported=true.
Reachable from the builder's new "Teleport to a place on the map" button.
- Route.GeohashChat carries a teleported flag; the chat VM owns the teleport
state (seeded from the route) and stamps ["t","teleport"] on outgoing messages.
- Composer gains a "Teleport" FilterChip so the user can toggle it per session.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
- HomeLiveFilter now also surfaces geohash cells with recent (15-min) activity as
live bubbles. Because geohash chat is anonymous (throwaway per-cell keys), these
are NOT follow-filtered — any recent message in a cell the user is engaged with
qualifies (the follow-presence sort simply scores them 0 and orders by recency).
- RenderGeohashBubble: an anonymous location bubble (pin + cell), tap -> the chat.
- Unifies the chat ViewModel onto the shared GeohashRelays directory so the CSV is
fetched once process-wide instead of per-surface.
Note: a richer "N follows posted near you" signal (from kind-1 geo-notes, the only
npub-linkable location source) is a documented follow-up; this surfaces anonymous
liveliness now.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
`BlossomServerListState` used `Dispatchers.IO` without importing the
common `kotlinx.coroutines.IO` accessor. On JVM/Android the JVM member
resolved fine, but on Kotlin/Native (iosSimulatorArm64) it resolved to
the internal `Dispatchers.IO` member and failed to compile:
Cannot access 'val IO: CoroutineDispatcher': it is internal in
'kotlinx.coroutines.Dispatchers'.
Add the missing `import kotlinx.coroutines.IO`, matching every other
common-source state holder in this module.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtPgFEy9AxoaUT5AqEFBZX
Makes geohash location channels first-class in the Messages tab by routing them
through the same LocalCache -> feed machinery every other room uses:
Phase A (foundation):
- commons GeohashChatChannel : Channel, keyed by the bare geohash, with a
placeholder-note so a just-joined cell shows before its first message.
- LocalCache: geohashChannels map, get/getOrCreateGeohashChannel, a
consume(GeohashChatEvent) that routes kind-20000 messages into the cell's
channel (presence 20001 stays with the live screen), plus getAnyChannel + the
prune loops.
- GeohashRelays: a process-wide geohash->relay directory (live CSV once, fallback
otherwise). FollowingGeohashChatSubAssembler + filterFollowingGeohashChats
subscribe the joined cells (account.geohashList) to each cell's nearest relays,
registered in ChatroomListFilterAssembler.
Phase B (Messages):
- ChatroomListKnownFeedFilter: a geohashChannels family (feed + incremental
updateListWith/applyFilter + filterRelevantGeohashChats + geohashRowKey so a
placeholder and its later real message resolve to one row).
- ChatroomHeaderCompose: a GeohashRoomCompose row (location pin, anonymous —
name from the message's n tag) -> Route.GeohashChat.
- AccountFeedContentStates: rebuild the list when the joined set changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
Redeeming a Concord invite link (`…/invite/<naddr>#<fragment>`) that was
minted by a newer/incompatible Concord client left the redeem screen on an
endless "Redeeming invite…" spinner with a retry button that could never
succeed. The bundle event fetches fine, but its content can't be decrypted
(e.g. a bundle tagged `vsk=8` vs the `vsk=6` this app mints/reads), so
`ConcordInviteBundle.parse` returns null and the old `String?` contract
collapsed every failure into a single generic "could not fetch" + retry.
`joinConcordViaInvite` now returns a `ConcordInviteResult` that separates the
outcomes — joined, invalid link, not reachable (transient), and a bundle that
was found but couldn't be opened (incompatible/newer). The invite screen shows
a specific message per case and only offers a retry for the transient miss, so
an invite this app can't open ends on a clear explanation instead of looping.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KngFNwrQDLYa9QW1f5RRD
Makes location channels reachable and manageable natively:
- quartz GeohashChannelLevel: the named Bitchat precision levels
(region2/province4/city5/neighborhood6/block7/building8) with cellFor
truncation, so one location fix yields the whole ladder. Tested.
- LocationState.preciseGeohashStateFlow: an 8-char (building) location flow
alongside the existing 5-char one (untouched, so the "around me" feed is
unchanged); channels truncate it per level.
- NewGeohashChatScreen (Route.NewGeohashChat): join a cell from your current
location (region -> building, with place names via LoadCityName) or by typing
a geohash. Joining adds it to the kind-10081 geohash list (followGeohash) and
opens the chat. Includes a "Your channels" section that lists joined cells with
open + Leave (unfollow) -- the management surface the list previously lacked
(only the per-cell Follow toggle on the notes screen existed).
- Adds a "Location channel" option to the Messages "+" new-conversation chooser.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
Audit follow-ups on the DM relay-list work:
- Bug: scheduleOutboxBackfill re-scheduled itself from inside the still-active
drain job, so the isActive guard made the tail call a no-op — any authors
past the first 100-author batch in a burst were stranded until another kind:0
happened to arrive. Drain in a while-loop inside one job instead, and mark it
@Synchronized so concurrent consume-path callers can't spawn duplicate jobs.
- Perf: the back-fill held each REQ open for a fixed 8s. Use fetchAll, which
returns on EOSE (bounded by the timeout), and reuses existing infra.
- DmInboxRelayResolver: drop the redundant `+ cachedOutbox` in the phase-2 seed
(cached outbox is already in phase1Seed, so it was always subtracted back
out), and bound the phase-2 fallback fetch to 5s so a cold send can't stack
two full fan-out timeouts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSc3LhGFSF5VZKr9h3qfn3
Records that today the only way to add to the kind-10081 list is the Follow
toggle on the kind-1 geohash notes screen (no arbitrary add, no manage screen),
and folds a "My location channels" manage surface + the builder as the add path
into Phase C.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
Integration plan across Home (live bubble), Messages (rooms list + the new
"+" conversation chooser), and a map-based teleport picker. Reuses the existing
kind-10081 geohash follow list as the "joined location channels" list and routes
geohash chat through LocalCache/feeds. Documents the anonymity constraint (the
"follows are here" signal comes from kind-1 geo-notes, not the anonymous chat)
and the open decisions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
Answers "do we load kind:10050 from each user's outbox write relays?" — now we
do, and we make sure we know where those relays are.
- DmInboxRelayResolver.resolve is now two-phase: query the curated indexers ∪
the recipient's known write relays (via a new outboxLookup) for 10050/10002;
if the indexers didn't carry the 10050, read it directly from the write
relays learned from their kind:10002 — the canonical NIP-65 outbox location.
Wired on desktop with cachedAdvertisedRelayList(pubkey).writeRelaysNorm().
- Whenever DesktopLocalCache ingests a user's kind:0, it fires
onProfileMetadataConsumed; the subscriptions coordinator back-fills that
user's kind:10002 (batched ≤100 authors/REQ, deduped, one REQ at a time) and
routes it through consume() so it's saved. So learning who a user is now also
learns where they write.
Tests: DmInboxRelayResolverOutboxTest covers reading 10050 from the recipient's
write relays (indexers only have their 10002) and the cached-outbox seed path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSc3LhGFSF5VZKr9h3qfn3
The account-config bootstrap subscription only queried the default relays,
so a user's Blossom server list (kind 10063) — published to their own write
relays, not the defaults — was never fetched, and the UI fell back to the
default server. NIP-65 relay lists hid the same gap because they're broadcast
widely and also have a local backup.
Add a subscription that re-fetches the account-config kinds (10002/10050/
10007/10006/10063) from the user's NIP-65 outbox (write + untagged relays)
once it's known, routing kind 10063 / 10002 through justConsumeMyOwnEvent
like the bootstrap does. This matches mobile's outbox model: the user's own
data comes from their write relays.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dkzkEY6cUsRfqEb7giHi2
The first pass warmed every conversation peer on disk from the 2s refresh loop.
Scope it to what the user is actually looking at instead:
- Move the prewarm to a reusable, deduped, concurrency-capped
DesktopIAccount.prewarmDmInboxRelays(pubkeys) any DM surface can call.
- Trigger it per-row from the conversation list's LazyColumn, which only
composes visible rows (+ buffer), so peers warm as their row scrolls into
view and no earlier — scrolling warms more.
- Warm a recipient the moment they're picked in the New DM dialog, so the
kind:10050 is ready before the composer opens.
- Room open already resolves peers via ChatNewMessageState.load().
ChatroomListState.prewarmPeerRelays now just delegates to the account.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSc3LhGFSF5VZKr9h3qfn3
- Jump effect: key on the note id alone and always clear the request
after one attempt. Previously keyed on (id, items.list), so a message
arriving mid-jump cancelled the scroll animation and could leave the
request stuck (never cleared when the target wasn't loaded), blocking
repeat taps. Now the animation can't be interrupted and a re-tap always
re-fires.
- Back-fill pinned bodies: add an id-scoped filter for the group's
pinnedEventIds on the host relay. A pin can point at a message older
than the 200-item timeline window, which the h-scoped timeline never
returns — without this the bar shows a blank preview and can't jump.
- Re-invalidate the relay-group filter when the pin list changes (keyed
on the pin ids only, so roster/metadata churn doesn't re-subscribe), so
the new pin ids are actually requested when 39005 arrives.
Replace the threaded blossomServers parameters with a LocalBlossomServers
CompositionLocal, provided once from the account's state holder
(iAccount.blossomServerList.flow) around the logged-in UI. Upload sites read
the list from context instead of receiving it down a parameter chain.
- Provide LocalBlossomServers in MainContent's existing provider (covers
feeds, chats, profile, settings) and around the top-level compose dialog.
- ComposeNoteDialog, EditProfileDialog, ChatPane and the media-server
settings section read LocalBlossomServers.current; drop the params and the
prop-drilling through UserProfileScreen and DesktopMessagesScreen.
- This also covers quote-compose from a feed row (NoteActionsRow), which the
parameter approach couldn't reach — the per-note card composables now get
the list from context, so it no longer defaults to the primal server.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dkzkEY6cUsRfqEb7giHi2
Replace the ICacheProvider.blossomServers(pubKey) cache helper with reads
straight from the account's own state holder — iAccount.blossomServerList.flow
(the shared BlossomServerListState) — threaded to each upload site. This is
the reactive, per-account source of truth and drops the cache+pubkey
indirection entirely.
- Hoist iAccount (with dmSendTracker + accountRelays) out of MainContent into
the LoggedIn branch so the top-level compose dialog can read the account's
blossom flow too; pass them into MainContent as params.
- Thread iAccount.blossomServerList.flow into ComposeNoteDialog (reactive:
the server picker updates if the list loads after the dialog opens),
EditProfileDialog (via UserProfileScreen), and ChatPane (via
DesktopMessagesScreen).
- Reduce BlossomServers.kt to just the DEFAULT_BLOSSOM_SERVER fallback used
when the account has published no kind-10063 list yet.
Known gap: quote-compose opened from a feed row (NoteActionsRow) still
defaults to DEFAULT_BLOSSOM_SERVER — the per-note card composables don't
carry the account handle, and threading it through the whole note-render
tree isn't worth it for that secondary path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dkzkEY6cUsRfqEb7giHi2
A chat bubble reserves ~8dp beneath its last line of text so the reaction
chips, which overlap the bottom border, ride over padding instead of the
text. That reserve was gated on `reactionsRow != null`, but the reaction row
was passed for every non-inner-quote message regardless of whether it had any
engagement — so every footerless bubble (the middle messages of an author
run, which don't show the timestamp) reserved space for an empty chip row,
leaving a visible gap between the text and the bubble's bottom edge.
Gate the chip row on actual engagement via a new `hasChatEngagement` helper
that mirrors the render gate in `RenderChatReactionChips` (reactions, zaps,
pending zaps, minichat replies) and keeps the same live subscriptions, so a
message that gains its first reaction still mounts the row. Footerless
messages with no engagement now hug their text; the reserve returns only when
chips are actually present.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ghZ7egU86LT4Z5BBvyLvY
The unread count, the channel-list/hub last-message previews, and the
Messages-row summary all read a broader note set than the channel feed
renders: the feed's ChannelFeedFilter hides kind-1111 CommentEvent thread
replies (and unacceptable authors), but these row surfaces counted/showed
them. A trailing minichat reply therefore stuck the unread badge at a count
opening the channel could never clear (markAsRead is monotonic and only
advances for rendered timeline messages), and showed up as a "last message"
that isn't on the timeline.
Centralizes the feed's predicate as isConcordTimelineMessage (loaded,
acceptable, not a CommentEvent) plus a newestTimelineNote helper, and routes
the unread count, both list-row previews, and the Messages hub filter through
it — so every channel-row summary agrees with what the open channel renders.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
consumeConcordRumor attaches a message row to its ConcordChannel BEFORE
justConsume loads the event (so the note carries its gatherer through the
Messages filter). That means Channel.addNote ran while createdAt() was still
null and never set lastNote — and on reprojects the containsKey guard skips
addNote entirely, so lastNote stayed null forever for every Concord channel.
The list/hub rows read lastNote, so they always showed "No messages yet" (and
activity sorting was a no-op) even after messages had loaded.
Adds Channel.refreshAfterEventLoad(note), called right after justConsume once
the event is present: it picks lastNote when newer and invalidates the notes
flow so previews, unread counts, and ordering recompute. Regression-tested in
commons (ConcordChannelLastNoteTest).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9Uv3h6GhSQVuUTY5Ffm6e
The kind-10063 list is already the per-account source of truth in the
cache, so the DesktopPreferences.blossomServers singleton was redundant —
and being process-global (not per-account) it was also a latent
account-switch bug: the mirror could hand one account's media servers to
another.
Remove it and have every consumer read the account's list from the cache:
- Add ICacheProvider.blossomServers(pubKey) / preferredBlossomServer(pubKey)
helpers (+ DEFAULT_BLOSSOM_SERVER fallback).
- Upload paths read per-account: ComposeNoteDialog (localCache), ChatPane
(its cacheProvider), EditProfileDialog (localCache threaded from
UserProfileScreen).
- Settings screen falls back to the default constant instead of the pref;
drop the flow→prefs mirror LaunchedEffect.
- Delete DesktopPreferences.blossomServers / preferredBlossomServer.
Uploads require network anyway, by which point the account-config
subscription has loaded kind 10063, so no local persistence is needed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dkzkEY6cUsRfqEb7giHi2
Implements NIP-29 message pinning (nostr-protocol/nips#2379):
Protocol (quartz):
- GroupPinnedEvent (kind 39005): relay-signed pinned-message list, `d`
group id + ordered `e` ids.
- UpdatePinListEvent (kind 9010): moderator `update-pin-list` write,
carries the full list so pin/unpin/reorder/clear are one submission.
- Register both in EventFactory; add a pinnedEventIds tag helper.
Model + cache:
- RelayGroupChannel now folds the pin list (pinnedEventIds / isPinned)
with the same createdAt-supersede guard as the roster.
- LocalCache consumes 39005 into the channel and stores the 9010 write;
39005 added to the group's metadata REQ filter so pins load.
Publish path:
- Account.pin/unpin/updateRelayGroupPins + AccountViewModel wrappers.
UI (non-intrusive, self-hiding):
- Collapsed pinned-message bar under the top bar: shows the current pin,
N-count cycling, tap to jump to the message in-feed (hoisted jump
request threaded through the shared chat feed view). Renders nothing
when the group has no pins.
- Moderator-only Pin/Unpin action under "Show more" in the chat message
bottom drawer, gated on membership.canModerate().
- Small pin glyph on pinned bubbles' footer.
Tests: quartz build/parse round-trip for both kinds; channel pin-fold
supersede/replace/clear semantics.
Builds on the last commit to push the server channel list beyond a plain
messaging list:
- Recent-poster facepile: a stack of overlapping avatars (newest on top) for
each channel's latest distinct authors, so a busy channel reads as "who's
here" at a glance. New ConcordAuthorFacepile reuses the existing avatar
primitives; recentAuthorHexes() picks the most-recent distinct posters in one
O(notes) pass.
- Live "X is typing…": each row consumes the community session's typing
heartbeats (kind 23311) and swaps its preview line for an italic typing
indicator, driven by one screen-level freshness ticker that only spins while a
heartbeat is live.
- Rows are now a two-line layout (name + facepile / preview + time + badge), and
the unread badge animateContentSizes as its count changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9Uv3h6GhSQVuUTY5Ffm6e
The community "server" channel list (ConcordChannelListScreen) showed only an
icon and name per channel. It now mirrors the chat-room list feel: each row
carries a preview of the last message (author + snippet), the relative time of
that message, and an unread-message count badge, with the name/icon/time
emphasized while unread.
Adds a shared, reactive unread-message counter (concordChannelUnreadCountFlow)
that combines each channel's persisted last-read marker with its own note store,
plus a shared ConcordUnreadBadge pill (capped at 99+, plural content
description). The Concord Channels hub (ConcordHomeScreen) now reuses both:
per-channel rows show the real new-message count instead of a bare dot, and a
community's badge sums new messages across its channels.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9Uv3h6GhSQVuUTY5Ffm6e
- Collect ConcordSession.state via collectAsStateWithLifecycle instead of
reading StateFlow.value in composition (fixes StateFlowValueCalledInComposition
lint error in GroupBottomBarEntries.kt).
- Add @OptIn(ExperimentalCoroutinesApi::class) for testScheduler.advanceTimeBy
and runCurrent usages in ResourceUsageLedgerTest and RelayAuthPromptBusTest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wx8SvzsWsru7rEQ1KcbSCW
The desktop app resolves each recipient's kind:10050 DM inbox (strictly, no
NIP-65 fallback) when sending NIP-17 messages and reactions, but only lazily
at send/room-open time via the indexer fan-out — and recipient relay lists
that arrived through the normal feed were being dropped, so the cache never
helped.
- Route kind:10050 (ChatMessageRelayListEvent) through DesktopLocalCache.route
into addressableNotes, mirroring kind:10002. Previously route() dropped it.
- Back the User model's pinned replaceable notes (kind 10002/10050/10019) with
the same addressableNotes map that consume writes into. Before this, the
desktop UserContext read a plain notes map that nothing populated, so
User.dmInboxRelaysStrict()/outboxRelays() always returned null — leaving the
DM inbox resolver's local lookup, the send-path fallback, and tier-1 AUTH for
the user's own DM relays effectively dead.
- Consume the user's own kind:10050 into LocalCache at bootstrap alongside its
persisted accountRelays copy.
- Prewarm each conversation peer's DM relay list as soon as their room appears
in the list (concurrency-capped, deduped), so the first send/reaction
resolves from cache and the composer's "no DM relays" gate settles early.
Adds DesktopDmRelayListConsumeTest covering kind:10050 routing, newer/older
replacement, and the unknown-recipient case.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSc3LhGFSF5VZKr9h3qfn3
The desktop app read its Blossom media server list only from a local
DesktopPreferences string (defaulting to blossom.primal.net) and never
looked at the user's NIP-B7 BlossomServersEvent (kind 10063) — the same
event the Amethyst mobile app loads via BlossomServerListState. A server
list configured on mobile therefore never showed up on desktop.
Load the list from the network event instead, mirroring the existing
desktop NIP-65 flow:
- Add a shared, platform-agnostic BlossomServerListState in commons that
reads the kind-10063 addressable event from ICacheProvider and exposes
a StateFlow<List<String>> plus a save helper.
- Store incoming kind-10063 events in DesktopLocalCache.route()
(consumeBlossomServerList, newest-per-author wins).
- Instantiate blossomServerList on DesktopIAccount and subscribe to
kind 10063 in the account-config bootstrap subscription.
- Mirror the loaded network list into DesktopPreferences so the upload
path and cold start reflect it; the network event stays authoritative.
- Feed the media-server settings screen from the network list and, on
edit, sign+broadcast a new kind-10063 event so changes sync to every
Amethyst client (writeable accounts only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dkzkEY6cUsRfqEb7giHi2
Re-skins the geohash location chat to use ChatBubbleLayout (the same bubble
component the modernized DM/channel feed uses), so it visually matches the rest
of Amethyst's chats: grouped bubbles by author + time, own-vs-other alignment,
an author line for others, a time footer, and a long-press Copy action. Keeps
the self-contained ephemeral subscription (no LocalCache dependency). The
ViewModel now exposes the per-geohash identity pubkey so own messages align.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
Geohash chat stays anonymous by default (posting under the real npub would
broadcast the user's location tied to their public identity), but instead of a
random per-device seed the throwaway identity is now derived from the account:
- GeohashKeyDerivation.accountSeed(privKey) = HMAC(privKey, versioned label);
per-geohash keys are HMAC(seed, geohash). The result is stable across the
user's devices and recoverable from the account, yet publicly unlinkable to
their npub and across cells (all one-way).
- GeohashChatIdentity picks the account-derived seed for local-key accounts and
falls back to the random encrypted device seed for NIP-46/NIP-55 signers whose
key we can't reach.
Tests cover determinism, account-specificity, and cross-cell unlinkability.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
Like NIP-17's addRelayToNoteAndInners, propagate the relays a Concord plane
wrap was seen on down to its inner rumor, so a received Concord/Armada
message shows the relays it actually came from — not just the channel's
configured relay set.
The wrap note already carries its seen-on relays (added by consumeRegularEvent
before the gift-wrap handler runs), so GiftWrapEventHandler hands them to
concordSessions.ingest, which threads them through the session/registry to the
rumor sink; LocalCache.consumeConcordRumor then stamps them onto the rumor note
after justConsume. Local-echo sends and buffer re-projections pass no relays
(default empty) since there's no per-wrap relay to attribute there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
Narrow appendMissingImetaUrls to fire only when the message content is
blank: in that case return the imeta URLs joined one per line, otherwise
leave the content exactly as-is. A message that already carries text is
never rewritten, so inline URLs and ordering are preserved untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SF85F6GHu7aQZYpTGNYdWk
The chat timestamp tap toggled relative/absolute time instead of opening
the relay dialog, because ToggleableTimeAgoText has its own inner click.
Give it a `toggleable` flag and turn it off for the chat time, so the
enclosing tap target opens the delivery dialog; the absolute time now shows
as a header inside that dialog so nothing is lost.
Also:
- Encrypted-channel messages (Concord/Armada) decrypt locally with no
per-relay attribution, so the dialog showed "no relay information". Fall
back to the channel's own relays as "where this message lives".
- Move the "Message Delivery" sheet tile out of the always-visible row into
the Show More section — it's a secondary action.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
Seeing which relays a message came from was hard to reach: the received
timestamp was only tappable when it had seen-on relays, own messages only
made the tiny tick icon tappable, and the target was a hair-thin text glyph
nested in the bubble's long-press handler.
- Unify the two into ChatTimeWithDelivery: the whole time (+ the own-message
acceptance tick when we have data) is one padded tap target that always
opens the relay/delivery dialog. The dialog distinguishes "waiting for a
relay" (our own pending send) from "no relay information" (old/received).
- Also expose it from the long-press sheet as a "Message Delivery" tile, so
it's reachable even on messages with no visible timestamp (mid-run).
- Reserve a little bottom space in bubbles that have overlapping reaction/zap
chips but no footer, so the chips no longer draw over the last line of text.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
The joined-groups preload (RelayGroupMyJoinedGroupsFilterAssembler) only
subscribed to roster kinds (39000/39001/39002). It never requested the
kind-9 chat timeline, so the Messages-list preview for a group reflected
only whatever chat events happened to already be in LocalCache from
unrelated subscriptions — reading as "scattered" messages — and the full,
recent timeline was fetched for the first time only when the group was
opened (filterMessagesToRelayGroup, limit 200).
Also prefetch a bounded slice of each joined group's most recent chat
(kind 9 + polls, limit 50, `#h`-scoped, pinned to the host relay) alongside
the roster, so list previews show the true newest message and opening a
group lands on already-cached content. Shares the per-relay EOSE/since with
the roster filter, matching the existing warmup/channel assembler pattern.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Vvc7LeGNTpg1ggm5P8AtH
The earlier imeta-only fix patched RenderChat (the thread-view path), but
the Concord channel feed renders messages through ChatMessageCompose →
RenderRegularTextNote, which handed the raw decrypted content straight to
the media renderer. So a NIP-C7/Concord image message from an interop
client (e.g. Ditto/Soapbox Armada) — empty content, image carried only as
a NIP-92 `imeta` tag — never rendered in the feed, even though the
encrypted blob's key/nonce were already registered for transparent
decryption at ingest.
Extract appendMissingImetaUrls into a shared helper (ui/note/types/
ImetaContent.kt) and apply it in RenderRegularTextNote against the
decrypted content, symmetric to the RenderChat fix and to
ChannelChat.imageMessage which appends the URL on send. Normal messages
whose imeta URLs are already inline are untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SF85F6GHu7aQZYpTGNYdWk
The public-chat, relay-group and Concord-community screens are reached both as
pushed details (from a list) and as pinned bottom-nav tabs. Give them the same
dual-mode chrome the other tab roots use, keyed off nav.canPop():
- Show the back arrow only when there is something to pop. Added an optional
showBackButton (defaulting to the current behavior) to TopBarExtensibleWithBackButton
and passed nav.canPop() from PublicChatTopBar and RelayGroupTopBar; gated the
Concord list screen's back icon the same way.
- Render AppBottomBar in all three screens. It hides itself when canPop, so a
pushed instance shows the back arrow and no bar, and a bottom-nav instance
shows the bar and no back arrow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017mxdSFQcarsKaL179tSub1
The read-only settings picker never fetches a group's kind-39000 metadata, so with
no cached event RelayGroupChannel.toBestDisplayName() fell back to the raw group id.
Resolve the label from the group's metadata name when loaded, otherwise the name the
user's joined-groups list already stored for it (the NIP-51 ["group", id, relay, name]
tag), and only then the id. Also helps the live bar before 39000 arrives.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017mxdSFQcarsKaL179tSub1
- Drag reorder: key each preview tab by its stable identity. Without a key the
tabs were position-identified, so when a swap reordered the list, the slot under
the finger recomposed with a different entry — restarting its pointerInput (keyed
on the entry) and cancelling the in-flight gesture, so dragging stopped on the
first swap. Keying moves the dragged composable (and its live gesture) instead.
- Add/Added button: both states now share one Surface + Row body (only color,
border and tint differ) instead of an OutlinedButton vs a Surface. The
OutlinedButton's ~40dp min height made "Add" taller than "Added" and broke row
alignment; a single body keeps the pill height constant.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017mxdSFQcarsKaL179tSub1
AnimatedVisibility's default enter (fadeIn + expandIn from the bottom-end) made
the category / chat child lists slide in from the top-left. Switch both to a pure
vertical expandVertically(Top) / shrinkVertically(Top) so the options unroll
straight down with the toggle, matching the accordion's open gesture.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017mxdSFQcarsKaL179tSub1
Adds a self-contained Android surface for Bitchat-interoperable public geohash
chat, reachable from the geohash feed screen's new chat action:
- GeohashChatScreen + GeohashChatViewModel: live subscription to the cell's
ephemeral kind-20000/20001 events on the geographically-nearest relays, renders
messages (nickname + teleport marker) with a live participant count, and sends
kind-20000 messages signed with the device's per-geohash throwaway identity
plus a small NIP-13 PoW.
- GeohashChatDeviceSeed: device-level random seed (global encrypted storage) all
per-geohash identities derive from, kept off any account so posting never
reveals the user's npub.
- Account.signWithAndSendPrivately; Route.GeohashChat + navigation wiring.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
Adds a thin `amy geochat` verb over the quartz + commons geohash-chat code so the
interop path is exercisable headlessly (and against a real Bitchat client):
`send` builds/signs/mines/publishes a kind-20000 message with the per-geohash
ephemeral identity; `listen` holds a live subscription (kinds 20000/20001 are
ephemeral, so relays broadcast but don't store them) and reports messages plus
distinct present pubkeys; `keys` shows the derived per-geohash pubkey.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
Adds GeoRelayDirectory, which maps a geohash cell to the Nostr relays closest to
its center so a client lands on the same relays every other client of that cell
uses (the rendezvous rule Bitchat location channels rely on): closest-N by
haversine distance with a host tie-break and :443 dedup, a parser for the public,
MIT-licensed georelays CSV both clients load, a small built-in fallback, and a
jvmAndroid GeoRelayCsvLoader that refreshes the live CSV over the app's OkHttp.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
Adds the wire-level protocol for interoperating with Bitchat's Nostr location
channels:
- GeohashChatEvent (kind 20000): plain-text public geohash message with a single
exact ["g", geohash] tag plus optional ["n", nickname] and ["t","teleport"].
- GeohashPresenceEvent (kind 20001): presence heartbeat carrying only the g tag.
- GeohashKeyDerivation: deterministic, unlinkable per-geohash ephemeral identity
(HMAC-SHA256(deviceSeed, geohash||counter) with retry + SHA-256 fallback).
- Registers both kinds in EventFactory; NIP-13 PoW reuses the existing PoWTag.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
Address two gaps in the redesigned bottom-bar setup screen.
- The preview IS the editor now: the mini nav bar is the reorder & remove
surface. Drag a tab within the bar to reorder it (holder moveTransient/commit),
tap its ✕ badge to remove. Drops the separate chip strip — one WYSIWYG bar
instead of a preview plus a duplicate list.
- Favorites render their real favicon / nsite / napplet icon (via the same
FavoriteAppIcon + rememberFavoriteIconModel the live bar uses) instead of a
generic globe glyph — in the preview tabs and the Browser picker rows alike.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017mxdSFQcarsKaL179tSub1
- Private Message trade-off now reads "Can't add users later" (accurate
for NIP-17: you can't add members to an existing DM room).
- Darken the Concord/Public/Disappearing accents so the white icon-tile
glyph and the white Create-button label meet WCAG AA contrast (the
amber/teal/orange were ~2.6–3.4:1 on white text).
- Bound the row title/tagline/chip with maxLines + ellipsis so a longer
localized string can't wrap and break the one-line row layout.
- Mark ConversationType/ConversationSection @Immutable so ConversationRow
is skippable — an accordion toggle now recomposes only the two affected
rows instead of all six.
- Fix ProConRow icon modifier order (padding before size) so the check/
close glyph is nudged, not squished.
Give the settings screen a point of view instead of a stock Material list.
Presentation only — same BottomBarEntry model, holder and resolver underneath.
- Live preview: a real mini nav bar at the top renders the pinned tabs (first
one highlighted like the bar on open) and updates as you add, remove and
reorder — the screen is now WYSIWYG.
- Your tabs: the pinned set is a horizontal, drag-to-reorder chip strip that
mirrors the bar's own shape, each chip with a leading avatar/icon and an ✕,
plus an "n / 5" slot hint that turns red past the recommended count.
- Available catalogue: collapsible category cards with a leading icon tile;
every option row has a tinted circular leading and an "Add → ✓ Added" pill
instead of a bare switch, so the control states its action and result.
- Group rows show the real group avatar; Browser / Public Chats / Relay Groups
/ Concord expand to your favorites / joined groups (read-only, no REQ storm).
Reorder reuses the holder's moveTransient/commit; all edit logic stays in the
unit-tested BottomBarSettingsState.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017mxdSFQcarsKaL179tSub1
Reworks the "Start a conversation" chooser to be scannable instead of a
wall of text. Each conversation type is now a one-line row — a
color-coded icon tile, the name, a short tagline, and a single chip
naming its deciding axis (1:1–5 rooms / Device-bound / Workspaces /
Unmoderated / Moderated / Live now). Tapping a row expands it in place to
reveal "Best for" and a compact Good / Trade-offs split, then a Create
button that routes to that type's existing flow (accordion: one open at a
time).
Copy now leads with each protocol's real differentiator — Marmot's
device-bound gotcha (chats don't follow an nsec to another app), Concord
as channel-split workspaces, and the relay-aware section grouping Public
Chat + Relay Group by the fact the relay can see and moderate who's there.
Per-type accent colors are lightened in dark theme for legibility; the
solid icon tile keeps the saturated hue in both themes.
Concord/NIP-C7 image messages from interop clients (e.g. Ditto/Soapbox
Armada) attach the image purely as a NIP-92 `imeta` tag and leave the
message content empty. Amethyst's shared renderer only shows media whose
URL appears in the text, so those images never rendered — even though the
encrypted blob's key/nonce were already registered for transparent
decryption at ingest.
RenderChat now appends any imeta URL missing from the content before
handing it to the media renderer (symmetric to ChannelChat.imageMessage,
which appends the URL on send), so imeta-only attachments display.
Also forward the thumbhash on the encrypted-image imeta: FileHeader
computes both blurhash and thumbhash, but only blurhash was being sent.
encryptedImageImeta now emits an additive `thumbhash` field and
toConcordImeta forwards the computed value, so receivers can paint a
placeholder while the blob decrypts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016TGjBiJL2fvTR37eePBKrG
Quality follow-up to the customizable bottom nav, addressing the audit's three
highest-impact items — behavior unchanged.
- Extract BottomBarSettingsState + the pure BottomBarEditing transforms out of
BottomBarSettingsContent, so pin/unpin/reorder/restore-default are unit-tested
(BottomBarSettingsStateTest) instead of only exercisable through the drag UI.
The composable now just renders and forwards events; moveTransient reorders
mid-drag and commit() persists once on drag end.
- Unify the phone bottom bar and the navigation rail on one rememberBottomBarSlot
resolver (route + icon per entry), removing the duplicated built-in/favorite/
group branches and the divergent selection logic that had drifted between them.
- Give the group resolvers a `subscribe` flag: the live bar/rail keeps a REQ open
per pinned group (bounded by the few slots), but the settings picker reads
cached metadata only — so expanding a chat category with many joined groups no
longer fans out into one relay subscription per row.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017mxdSFQcarsKaL179tSub1
Messages sent in a previous session have no delivery-tracker entry, and
frequently no seen-on relays either, so the ticks rendered a "pending"
clock and stayed tappable — implying a days-old message was still waiting
for a relay, and opening a near-empty Message Delivery dialog. When we have
neither tracker data nor seen-on relays there is nothing meaningful to
report, so render no tick at all and just show the timestamp. Messages sent
in the current session keep their live pending -> accepted ticks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
Tapping the delivery tick on a just-sent message that no relay has
acknowledged yet opened the Message Delivery dialog with only its title:
no tracker entry, no target relays, and no seen-on relays meant every
branch rendered an empty list. Fall back to the pending status line
("Waiting for a relay to accept this message") so the dialog always says
something, and guard the recipient/relay branches against empty lists.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
The sats count in the under-bubble zap chip was gray like the reaction
counts. Match it to the lightning glyph's bitcoin orange (theme-aware for
light/dark contrast) so the zap reads as a payment, not a reaction.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
detectCleartextNetwork() is enforced by netd per-UID at the packet level: it
flags any socket whose first bytes aren't a TLS handshake, with no per-host
exemption. Every relay WebSocket tunneled over the embedded Arti Tor SOCKS
proxy on 127.0.0.1:17392 opens with a cleartext SOCKS5 greeting, so the
detector fired constantly on legitimate loopback traffic.
The Network-Security-Config localhost allowlist does NOT silence it — that flag
only governs the voluntary NetworkSecurityPolicy.isCleartextTrafficPermitted()
check that HTTP stacks consult, not netd's packet inspection. The old XML
comment claiming otherwise was wrong.
Drop the detector (the app already permits cleartext globally for ws:// relays,
so it produced little signal) and correct the misleading comment.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Messages "+" button used to fan out four cryptic one-word FABs
(Private / Public / Group / Find groups). The labels didn't explain what
each protocol does, and two conversation types (Disappearing Chat and
Concord communities) had no entry point at all.
Replace the speed-dial with a single "+" that opens a new full-screen
"Start a conversation" chooser. It groups the six conversation types into
three intent buckets — Direct & private, Encrypted groups, Public & open —
and presents each as a card with an icon, a one-line tagline, a "Best for"
hint, and short pros/cons lists, so users can pick the right message
protocol for what they're building. Each card routes to that type's
existing creation (or browse) flow.
- New Route.NewConversation + NewConversationScreen
- ChannelFabColumn simplified to a single button opening the chooser
- Adds the previously-unreachable Disappearing Chat and Concord entries
Chats no longer participate in the complete-UI toggle: tapping a bubble
no longer swaps in a detail row, and the isComplete/hasDetailsToShow/
detailRow machinery is gone from ChatBubbleLayout. Everything the detail
row exposed now lives in the compact design:
- Reply / Like / Zap were already reachable via swipe, double-tap, and the
long-press sheet, so those buttons simply drop.
- A new ChatMessageFooter carries the timestamp plus per-message status
glyphs that used to hide in the detail row — legacy-DM marker, NIP-40
expiration, geohash, and proof-of-work — each shown only when present.
It renders on the last message of an author run (for the time) or on any
message that carries such metadata.
- Own messages keep the relay-acceptance delivery ticks; received messages
get a tappable timestamp (ChatReceivedTimeInfo) that opens the same
relay-list dialog, so "seen on relays" is now available for every message.
- The zapraiser goal bar moves inline under the message content.
Net removal of code; the bubble tap is now a no-op except for inner quotes
(which still scroll to the quoted message).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
- PoWPublishQueue: use non-deprecated PersistentMap.putting()/removing()
- NappletBrokerTest: drop cast that can never succeed after assertIs
- PrivacyLockStateTest: remove redundant !! (smart-cast already non-null)
- MinichatScreen: drop unnecessary !! on smart-cast non-null Strings
- Concord screens: remove unnecessary safe calls on non-null ChannelEntity
and ResponseBody, and the now-dead elvis fallbacks
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Myhus2x1c3BSWCtjenSmkf
Rework the Bottom Navigation Bar settings picker so users can pin specific
joined chats — not just the aggregate list screens.
- Add PublicChat, RelayGroup and Concord variants to BottomBarEntry (stable
@SerialName discriminators), so a specific NIP-28 channel, NIP-29 relay group
or Concord community can be pinned as its own tab. The bottom bar and the
navigation rail resolve each to its avatar + chat/home route, live from the
local cache via a shared GroupBottomBarEntries resolver.
- Redesign BottomBarSettingsScreen: the pinned bar stays a drag-reorderable
section on top; the "Available" list is now grouped into ordered, collapsible
categories (Main, Chats & Groups, You, Feeds, Apps & Web, Other) instead of
the flat, scattered catalog order. Browser expands to your favorite apps, and
each chat type expands to your joined groups, each child pinnable with a
toggle.
- Curated category ordering lives in BottomBarCategories, covered by a test that
asserts every catalog id is placed in exactly one category.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017mxdSFQcarsKaL179tSub1
The compact timestamp was always aligned to the bubble's end, so on the
logged-in user's own (right-aligned) bubbles it hugged the screen edge
while everyone else's sat on the center-facing side. Align it to the start
for own bubbles so both sides mirror — the time always sits on the edge
facing the center of the chat.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
Render the "Public Chat" / "Marmot Group" (and the other room-type)
labels in the Messages list as a muted HeaderPill chip — the same faint
rounded chip the Concord community label uses — instead of plain gray
inline text, so every group kind reads the same way across the screen.
Each type gets a fitting icon (Public, Timer, Lock, Dns, Group).
Also rename the "MLS Group" label to "Marmot Group".
Two issues with the engagement-row zap chip:
- It sat below the bubble border instead of riding it like the reaction
chips. The zap (and minichat) chips used the Surface onClick overload,
which pads content to the 48dp minimum touch target, so their content
centered ~12dp lower than the shorter reaction chips. Switched both to a
plain Surface + Modifier.clickable, matching the reaction chip, so all
chips share one height and center on the border.
- After firing a zap from the drawer there was no feedback until the receipt
landed, because the sats chip only appears once an amount exists. Added a
per-note "zap in flight" signal on AccountViewModel that the drawer sets on
send; the bubble now shows a pending lightning+spinner chip in that gap. It
settles into the real sats chip when the receipt arrives, and clears on
error, on external-wallet handoff, or by a safety timeout.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
The long-press sheet opened at nearly full height because every action
tile section was always laid out. Two changes:
- The quick-reaction row now wraps onto multiple rows (FlowRow) like the
zap amount grid, instead of a single horizontally-scrolling strip, so
long custom-reaction sets stay reachable.
- The full note-action inventory is tucked behind a "More Options" toggle.
The sheet now opens compact — react, zap, reply — and only grows to full
height when the user expands it, so it no longer occupies the whole screen.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
Adds note scheduling and NIP-37 opt-in encrypted draft sync to Amethyst
Desktop, and extracts the existing Android scheduled-post code into
`commons` so both platforms (and PowJobRestorer) share one implementation.
- Compose → clock icon → date/time picker (presets + exact-minute); the
note is pre-signed and stored locally, then published at its time.
- Publishes while the app is open (45s in-app tick + launch catch-up) AND
while fully closed: an OS job (launchd / schtasks / systemd, registered
only while the queue is non-empty) relaunches the binary in a headless,
key-free `--publish-scheduled` mode that opens a websocket and pushes the
pre-signed bytes.
- A "Scheduled" deck destination (tabs Scheduled / Drafts / Articles):
status, cancel, publish-now, edit (cancel + reopen prefilled).
- Drafts: save-as-draft with a default-OFF "Sync across devices
(encrypted)" toggle publishing a NIP-37 DraftWrapEvent (kind 31234,
NIP-44 to self); drafts sync down on a fresh device.
Extraction / de-dup: ScheduledPost → commons/commonMain; ScheduledPostStore
+ ScheduledPostPublisher → commons/jvmAndroid (Jackson/java.io.File are
gate-forbidden in commonMain). The commons store is a strict superset of
upstream's parallel Android store (account-scoped claim, CLAIM_TTL crash
recovery, PUBLISHING-only status guards, reload-before-claim); upstream's
new ScheduledPostWorkGate gating is adopted to drive it. Single-writer file
lock + reload-before-claim so the in-app timer and headless process never
double-publish. Store file 0600, dir 0700.
macOS verified on the packaged app-image (compose+schedule, in-app publish,
app-closed launchd firing, Scheduled screen, NIP-37 draft round-trip).
Windows/Linux OS-integration authored but untested; headless has no Tor
routing yet — both documented in the PR.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RelayAuthenticator.signWithAllLoggedInUsers gained an `interactive`
Boolean parameter, but these two geode auth tests still passed a
two-arg lambda and no longer compiled. Accept and ignore the flag.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
The Concord send path published the encrypted plane wrap but never
registered with chatDeliveryTracker, so own messages showed no
acceptance ticks. Relays OK the wrap (kind-1059) while the feed row is
the decrypted inner rumor, so a plain trackPublic wouldn't match.
Added trackWrappedPublic, which maps the wrap id onto the displayed
rumor id (relay-only delivery, no per-recipient breakdown — the same
ticks a public room gets). The Concord message/image send paths re-open
the wrap they just built to key the tracker by the rumor id; reactions
and typing wraps are skipped since they never become a feed row.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
Concord channel messages render through the redesigned chat feed, so two
features that main wired to the old surfaces were unreachable there:
- Ban / make-admin moderation lived only in the social-feed NoteQuickActionMenu.
Moved into the shared noteActionSections catalog (gated by concordAdminTarget /
concordBanTarget), so both the chat long-press sheet and the 3-dot menu now
expose it. The community-rekeying ban keeps a confirmation dialog, shared by
both hosts via ConcordBanConfirmationDialog.
- The minichat reply-count chip sat in the tap-to-expand detailRow, hidden in the
modern compact layout. Moved into the compact engagement row (ChatReactionChips)
as a "N replies" chip alongside reactions and zaps, so the thread affordance is
always visible; tapping opens the message's minichat thread.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
`kotlin.jvm.*` is a default import on the JVM target but not on Kotlin/Native,
so `@JvmInline` on the ConcordPermissions value class resolved on JVM/Android
yet failed the iOS (compileKotlinIosSimulatorArm64) build with "Unresolved
reference 'JvmInline'". Add the explicit import; JVM is unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
The channel-list screen titled itself `state.metadata.name ?: app_name`, so
before the metadata edition folded it showed the app's own name — "Amy Debug"
in a debug build. Prefer the folded name, then the stored community name from
the list entry (always present from the join/create, and what shows everywhere
else); the app-name fallback is now effectively unreachable.
The channel-create FAB used Material 3's default rounded-square shape; every
other FAB in the app is a circle. Set shape = CircleShape to match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Picking a community icon or banner always failed with "Failed to upload media".
The upload was launched from the form's rememberCoroutineScope() (the Compose
Main dispatcher), so the very first pipeline step — MediaCompressor.compress —
hit Amethyst's checkNotInMainThread() guard and threw OnMainThreadException
before any bytes left the device.
Run ConcordImageUploader.uploadEncrypted inside withContext(Dispatchers.IO) so
the whole compress → strip → AES-GCM-encrypt → Blossom pipeline is off-main; the
Compose state write stays on the launching (Main) scope.
Also stop the form's catch from swallowing the real cause: surface the actual
exception message in the toast (falling back to the generic string only when it
has none), log it, and rethrow CancellationException instead of eating it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
NotificationFeedFilter derived `isConcord` from the note's own gatherers
(is it in a joined ConcordChannel), but LocalCache.consumeConcordRumor only
attaches kind-9 messages and kind-1111 replies to the channel — never a kind-7
reaction. So a reaction's `isConcord` was always false, it didn't bypass the
follow filter, and since a fellow member usually isn't a follow it was dropped
in Curated/Selected mode.
Recognize a reaction/repost as Concord through its TARGET instead: if
`replyTo.lastOrNull()` is a message in a community I've joined, it bypasses the
follow filter exactly like a reply. Relevance is still the existing p-tag gate,
so only reactions that actually tag me notify (a well-formed NIP-25 kind-7
p-tags the reacted author, which is what our own ChannelChat.reaction writes).
The "Messages in notifications" toggle now gates only Concord messages, not
reactions — a like isn't a message, so it follows the same rule as any other
reaction.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A Concord (and MLS/WhiteNoise) chat reply is a kind-9 ChatEvent that carries
its reply target as a NIP-18 `q` (or NIP-10 `e`) tag, not a NIP-10 thread — so
it isn't a BaseThreadedEvent and RenderTextEvent's reply-to preview never fires
for it. On the chat feed the preview is drawn by chat-only code, but everywhere
else NoteCompose routes kind-9 through RenderChat, which rendered only the
content and never `note.replyTo`. Result: on the Notifications tab a Concord
reply showed no quoted parent (no border) — most visibly when replying to an
image, whose target is likewise a kind-9.
RenderChat now takes unPackReply and, when FULL and not makeItShort, renders
ReplyNoteComposition(note.replyTo.lastOrNull()) like the threaded path does,
skipping it when the parent is already cited inline (`nostr:...`) so an
MLS-style quote isn't drawn twice. NoteCompose forwards unPackReply; the thread
view passes NONE since its structure already shows the parent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The members roster was a fraction of the real membership (e.g. ~13 vs ~44 on
Armada). Concord membership includes every "observed author" (CORD-02 §5 — anyone
seen publishing), but the live channel subs only carry the recent tail the relay
serves, so most members — who posted outside that tail and never sent a Guestbook
Join — never appeared.
Add ConcordMemberHarvest: a headless, run-once background sweep mounted by the
members screen that pages every folded channel's history back to a bounded window
(90 days — tunable; bounds the data pulled onto the device, per the "how far back"
limit) in one pooled `fetchAllPagesFromPool`. The wraps ride the app's normal ingest
(global CacheClientConnector → concordSessions.ingest), which folds each author into
`observedAuthors`, so the roster fills in with no extra plumbing. AUTH is free — the
channel stream keys are already registered for these relays. `beginMemberHarvest()`
gates it to once per community.
Prerequisite fix: `ConcordCommunitySession.ingest` re-decrypted a channel's WHOLE
wrap buffer on every incoming message (reprojectChannel), which is O(n²) in the
message count — fine for a ~50-wrap live tail but fatal for a history sweep. Split
it: a message now projects only its own wrap (O(1)); the re-decrypt-all path stays
for a re-fold (where channel keys can change). This also speeds the live path.
`ConcordCommunitySessionTest` now asserts the one-wrap-per-message projection.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The rename-echo assertion was flaky: firing two GCE commits (set-image + rename)
3s apart races whitenoise's per-epoch processing, so the second commit sometimes
lands as "unprocessable" and the name never propagates — a false negative.
The interop itself is confirmed working: in a harness run whitenoise's own log shows
`background_sync_group_image_cache_if_needed` parsing amy's image extension and
attempting a Blossom fetch (404 only because the harness sets the image without
uploading a blob), and test_09 (whitenoise messaging in the same group) passes right
after the image commit — both impossible if the image extension had been rejected.
Rewrite test_17 to the robust form: A sets the image, then sends ONE application
message at the post-image epoch; whitenoise can only decrypt it if it applied the
image-bearing commit, so wait_for_message B receiving it is direct proof the extension
parsed. Also skip cleanly when A is no longer a member of the group.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JL3GXW1fmHa3xWfQjLLqfp
The Concord create/edit screens showed each community relay as a bare URL string
with a remove button — far more basic than every other relay list in the app.
Extract a shared ConcordRelayListEditor that renders each relay the way the Relay
Settings / Marmot screens do: the relay's NIP-11 favicon, its advertised name, and
its host, with the row tapping through to the full relay-info page
(Route.RelayInfo) and long-press copying the URL. Both screens now call the one
editor (also removes the duplicated relay block).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Adds a "+" FloatingActionButton to the Public Chats screen (reached from
the left drawer) that navigates to the NIP-28 channel-creation flow
(Route.ChannelMetadataEdit with no id), mirroring the create-public-chat
action already offered by the Messages FAB.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDKFHhUqcDJvNEA7s7Qspm
The shared TopBarWithBackButton renders its title as a plain Text (Material3's
default title weight), and screens like the Marmot group list follow that. The
Concord screens (and the minichat screen added alongside them) hardcoded
FontWeight.Bold on their TopAppBar titles, standing out from every other screen.
Remove the bold so the nav bars read consistently; content emphasis (unread
markers, names, section headers) is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
whitenoise-rs became a workspace (core `whitenoise` crate at root, wn/wnd moved to
crates/whitenoise-cli) and grew native --discovery-relays / --default-account-relays
flags, which broke the headless harness's patch/build assumptions.
- Repath the mock-keyring patch to crates/whitenoise-cli/src/bin/wnd.rs and match the
new main() (env-gated Whitenoise::initialize_mock_keyring_store(); the fn is pub
under the integration-tests feature).
- Drop the discovery-env / defaults-env patches: start_daemon now passes the native
--discovery-relays / --default-account-relays flags instead.
- Build wn/wnd via `cargo build -p whitenoise-cli --features whitenoise/integration-tests`
(the binaries left the root crate; integration-tests brings in the mock keyring).
- The skip-unprocessable-retry patch still applies (root crate, 19-line offset).
Enables the end-to-end test_17_group_image_commit (amy sets a group icon -> whitenoise
must still process the commit) added in the previous commit to actually run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JL3GXW1fmHa3xWfQjLLqfp
Add `amy marmot group set-image <gid> <file> [--server URL]` and
`amy marmot group clear-image <gid>`: encrypt the avatar with the MIP-01 v2 scheme
(interoperable with mdk/whitenoise), optionally push the ciphertext to Blossom signed
by the keypair derived from image_upload_key, and commit the image fields into the
group's NostrGroupData extension. Thin assembly over quartz
(MarmotGroupImageEncryption) + commons (updateGroupMetadata) per the CLI rules.
Add headless interop test_17_group_image_commit: A (amy) sets a group image on a group
whitenoise is a member of, then renames. The rename is a later MLS epoch, so wn can
only observe the new name if it first applied the image-bearing GCE commit — proving
the image extension stays parseable on mdk (which rejects trailing bytes). This is the
end-to-end guard for the group-icon interop fix.
Note: the marmot interop harness currently can't build current whitenoise-rs — its
patches target the pre-restructure `src/bin/wnd.rs` layout, but whitenoise-rs has moved
to a `crates/whitenoise-cli/` workspace. Refreshing those patches is separate harness
maintenance; the interop correctness is otherwise covered by the pinned-mdk source
analysis and the MarmotGroupImageTest wire regression test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JL3GXW1fmHa3xWfQjLLqfp
Address the deep-audit findings across the new Concord code:
- H1: stop persisting kind-21059 ephemeral typing wraps as durable notes.
EphemeralGiftWrapEvent extends GiftWrapEvent, so every heartbeat was
stored forever; drop it once the session has ingested it.
- H2: follow()/unfollow() now read the offline backup (entriesWithBackup),
so a join racing the async backup load can no longer wipe the joined list.
- M1 (banlist): fold to the head (honors a chained unban) then union in
authorized editions that aren't ancestors of the head — concurrent bans
are healed without resurrecting an on-chain unban (CORD-06 down-only).
- M2: reproject only the newly-arrived channel wrap incrementally instead
of re-decrypting the whole buffer per message (was O(n^2)); refold only
projects newly-folded channels.
- M3: cancel a session's old state-watcher before replacing it on a
Refounding rebuild (was a coroutine + session leak per rekey).
- M4: publish typing/state/members/observed-authors under the lock and
make revision/observedAuthors updates atomic; clamp future-dated typing.
- M5: notification Concord bypass now requires the community to be one this
account has currently joined (mirrors the Marmot guard).
- M6: Concord chat honors the "Messages in notifications" toggle.
- L1: carry NIP-30 emoji tags on minichat replies, image captions and
custom-emoji reactions.
- C1: make the composer VM init() idempotent so recomposition can't wipe a
picked image or an open suggestion list.
- C2/C3: ConcordHome channel rows and unread badges react to the channel's
own notes flow instead of the global revision (no stale rows / flicker).
- C4: try/finally around mint-invite / create / save so a thrown call can't
strand the button disabled.
- C5: gate the typing ticker on active heartbeats so an idle channel stops
waking a 2s loop.
Adds regression tests for concurrent-ban union-heal and unauthorized bans.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Verified against the mdk-core revision whitenoise-rs pins (marmot-protocol/mdk
@e8cd584): its NostrGroupDataExtension parser consumes name/description/admins/
relays/image_hash/image_key/image_nonce/image_upload_key and rejects ANY trailing
bytes at a known version, and its extension/group_image.rs fully implements avatar
encryption. The previous "canonical raw-key + media_type" approach both (a) added a
trailing media_type field that mdk rejects — breaking the whole group for whitenoise
members — and (b) used a key scheme mdk can't decrypt.
Re-implement to mdk's exact MIP-01 v2 scheme so avatars interoperate byte-for-byte:
- image_key / image_upload_key are HKDF seeds (reusing Mip01ImageCrypto's
mip01-image-encryption-v2 / mip01-blossom-upload-v2 labels; HKDF-SHA256 with empty
salt == mdk's Hkdf::new(None, seed)). AEAD key derived from the seed.
- ChaCha20-Poly1305, 12-byte nonce, EMPTY AAD, image_hash = SHA-256(ciphertext).
- Decrypt tries v2 (HKDF) then falls back to v1 (raw key), exactly like mdk.
- Remove media_type from the wire entirely (and from the model/cipher/uploader), so a
v2 image extension ends at image_upload_key with zero trailing bytes. The plaintext
MIME isn't stored; the display path lets Coil sniff the format.
- Derive the Blossom upload keypair from image_upload_key instead of storing a raw key.
Adds a regression test that reproduces mdk's v1/v2 field consumption and asserts a
v2 image extension has no trailing bytes, plus a test pinning the HKDF-seed + empty-AAD
scheme so future drift from mdk is caught.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JL3GXW1fmHa3xWfQjLLqfp
- Resolve the group icon across the viewer's default Blossom server AND the group
admins' configured servers via BlossomServerResolver (BUD-03), so members other
than the uploader can actually load it; optimistically load the default server
while the async probe runs. Reuses existing resolver + HEAD caches.
- Compress/downscale the picked avatar up front so the stored media_type matches
the actual (post-compression) bytes rather than the original picked MIME; clean
up the intermediate temp file.
- Register the decryption cipher only when the URL/cipher changes instead of on
every feed-row recomposition.
- Give the create-screen placeholder avatar a stable random seed instead of "".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JL3GXW1fmHa3xWfQjLLqfp
The Concord community chip (Messages header + Notifications) no longer needs the
strong secondaryContainer highlight — the community's logo is now the row avatar,
so the name reads as faint tappable metadata (same wash as the note-header
markers). The NIP-29 relay-host chip (RelayNameChip) keeps its highlight; a relay
group has no avatar of its own.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Use featureSet.tryEmit(...) instead of assigning .value inside the
@Composable preview, matching the pattern used elsewhere (AppSettingsScreen).
Fixes the StateFlowValueCalledInComposition lint error.
Wire the shared NIP-30 custom-emoji picker into the Concord composer like the
@-mention flow: typing `:shortcode:` opens ShowEmojiSuggestionList (backed by
EmojiSuggestionState(account.emoji)); WatchAndLoadMyEmojiList loads the user's
packs. On send, account.emoji.findEmojiTags(text) attaches the NIP-30 emoji tags
to the kind-9 rumor (plain message + inline reply), so recipients render the
custom image inline via the shared chat renderer. Image uploads in messages,
icon and banner were already wired.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Two ways to update a Concord community/its channels that were missing:
Channels (net-new): ConcordModeration.defineChannel writes a ChannelEntity
control edition (create/rename/delete via version chaining); Account gains
createConcordChannel/renameConcordChannel/deleteConcordChannel. The channel-list
screen gets a create FAB and a per-row rename/delete menu, all gated on
MANAGE_CHANNELS (the same predicate the fold enforces).
Community metadata: the edit screen now edits the banner (encrypted ImagePointer
upload via the shared banner hero, reusing ConcordImageUploader) and the relay
set (add/remove chips + RelayUrlEditField). Also fixes editConcordMetadata
silently dropping the banner on every save (it now round-trips it).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Add first-class support for Marmot (MLS-over-Nostr) group avatars.
Protocol (quartz):
- Implement the canonical `marmot-group-image-v1` scheme: raw ChaCha20-Poly1305
key + 12-byte nonce, AAD = "marmot-group-image-v1" || 0x00 || media_type,
image_hash = SHA-256(ciphertext). MarmotGroupImageEncryption emits canonical
and decrypts both canonical and the deprecated MIP-01 HKDF-seed scheme.
- Add the `image_media_type` field to MarmotGroupData as a trailing TLS field
(older readers ignore it; disappearing_message_secs stays positionally
unambiguous). Add withImage/withoutImage helpers.
- MarmotGroupImageCipher (NostrCipher) drives both encrypted upload and
transparent decrypt-on-download.
Model/manager (commons):
- MarmotGroupChatroom exposes an `image` StateFlow; MarmotManager.syncMetadataTo
populates it from the group metadata.
Android:
- Show the decrypted group icon in the Messages feed; when a group has no image,
fall back to the NIP-11 icon of one of its relays (fetched on cache miss).
- Group metadata editing gains an icon picker (add/change/remove) in both the
create and edit screens; create also gains a description field. Icons are
encrypted and uploaded to Blossom via UploadOrchestrator, signed with a fresh
per-image keypair stored as image_upload_key.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JL3GXW1fmHa3xWfQjLLqfp
Two roster gaps against the reference client (Armada):
1. Moderators showed as "Admin" and role definitions were often empty. The
displayed roles came from ConcordCommunityState.roles, which was built from the
RAW structural fold heads — so a rogue higher-version edition on a role's
coordinate (e.g. marking the Admin role deleted) corrupted or emptied the roster,
and even when present the UI collapsed every role-holder to a single "Admin"
badge. Now state.roles comes from the authority-gated resolver
(AuthorityResolver.roles(), exposed alongside rolesFor()), and ConcordMembersScreen
renders each member's actual most-privileged role name (Admin / Moderator / custom).
2. Member count was a fraction of the real one (e.g. 10 vs ~44). CORD-02 §5: "an
author seen publishing is observably present, auto-included even if their Join
never arrived." The roster only counted Guestbook joiners + the privileged roster,
omitting the bulk of members who never post a Join. ConcordCommunitySession now
tracks observedAuthors from every decrypted channel message and folds them into
allMembers() and the roster.
Also: amy's `concord roles/grant/ban/...` now register the control-plane stream key
before draining (like `channels`/`read`/`send` already do), so the mod verbs aren't
served an empty fold on NIP-42-gated relays — used to ground-truth the resolved roles.
Verified via amy against live Soapbox: `concord roles` now returns Admin (pos 1) and
Moderator (pos 2) instead of []. quartz + commons concord suites green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A Concord minichat pins its root and backfills it from the channel history, but
when the root is still loading (or you aren't a member yet) there was no way out.
Add an Open channel action to the top bar for Concord threads, navigating to the
full channel (Route.Concord) where the whole timeline loads and membership lives
— the correct affordance to click through to the chat room itself.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
A "like" on a group message was built as a plain NIP-25 reaction — `e`
(message) + `p` (author) + `k` — with no `h` tag. The recipient's only
notification query that reaches the group's host relay,
filterGroupNotificationsToPubkey, is scoped `#p`=them AND `#h`=their
groups (kind 7 is already in GroupNotificationKinds), so a like with no
`h` tag is never matched there. It would only surface if NIP-65 routing
happened to drop it on one of the recipient's inbox relays — never for a
host-relay-only group — so likes on group messages effectively never
notified.
Copy the target's `h` tag onto public reactions to group-scoped events,
mirroring how kind-9 replies carry it. ReactionEvent.build gains an
`initializer` (the API GroupScope's KDoc already documented); ReactionAction
applies the group `h` tag for both the tracked and fire-and-forget paths.
The like now lands on the host relay in-group and the existing kind-7
`#p`+`#h` query picks it up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VxpT7J4xt37EF5yJDw1htK
routeFor sent every Concord-gathered note to the channel screen, so tapping a
kind-1111 minichat reply (e.g. from Notifications or the thread view) landed in
the channel instead of the reply's thread. Route a Concord CommentEvent through
minichatRouteFor (its thread); a top-level message / inline reply / reaction
still opens the channel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Review of the notification path (datasource + dal + display) for Concord replies
and likes that p-tag the user:
- Datasource OK: ConcordChannelPreload (mounted always-on in LoggedInPage) keeps
every joined community's control + channel planes subscribed app-wide, so the
wrapped reply/reaction wraps arrive regardless of tab. (The account-level
#p=self notification sub can't see them — the outer wrap p-tag is ephemeral.)
- Projection OK: channelRumors filters only by channel/epoch binding, so kind-7
reactions and kind-1111 replies both reach LocalCache via consumeConcordRumor.
- DAL had two gaps, now fixed:
1. The follow-scope gate dropped notifications from community members who
aren't in my follows (they usually aren't) unless in Global mode. Concord
notes now bypass it like Chess/DM/Marmot — the explicit p-tag already scopes
to genuine replies/reactions/mentions, so general chatter never leaks.
2. Concord's default reply mode is an INLINE reply (kind-9 ChatEvent), which
wasn't in NOTIFICATION_KINDS at all, so inline replies and @-mentions never
notified. Added kind-9 (only notifies when it p-tags me). Minichat replies
(kind-1111) and likes (kind-7) were already displayable.
Also let Concord notes skip the per-kind relevance heuristic (the p-tag is the
relevance signal), so a reaction still notifies when my target message hasn't
loaded yet to resolve replyTo.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
A reply to my message inside a joined NIP-29 relay group is a kind-9
ChatEvent that p-tags me (see ChannelNewMessageViewModel). It is already
fetched at startup by filterGroupNotificationsToPubkey (scoped `#p`=me +
`#h`=my groups on the group's host relay), but NotificationFeedFilter's
`acceptableEvent` checks `kind in NOTIFICATION_KINDS` before the p-tag
gate, and kind 9 (ChatEvent) was missing from that set — so those replies
were dropped before ever reaching the p-tag check and never surfaced.
Add ChatEvent.KIND to NOTIFICATION_KINDS. The existing gates handle the
rest: my own messages are excluded (author != me), the reply p-tags me
(isTaggedUser), and its `q`-tag to my message makes it pass
tagsAnEventByUser in Selected mode too. Pin the behaviour with a tripwire
test in NotificationKindsContractTest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VxpT7J4xt37EF5yJDw1htK
`wot` claimed the whole web-of-trust concept for what is actually a cheap
single-hop metric — the count of your follows who also follow X. The real
computed web of trust is `graperank`. Renamed the command (and WotCommand ->
FofCommand) to `fof`, reframing its KDoc/usage away from "trust," and kept
`wot` as a deprecation alias that warns and points at both `fof` and
`graperank`.
Also documents the command for the first time: `amy wot` had no --help block
and no README row. Added both (help block + three README rows for
get/list/sync), so the follows-of-follows score and its relationship to
graperank are now discoverable. JSON output shape is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56
A Concord community icon is CORD-02 §6 encrypted media: rememberConcordImageModel
fetches the ciphertext, AES-256-GCM-decrypts it, and caches the plaintext at a
local file:// path, which it hands to the avatar as the model. But
RobohashFallbackAsyncImage always wrapped the model in ProfilePictureUrl, and the
thumbnail-cache fetcher behind it (ProfilePictureFetcher) delegates a cache-miss
to Coil's http-only NetworkFetcher — so the file:// load failed and the row fell
back to the robohash, even though the icon had decrypted and cached correctly.
Only route remote http(s) pictures through the thumbnail cache; hand local/content
URIs straight to Coil's native fetchers, which load them directly. Verified
on-device: the Soapbox community icon now renders on the Messages rows and the
community header instead of a robohash.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Audited every graperank verb/flag in the code against the three doc
surfaces and fixed the drift:
- Main.kt `--help`: rewrote the GrapeRank block to match the terse house
style (one line per command, key flags on continuation lines) — it had
grown to ~68 lines of prose. Now ~33 lines, reordered into pipeline
order (crawl -> score -> publish, then rank/status/refresh, then the
provider/operator discovery group), with the per-verb timeout-semantics
prose dropped (it lives in the KDoc). Verbs and primary flags verified
against the dispatch and each function's arg reads.
- README: added the missing `graperank crawl` and `graperank score` rows
(stage 1 and 2 of the pipeline — crawl had no table row at all, score
was only mentioned inline) and labelled the three pipeline stages.
No stale references remain (no `graperank sync`, no removed publish/
bench flags, `operator providers`/`update` only as documented aliases).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56
`graperank sync` is gone — `crawl` is the only spelling. Because the dispatch
treats any non-verb first token as an OBSERVER, this exposed a latent leniency
in the shared resolver: decodePublicKeyAsHexOrNull's fallback runs Hex.decode
without a length check, so a short bech32/hex-ish word like `sync` decodes to a
bogus few-byte "pubkey" instead of failing — turning `graperank sync` into a
silent network crawl over garbage. Context.requireUserHex now requires the
resolved value to be exactly 64 hex chars (a pubkey is always 32 bytes), so a
mistyped OBSERVER/USER on any amy command errors cleanly (exit 2) instead of
silently scoring/fetching against a garbage key. `graperank sync` now returns
`bad_args` rather than crawling.
Also drops the never-built `graperank compare` idea from the roadmap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56
- New `graperank status`: read-only local inventory with no network, no
signing, and no side effects — WoT record counts in the store (the "do I
need to crawl again?" answer), reachability-cache size + newest-record
age, operator/service-key state, and persisted card + retraction counts
per observer. Reads the reachability cache through a throwaway signer so
a fresh machine's status never lazily creates the operator master.
- Flag consistency: --relay-concurrency and --concurrency are now accepted
interchangeably on `graperank refresh`, `graperank publish`, and
`relay probe` (each keeps its documented spelling as canonical), and the
help text states what --timeout means per verb (per-REQ drain 10s for
crawl/score, idle watchdog 30s for refresh/publish, per wave 15s for
probe, drain 8s for rank/register/providers).
- `graperank sync` now prints a deprecation warning before running crawl —
the name points at the wrong concept since the negentropy record refresh
became `graperank refresh` — and is removed from the docs; removal comes
in a later release.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56
The fold selected each entity's head by STRUCTURAL fold only and never dropped
editions from unauthorized signers, so a spoofed edition that structurally
supersedes a legit one won — on the live Soapbox community this surfaced a decoy
metadata edition ("invalid ... clients should be ignoring this", no icon) over
the real owner-authorized one, so no community icon/banner ever rendered.
Two stacked wire/authority bugs, both fixed:
- RoleEntity.scope was typed String, but the reference client (Armada) writes it
as an object ({"kind":"server"} / {"kind":"channel","channel_id":...}) per
CORD-04 §2. The type mismatch failed the whole RoleEntity decode, dropping the
role — and with it every grant depending on it — so no admin ever resolved.
Added RoleScope and retyped the field (no writer set it, so no migration).
- AuthorityResolver.resolve now folds each role/grant/banlist CHAIN through
authorized editions only, via an owner-rooted fixpoint over the full edition
set (not the post-hoc structural heads). A rogue higher-version grant from an
unprivileged key is dropped instead of superseding the owner's grant, so the
legit authority stands. ConcordCommunityState.fold then gates metadata by
MANAGE_METADATA, channels by MANAGE_CHANNELS, the banlist by BAN, and the
dissolution tombstone to the owner alone.
Verified live end-to-end: `amy concord channels Soapbox` now folds the real
metadata (name "Soapbox Community", the blossom icon + banner pointers) and all
9 channels; fetching + AES-256-GCM-decrypting the icon pointer yields a
hash-verified PNG. Regression tests cover the object-scoped role and the rogue
superseding grant.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Concord relays gate a plane's kind-1059 wraps behind NIP-42 and serve them
only to a connection authenticated AS the plane's derived stream key — the
member is neither the wrap's author (the stream key) nor its recipient (a
throwaway ephemeral key), so an account AUTH is refused and every plane REQ
came back empty (no channels, no messages), even though the community folded
its name.
Mirror the app's per-plane AUTH (60475c10c0) in the CLI:
- Context.registerConcordStreamKeys(relays, secrets) records the derived
control/channel stream secrets, scoped to the community's relays.
- The RelayAuthenticator provider now signs one kind-22242 per registered
stream key alongside the account AUTH — locally, from the raw derived key
(NostrSignerSync), never the account, so no user identity is exposed.
- The concord channels/read/send verbs register their control + channel
stream keys before draining/publishing.
- drain() gains pendingOnAuthRequired: an auth-required CLOSED keeps the relay
pending instead of terminal, so the post-auth subscription re-fire delivers
the events rather than the one-shot drain returning empty. The concord verbs
opt in; all other drains are unchanged.
Verified end-to-end against the live Soapbox community (relay.ditto.pub /
relay.dreamith.to): `amy concord channels` now folds the name + 9 channels
consistently and `amy concord read` returns messages. `channels` also emits
the folded icon/banner/description pointers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CORD-05 Appendix A.1 says the HKDF `id` is always present — 32 bytes,
all-zero for a label with no meaningful id — and A.6 lists
`concord/invite-key` with `id = 0…0`. inviteBundleKey built its info with
no id at all, so it derived a different bundle key than the reference
client (Armada) and NIP-44 decryption of an Armada-minted invite bundle
failed with "Invalid Mac" — the join aborted with "no valid bundle for
this link". amy round-tripped with itself (same wrong key both sides), so
its own tests passed and the divergence went unnoticed.
Verified by decrypting a real Armada invite bundle: the no-id key fails the
MAC; the zero-id key yields a valid CommunityInvite JSON. Pass ByteArray(32)
like the sibling banlist/dissolved derivations already do.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After a Refounding (CORD-06 §3), compaction re-wraps each entity's head
verbatim, so a head edited past genesis still carries an `ep`/prev citing
an edition in the prior epoch that a fresh joiner never fetches.
EditionFold.foldEntity required a genesis edition (prevHash == null) and
returned null otherwise, so a fresh login folded the whole Control Plane to
nothing: no community icon, no name, no edited channels (while Armada,
which implements the CORD-04 §1 fresh-joiner rule, showed them all).
Anchor at the lowest-version edition when no genesis is present and walk up
from there. Safe: Amethyst always re-folds the whole buffer from scratch
(no persistent floor), so it is structurally always a fresh joiner;
authority is validated on top by AuthorityResolver, so an unrooted forgery
is still dropped; and a genuine mid-chain gap still stops at the intact
prefix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- `graperank update` -> `graperank refresh`: the verb says what it does
(refresh the WoT record kinds from each author's outbox) and stops
colliding with the sync/crawl naming tangle. `update` stays as an alias.
- New `graperank unregister PROVIDER [--service KIND:TAG] [--relay URL]`:
the missing inverse of `register` — removes matching entries (public +
private) from the account's kind:10040 and re-publishes it; without
narrowing flags every entry for that provider key is dropped.
- `graperank operator providers` -> `operator keys`: it lists the
observer -> service-key map, and the old name collided with
`graperank providers` (the kind:10040 read). `providers` stays as an
alias; the JSON key `providers` becomes `keys` in both the listing and
`operator status` (breaking, matches the rename).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56
The census probes the whole known relay universe and feeds the shared NIP-66
reachability cache (kind:30166) that every reachability-aware command reads —
it was never graperank-specific, so it moves from GrapeRankCommand to
RelayCommands as `amy relay probe`. `amy graperank probe` stays as an alias,
and flags, JSON output, and behaviour are unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56
Every `amy graperank` / `graperank score` run now reconciles its result into
the local event store as NIP-85 kind:30382 cards signed by the per-observer
service key (cutoff --min-rank, default 2): changed ranks are re-signed,
unchanged ones skipped (no event-id churn), dropped targets retracted with a
kind:5 the store applies on insert. The store is the source of truth, so the
score of a run is durable and reusable instead of ephemeral stdout.
New verbs complete the crawl -> score -> publish pipeline:
- `graperank publish [OBSERVER] [--relay URL[,URL...]]` — transport only:
one NIP-77 up-only reconcile per operator relay over the provider key's
kind:30382 + kind:5, converging the relay to the local set (deletions
propagate, lost cards restored, full-set publish fallback for relays
without negentropy); also refreshes the observer's kind:10040 pointer.
- `graperank rank USER [--provider P] [--refresh]` — the consumer side:
newest card per provider from the local store, with a relay drain on miss.
GrapeRankPublisher (quartz) is reworked accordingly: reconcileAndPublish is
replaced by reconcileLocal (sign+insert+retract against the store) and
syncToRelays (NegentropyStoreSync up-only + blastPublish fallback), with a
commonTest covering upsert/skip/retract and re-carding a retracted target.
BREAKING (--json / flags): `--publish`, `--publish-limit`, `--publish-relay`
and `--bench-sign` are removed from the score command. Its JSON drops
published/publish_rejected/deleted/delete_rejected/skipped_unchanged/
publish_truncated/published_kind/published_to/publish_error/observer_10040/
bench_signed/bench_sign_ms and gains provider_pubkey/min_rank/cards_total/
cards_signed/cards_unchanged/cards_retracted/cards_ms. Publishing moved to
the new `graperank publish` verb.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56
Extract the Messages row's highlighted community chip into a shared
ConcordCommunityPill (secondaryContainer, group icon, 20-char cap) and render it
in NoteCompose's header markers for any Concord-gathered note, so a Concord
message surfaced in Notifications names its community and taps through to the
channel list — the same chip, wherever the message appears.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
routeReplyTo special-cased Marmot groups but had no Concord case, so replying to
a Concord message outside a chat screen (e.g. from Notifications) fell through to
a public kind-1111 comment — leaking the private rumor id onto public relays and
producing a reply that doesn't bind to the channel and no member would see.
Route a Concord-gathered note's reply into its minichat (its thread root for a
kind-1111, the message itself for a kind-9), whose composer sends the wrapped
kind-1111 on the channel plane. (Reactions already route through
reactToConcordMessage; zaps are forced PRIVATE via isPrivateRumor since a Concord
rumor is unsigned.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
When a chat message replies to the message rendered immediately above it
in the feed, the reply quote just repeats what the reader already sees.
ChatFeedLoaded now hands each row the id of its next-older neighbour and
the reply row skips rendering on a match — checked both on the raw reply
target and again after the gift-wrap unwrap, since unwrapping can map a
wrap id onto the displayed rumor.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
PR #3559 muted the shared HeaderPill (correct for NoteCompose's PoW/OTS/location
markers), but that also flattened the Messages tab's server/community chip, which
is a first-class navigation entry point and should stand out. Give RelayNameChip
back its own secondaryContainer highlight instead of the muted pill, and hard-cap
the community name at 20 chars so a long title can't crowd the row.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
The relay rows on the Find Groups (browse) screen now use the 55dp
LargeRelayIconModifier, matching the avatar size of the Messages list,
instead of the 13dp default.
When a relay's NIP-11 document resolves with a supported_nips list that
lacks "29" and no self key, the row shows a 'May not support groups
(NIP-29)' warning, and the relay's (empty) group directory screen says
the relay doesn't advertise NIP-29 instead of the generic empty text.
The check lives in looksLikeNonNip29Relay next to the other NIP-29
NIP-11 helpers; a null supported_nips (doc still loading or fetch
failed) never warns.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RsPtTBsJ3sJkjodN1Knbx6
In the Messages list, a NIP-29 group whose kind-39000 metadata has no
(or a blank) picture now shows its host relay's NIP-11 icon instead of
jumping straight to the robohash fallback. loadRelayInfo fetches the
NIP-11 document on demand if it isn't cached yet, and the robohash
still kicks in when the relay has no icon either.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RsPtTBsJ3sJkjodN1Knbx6
Concord channel messages can now carry images, wire-identical to Soapbox
Armada's `encryptAttachments`: a normal channel-bound kind-9 whose ciphertext
URL is appended to the content and annotated by a NIP-92 `imeta` tag with
`encryption-algorithm aes-gcm`, hex `decryption-key`/`decryption-nonce`, and the
plaintext `ox` hash (no `x`). The blob is AES-256-GCM ciphertext on Blossom, so
the media host and relays only ever see encrypted bytes — the community's E2E
guarantee holds.
Reuses the NIP-17 encrypted-media stack end to end: quartz's imeta tag vocab
and IMetaTagBuilder to build/parse the tag (ChannelChat.imageMessage /
encryptedImageImeta / encryptedImagesOf), the shared UploadOrchestrator
encrypted upload + ChatFileUploadDialog picker on the send side, and the OkHttp
EncryptedBlobInterceptor keyCache on the receive side — registering each
attachment's cipher (keyed by URL) lets the normal feed renderer display the
decrypted image with no shared-render changes. Encryption is mandatory
(no toggle, and a missing cipher fails closed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
The chevron tri-state (closed / unread peek / all) lived in a plain remember,
so opening a channel and returning to the hub reset every community to closed.
Move it to rememberSaveable with a Saver so the expansion each user set is
restored when the hub re-enters composition.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Every reply in a minichat is rooted at the note pinned at the top, so each
reply row was redundantly rendering that same root as an inner-quote reply
preview. Add a LocalSuppressReplyToNoteId composition local that RenderReplyRow
honors, and have the minichat provide its root id around the list — so a reply
whose parent IS the root shows no preview, while a reply to another reply still
shows its (distinct) parent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Publish a kind-23311 typing heartbeat as an ephemeral (21059) stream wrap
on the channel plane, throttled to once every few seconds while composing.
The session folds inbound heartbeats into a per-channel typing map with an
8s freshness window (never echoing the local user), and the channel screen
renders a slim "X is typing…" line above the composer with a ticker so a
typist who stops silently fades out.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Tapping a minichat reply (a kind-1111 CommentEvent) in notifications routed to the whole channel:
routeFor(note) matched the reply's Concord / relay-group / public-chat gatherer and opened the
channel, swallowing the thread it was replying in. routeFor now recognizes a chat-context CommentEvent
as a minichat reply and routes to Route.ChatMinichat(rootId) — rootId being the reply's NIP-22 root
(the parent message), for all three chat contexts.
For Concord, the parent message may not be cached (a cold reply notification), and MinichatScreen used
to resolve the plane only from the root note's gatherer — so an unloaded parent could never pick a
relay. The reply itself arrived over the channel plane, so its ConcordChannel gatherer carries the
community/channel: Route.ChatMinichat now also threads concordCommunityId/concordChannelId from the
reply, and MinichatScreen uses them to mount the plane subscription + this channel's backward-history
pager, paging until the parent message loads. Its whole kind-1111 thread then projects normally.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Guestbook membership fold was already written but dormant: I had decoupled
its plane from the shared REQ + AUTH while chasing the empty-channels
regression. The maintainer's `re-authenticate on an auth-required CLOSED` fix
addresses that root cause, and the Guestbook + next-rekey stream keys derive
from the entry alone (so they AUTH on the initial connection, unlike channel
keys that appear only after the Control Plane folds). Re-enable them:
- streamAuthSecretsFor now also signs the aux (Guestbook + next-rekey) stream
keys; the assembler re-adds auxiliaryPlaneSubs to the plane subscription.
- ConcordCommunitySession.allMembers()/memberCount(): Guestbook joins ∪ owner ∪
role-holders, minus banned — a best-effort floor (a silent key-holder who
never posted a join and holds no role is invisible).
- Surface it: the hub community header subtitle shows "N channels · M members",
and the Members screen lists the Guestbook members alongside owner/admins/banned.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
ConcordImageUploader now drives UploadOrchestrator.uploadEncrypted — the same
path DM/chat encrypted media uses — instead of a hand-rolled BlossomUploader
call. A community icon now gets image compression, EXIF/metadata stripping, and
the account's configured Blossom server, keeping the simple photo picker. It
hands the orchestrator a fresh AESGCM cipher and maps the result — ciphertext
url + plaintext hashBeforeEncryption — into the CORD-02 §6 ImagePointer, which
the read path (rememberConcordImageModel) round-trips.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The live plane subscription only carries each channel's relay-capped recent
tail, shared across one merged REQ per relay for every channel, so a channel
with plenty of history opened showing just its last few messages until the user
scrolled. The old bootstrap only paged when the feed was completely empty.
ConcordBackfillHistoryToWindow now pages older history on open until the feed
holds at least CONCORD_HISTORY_TARGET (50) messages or the relays are exhausted
— mirroring Armada's multi-page backfillStore — page by page via the existing
BackwardRelayPager, then latches off and lets scrolling drive further paging.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every inbound plane wrap that a session claimed — including a plain chat
message — bumped ConcordSessionManager.revision, and the always-on preload,
the channel subscription, and the open-channel history subscription each call
invalidateFilters() on every bump. So every message re-derived and re-REQ'd
every community's control + channel planes. On a cold load of hundreds of
buffered messages that is hundreds of re-subscriptions, which the relays answer
with "there is a bug in the client, no one should be making so many requests"
and close the plane subs mid-load (each needing a fresh NIP-42 AUTH). The
result: channels load only their last few messages, or none.
ingest() now reports a ConcordIngestOutcome (NOT_MINE / NON_STRUCTURAL /
STRUCTURAL). Only a STRUCTURAL wrap — a Control-Plane fold, a guestbook
membership change, or a buffered base-rekey — bumps the revision. Chat messages
are NON_STRUCTURAL: they still reach the feed via the rumor sink → LocalCache,
but no longer churn the subscriptions. The manager keeps its Boolean contract
(claimed) for DecryptAndIndexProcessor. Verified on-device: the "so many
requests" rate-limit is gone and the plane subscription stays open and drains
steadily instead of being closed and reopened per message.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tapping a community header now cycles three states instead of two:
CLOSED → UNREAD (peek only the channels with new messages) → OPEN (all) → CLOSED
The UNREAD peek is skipped when a community has nothing unread, so a quiet
community goes straight CLOSED → OPEN → CLOSED and never lands on an empty
middle state. In the peek, read channels hide themselves (reactively, off each
channel's last-read) and a "Show all channels" footer jumps to the full view;
the chevron shows ▲ only when fully open (▼ otherwise = "more to reveal"), and
the banner hero is reserved for the full-open view.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Part A — remove the top community rail. It showed the same community icons
that already appear as the list rows below, and its only action (toggle the
accordion) duplicated tapping a row. The accordion list is self-sufficient.
Make it alive — surface the activity we already fold, no new subscriptions:
- Activity sort: communities and channels order by their most-recent message
(Channel.lastNote), so the ones you'd actually open float to the top.
- Unread: a per-channel bold + dot and a per-community count badge, read from
the last-read the open channel already persists. Extracted a shared
concordChannelLastReadRoute() so the write (ConcordChannelScreen) and read
(hub) sides can't drift.
- Last-message preview: author + snippet + relative time under each channel.
- Banner hero: render the community's CORD-02 §6 encrypted banner when expanded
(the maintainer's rememberConcordImageModel already resolves it).
Deferred (need plumbing that doesn't exist yet): voice presence (kind 23313 is
modeled in quartz but has no consumer), typing (23311, not implemented), and a
true member count (needs Guestbook membership, not subscribed here).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
The Concord Channels hub was only reachable from the messages "+" FAB. Move it
into the left navigation drawer as its own item right after Relay Groups, and
make the same screen usable as a bottom-nav destination so users can pin it and
reach their communities in one tap.
- NavBarItem: add a CONCORD catalog entry (label "Concord Channels", Group icon,
route Route.Concords) and insert it into DrawerFeedsItems right after
RELAY_GROUPS. This renders the drawer row and lists it in the bottom-bar
customization picker (which enumerates the whole catalog).
- BottomBarFeedPreloaders: preload the Concord plane subscription when CONCORD is
pinned, like every other bottom-bar feed.
- ConcordHomeScreen: host AppBottomBar (auto-hides when pushed), show the back
arrow only when canPop, and pad the create-FAB above the bar — so it works both
as a pushed drawer destination and as a bottom-nav root.
- ChannelFabColumn: drop the now-redundant Concord sub-FAB.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Completes the CORD-02 §6 image write path: the community-metadata form's
icon hero is now a photo picker that AES-256-GCM-encrypts the chosen image
under a fresh key/nonce, uploads the *ciphertext* as an opaque blob to the
account's Blossom server, and seals the resulting ImagePointer
{url,key,nonce,hash} into the metadata — the inverse of the read path, and
what Armada does in concord-v2 (`encryptImageBlob` + Blossom upload).
- ConcordImageUploader reuses the existing NIP-17 DM encrypted-media
primitives (AESGCM + BlossomUploader.upload(inputStream, …) + the account's
Blossom server list + createBlossomUploadAuth). The blob is content-
addressed by the ciphertext SHA-256; the pointer's hash is the plaintext
SHA-256 for read-side integrity.
- ConcordMetadataFields now holds an ImagePointer? and its hero opens the
photo picker (spinner while uploading), replacing the plain-URL field — a
URL-string icon was never CORD-02-valid (Armada renders robohash for it).
- Create/edit pass the encrypted pointer straight through to
createConcordCommunity / editConcordMetadata.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Concord community icons never showed (robohash instead), and the community
name silently fell back to the invite name. Root cause: the icon/banner are
CORD-02 §6 **encrypted media** — the metadata entity carries an
`ImagePointer` object `{url,key,nonce,hash}` (AES-256-GCM ciphertext at
`url`, decrypted with `key`/`nonce`, `hash` = SHA-256 of the plaintext) —
but `MetadataEntity.icon` was typed `String?`. An object where a String is
expected fails the whole entity's decode, so metadata came back null: no
icon, and the name dropped to the entry fallback. Matches the Concord v2
reference client (Armada `concord-v2/lib/{types,image}.ts`).
- Promote `ImagePointer` to a shared CORD-02 type (was invite-only) and give
it `decryptOrNull` (AES-256-GCM via the existing `AESGCM`, verifying the
plaintext SHA-256 — a swapped blob fails closed).
- `MetadataEntity.icon`/`banner` are now `ImagePointer?`, so the entity (and
the community name) decodes. `ConcordChannel` carries the pointers.
- `rememberConcordImageModel` resolves a pointer for the avatar: a plain-URL
pointer (Amethyst's own form) passes through; an encrypted one is fetched,
decrypted, verified, cached to disk, and rendered — else the robohash. Wired
into the Concord hub avatars and the Messages-tab community chip.
- Amethyst's create/edit still take a URL and wrap it as a url-only pointer;
authoring encrypted images (encrypt + upload) is a follow-up.
Adds ImagePointerTest: Armada-shape object decode, decrypt round-trip, and
fail-closed on a tampered hash.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Communities only folded — revealing their channels, metadata (name/icon)
and membership — while a Concord screen was actually open, because the
Control-Plane subscription (ConcordChannelSubscription) was mounted only on
the six Concord screens. Sit on the Home feed and nothing streams in; a
community you haven't opened stays unfolded (no channels, no image).
Concord control-plane wraps are addressed to derived stream keys, not
`#p=self`, so the always-on account/DM gift-wrap tail can't pick them up.
Add ConcordChannelPreload — an account-level, always-on mount using the
non-lifecycle KeyDataSourceSubscription (the same primitive
AccountFilterAssemblerSubscription uses for DMs) with the existing
revision-driven filter re-derivation — and mount it in LoggedInPage next to
the DM/account preload. Now every joined community's planes are requested
from login regardless of screen, so folds happen in the background. The
already-always-on ingest/refreshConcordChannelIndex path was only missing
this continuous network request.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The relay-auth fix added an `interactive` flag to
RelayAuthenticator.signWithAllLoggedInUsers; update the desktop and cli
implementers (which don't prompt) to the 3-arg lambda.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Concord channels only showed the recent tail the relay served for the
channel plane and never loaded older messages on scroll. Add backward
`until`+`limit` paging, per relay, on demand — the same model as the
NIP-04 per-conversation history.
Reuses the shared paging stack as-is (BackwardRelayPager,
RelayLoadingCursors, the RelayReach* markers/sentinels and
DmHistoryLoadingCard); only the Concord-specific data layer is new:
- ConcordChannel holds a per-channel RelayLoadingCursors (`history`), so
cursors share the channel's cache lifetime.
- ConcordChannelHistory{FilterAssembler,SubAssembler} binds a pager to the
open channel, builds `{kinds:[1059], authors:[planePk], until, limit}`
per armed relay, and forwards relay callbacks; registered in
RelaySubscriptionsCoordinator and mounted by the channel screen.
- ConcordCommunitySession.channelPlaneAddress() resolves a channel's REQ
author from the fold.
- ConcordChannelScreen wires the olderBoundary/markersInGap/sentinels feed
hooks and bootstraps an empty channel.
The history floor is `now` (not the DM 7-day tail): the Concord live sub
isn't a strict recent-tail (it asks the plane author unbounded and the
relay caps the result), so paging must walk the whole history from the top
to reach recent-but-capped messages. Overlap with the live tail is
harmless — wraps dedup by id on ingest.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A Concord chat row rendered a permanent "Event is loading or can't be
found in your relay list" placeholder when a message's kind-5 delete was
processed before the message itself — easy to hit because a reproject
re-emits the whole wrap buffer and wrap ordering isn't guaranteed.
`consumeConcordRumor` attaches the row before `justConsume`, but
`justConsume` bails without loading the event once the rumor has been
deleted, leaving an event-null note pinned in the channel forever.
Skip attaching a row for a rumor already known deleted (also avoids
add/remove churn on every reproject), and after consuming, drop the row if
its event never loaded. The reverse order (delete after the message) is
still handled by the normal deletion cascade unlinking the note.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Concord channels loaded empty because the channel-plane REQ was refused
until the relay happened to re-issue an AUTH challenge (~27s later, or
never on relays that don't re-challenge).
A channel's derived stream key only exists after the community's Control
Plane folds, but by then the connection's initial NIP-42 AUTH already ran
with just the control key. `RelayAuthenticator` only re-authenticated on a
fresh `AUTH` message and ignored `auth-required` CLOSED frames — so the
newly-revealed channel keys were never sent.
NIP-42 says the client must store the connection's challenge and reuse it
"in response to the auth-required CLOSED message". Do that: remember the
last challenge per relay, and on an `auth-required:` CLOSED re-run the
sign/send pass with it. `saveAuthSubmission` dedups by (pubkey, challenge)
so only not-yet-authed identities (the folded-in keys) are sent — a no-op
once they all are, so no loop. A burst guard skips re-signing while an AUTH
is already in flight (syncFilters re-drives the REQ when it settles), and
the re-auth is non-interactive: it re-sends only already-approved
identities (ledger-ALLOW accounts + stream keys) and never raises a prompt,
so it can't drag a bystander account onto a paid relay.
Adds an `interactive` flag to the signing callback and a
RelayAuthenticatorReauthOnClosedTest covering reuse, loop-safety,
burst-coalescing, and the non-auth-required CLOSED no-op.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The chip that names each Concord channel's parent community (and opens it on
tap) renders only when channel.communityName is set. That value is populated
by refreshConcordChannelIndex -> ConcordChannel.updateFrom on each Control
Plane fold, but nothing invalidated the channel's metadata flow afterward, so
the row (which observes metadata.stateFlow via observeChannel) never
recomposed to show the chip — it appeared only if the row happened to
recompose for another reason.
updateFrom now returns whether a displayed field actually changed, and the
index refresh calls updateChannelInfo() only on a real change, so the community
name, icon and chip recompose the moment the fold resolves them, without
churning every row on every fold tick.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Per-row cost inventory of the redesigned chat feed (date formatting,
per-row coroutines, observer churn, relay-filter enrollment during
fling) plus a measure-first phased plan: macrobenchmark baseline,
per-row CPU/coroutine elimination, velocity-gated side effects, and
structural options if numbers demand them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
Regression: after wiring CORD-06, joined communities showed but their
channels were empty. Folding the Guestbook + next-epoch base-rekey planes
into the SAME kind-1059 REQ and NIP-42 stream-key AUTH set as the control
and channel planes starved the whole subscription on relays that gate a
REQ on stream-key AUTH: the control plane stopped folding, so no channels
appeared (the community list is a separate kind-13302 fetch, so it still
showed).
Restore the control + channel subscription and AUTH set to exactly their
pre-CORD-06 form: drop auxiliaryPlaneSubs from the shared REQ, and drop the
Guestbook/next-rekey keys from streamKeys() (moved to auxStreamKeys() for a
future isolated subscription). The receive-side pieces (guestbook fold,
rekey buffering/drain) stay in place but are dormant until re-introduced in
their own subscription that cannot affect the core chat path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Ban is only a soft removal — the member still holds the room key and can
decrypt everything; clients just decline to show their posts. This wires
CORD-06 Refounding: the hard removal that rotates the community_root so a
removed member's key stops working for anything sent afterwards.
Quartz:
- ConcordKeyDerivation: baseRekeyAddress / channelRekeyAddress (the rekey
stream addresses) and epochKeyCommitment (prevcommit, CORD-02 A.5).
- ConcordRekey: signer-based blobForSigner / findNewKeyWithSigner so a
bunker account opens its blob with a single nip44Decrypt.
- ConcordRefounding: compactControlPlane (re-wrap each head edition's
original plaintext seal under the new root, preserving signatures),
buildBaseRekeyWraps, build, findNewRoot. OpenedStreamEvent now carries
the inner seal for compaction. ConcordRefoundingTest.
Commons:
- ConcordActions: guestbookPlane / nextBaseRekeyPlane, buildGuestbookJoin /
guestbookMembers, buildRefounding, openBaseRekey.
- ConcordCommunitySession folds the Guestbook plane into members (the
recipient set), buffers inbound base-rekey wraps, exposes controlPlaneWraps,
and AUTHs to + subscribes the Guestbook and next-epoch base-rekey planes.
- ConcordSessionRegistry.sync rebuilds a session when its entry's root/epoch
changed; ConcordSubscriptionPlanner.auxiliaryPlaneSubs REQs the new planes.
Amethyst:
- Account announces a Guestbook JOIN on create/join; refoundConcordCommunity
(owner / BAN-holder) bans + rolls + publishes + persists; drainConcordRekeys
adopts an inbound rotation from an authorized rotator; adoptConcordRoot
persists the new root (prior kept as a HeldRoot) and re-seeds the new epoch's
Guestbook, guarded against double-adopt.
- AccountViewModel.removeConcordMember; ConcordMembersScreen "Remove from
community" action + confirmation dialog.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Every Concord/minichat screen now stands on its own regardless of how it's reached:
- MinichatScreen mounts its own datasource (the Concord plane subscription when the
root is a Concord message; a relay reply REQ for public chats) and drives its list
from a reactive MinichatFeedViewModel, so new replies arrive live and auto-scroll
into view even when opened via deep link. Adds a LazyListState that scrolls to the
newest reply as they land.
- ConcordMembersScreen and ConcordEditScreen now mount the plane subscription too
(previously they showed nothing when opened directly), and all three community
screens (channel list, members, edit) re-resolve sessionFor(communityId) on each
session revision instead of caching it once — so a deep link that lands before the
community has folded picks it up as soon as it does.
Minichats are deliberately kept OFF NIP-17 DMs: the "N replies" chip is gated to the
chat types where minichats are wired (Concord, NIP-28, NIP-29), since most clients
don't render kind-1111 replies in a DM view. The DM composer already has no thread
toggle, so DMs neither create nor surface minichats.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
- New noteActionSections: the full note-action inventory (follow, copy,
share, edit, broadcast, timestamp, pin, label, bookmarks, playlists,
emoji packs, mute, delete/report) lives once, with all gating, and is
rendered by BOTH the 3-dot NoteDropDownMenu (as M3 rows) and the chat
long-press sheet (as icon tiles) - the two surfaces can no longer
drift. The menu also gains the delete confirmation dialog and the
privacy-safe private-rumor delete the sheet got in the audit fixes.
- payViaIntentOrManualSplit: the shared zap-payment tail (wallet intent
vs manual split screen), replacing three verbatim copies in
ReactionsRow and one in the sheet.
- Chat bubble shapes move to commons ChatTheme.kt as the single source
of truth (18dp geometry incl. grouped variants); Desktop now inherits
the modernized corners, and the stale 15dp duplicates are gone.
- Deleted the unreferenced RenderCreateChannelNote /
RenderChangeChannelMetadataNote card renderers (~360 lines).
- ChatSystemMessage renders one Surface with a conditional clickable
instead of two duplicated branches.
- ChatEngagementDetailSheet rows share one EngagementRow scaffold;
delivery tick selection deduped into DeliveryLadderTick /
DeliveryStatusTick.
- Hardcoded chat font sizes replaced with Font12SP / named constants.
Not merged on purpose: ChipReactionGlyph vs MultiSetCompose's gallery
glyph dispatch - the gallery variant handles interactive secret emoji
and alignment modifiers, so unification would change behavior.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
Correctness (from the branch audit):
- ChatDeliveryTracker.destroy() unregisters its persistent OK listener;
wired into AccountCacheState.removeAccount/clear so account switches
no longer leak listeners on the shared client.
- Deletion from the long-press sheet asks for confirmation again
(respecting the hide-dialog opt-out), and own NIP-17 DM messages get a
privacy-safe delete via the gift-wrapped deletion (deletePrivately)
instead of a nonsensical self-report tile.
- Jumbo-emoji bubble transparency now updates when async decryption
lands, so encrypted emoji-only DMs no longer latch an opaque bubble
around 50sp emoji.
- Group positions recompute when a neighbor's event/author loads
(watchChatGroupPosition observes the three notes' metadata flows).
- Delivery ticks: PoW-mined wraps register with the tracker (rumor id
threaded through mineWrapsInBackground); the double-check and k/n
count describe the other participants only (self-copy shown in the
detail dialog, where it belongs); relay lists resolve once per wrap
instead of twice.
- Zap cards keep the sats amount visible against long display names
(weight(fill=false) on the username).
- Bubble colors re-derive when the theme or accent changes in place
(keyed remember; this predates the branch but was re-exposed by it).
- Keycap emoji (1️⃣) count as jumbo; incomplete flag pairs and bare
keycap bases make a message non-jumbo instead of miscounting.
Performance:
- ChatDeliveryTracker holds one small StateFlow per tracked message, so
a relay OK updates a single message instead of copying a whole map and
waking every collector.
- Swipe-to-reply tracks the drag in plain float state (no coroutine per
pointer frame); one cancellable job settles the spring-back.
- JumboEmoji uses the KMP-safe codePointAtKmp/codePointCharCount helpers
(unblocks a future move to commonMain).
- Documented the deliberate per-message reaction/zap observer parity
with the main feeds in ChatReactionChips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
Follows the code-review of the dual-reply + Concord work:
1. Public-chat/relay-group minichat replies no longer drop @-mentions, hashtags,
URL references, quotes, custom emojis, attachments, content-warning or
expiration: ChannelNewMessageViewModel.createTemplate's MINICHAT branch now runs
after NewMessageTagger and builds the kind-1111 from tagger.message with the same
enrichment the inline path uses (parent author is already tagged by replyBuilder).
2. refreshConcordChannelIndex no longer runs a full re-index + ban rescan on every
ingested message: the revision collector is sample(500)-throttled, coalescing
bursts into at most one pass per window.
3. Concord stream-key AUTH is no longer blocked behind the user-auth prompt: on a
relay that hosts our planes, the ASK path is DISMISSed so the derived stream-key
AUTHs return immediately instead of waiting on (or being dropped by) a dialog.
4. Account.sendMinichatReply evaluates chat.relays() once instead of twice.
5. AuthCoordinator caches the per-stream-key NostrSignerSync by secret, so the
secp256k1 keypair isn't re-derived for every plane on every relay challenge.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Rows in the engagement detail sheet now navigate to the zap receipt's
or reaction event's own thread view - where replies and reactions TO
the zap/reaction are visible - instead of the author's profile; the
avatar keeps the one-tap profile shortcut and rows fall back to the
profile while a zap receipt is still unknown. The zap pill cards in
live chats become tappable to the same thread view.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
Generalizes the minichat feature beyond Concord to the public chats:
- ChannelNewMessageViewModel gains a replyMode state + toggle; createTemplate now
builds a kind-1111 CommentEvent for a MINICHAT reply (with the NIP-29 `h` tag on
relay groups) instead of the native reply, so users can start threads in NIP-28
public chats and NIP-29 relay groups.
- Account.sendMinichatReply resolves PublicChatChannel and RelayGroupChannel from
the note's gatherer and publishes a public kind-1111 (h-tagged + host-relay for
groups), so replying inside a public-chat minichat works too.
- observeNoteMinichatReplyCount re-registers each visible message with the
EventFinder subscription, which batches their ids into shared REQs for kind-1111
replies — so the "N replies" chip appears on public-chat messages as their
threads load. (Concord's replies still arrive over the plane; that REQ no-ops.)
- The reply-mode toggle is extracted to a shared ReplyModeToggle composable used by
both the Concord and public-chat composers.
The chat-styled minichat screen already generalizes: it reads the root's kind-1111
replies and works for any chat type. DMs (NIP-17) remain the deferred Phase 3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Adds the "chat within a chat" screen the N-replies chip now opens: the root
message pinned at top, its kind-1111 thread replies below as chat bubbles (reusing
ChatroomMessageCompose so they look identical to the main chat), and a composer
that posts a kind-1111 rooted at that message — flat, so replying inside a minichat
doesn't spawn sub-threads.
- Route.ChatMinichat(rootId) + AppNavigation registration + MinichatScreen.
- The shared "N replies" chip now navigates to the minichat instead of the generic
thread view.
- Account.sendMinichatReply resolves the chat context from the note's gatherer and
drives the Concord channel path (kind-1111 on the plane). Observing the root's
replies also loads kind-1111s from relays, so public-chat minichats already
display; their send path (NIP-28/NIP-29) is the remaining follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Adds the two-way reply model: an INLINE reply stays in the timeline (native chat
message referencing its parent), a MINICHAT reply is a kind-1111 NIP-22 comment
pulled into a thread opened from the parent — matching Armada.
Foundation (quartz/commons):
- ReplyMode enum (INLINE default, MINICHAT).
- ChannelChat.inlineReply / ConcordActions.buildChannelInlineReply restore the
kind-9 q-tag quote path alongside the existing kind-1111 ChannelChat.reply.
- ChannelFeedFilter excludes kind-1111 CommentEvents from the chat timeline (they
belong in the minichat), so inline replies stay and thread replies move aside.
- observeNoteMinichatReplyCount: local count of a message's kind-1111 replies.
Concord composer + send (amethyst):
- ConcordNewMessageViewModel gains a replyMode state + toggle; the composer shows a
"In chat" / "In thread" toggle beside the reply preview.
- Account.sendConcordChannelMessage routes MINICHAT to kind-1111, INLINE to kind-9
quote, fresh post to kind-9 message.
Shared row chip (all chat types):
- ChatMessageCompose's action row shows an "N replies" chip when a message has
kind-1111 thread replies; tapping opens the thread. Wired to the thread view for
now; a chat-styled minichat screen and NIP-28/NIP-29 loading follow.
Plan: amethyst/plans/2026-07-12-dual-reply-minichat.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Concord replies were kind-9 chat messages carrying a `q` tag. In the Concord
model (matching Soapbox Armada) a `q` on a kind-9 is an inline *quote*, which
clients deliberately keep OUT of threads — a real thread reply is a kind-1111
NIP-22 comment. So our replies rendered (and were sent to Armada) as inline
quotes, never grouping into a message's thread.
ChannelChat.reply now builds a CommentEvent via CommentEvent.replyBuilder: the
uppercase K/E/P tags pin the immutable thread root and the lowercase k/e/p tags
point at the immediate parent (root inherited when the parent is itself a
comment, so the root is stable at any depth), plus the same channel/epoch binding
every Chat Plane rumor carries. This is byte-compatible with Armada's
buildV2CommentTags, so replies thread correctly in both directions. The read path
already accepts these (they carry the binding, and consumeConcordRumor handles
CommentEvent), so incoming Armada thread replies now land bound to their channel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
- Swipe-to-reply: drag a bubble toward the screen center (right for
received, left for own); a reply icon fades/scales in behind it and a
haptic tick marks the commit threshold - releasing past it quote-
replies. Disabled for drafts and inner quotes.
- Send/arrival motion: chat feed items use animateItem so a new message
fades in and neighbors slide to make room instead of the list
snapping. Skipped in performance mode.
- Press feedback: bubbles scale down slightly while pressed, with
haptics on long-press (action sheet) and double-tap (like).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
Four features building on the redesign's foundations:
- Who-reacted/who-zapped sheet: long-press a reaction chip (or tap the
sats chip) to open a sheet listing every zapper with amount + comment
and every reactor with the emoji they sent; rows open the profile.
- Delivery detail: tapping the delivery tick opens a dialog showing
per-recipient acceptance for DMs or per-relay acceptance for rooms,
with a re-broadcast action for stuck messages.
- Per-user name colors: group-chat author names get a stable
pubkey-derived hue, tuned separately for light and dark themes.
- Jumbo emoji: messages of 1-3 emoji render as large bare emoji with a
transparent bubble (scroll-to-highlight still tints them); ZWJ
sequences, skin tones, and flags count as single emoji.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
Each Concord channel row in the Messages tab has a chip naming its parent
community (tap → opens the community), mirroring the NIP-29 relay chip — but it
only renders when ConcordChannel.communityName is set, and that was populated
solely by refreshConcordChannelIndex() inside the Concord hub screen's
subscription composable. On the Messages tab that screen isn't mounted, so the
channel objects there never got their community name/icon and the chip was
absent.
Moves the channel-index refresh to an account-scoped collector on the
ConcordSessionManager revision, so community metadata (name/icon, channel flags,
membership) and per-community ban pruning apply across the whole app the moment a
Control Plane folds — not only while the hub screen is open. The hub subscription
now just re-derives its filters; the shared refresh lives in Account.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Concord channel messages arrived as 6 rows stuck on the 'Event is loading or
can't be found in your relay list' placeholder. A Concord inner rumor is
unsigned (empty sig) and never exists on a relay, but consumeConcordRumor fed it
to justConsume with wasVerified=false, so justVerify ran a signature check,
failed, and the event never loaded onto its Note — the chat row then fell back to
loading-from-relay and showed the placeholder forever.
The rumor's authenticity is already established when the envelope is opened
(ConcordStreamEnvelope.open verifies the seal signature, binds rumor.pubKey ==
seal.pubKey, and checks rumor.verifyId()), exactly like a NIP-59 gift-wrapped DM
rumor. Consume it as pre-verified (wasVerified=true) so the event loads and the
message text renders in the Messages tab alongside other groups.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Concord relays gate a plane's kind-1059 wraps behind NIP-42 and serve them only
to a connection authenticated AS the plane's derived stream key — a Concord wrap
is authored by the stream key and p-tagged to a throwaway ephemeral key, so the
member is neither author nor recipient. Amethyst authenticated only as the user,
so every plane REQ was refused ('auth-required: all authors must be
authenticated') and no channel or message ever loaded, even though the community
list (kind 13302) folded correctly.
Amethyst's relay-auth plumbing already supports multiple identities per
connection: RelayAuthenticator sends one AuthCmd per event in the list its
provider returns, deduped on (pubkey, challenge). This wires the derived stream
keys into that provider:
- ConcordCommunitySession.streamKeys() exposes control + folded-channel GroupKeys.
- ConcordSessionManager.streamAuthSecretsFor(relay) returns the stream secret keys
a NIP-42 challenge from that relay must be answered with (relay-scoped to each
community's own relays — the same scope the plane subscription uses).
- AuthCoordinator signs one kind-22242 per stream key locally (raw KeyPair via
NostrSignerSync — never the account signer, never exposing user identity), and
attaches them to every challenge independent of the user-auth policy. On auth
success the existing syncFilters re-fires the previously-refused plane REQ.
- RelayAuthStatus LruCaches widened 10 -> 200 since one connection now
authenticates as the user plus many plane keys (control + channels).
Mirrors Armada's streamAuth.ts (sign one AUTH per scoped stream key, accumulating
on the connection). Verified live on-device by the debug pass: authing as the
derived stream key returns the control-plane wraps that were previously refused.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
The 'More options' handoff is gone: every action from NoteDropDownMenu
now lives directly in the sheet as icon tiles grouped by similarity into
rows - message (reply, edit, copy text/id/json, share, broadcast),
author (follow, follow sets, copy pubkey), organize (timestamp, pin,
label, bookmarks), and moderation (mute thread, delete/report). The
sheet scrolls; share and on-chain zap swap the sheet for their own
surface, report/label/edit dialogs stack over it, matching the menu's
behavior. Private-rumor and ownership gating mirror the menu exactly.
Playlist/emoji-pack curation specials are omitted - those kinds never
render in chat feeds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
Instead of a single zap icon that opens the amount popup, the sheet now
embeds the rail-aware amount chip grid itself (same chips as the popup:
lightning/cashu/on-chain per amount, reload-mint, preset editor).
Lightning and cashu zaps fire immediately and close the sheet; on-chain
amounts swap the sheet for the OnchainZapSendDialog it hosts.
Extracted from ReactionsRow for reuse: ZapAmountChoiceGrid (the chip
grid without the popup card) and observeZapRailCapability (the async
rail availability computation).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
StreamSystemCard's default corner shape moves from 8dp to 18dp so the
full-width raid and clip cards share the rounding of the redesigned
bubbles, zap pills, and system messages. The zap renderer drops its now
redundant shape override.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
The community-list import only queried the 4 Concord stock relays, so a list the
user copied onto their own outbox/read relays (or that a client published there)
was never fetched — the normal account subscription never asks for kind 13302.
Widens the query to stock ∪ mineRelays ∪ outboxRelays with a 30s window (stock
relays like relay.ditto.pub can take 10–20s to first response), and logs how many
13302 events were fetched and how many entries decoded so an empty hub is
diagnosable. Renames importConcordCommunitiesFromStockRelays → importConcordCommunities.
Verified live: the account's newest kind-13302 is served without AUTH by the
dreamith stock relay (and, slowly, relay.ditto.pub); the codec, LocalCache
dispatch, and fold path all match.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Removes the last catch-all ConcordKinds constant object. Each remaining kind
now lives on the class that owns it:
- wrap/seal kinds were already ConcordStreamEnvelope.KIND_WRAP/SEAL_* — callers
(ConcordActions, ConcordSubscriptionPlanner) reference those directly.
- guestbook join/leave (3306) and kick (3309) become Guestbook.KIND_JOIN_LEAVE /
KIND_KICK.
- direct-invite (3313), voice-presence (23313), and rekey (3303) inline their
literals on ConcordDirectInvite/VoicePresence/ConcordRekey.
The unused edit/webxdc/typing/snapshot/invite-list/ephemeral-wrap constants are
dropped; they will own their literals when those events get dedicated classes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Amethyst serialized the joined-community list (kind 13302) as a bare JSON
array of flat camelCase entries, so it could not parse the document Soapbox
Armada actually publishes — a community created in Armada never appeared in
Amethyst even after the 13302 reached a shared relay.
Rewrites the codec to Armada's communityList.ts shape:
{ entries: [ { community_id, seed: JoinMaterial, current: JoinMaterial,
added_at } ], tombstones: [ { community_id, removed_at } ] }, where
JoinMaterial is the snake_case per-snapshot key bundle (community_id, owner,
owner_salt, community_root, root_epoch, channels[], relays, name, held_roots?,
refounder?). Hydration prefers current over seed; liveness is derived from
tombstones (an entry is dropped only when removed strictly after it was added).
New create/join entries now stamp added_at (ms) so the liveness tiebreak works.
Adds interop tests that decode a real Armada document and exercise the
tombstone-after-add drop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Removes the CONTROL/COMMUNITY_LIST/INVITE_BUNDLE constants from ConcordKinds
now that each has a dedicated Event class. Callers reference
ControlEditionEvent.KIND, ConcordCommunityListEvent.KIND, and
ConcordInviteBundleEvent.KIND directly, matching the nip88Polls per-kind
event convention.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Search ran the term through Nip19Parser, which extracted the invite URL's embedded
kind-33301 naddr and routed to the generic addressable-event screen — which has no
renderer for 33301, so it showed 'unable to render kind 33301'. Detect a Concord
invite link before the nip19 parse and route to Route.ConcordInvite (carrying the
whole URL so the fragment token survives). Also guard the bare-naddr path: a 33301
naddr goes to the invite flow instead of the unrenderable event screen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Zap messages in live streams (and other chat feeds) now render as a
content-hugging, centered pill with the 18dp rounding of the redesigned
bubbles and system messages, instead of an edge-to-edge 8dp card. The
bitcoin-orange accent, big-zap emphasis, and inline comment rendering
are unchanged.
StreamSystemCard gains opt-in fillWidth/shape parameters (defaults
preserve the raid and clip cards, which carry media previews and want
the full width).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
NIP-28 channel create (kind 40) and metadata update (kind 41) events
narrate the room rather than talk in it. They now render as a centered,
muted system pill - 'X created the channel Y' / 'X updated the channel
profile' - that taps through to the channel, instead of a full channel
profile card inside a regular user bubble (chat design best practice:
system messages should be visually distinct from user messages).
The old card renderers (RenderCreateChannelNote / RenderChannelData)
stay in the tree but are no longer referenced by the chat feed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
Third refactor slice (plan: quartz/plans/2026-07-11-concord-event-classes.md).
The kind-33301 public invite bundle is now a proper addressable Event class:
- New cord05Invites/bundle/ConcordInviteBundleEvent (BaseAddressableEvent), with a
build{} template that emits the ['d',''] + ['vsk','6'] tags via the standard DTag
ext and the shared VskTag (ControlEntityKind.INVITE_LIVE). Registered kind 33301
-> ConcordInviteBundleEvent in EventFactory so fetched bundles parse as the class.
- ConcordInviteBundle.build now signs ConcordInviteBundleEvent.build(...) with the
per-link key; the raw TAG_D/TAG_VSK/VSK_LIVE constants are gone. Tag order (d, vsk)
is preserved so the bundle event id / naddr are unchanged — the cord05Invites
tests (bundle round-trip + join flow) pass.
Remaining invite kinds: 3313 direct-invite (rumor has empty tags; p/k index is on
the giftwrap) and 13303 invite-list (unimplemented stub) — tracked in the plan.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
When the author types a NIP-05 mention (full `m@testls.bit` form or bare
`testls.bit` domain), the existing user-suggestion popover already resolves it
asynchronously on Dispatchers.IO via Nip05Client (Namecoin for .bit, regular
.well-known/nostr.json for everything else). On pick, however, the inserted
token was `@npub1…`, losing the relay hints already harvested during
resolution.
Insert `nostr:${user.toNProfile()} ` for NIP-05-shaped picks so:
* the send-time NewMessageTagger parses the bech32 inline through its
existing `nprofile1` branch — no extra main-thread I/O, no per-word
network round-trips that could compound into 30s+ stalls on dead servers;
* relay hints harvested by nip05ResolutionFlow ride along in the nprofile;
* the email-vs-mention call is the author's: typing `vitor@vitorpamplona.com`
surfaces the suggestion but only commits as a mention when the author taps
the row. Just typing the address sends as plain text.
Non-NIP-05 picks (search by name, typed npub/nprofile, hex pubkey) keep the
existing `@npub1…` insertion form.
Also collapses the open-coded `Nip05Id("_", prefix.lowercase())`
bare-domain synthesis onto Nip05Id.parseLenient so there's one place doing
that conversion.
Addresses review feedback on #3165: avoids the extra parser pass and the
inline send-time resolve loop in NewMessageTagger by routing all NIP-05
mentions through the popover the author already uses.
Add a small companion helper that lets callers parse either:
- a full NIP-05 identifier (`name@domain.tld`), same as Nip05Id.parse; or
- a bare domain (`domain.tld`), synthesized as the wildcard form
`_@domain.tld` per NIP-05.
This is the building block used by upcoming mention parsing in note
compose (NewMessageTagger) and note rendering (RichTextParser) so that
typing `@m@testls.bit` or `@testls.bit` in a note tags the user
correctly, and a received note that contains `m@testls.bit` becomes a
clickable user link instead of falling into the email/mailto bucket.
The helper is intentionally split out from the strict `parse`
(unchanged) so its lenient behaviour (bare-domain synthesis) cannot
sneak into NIP-05 verification flows that need the strict form.
Second refactor slice (plan: quartz/plans/2026-07-11-concord-event-classes.md).
The kind-3308 control edition now follows the nip88Polls per-kind shape:
- New cord04Roles/control/ package: ControlEditionEvent (the Event subclass +
build{} template + typed accessors), TagArrayBuilderExt (vsk/eid/ev/ep/vac),
TagArrayExt (readers), and tags/ classes VskTag/EidTag/EvTag/EpTag/VacTag with
parse/assemble/isTag.
- ControlEditionBuilder.rumor now delegates to ControlEditionEvent.build +
RumorAssembler; the raw stringly-typed tag construction and the
ControlEdition.TAG_* constants are gone. Tags stay in the fixed vsk,eid,ev,ep,vac
order so chain rumor ids are unchanged.
- ControlEdition.fromRumor reads the typed tags, preserving exact validation
(present-but-malformed ep/vac still rejected; absent = genesis/owner).
- Register kind 3308 -> ControlEditionEvent in EventFactory so decrypted control
rumors parse as the typed class.
The edition hash is over content fields (not tags), and all Concord tests —
ControlEditionTest, the fold/authority suite, and the join-flow round-trip — pass,
so the wire format and chain are preserved.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Ticks are sourced from relay OK acceptances, rendered next to the
timestamp on the last message of each own-author run:
- clock: published but no relay accepted yet
- single check: accepted by at least one relay
- double check (green): DMs - every participant's gift wrap accepted by
that participant's relays; rooms - all target room relays accepted
- group DMs additionally show a delivered k/n participant count
New ChatDeliveryTracker captures the recipient -> wrap -> target-relays
mapping inside Account.broadcastPrivately (the only place it exists,
before wraps are aliased onto a single note) and room targets in
signAndSendPrivatelyOrBroadcast, then attributes relay OKs back per
recipient via a persistent listener. Messages from before a restart fall
back to the note's seen-on relays.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
First slice of the nip88Polls-structure refactor (plan in quartz/plans/
2026-07-11-concord-event-classes.md). A Concord chat rumor IS a standard Nostr
event plus a channel/epoch binding, so stop re-deriving it by kind number:
- New cord03Channels/tags/ChannelTag + EpochTag (typed, parse/assemble), plus
TagArrayBuilderExt (channel/epoch/channelBinding) and TagArrayExt
(concordChannel/concordEpoch/isConcordBoundTo) — the poll-package shape.
- ChannelChat.message/reply now build via ChatEvent.build{ channelBinding(...) }
and assemble the template into a rumor; reaction reuses ReactionEvent.KIND. The
minimal e/p/k reaction tags and q/p reply tags are kept byte-identical for Armada
interop (guarded by the Concord round-trip tests, which pass).
- Drop the ConcordKinds.MESSAGE/COMMENT/REACTION/DELETE aliases (they shadowed
ChatEvent/CommentEvent/ReactionEvent/DeletionEvent KINDs); callers use the real
event KINDs. VoicePresence uses the new tag classes instead of bindingTags.
Remaining Concord-specific kinds (control 3308, invites, guestbook, rekey, voice,
seals/wrap) get their own per-kind packages in later slices per the plan.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
The Concord hub was empty even after the 13302 arrived: ConcordCommunityListEvent
extended plain Event, so LocalCache filed it by id and the dispatch when() had no
branch for it — it fell through to consumeRegularEvent. But ConcordChannelListState
observes the addressable note at Address(13302, pubkey, ""), which never received
the event, so liveCommunities stayed empty.
Make ConcordCommunityListEvent a BaseReplaceableEvent (kind 13302 is a NIP-01
replaceable — fixed empty d-tag, address = (kind, pubkey, "")) and add the
consumeBaseReplaceable branch next to the kind-10009 list, so it lands in the
addressable cache exactly like its NIP-29 sibling. The plane-wrap path (1059 ->
concordSessions.ingest in DecryptAndIndexProcessor) was already correct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
A small time label now sits in the bubble's bottom-end corner of the last
message of a group (and singles), so temporal context is always visible
without repeating it on every bubble. Hidden while the tap-to-expand
detail row is open, which already includes the time.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
- Grouped bubbles: middle messages now use sharp corners on every edge so
full rounding only appears at the top and bottom of an author run.
- Reaction chips overlap the bubble: the chip row's vertical center rides
the bubble's bottom border instead of floating detached below it.
- Add the full zap flow (amount popup, custom amounts, progress) to the
long-press bottom sheet's quick-reaction row.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
Two things that make the Armada-interop scenario actually usable:
- Bootstrap from the Concord stock relays: Account.importConcordCommunitiesFromStockRelays
fetches this account's kind-13302 joined list from InviteRelayDictionary.STOCK (where
the reference client publishes it — e.g. relay.ditto.pub — not the user's outbox) and
folds the newest into LocalCache. Triggered on Concord-hub open (scoped so only Concord
users reach those relays). Read-only/newest-wins, safe to repeat. This is why a community
joined on Armada never showed up before: Amethyst only queried the user's own relays.
- The hub is now a single-screen browser: a community rail across the top plus an
expandable accordion where each community reveals its #/lock/mic channels inline — browse
community-first, then channels, without leaving the screen. Mounts the live plane
subscription so channels fold in while browsing; tapping a channel opens its chat, the
community avatar opens the full server view.
Still open for true two-client parity: verifying the 13302 encrypted-list JSON schema
against a real Armada-written event (field-name decode) and union-merging on write.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
- Group consecutive messages by the same author (within 10 min, same day,
no subject divider) with connected bubble shapes, tighter spacing, and
the author line only on the first message of a run.
- Render received reactions and zaps as messenger-style chips under the
bubble: one chip per emoji with count (highlighted when the logged-in
user reacted; tap toggles the reaction) plus a sats total chip.
- Replace the old long-press quick-action popup in chats with a modern
bottom sheet: quick-reaction row, reply/copy rows, and a handoff to the
full note options menu (the 3-dot menu) which previously had no home
in chats.
- Add double-tap-to-like on chat bubbles.
- Stronger bubble colors: accent-following chatBubbleMe and a
higher-contrast chatBubbleThem so mine/theirs/background separate
clearly in light and dark themes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
Completes the parity gaps with NIP-29 relay groups:
- Rich invite card (ConcordInviteCard): an invite link in note content now renders
as a tappable card that peeks the kind-33301 bundle (Account.peekConcordInvite) to
show the community name, instead of a bare link. Wired into RichTextViewer.
- Bare naddr (kind 33301) in ClickableRoute now shows an informative label rather
than an empty addressable-note card (a naddr has no unlock token, so it can't be
joined — only the full link can).
- External invite URLs open the app: AndroidManifest intent-filter for
amethyst.social/invite/*, and MainActivity.uriToRoute maps the full URL (fragment
included) to Route.ConcordInvite so the redeem flow keeps the token.
- Messages group-by-community view mode (ConcordViewMode INLINE/GROUPED), the
analog of NIP-29's group-by-relay: GROUPED collapses each community's channels
into one ConcordServerRoomNote row (rendered by ConcordServerRoomCompose, opens the
channel list). Adds updateConcordViewMode + LocalPreferences persistence + feed
invalidation + the ChatroomListKnownFeedFilter branch + a Messages-settings toggle.
Also resolves a silent merge artifact: main's markDmRoomAsRead(signedEvents.msg)
landed in the wraps-only broadcastPrivately overload (no signedEvents in scope);
moved it to the Result overload that carries .msg.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Adds the two management screens the NIP-29 relay groups have but Concord lacked,
plus a visual overhaul of the create screen:
- Members roster (ConcordMembersScreen): owner + role-holders + banned, each with
avatar/name and owner/admin/banned badge; overflow menu promotes/demotes (owner)
and bans/unbans, gated on the viewer's authority. New AuthorityResolver.roleHolders()
and bannedMembers() accessors expose the privileged roster (membership is otherwise
key possession, so there is no silent-member list).
- Edit metadata (ConcordEditScreen): new write path — ConcordModeration.editMetadata
(METADATA edition, entityId = communityId) + Account.editConcordMetadata; prefilled
from the folded state, honored on fold only for MANAGE_METADATA holders / owner.
- Create screen redesign: shared ConcordMetadataFields with a circular icon hero
(live preview from the URL) + section headers, mirroring the NIP-29 GroupImagePicker
layout, replacing the bare stack of text fields.
- Routes ConcordMembers/ConcordEdit + nav registration + top-bar entry points on the
channel-list screen (Members always, Edit for those who can manage metadata).
- Account.peekConcordInvite for the upcoming invite card.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
RouteMaker resolved Marmot and NIP-29 relay-group channel gatherers to their
chat screens but had no ConcordChannel branch, so tapping a Concord message
(from the Messages inbox row, a quoted note, go-to-conversation) fell through
to the generic thread view instead of opening the channel. Add the branch and
a routeFor(ConcordChannel) mirroring routeFor(RelayGroupChannel).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
The account info/lists subscription loaded every other joined-list up front
(NIP-29 simple groups, relay lists, wallet, …) but not the Concord
joined-communities list, so a community joined on another client sharing the
same key (e.g. the Armada reference client) never surfaced: nothing REQ'd
kind-13302 for the account, leaving ConcordChannelListState empty until the
user created or redeemed an invite inside Amethyst.
Add ConcordCommunityListEvent.KIND to the up-front fetch, mirroring the NIP-29
SimpleGroupList rationale already there.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
The hub's create FAB fell back to Material3's default rounded-square shape;
every other FAB in the app (RelayGroupChannelListScreen, MarmotGroupListScreen,
NewNoteButton, …) uses shape = CircleShape with a 24dp icon. Match it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
The Android assembler was hand-rolling the plane-address -> per-relay
kind-1059 RelayBasedFilter collapse (with per-relay since). That logic is
platform-agnostic — RelayBasedFilter and SincePerRelayMap are both
commons/quartz types — so lift it to ConcordSubscriptionPlanner.relayBasedFilters
alongside the existing filtersByRelay one-shot variant. The assembler now only
does the account-dependent step (deriving channel planes from folded session
state) and delegates the collapse. Covered by a new planner unit test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
- ConcordHomeScreen: each community row now shows a circular avatar
(RobohashFallbackAsyncImage, community icon or robohash fallback) and a
pluralized channel count, re-read reactively on each Control-Plane fold.
- ConcordChannelListScreen: channel rows show a type icon (# public / lock
private / mic voice) and an explicit empty state, styled like the relay-group
channel list (thin dividers).
- Inbox row (ConcordRoomCompose): passes the folded community icon to ChannelName
instead of null, so Messages shows the community avatar.
- ConcordChannel exposes communityIcon from the folded MetadataEntity.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Replaces the comma-separated relay text box with the app's RelayUrlEditField
(type a relay → NIP-11 name/icon autocomplete, tap to add), shown above a
removable list of chosen relays. Adds an optional community icon URL, plumbed
through createConcordCommunity → ConcordActions.createCommunity →
ConcordCommunityFactory into MetadataEntity.icon. Section header styling matches
RelayGroupMetadataScreen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Replaces the bespoke plain-text composer with the app's standard chat input,
mirroring MarmotNewMessageViewModel: ConcordNewMessageViewModel drives
UserSuggestionState (type @ → avatar + name + NIP-05 dropdown, ranked by people
who've posted in the channel), and the field uses ThinPaddingTextField with
MentionPreservingInputTransformation + UrlUserTagOutputTransformation (npub
mentions render inline as colored @names), DisplayReplyingToNote for the reply
preview, and ThinSendButton. Send still routes through the Concord plane wrap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Adds an owner-only role toggle to the Concord note quick-action menu, alongside
Ban:
- Account.concordAdminTarget gates the action to the owner (only rank 0 strictly
outranks the position-1 Admin role, as the resolver requires), never the
owner's own note or the owner as target, and reports whether the author is
already an admin (via the new AuthorityResolver.rolesOf accessor).
- makeConcordAdmin mints a default Admin role (all management + moderation
permissions, position 1) if the community doesn't have one yet, then grants it;
removeConcordAdmin revokes via an empty grant.
- The menu item flips between "Make admin" and "Remove admin" and fires
toggleConcordAdmin.
Extends ConcordModerationTest to cover rolesOf and revoke-via-empty-grant.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Makes the CORD-04 moderation reachable and enforced on the phone:
- A "Ban" action in the note quick-action menu for Concord messages, shown only
when this account may actually ban the author (owner or holds BAN, target is
not the owner/self — Account.concordBanTarget). Confirms, then publishes the
banlist edition via banConcordMember.
- Read-time enforcement: Account.isAcceptable drops a Concord message whose author
is banned in that community's fold, so banned content is hidden across the inbox
and chat feed (filter, not delete — matching how the app handles mutes/blocks).
- Reactive: the decrypt sink is gated to drop a banned author's NEW messages
before they become Notes, and on re-fold (a ban that lands after messages
loaded) refreshConcordChannelIndex removes their existing notes — removeNote
invalidates the feed so the ban shows live.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Adds the moderation verbs over the tested ConcordModeration write path:
- roles <community> list live roles + current banlist
- role <community> <name> <pos> PERM... define a role (perms by name), prints role_id
- grant <community> <user> <roleId> grant a role to a member
- ban / unban <community> <user> banlist add/remove
Each drains the Control Plane, chains the next edition onto the current head,
and publishes it; authority is enforced on fold by every client. Users resolve
via npub/nprofile/hex/nip05 (ctx.requireUserHex).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
ConcordModeration builds the Control Plane editions for defining a role,
granting roles to a member, and banning/unbanning — each a kind-3308 edition,
plaintext-sealed and wrapped on the Control Plane. It chains the next version
onto the entity's current head (version+1, prevHash=head.hash) and unions the
banlist, taking the community's current editions as input.
Authority is enforced at fold time by the AuthorityResolver, not here: a round-
trip test confirms an owner can define an Admin role, grant it (member gains BAN),
ban/unban (version chaining heals), and that a grant forged by a non-outranking
troll is dropped by the fold.
- ConcordActions.controlEditions opens control wraps into editions
- ConcordCommunitySession exposes controlEditions()/controlPlaneKey() so edits
chain onto live state
- Account.grantConcordRole / banConcordMember / unbanConcordMember publish the
edition with an instant local echo (the session refolds it)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Wires the create/invite Account actions (which had no UI) into screens:
- ConcordCreateScreen: name/about/relays form → createConcordCommunity → opens
the new community.
- ConcordHomeScreen (Route.Concords): the Concord Channels hub — lists joined
communities with a Create FAB. Documents that Concord has no public directory
(E2E-encrypted, invite-gated), so there is intentionally no browse feed.
- ConcordChannelListScreen: an "invite people" top-bar action mints a kind-33301
bundle and shows the shareable link with copy-to-clipboard.
- Reachable from the Messages new-chat FAB menu.
Note on the planned discovery feed: the kind-33301 bundle exposes only
`["d",""],["vsk","6"]` publicly; name/relays/secrets are NIP-44-encrypted under
the per-invite token, and it is signed by an ephemeral link key — so there is no
public metadata to browse or filter. A GitRepositories-style discovery feed is
not applicable; discovery is invite-link-only by design.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Receiving reactions/replies already worked (they decrypt via channelRumors and
wire to their target Note by id). This adds the send side, which must NOT go
through the generic public/NIP-17 reaction path — a plaintext kind-7 would e-tag
the private rumor id onto public relays, and the NIP-17 path wraps to named
recipients, not the channel plane.
- ChannelChat.reaction/reply build kind-7/kind-9 rumors bound to channel+epoch
(reaction e-tags the target; reply q-tags the parent)
- ConcordActions.buildChannelReaction/buildChannelReply wrap them on the plane
- Account.reactToConcordMessage + sendConcordChannelMessage(replyTo) publish the
wrap with an instant local echo, factored through publishConcordWrap
- AccountViewModel.reactToOrDelete intercepts Concord notes (detected by the
ConcordChannel gatherer) and routes to the plane-wrapped reaction
- ConcordChannelScreen wires onWantsToReply into the composer with a reply banner
Zaps already route correctly: a Concord message is an unsigned rumor, so the
existing isPrivateRumor() path forces a PRIVATE (NIP-57 encrypted) zap, same as
NIP-17 DMs. A fully on-plane nutzap (no public receipt) remains a future upgrade.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Follows the NIP-28/NIP-17 model instead of a private per-session flow. The
community session is now purely a decrypt-and-validate gate: each validated
inner rumor is handed to a sink (ConcordRumorSink) that the app wires to
LocalCache.consumeConcordRumor — landing it as a real Note keyed by rumor id
and attaching message-like kinds (9/1111) to the ConcordChannel.
Consequences: messages are first-class Notes, so reactions (kind 7), replies
(1111), deletes (5), OTS and zaps wire up automatically by id through the normal
consume path; the Messages inbox shows real last-message previews and updates
incrementally (filterRelevantConcordMessages, keyed on the ConcordChannel
gatherer) instead of feed() rebuilds; and the chat screen reuses the shared
ChannelFeedViewModel + RefreshingChatroomFeedView (full note UI) with only the
Concord-specific encrypted send path kept bespoke.
- ConcordActions.channelRumors returns validated bound rumors (all chat kinds)
- ConcordCommunitySession/SessionRegistry/SessionManager thread the sink; the
messagesFlow/ConcordChatMessage projection is gone from the session
- LocalCache.consumeConcordRumor attaches gatherer before justConsume so the
inbox incremental filter can route the row
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
RichTextParser now classifies `…/invite/<naddr>#<fragment>` URLs as a
ConcordInviteLinkSegment (cheap substring gate before the base64/bech32 parse),
and RichTextViewer renders them via ClickableConcordInviteLink — tap opens the
redeem flow, long-press copies the link.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Account gains createConcordCommunity (mint genesis, publish, join),
mintConcordInvite (publish kind-33301 bundle, return the shareable link), and
joinConcordViaInvite (parse link, fetch+unlock bundle, add to the 13302 list).
ConcordInviteScreen auto-redeems an invite deep link (Route.ConcordInvite) and
forwards to the joined community's channel list, with a retry on relay miss.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
- ConcordChannelScreen: renders a channel's decrypted message flow with a
composer; posting derives the channel plane key and publishes an encrypted
wrap to the community relays (Account.sendConcordChannelMessage), with an
instant local echo via the session fold.
- ConcordChannelListScreen: the community "server" view listing folded channels.
- Registers the ConcordChannelFilterAssembler in RelaySubscriptionsCoordinator
and wires Route.Concord / Route.ConcordServer into AppNavigation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Adds a 6th concat to ChatroomListKnownFeedFilter: each folded Concord channel
is its own Messages row (placeholder note carrying its ConcordChannel gatherer),
interleaved with DMs and other chats. ChatroomHeaderCompose renders it via a new
ConcordRoomCompose showing the channel name plus a community chip that opens the
community's channel list; tapping the row opens the encrypted chat.
Adds Concord / ConcordServer / ConcordInvite / Concords navigation routes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Adds ConcordChannelFilterAssembler (kind-1059 authors=[planePk] per relay,
control planes upfront + channel planes once a Control Plane folds) and the
ConcordChannelSubscription composable that, on each ConcordSessionManager
revision, refreshes the LocalCache ConcordChannel rows from the freshly-folded
state and re-derives the filters to pick up newly-revealed channel planes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Adds ConcordSessionManager (commons) that keeps one folding session per
joined community in step with the concord list and turns folds into an
observable revision the app watches to re-derive subscription filters.
Account owns one per account; the giftwrap decrypt path (GiftWrapEventHandler)
routes recognized Concord plane wraps to it before the NIP-59 DM check, so
ephemeral-p Concord wraps fold instead of being dropped as undecryptable DMs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Holds one live ConcordCommunitySession per joined community, fans inbound
kind-1059 wraps out to the owning session, and exposes the union of
control/channel plane addresses to subscribe. sync() reconciles sessions
against the joined list while preserving already-folded state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
The live, stateful read-model of one joined community that a screen/ViewModel
binds to and a subscription feeds — bridging the pure logic to the UI:
- derives the Control Plane address up front (from the entry's secrets)
- ingest(wrap) routes by stream address: control wraps re-fold into a
state StateFlow (metadata + channels + authority) and re-derive each channel's
Chat Plane address; channel wraps re-project into per-channel message flows
- exposes controlPlaneAddress + channelAddresses() (what to subscribe to),
state, membership(), and messagesFlow(channelId)
- thread-safe; unknown wraps (other communities) are ignored
Test: feed genesis control wraps -> "Nostrichs" + #general + OWNER membership +
the general plane becomes a known address; feed a channel message -> it lands in
#general's flow; a stray community's wrap is ignored. Green on :commons:jvmTest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
The read-path counterpart to the subscription planner: maps derived plane
addresses (group_key.pk) to the keys that open them, so an inbound kind-1059 wrap
is recognized as Concord traffic and decrypted with the right per-plane key.
- registerControlPlanes(entries): control addresses, known from secrets alone
- registerChannels(entry, foldedState): channel addresses, known after the
Control Plane folds
- route(wrap): if wrap.pubkey is a registered plane, open+verify and return the
routed rumor with its plane kind/community/channel; else null
Because the wrap p-tag is ephemeral, address matching is the only route — a
non-member never registers the address, so never decrypts. Thread-safe.
Test routes a genesis control wrap to CONTROL and a channel message to CHANNEL
(right channel id + decrypted content), and rejects a wrap from another
community. Green on :commons:jvmTest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
The commons half of the "keep channels live" subscription, mirroring NIP-29's
RelayGroupMyJoinedGroupsFilterAssembler: turns the account's joined-communities
list into per-plane REQs by derived stream address (there is no #p=me for
Concord, so each plane is fetched by authors=[planePk]).
- ConcordSubscriptionPlanner.controlPlaneSubs: one Control Plane sub per joined
community (known from the entry's secrets alone)
- channelPlaneSubs: one Chat Plane sub per channel once the Control Plane folds
- filtersByRelay: collapses subs to one kind-1059 author filter per relay
- ConcordActions.planeFilterFor(authors): multi-author plane filter
Test derives a community, asserts the control/channel sub addresses equal the
derived plane pks and that the per-relay filter carries both. Green on
:commons:jvmTest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
The channel model the shared chat UI renders, mirroring RelayGroupChannel:
- quartz ConcordChannelId(communityId, channelId): the stable channel address
(analog of NIP-29 GroupId, but community-scoped rather than relay-pinned)
- commons ConcordChannel : Channel — name/voice/private from the folded Control
Plane (no single relay-signed metadata event), relays() = the community relay
set (a plane may be mirrored on several), membership from the authority
resolver, canPost(), placeholderNote() for immediate Messages-list rows;
updateFrom(state, relays, myPubKey) refreshes on each re-fold
- LocalCache: concordChannels LargeCache index + getOrCreate/getIfExists
Compiles across :quartz/:commons/:amethyst. Next: the decryption subscription
that folds inbound channel-plane wraps into these, then the Messages inbox hook.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
The per-account home base for Concord Channels, mirroring RelayGroupListState:
- commons ConcordChannelListState observes the kind-13302 addressable note,
exposes liveCommunities (joined secret-bearing entries) and liveServers
(community ids), and follow/unfollow read-modify-write the self-encrypted list;
a ConcordListRepository backs offline restore
- AccountSettings implements ConcordListRepository (backupConcordList +
concordList/updateConcordListTo) and gains a concordViewMode setting
- Account instantiates concordChannelList and exposes joinConcordCommunity(entry)
/ leaveConcordCommunity(id), publishing the list via the account outbox
Concord's "in-between" shows here: same wiring as the NIP-29 list, but entries
carry the community secrets (decrypt yields root/salt/epoch/channel keys), so one
self-encrypted event both syncs membership and re-derives every plane. Compiles
across :commons and :amethyst.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Promote the kind-13302 joined-communities list to a proper Event subclass so the
Android LocalCache/Account layer can observe it the way RelayGroupListState
observes SimpleGroupListEvent (10009):
- ConcordCommunityListEvent : Event — createAddress (13302, pubkey, ""), a
suspend create(signer, entries), and decrypt(signer); content stays NIP-44
self-encrypted and carries each community's secrets
- register the kind in EventFactory so inbound events parse to the typed class
- extract encode/decode JSON helpers in ConcordCommunityList (shared by both)
Test: create -> the wire form hides the name, a JSON round-trip resolves to the
typed class via EventFactory, decrypt recovers entries, and the replaceable
address is (13302, pubkey, ""). Green on :quartz:jvmTest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Concord sits between NIP-17 and NIP-28/29: NIP-28/29-style author-addressed
plane subscriptions (never #p=me, since the wrap p-tag is ephemeral) + NIP-17
shared-key E2EE + a kind-13302 self-encrypted membership list that also carries
the community secrets. Home base = ConcordChannelListState over 13302 (mirrors
RelayGroupListState/EphemeralChatListState, but entries hold keys); LocalCache
projects live ConcordChannels; a per-plane author-subscription assembler keeps
them fresh.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
First slice of the Android integration, mirroring NIP-29's commons model:
- ConcordMembership (OWNER/ADMIN/MEMBER/BANNED/NONE) derived from the folded
owner-rooted AuthorityResolver + banlist — membership is key possession, roles
layer moderation power, the banlist removes standing; with isMember/canModerate
- ConcordViewMode (INLINE/GROUPED) for how joined channels surface in Messages
Test classifies owner/admin/member/banned/stranger from a folded control plane.
Green on :commons:jvmTest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Blueprint for the Android app layer, cloning the just-merged NIP-29 relay-groups
touch points with Concord equivalents: commons ConcordChannel + kind-13302
ConcordChannelListState, Account/LocalCache wiring, the 6-way Messages-inbox
concatenation + synthetic server row (chip opens the channel), the reused NIP-28
ChannelView chat screens, nav routes, a GitRepositories-style discovery feed, and
notification routing + on-plane zaps/likes.
Documents the one structural difference from NIP-29: Concord communities are E2EE
(plane-pubkey addressing, no public relay-signed metadata), so discovery surfaces
public invite links rather than browsable metadata.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Full Concord stack through the CLI, thin over commons ConcordActions + Context:
- ConcordStore (~/.amy/<account>/concord.json, 0600): joined communities +
their secrets for re-derivation across runs
- concord create — mint community + publish genesis, save locally
- concord list — list joined communities
- concord channels — drain + fold the Control Plane, list channels
- concord send — post an encrypted kind-9 message to a channel
- concord read — drain + decrypt a channel's messages (oldest-first)
- concord invite — mint + publish a shareable invite link (bundle 33301)
- concord join URL — fetch + decrypt the bundle with the fragment token, save
Verified end-to-end against a local `amy serve` (geode) relay: Alice creates a
community and mints an invite; Bob joins from the URL alone, both post to
#general, and both read the identical decrypted message list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Pure builders + relay-filter assembly + folding for Concord, usable from amy CLI
and the Android app (like DmActions, it never touches the network):
- plane key derivation (controlPlane/publicChannel)
- relay filters (planeFilter, bundleFilter, directInvitesFilter)
- createCommunity, foldCommunity (open control wraps -> editions -> live state)
- buildChannelMessage + channelMessages (open, bind-check, order oldest-first)
- invite helpers: inviteFor, mintInviteLink, parseInviteLink, openBundle
(decrypt+validate), controlPlaneFor
Test covers the create -> fold -> send -> read round-trip and the mint -> parse
-> open -> read invite flow. Green on :commons:jvmTest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Completes CORD-05: for a known npub, deliver the CommunityInvite as a NIP-59
giftwrap instead of a public bundle — a kind-3313 rumor sealed (kind 13) to the
recipient and wrapped (1059) with ["p", recipient] and a ["k","3313"] index tag
so recipients can query pending invites without decrypting every giftwrap.
Cannot be revoked (recipient holds the keys on arrival).
Reuses SealedRumorEvent + the giftwrap primitives. Tests cover round-trip to the
intended recipient (with p/k tags) and that strangers cannot open it. Green on
:quartz:jvmTest.
With this, the Quartz protocol layer covers CORD-01..07 end to end.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
- VoicePresence: kind-23313 join/left presence rumors bound to the channel/epoch
and carrying the SFU identity + broker, with heartbeat/stale constants and a
verifiedParticipants fold that renders an identity only when exactly one author
claims it (contested identities stay unverified)
- ConcordBrokerToken: the NIP-98-style kind-27235 token request signed by the
channel's derived voice signer key (its pubkey is the SFU room name), the
'Authorization: Concord <base64(event)>' header, and the
/.well-known/concord/av/<room> path
Voice key derivation (voice_signer/voice_media/voice_sender) already lives in
ConcordKeyDerivation. Tests cover presence round-trip, uncontested-only
verification, staleness, and that the broker token is signed by the voice-room
key. Green on :quartz:jvmTest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Completes the public invite path (CORD-05), pinned to Concord v2 (Armada
invite.ts):
- CommunityInvite: the bundle contents with exact snake_case field names
(community_id, owner, owner_salt, community_root, root_epoch, channels[],
relays, name, icon, expires_at, creator_npub, label) + ImagePointer/InviteChannel
- ConcordInviteBundle: build/parse the kind-33301 event (content =
nip44(CommunityInvite, inviteBundleKey(token)); tags d="",vsk="6"; signed by a
per-link signer), self-certification validate (owner+salt reproduce
community_id), expiry check, and mintLink (fresh token + link signer -> bundle
event + shareable URL)
End-to-end test: create a community, mint an invite link, a stranger parses the
URL, decrypts the bundle with the fragment token, validates the owner
commitment, reconstructs the root, and reads the genesis #general channel.
Wrong-token and forged-owner rejections covered. Green on :quartz:jvmTest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Completes the "create a community" path (CORD-02 Genesis), pinned to Armada
(concord-v2 control.ts/community.ts):
- ConcordKeyDerivation: control/guestbook plane keys and the keyless entity
coordinates — grantCoordinate = hkdf32(communityId, "concord/grant"||member),
banlistCoordinate (ZERO32 id), inviteLinksCoordinate (creator)
- ControlEditionBuilder: assembles kind-3308 edition rumors (vsk/eid/ev/ep/vac),
the inverse of ControlEdition.fromRumor
- MetadataEntity gains relays
- ConcordCommunityFactory.create: mints owner_salt + self-certifying community_id,
an independent community_root, and two owner-signed genesis editions (metadata
with eid=communityId, and a public #general channel) as plaintext-seal wraps on
the Control Plane at epoch 0
Test creates a community, verifies the id commitment, opens the genesis wraps
(20014 seals, owner-authored), folds them into live ConcordCommunityState with a
#general channel and owner authority, and confirms one owner yields distinct
communities. Green on :quartz:jvmTest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
The NIP-51 analog for returning to signed-up Concord communities (CORD-05):
- ConcordCommunityListEntry: per-community credentials needed to re-derive planes
on any device (id, owner, ownerSalt, current root + rootEpoch, past heldRoots,
privateChannels keys, relays, cached name)
- ConcordCommunityList: build/parse the replaceable kind-13302 event, NIP-44
self-encrypted so relays store only ciphertext, plus a cross-device merge that
keeps the freshest root epoch per community
Channels are intentionally not listed — holding the root and folding the Control
Plane yields them. Tests cover self-encrypted round-trip, that only the owner can
decrypt, and epoch-wins merge. Green on :quartz:jvmTest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Non-ratcheted async key rotation to remove members from a channel or (root
scope) the whole community, pinned to Concord v2 (Armada rekey.ts):
- RekeyPayload: the 72-byte scope_id||epoch_be8||new_key blob codec
- RekeyBlob: per-recipient {locator, wrapped} entry
- ConcordRekey: blobFor (locator = recipient pseudonym; wrapped = base64 payload
NIP-44-encrypted under the rotator<->recipient pairwise key), kind-3303 rumor
tags (scope/newepoch/prevepoch/prevcommit/chunk) and content codec, and
findNewKey (recipient computes their locator, matches, decrypts, verifies
scope+epoch) with absence == removal
Test proves remaining members recover the rotated key while a removed member
finds no matching blob, and that the locator is epoch-bound. Green on
:quartz:jvmTest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
- ConcordKeyDerivation.inviteBundleKey: derives the bundle decryption key from a
link's 16-byte unlock token via hkdf32(token, "concord/invite-key")
- InviteRelayDictionary: the v4 stock relay set + id<->url mapping
- ConcordInviteLink: encode/decode the {base}/invite/{naddr}#{fragment} link and
the [version=4][flags][relays?][token:16] fragment (stock-set flag, dictionary
ids, wss:// host and full-url relay entries), rejecting non-v4 versions; builds
the naddr (33301, link_signer, d="") and parses it back
Tests cover stock/dictionary/literal/full-url relay round-trips, wrong-version
rejection, full URL round-trip through naddr, and token-bound bundle key
derivation. Green on :quartz:jvmTest. Pinned to Concord v2 (Armada) constants.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
- ChannelEntity/MetadataEntity content DTOs (CORD-02/03)
- ConcordCommunityState.fold: folds control editions + known owner into the live
community view — metadata, non-deleted channels, live roles, the owner-rooted
AuthorityResolver, and a dissolved flag from the tombstone
- Guestbook: self-signed join/leave (kind 3306, with invite attribution) and
authorized kick (kind 3309) rumor builders + parsers; off-consensus membership
motion
Tests cover metadata/channel/role folding with deleted-channel exclusion,
authority wiring, the dissolution tombstone, and join/leave/kick round-trips.
Green on :quartz:jvmTest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
CORD-03 Chat Plane vertical slice tying crypto + envelope together:
- ConcordChannelKeys: public (community_root) and private (channel_key) channel
key derivation, both via group_key with channel_id folded in so each channel
has a distinct, epoch-rotating address
- ChannelChat: channel/epoch binding tags, a kind-9 message rumor builder, and
isBoundTo validation so an event can't be replayed across channels/epochs
End-to-end test proves two members holding the same community_root independently
derive the identical public channel plane and one reads the other's message with
no key distribution, non-members can't derive the plane, cross-channel/epoch
replay is rejected, and epoch rotation rotates the address. Green on
:quartz:jvmTest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Implement CORD-04 authority resolution over a folded Control Plane:
- ControlEntities: Role and Grant content DTOs (kotlinx.serialization, lenient +
extensible) and a Banlist array parser; ConcordJson facility
- AuthorityResolver: builds roster state from the entity heads + known owner and
answers rank (lower = higher; owner = 0), effectivePermissions (union of a
member's roles), isBanned, hasPermission, and canActOn (holds the bit AND
strictly outranks the target — equal cannot act on equal; owner unremovable)
Grants are validated by an owner-rooted fixpoint: a Grant is honored only when
its signer already outranks every assigned Role and holds MANAGE_ROLES, so the
roster grows strictly outward from the owner and self-referential cycles never
bootstrap. Deleted/position-0 roles and banned members are dropped. Banlists heal
to their union.
Tests cover owner-rooted ranks/permissions, fixpoint order-independence,
unauthorized/insufficient-rank grant rejection, ban vanishing, and invalid-role
dropping. Green on :quartz:jvmTest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
- ControlEdition: parses a verified kind-3308 rumor's vsk/eid/ev/ep/vac tags into
a typed edition, computes its domain-separated edition hash, and rejects
malformed editions (bad kind, missing eid/ev, unknown vsk, bad hex)
- AuthorityCitation: the vac Grant pin an actor claims rank under
- EditionFold: folds editions into each entity's current head — genesis
anchoring, intact-chain / no-downgrade advancement, deterministic lower-rumor-id
tie-break for convergence (authority-weighted tie-break layered in the resolver)
Tests cover tag parsing + hash, malformed rejection, genesis prev handling,
order-independent chain walk, downgrade/broken-chain refusal, tie-break, and the
hold-without-genesis case. Green on :quartz:jvmTest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
- ConcordKinds: every Concord event kind (envelope, chat, guestbook, control,
rekey, bookkeeping), pinned to Concord v2 (Armada kinds.ts)
- ControlEntityKind: the kind-3308 `vsk` sub-kinds (metadata=0..dissolved=10)
with wire<->enum mapping
- ConcordPermissions: u64 permission bitfield with frozen bit positions, union
(effective = OR of a member's roles), and decimal-string wire codec that
preserves the high bits (no floating-point corruption)
Tests cover frozen bit positions, union, decimal round-trip incl. bit 63, blank
and garbage handling, and vsk mapping. Green on :quartz:jvmTest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Implement the Concord CORD-01 stream envelope in quartz `concord/envelope`:
the inverted NIP-59 three-layer wrap -> seal -> rumor that carries every plane's
traffic. The outer kind-1059/21059 wrap is signed by the shared stream key and
its content is NIP-44-encrypted under the plane's self-ECDH conversation key,
with an ephemeral p tag, so relays never see plaintext.
- ConcordStreamEnvelope.seal: 20014 plaintext (verbatim rumor JSON, for the
Control Plane) or 20013 encrypted seal, signed by the real author
- wrapSeal/wrap: sign+encrypt the wrap at a GroupKey plane address
- open/openOrNull: verify wrap author == stream address + wrap sig, decrypt seal,
verify seal sig, decrypt/parse rumor, enforce rumor.pubkey == seal.pubkey and
rumor.id == NIP-01 hash
- OpenedStreamEvent: verified rumor + seal kind + author
Reuses RumorAssembler, NostrSigner, NostrSignerSync and Nip44v2. Round-trip
tests cover plaintext/encrypted seals, ephemeral wraps, non-member rejection
(wrong epoch/secret), and confirm plaintext never leaks into wrap content.
Green on :quartz:jvmTest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
# Distinguish a formatting failure (block) from Gradle being unable to RUN —
# e.g. deps can't resolve in a restricted sandbox. An infra failure must not
# strand the agent; warn and let CI's spotlessCheck be the backstop.
if grep -qiE "could not resolve|could not (get|download)|handshake|connect timed out|no address|unable to (find|resolve) host|read timed out""$log";then
echo"WARN: could not run spotlessApply (Gradle infra/network failure), skipping the formatting gate." >&2
echo" CI's spotlessCheck still enforces formatting on the PR." >&2
rm -f "$log"
exit0
fi
# Any failure blocks. The web sandbox pre-seeds the Gradle distribution (see
# .claude/hooks/session-start.sh) and Gradle resolves deps through the proxy,
# so spotlessApply no longer fails for infra reasons — a failure here is a
# real formatting/compile error, not a restricted-sandbox hiccup.
echo"BLOCKED: spotlessApply failed — fix the build/formatting error before pushing." >&2
@@ -7,7 +7,9 @@ description: Use when comparing Android strings.xml locale files to find untrans
## Overview
Extract string resource keys from the default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs missing keys and offers to translate them.
Extract string resource keys from a default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs missing keys and offers to translate them.
The repo now has **two independent Crowdin-managed resource trees** — you must scan **both** (see "Resource trees" below).
## When to Use
@@ -15,6 +17,56 @@ Extract string resource keys from the default `values/strings.xml` that are abse
- Preparing a batch of strings for a translator
- Checking translation coverage after adding new features
## Resource trees (scan BOTH)
There are two separate `strings.xml` trees, each with its own default `values/` and per-locale `values-<locale>/` files, each wired into `crowdin.yml` independently:
The `commons` tree appeared when shared event-renderer composables were extracted out of `amethyst/` into `commons/` (Compose Multiplatform `stringResource`). It is **not** a copy of the amethyst tree — the vast majority of its keys are commons-only; only a small handful overlap. Every diff/count/translate command below works on either tree by swapping the base path — **run the whole technique once per tree** and report them separately (each maps to its own Crowdin file, so the counts should reconcile against two different Crowdin UI numbers).
**Locale-qualifier caveat:**`commons` uses the same region-qualified locale dirs as amethyst for our four targets (`values-cs`, `values-de-rDE`, `values-sv-rSE`, `values-pt-rBR`), but the *full* set of locale dirs differs between trees. Enumerate `values-*` under each tree's own base rather than assuming they match.
**Overlap (copy — but only after checking the English matches):** a few `commons` keys share a *name* with a key in the amethyst tree. For such a key already translated in the amethyst locale file you may **copy the existing approved translation verbatim** — but **only if the two English source values are byte-identical.** A shared key name does **not** guarantee a shared meaning.
> ⚠️ **Mistake we actually made (2026-07-18):** `napplet_card_permissions` exists in *both* trees with the *same key name* but *different English* — commons = `"What it can access"`, amethyst = `"Permissions:"`. Copying the amethyst translation by key name produced the wrong string in commons (it said "Permissions:" where the UI reads "What it can access"). **Always diff the English values, not just the key names.** When the English differs, translate the commons value fresh — or, better, find the amethyst key whose *value* matches (here `favorite_app_access_show` = "What it can access") and copy *that* approved translation.
Detect name-overlap **and flag value mismatches** in one pass:
Only `SAFE-COPY` keys may be copied verbatim. For `VALUE-DIFFERS`, translate the commons English fresh (or copy from the amethyst key that has the *matching value*).
**Whitespace-quote convention differs between trees.** Android string resources use surrounding double-quotes to preserve leading/trailing whitespace (`"replying to "`). The **commons Compose-resources tree does NOT use this convention** — it authors trailing/leading spaces raw and unquoted (`replying to `). So when copying/translating a commons string with edge whitespace, **match the commons source: raw spaces, no wrapping quotes.** (Mistake we made: we copied amethyst's quoted `"replying to "` into commons, where the quotes would render literally.) A quick check for stray quote-wrapping you introduced:
# The commons English tree has zero quote-wrapped values — any hit in a locale file is almost certainly a bad copy from amethyst.
```
**Why two catalogs exist — the duplication is NOT a bug to "fix" (don't ask again).** You will see the same English text (`Cancel`, `Save`, `Delete`, `Open`, …) defined *many* times across the amethyst tree under per-feature keys **and** once more in commons under generic keys (`action_cancel`, `action_save`, …). This is **required architecture, not an error:**
- The two trees are **different resource systems**: amethyst uses Android `R.string`; commons uses Compose-Multiplatform `Res.string` (`com.vitorpamplona.amethyst.commons.resources.Res`).
- **`commons` cannot depend on `amethyst`** (amethyst depends on commons — the reverse would be circular). So a composable extracted *into* commons physically cannot reference `R.string.cancel`; it needs its own string, hence the generic `action_*` keys. That is the only way an extracted shared composable can render "Cancel."
- The scattered amethyst per-feature duplicates (`nip46_signer_cancel`, `nest_create_cancel`, …) are **pre-existing tech debt**; the commons keys did not create them.
- Both catalogs are Crowdin-managed **independently**, and Crowdin's translation memory pre-fills repeats, so translating the same word in both trees is **not** wasted effort.
**Do not** treat the value-overlap as something to deduplicate during a translation pass. Migrating amethyst's own screens onto the shared `action_*` strings is a *separate, optional* refactor and a maintainer call — out of scope for this skill. Just translate each tree correctly and independently.
## Background: Crowdin strip-identical behavior
This repo syncs translations via Crowdin (branch `l10n_crowdin_translations`). Crowdin's default export behavior **omits any translation that exactly equals the source**, so a key that the translator deliberately kept as English (common for brand terms like `"Nowhere Drop"`, single-word loanwords like `"Apps"` / `"Feed"` / `"Issues"`, or version prefixes like `"v%1$s"`) will not appear in the locale's `strings.xml` even though the Crowdin UI shows it as 100% translated.
@@ -53,9 +105,25 @@ The default set of locales (unless the user specifies otherwise):
### 1. Identify files
Do this for **each** resource tree (see "Resource trees" above). The examples below use the amethyst base path; repeat every step with the commons base path swapped in.
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):
```bash
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml;do
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml\
# Skip Arabic and Welsh — they natively use the zero category.
case"$f" in
*values-ar*|*values-cy*)continue;;
@@ -260,14 +332,48 @@ When adding translated strings to locale files:
- **Append new strings at the bottom** of the file, just before the closing `</resources>` tag.
- Do NOT try to insert them in alphabetical or matching order — a separate process handles ordering.
- **Insert into each locale ONLY the keys missing from *that* locale — never a shared "union" block.** Because Crowdin strips keys asymmetrically (Step 2), a key you translate may already exist in some target locales. If you compute one union set of missing keys, translate it, and paste the *same* block into every locale, you will create **duplicate keys** in whichever locales already had them. Drive the insertion off the **per-locale** diff, not the union:
```bash
# For each locale, insert only the keys comm -23 reports missing FOR THAT LOCALE.
# ... append ONLY the $missing keys' translations to values-$l/strings.xml ...
done
```
(This bit us on 2026-07-21: `ps1_save_block`, `podcast_value_for_value`, and `chats_history_relays` were each missing in only *some* commons locales, but the same 3-key block was pasted into all four — producing duplicates in the locales that already had them.)
- **After inserting, verify each edited file has no duplicate keys AND is well-formed XML — before you call the task done.** A duplicate key is not a warning: the `commons` tree's Compose-resources build task fails hard on it (`convertXmlValueResourcesForCommonMain: … Duplicated key '…'`), which breaks the build for everyone. Quick post-insertion gate over every file you touched:
- **Scanning only the amethyst tree** — there are now **two** Crowdin-managed `strings.xml` trees (`amethyst/src/main/res` and `commons/src/commonMain/composeResources`). A key extracted into `commons/` will never show up in the amethyst diff. Run the whole technique once per tree (see "Resource trees") and report each separately.
- **Copying an overlapping `commons` translation by key name alone** — a shared key name does NOT mean shared English. `napplet_card_permissions` is "What it can access" in commons but "Permissions:" in amethyst; copying by name produced the wrong string. Diff the English *values* first; copy verbatim only when they're byte-identical, else translate fresh (see "Overlap" in Resource trees).
- **Applying amethyst's `"…"` whitespace-quote convention to a commons string** — the commons Compose-resources tree authors edge whitespace raw and unquoted; wrapping quotes copied from amethyst render literally there. Match the commons source format.
- **Trying to "dedupe" the amethyst↔commons value-overlap** — it's required architecture (commons can't depend on amethyst, so shared composables need their own `Res.string` catalog), not an error. Don't fold consolidation into a translation pass.
- **Forgetting `translatable="false"`** — these should never appear in locale files
- **Diffing only `<stringname=`** — `<plurals>` is a separate resource type; a source `<plurals>` missing from a locale will never show up in a `<string>` diff. Always run the diff twice (once per resource type) as shown in Step 2. The same goes for `<string-array>` if the project uses it.
- **Trusting a git "sync-timestamp" heuristic to pre-filter the list** — this skill used to skip keys added before the last `New Crowdin translations` commit, on the theory that Crowdin had already "decided" them. It was dropped: a key added shortly before an export that translators hadn't reached yet is genuinely missing, so the heuristic silently dropped real work. Use the raw on-disk diff and reconcile against the Crowdin web UI's untranslated count instead.
- **Adding source-identical fallbacks locally** — they get overwritten on the next Crowdin sync. Android falls back to `values/strings.xml` at runtime anyway, so a key intentionally kept as English already renders correctly. Skip these by inspection (brand terms, loanwords, `v%1$s`-style strings); don't translate them to an identical value.
- **Skipping per-locale diffs when only diffing cs** — Crowdin can strip different keys in different locales (each translator's choice), so cs is not a reliable upper bound. Diff each target locale and union the results.
- **Pasting the union set of missing keys into every locale → duplicate keys** — the union is the right set to *translate*, but the wrong set to *insert*. A key missing in only some locales, inserted into all of them, duplicates in the ones that already had it. Drive each file's insertion off its own per-locale diff (see Step 6). In `commons`, a duplicate key is build-breaking: `convertXmlValueResourcesForCommonMain` fails with `Duplicated key '…'`. **Always run the post-insertion duplicate + XML-wellformedness gate in Step 6 before declaring done.** (Happened 2026-07-21 with `ps1_save_block` / `podcast_value_for_value` / `chats_history_relays`.)
- **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`)
- **Tracker fan-out fixed**: delivery state is one small StateFlow per message.
## Per-row cost inventory (composition of ONE new row during fling)
| # | Cost | Where | Class |
|---|------|-------|-------|
| 1 | 4×`dateFormatter` (ThreadLocal `SimpleDateFormat` for >24h-old messages) for group break checks, + 2 more in `NewDateOrSubjectDivisor` | `ChatGroupPosition.groupsWith`, `NewDateOrSubjectDivisor` | CPU, per row |
| 8 | Media/link previews kick Coil requests immediately | `TranslatableRichTextViewer` children | IO churn during fling |
Feed-level:
| # | Cost | Where |
|---|------|-------|
| 9 | `shouldHighlight = highlightedNoteId.value == item.idHex` reads one state in every item lambda → all visible items recompose when it changes (rare; low) | `ChatFeedLoaded` |
| My-own `#h` (`authors=[me]`) | ChannelFromUser — **redundant for groups** (the all-authors `#h` window already returns my messages; a group is pinned to one host relay) |
| Threads `#h` (11 + 1111) | Warmup, ThreadFeed |
Two concrete defects fall out of this shape:
1.**Slow / partial first load** (the reported bug). Content is fetched in **fixed
windows** (limit 50 / 200) gated by a *shared per-relay*`since`
(`RelayGroupMyJoinedGroupsSubAssembler` is keyed by `Account`, so its `since`
collapses to one map per relay, not per group). A group joined or surfaced
after that relay's `since` advanced never backfills; opening it waits a full
relay round-trip.
2.**We can miss messages.** A fixed `limit=200` window has no way to reach older
history, and no demand-driven paging: scroll up past 200 and there is nothing
behind it. There is also a **serving-relay keying hazard** (see below) where a
referenced group message lands in a channel the UI never reads.
Every *other* chat surface in the app already solved this with a **two-subscription
model** — an always-on live tail + an on-demand backward history pager — and there
is a reusable framework for it. Group chat is the outlier that never adopted it.
## Goal
Split group chat into the same shape every other chat uses, and delete the
- **Tier C — serves-the-shape, against the in-process `geode` relay** (`quartz/src/jvmAndroidTest/.../nip29RelayGroups/RelayGroupFilterServingRelayTest.kt`): C1 state `#d`, C2 batched preview tail, C5 threads, C6 pinned-body-below-window by id, C7 notification `#p`+`#h`, C8 directory.
- **Tier C3 / E1 — can't-miss + resilience** (`quartz/src/jvmAndroidTest/.../paging/RelayGroupHistoryPagingRelayTest.kt`): backward `#h` walk covers every message once + stops on empty page; same-relay group isolation with overlapping `createdAt`; the **production `RelayLoadingCursors`** driven to the bottom over the wire; short-page-≠-exhaustion.
**Deliberately not duplicated (already covered generically at the unit level):** echo-newest→done and rewind/prune are in `RelayLoadingCursorsTest`; no-EOSE watchdog in `WindowLoadTrackerIdleTest`; auth-CLOSED/stall/cannot-connect in `BackwardRelayPagerTest`. E1's remaining hostile-relay faults (ignore-`since`, out-of-order, AUTH-CLOSE, silence) are those same state-machine paths — re-asserting them under NIP-29 naming adds no coverage since the cursor/pager never sees the `#h` filter, only `onEvent(createdAt)`/`onEose`.
**Not headless-runnable in this environment (flagged for a human):**
- **Tier D** (Android emulator/device, per screen) — needs a device; each row must be run and any unrun row flagged.
- **Tier E2** (conformance against a *real* third-party NIP-29 relay in a container) — non-deterministic + needs a container image; geode/strfry are generic and can't surface a real NIP-29 relay's bugs.
## What is / isn't verifiable headless
| Layer | Harness | Covers |
|---|---|---|
| Filter **shapes** each assembler builds | amethyst JVM unit tests (new) | the REQ is correct for the screen's job |
| Relay **serves** those filters; **ingest** into `LocalCache`→`RelayGroupChannel.notes`; paging framework | **`amy` + `geode`/`amy serve`** (drives the same quartz+commons client) | the reused machinery + filter shapes work against a real relay |
| 9 | `ChannelPublicFilter` (relay-group branch) | open group chat screen | open group metadata + **pinned-id backfill** (incl. non-joined; pins older than window) |
| 10 | `filterGroupNotificationsToPubkey` (always-on notifications) | account-level | group content that **p-tags me**, even if I never opened the group |
## Harness setup (headless)
```bash
./gradlew :cli:installDist # build amy
RELAY=ws://127.0.0.1:7447
amy serve --port 7447&# embedded relay (geode); or run :geode directly
# Identities: one "relay/operator" key (signs 39xxx), a few member keys, and "me".
## Tier B — new amethyst unit tests (filter shapes per assembler)
Construct a minimal `Account` with `relayGroupList.liveRelayGroupList` = {G@R, G2@R} (+ a mock
`INostrClient`). If wiring a full `Account` is too heavy, **first refactor the filter construction out
of each `updateFilter` into a pure function** (`buildJoinedChatTailFilters(joinedTags, since)`,
`buildOpenChatHistoryFilters(groupId, armed, until, limit)`, …) and test those — this is itself a
worthwhile testability change. Assert, per assembler:
- **1 State:** one `#d` filter per host relay; kinds = 39000/39001/39002/39003/39005; `d` = all joined ids on that relay; `since` = shared per-relay EOSE. Disabled when NIP-29 toggle off / joined empty.
- **2 JoinedChatTail:** one `#h` filter per host relay; kinds = [9,poll]; `h` = all joined ids on that relay; `since = recentBoundary()`; **no per-group `limit`**. Two groups on one relay ⇒ **one** filter.
- **3 OpenChatTail:** one `#h` filter, host relay, kinds [9,poll], `since = recentBoundary()`, the single open group id.
- **4 OpenChatHistory:** with no relay armed ⇒ empty; after `advance(relay)` ⇒ one `#h` filter at `requestedUntilFor(relay)`, `limit = pageLimit`, **all authors** (no `authors`).
- **6 CardWarmup:** a **joined** group ⇒ `emptyList()`; a **non-joined** group ⇒ metadata (unless contentOnly) + `#h` content (9,poll,11,1111) `limit`.
- **Reconnect stability:** re-run each `updateFilter` after a simulated EOSE; assert `FiltersChanged.needsToResendRequest(old,new)` is **false** for the tails/state (a `since`-only bump) — i.e. no full replay.
Issue the **exact filter shapes** from Tier B against the seeded relay and assert results:
- **C1 State load:** `amy fetch --kind 39000,39001,39002,39003,39005 --tag d=G --json` returns the seeded state. (screen-1)
- **C2 Preview/tail:** `amy fetch --kind 9 --tag h=G --since <now-7d> --json` returns only in-window messages; the newest equals the true newest. Batched: `--tag h=G --tag h=G2` returns both groups' recent in one query. (screens 2,3)
- **C3 History paging (CAN'T-MISS — the crown jewel):** seed **N=120** messages (older than 7d, spread over months). Starting `until=now`, repeatedly `amy fetch --kind 9 --tag h=G --until <cursor> --limit 50 --json`, setting the next `until = oldest.created_at - 1`, until an empty page. Assert the **union of all pages = all 120 ids, no gaps, no infinite loop** (mirror `RelayLoadingCursors.advance/onEose`). Then confirm a relay that returns the same newest events on a repeat page terminates (the `onEose` "not strictly older ⇒ done" guard). (screen-4)
- **C4 Ingest:** drive `amy subscribe`/`fetch` so events flow through the real client, then assert they land in a `RelayGroupChannel` keyed by `GroupId(G, R)` and surface via the `ChannelFeedFilter` predicate (kind 9/poll in, 1111 out). (screens 2,3)
- **C5 Threads:** `--kind 11,1111 --tag h=G` returns thread + reply; confirm 1111 attaches to threads, not the chat timeline. (screen-5)
- **C6 Pins:** a pinned kind-9 older than the window is **not** returned by C2 but **is** by `amy fetch --ids <pinnedId>` — proving the pinned-id backfill path still reaches it. (screen-9)
- **C8 Directory / discovery:** `--kind 39000 --limit 500` on R lists G+G2; a `#p=<follow>` roster query on the host relay surfaces a follow's group (the discovery augmentation). (screens 7,8)
- **C9 Multi-relay + reconnect:** repeat C2 against R and R2; drop and re-issue the subscription and confirm (via `--json` counts / relay logs) that a `since`-carrying re-REQ returns only the tail, not a full replay.
## Tier D — Android app, per screen (emulator/device; flag if unrunnable)
Boot `:amethyst:installDebug` against the seeded relay (point the account's relay list at `$RELAY`).
- **D1 Messages list (1,2):** cold start with app already having joined G → the G row shows its **true newest** message (not a stale/scattered one), and its name/avatar (state). Join **G2 mid-session** (don't restart) → within seconds G2 appears with a real preview — *this is the original bug; it must now pass.*
- **D2 Open joined group (3,4,9 + backfill):** tap G → lands on a populated first screen (~50, the backfill-to-window), name/pins present. **Scroll up** past the window → older pages load, the reach marker advances, the "loading older" card shows then flips to "all caught up" at the bottom. No duplicate rows.
- **D3 Open non-joined group by link (3,4,9):** open a `naddr`/link to a group you have **not** joined → recent chat + live updates load (OpenChatTail) and scroll-up pages (OpenChatHistory), even though it's absent from the joined tail.
- **D4 Threads tab (5):** open Threads → kind-11 threads list; open one → its 1111 replies.
- **D5 Discovery (8,6):** open Discovery → groups list; a card fills name+activity (CardWarmup for non-joined). A **joined** group shown in "My Groups" still renders (from cache) though CardWarmup emits nothing for it.
- **D6 Relay channel-list / browse (7,6):** browse a relay → its directory lists groups; tapping one warms + opens.
- **D8 Reconnect (2,3,4):** toggle airplane mode on the open group and Messages → on reconnect, logcat shows incremental `since`/`until` REQs, **not** a full page replay; no missing or duplicated messages.
- **D9 Notifications (10):** with G *not* open, have another key post a message p-tagging me → it appears in notifications / unread.
- **D10 Quiet group:** a group whose newest message is older than 7d → Messages row falls back to cached/placeholder (documented limitation), and opening it backfills via the pager.
## Tier E — relay-behavior resilience & third-party conformance
Tiers C/D run against geode/`amy serve` — a **compliant relay we control**. Real NIP-29 groups live on
relays managed by other people, which have bugs and quirks. Two distinct concerns:
### E1 — client resilience to a MISBEHAVING relay (deterministic, mock)
Build a scriptable WebSocket relay (reuse the quartz relay-server + `RelayClientTestFakes`) that injects
one fault per run; assert the tail/pager still **converge** — all messages ingested, no infinite
`advance`, no hang, correct terminal state (`done` vs `stalled`), no duplicate rows:
- ignores `since` (returns everything) → tail must **dedup**, not duplicate.
- ignores / partial `until` → pager makes progress or marks done, **never loops**.
android:value="Persistent real-time messaging relay connection for Nostr protocol. Maintains WebSocket connections to user-configured inbox relays for immediate notification delivery of direct messages, zaps, and mentions."/>
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.