Compare commits

...

2116 Commits

Author SHA1 Message Date
Vitor Pamplona
72b8df7f6d Merge pull request #3663 from vitorpamplona/claude/sqlite-query-performance-bh2j27
SQLite query scaling: FTS contentless, tag-merge, pooled statements
2026-07-21 19:38:22 -04:00
Claude
d571bee394 fix(store): rank multi-filter search REQs + harden merge from branch audit
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
2026-07-21 23:33:33 +00:00
Claude
52ba19d324 test(store): adversarial search-rank cases (two-tag+search, count parity, non-searchable delete)
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
2026-07-21 23:12:59 +00:00
Claude
de114d1621 fix(store): relevance-order search+tag queries too, not just tag-free search
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
2026-07-21 22:54:47 +00:00
Claude
8e7b1b63be fix(store): order NIP-50 search by relevance (bm25), not created_at
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
2026-07-21 22:21:54 +00:00
Claude
e6a8f80826 fix(store): drop non-compliant rowid search ordering, keep FTS delete/size wins
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
2026-07-21 22:05:30 +00:00
Claude
c7670a52d4 perf(store): scale NIP-50 search and tag-watcher queries with corpus size
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
2026-07-21 21:45:42 +00:00
Vitor Pamplona
35e61e1d98 Merge pull request #3662 from vitorpamplona/fix/docs-license-badge-and-skill-md
docs: fix license badge and refresh stale SKILL.md
2026-07-21 17:38:07 -04:00
Vitor Pamplona
6730100853 docs: fix license badge and refresh stale SKILL.md
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>
2026-07-21 17:27:39 -04:00
Vitor Pamplona
69671d0009 Merge pull request #3660 from vitorpamplona/claude/nostr-relay-filters-mapping-bu95ib
Optimize relay query performance: cost-based driver selection, tag-author indexing, and fanout memoization
2026-07-21 15:35:38 -04:00
Claude
078758888a perf(relay): remove live-path allocations in LiveEventStore + FilterIndex
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
2026-07-21 19:23:38 +00:00
Claude
387bfe99ee perf(relay): drop per-op allocations on the frame + search paths
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
2026-07-21 19:23:21 +00:00
Vitor Pamplona
4df7354ffc Merge pull request #3658 from vitorpamplona/fix/concord-messages-cold-boot
fix: Concord groups missing or slow on the Messages tab after a cold boot
2026-07-21 14:38:08 -04:00
Vitor Pamplona
a11de43400 Merge pull request #3659 from vitorpamplona/fix/persist-keypackage-algo-lists
fix: persist the MIP-00 key-package and favorite-algo-feeds lists
2026-07-21 14:37:54 -04:00
Vitor Pamplona
0a228a935b fix: persist the MIP-00 key-package and favorite-algo-feeds lists
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>
2026-07-21 14:23:38 -04:00
Vitor Pamplona
9ddf571b11 fix: persist the Concord community list so cold boot doesn't refetch it
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>
2026-07-21 14:09:59 -04:00
Vitor Pamplona
1fee674350 fix: keep event-less placeholder rooms when a deletion arrives
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>
2026-07-21 13:22:06 -04:00
Vitor Pamplona
50025660a3 perf: cut Concord revision churn and quadratic control-plane re-folds
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>
2026-07-21 13:21:54 -04:00
Vitor Pamplona
f67d242f7a fix: show Concord channels on Messages as soon as the control plane folds
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>
2026-07-21 13:21:35 -04:00
Claude
6033957b1c perf(relay): persistent-map FilterIndex snapshots, benchmark the sub population
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
2026-07-21 16:22:13 +00:00
Claude
37923d9101 perf(relay): run the stored REQ replay undispatched
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
2026-07-21 16:14:12 +00:00
David Kaspar
a960ceb6a8 Merge pull request #3657 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-21 16:25:36 +01:00
davotoula
3fdb976aa9 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-21 15:24:41 +00:00
davotoula
a6b40cfc0f docs(skill): prevent duplicate-key mistake in find-missing-translations 2026-07-21 16:19:39 +01:00
davotoula
b616f69c1d update cz, se, pt, de 2026-07-21 16:19:31 +01:00
Claude
57ffb3386d feat(geode): enable the tag+kind+pubkey index, refresh measured docs
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
2026-07-21 15:07:12 +00:00
Claude
a3e239d33f feat(store): cost-based FS driver pick, runtime index materialization, new relayBench shapes
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
2026-07-21 14:54:48 +00:00
Vitor Pamplona
b6bf3b8a70 Merge pull request #3656 from davotoula/fix/bidi-control-literals
Replace raw bidi/invisible Unicode characters in source with \u escapes
2026-07-21 10:53:49 -04:00
Claude
41240eeab9 docs(store): record measured index/planner gaps as TODOs in both stores
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
2026-07-21 14:39:21 +00:00
Claude
5988db010d test(store): add tag∩author and FS driver-selection benchmarks
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
2026-07-21 14:34:57 +00:00
David Kaspar
73a58e042d Merge pull request #3655 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-21 15:34:00 +01:00
davotoula
e3dae26483 docs: add coding standard banning raw invisible/bidi characters in source 2026-07-21 15:04:57 +01:00
davotoula
86c8d0b12c fix: replace raw bidi/invisible control characters in source with \u escapes
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).
2026-07-21 15:04:47 +01:00
vitorpamplona
db0ff71432 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-21 14:00:35 +00:00
Vitor Pamplona
72e27a3530 Merge pull request #3654 from davotoula/feat/share-note-as-qr
Share a note (link) as a QR code
2026-07-21 09:57:06 -04:00
davotoula
556ef9f880 fix(share-qr): stop the mode toggle labels clipping 2026-07-21 14:30:04 +01:00
davotoula
52c863a9c6 feat(share): add QR payload selection for notes
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
2026-07-21 14:29:49 +01:00
Vitor Pamplona
5e5ecdab8b Merge PR: NIP-34 git parity for amy + quartz interop fixes
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>
2026-07-20 23:54:49 -04:00
Claude
062ab0f012 revert(amethyst): drop GitStatusIndex auth change; defer to separate proposal
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
2026-07-21 03:38:20 +00:00
Claude
5f5356c0fe fix: complete PR clone multi-value, git-status auth spoofing, CLI robustness
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
2026-07-21 03:37:58 +00:00
Claude
4e0ac212ee fix(cli): second audit pass — read routing, status perf, publish-ack + robustness
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
2026-07-21 03:37:58 +00:00
Claude
63d01c9287 fix(cli): audit fixes — shallow-clone euc, read truncation, process deadlock
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
2026-07-21 03:37:58 +00:00
Claude
fa32ea50a3 test(cli): live interop check against the real ngit-published amethyst repo
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
2026-07-21 03:37:58 +00:00
Claude
1a77c95531 fix(quartz): NIP-34 wire-format interop with ngit (clone/web, issue p, plain r)
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
2026-07-21 03:37:58 +00:00
Claude
d3e208322c feat(cli): add amy git label (NIP-32) and amy git apply (patch → working tree)
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
2026-07-21 03:37:58 +00:00
Claude
b06184ac49 feat(cli): add amy git init — bootstrap a repo from the local git checkout
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
2026-07-21 03:37:58 +00:00
Claude
45d33c016e feat(cli): add amy git browse|cat|log — read git objects over smart-HTTP
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
2026-07-21 03:37:57 +00:00
Claude
9922eef9f7 feat(cli): add amy git grasp list|set (NIP-34 GRASP server list, kind 10317)
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
2026-07-21 03:37:57 +00:00
Claude
ad66e6c224 feat(cli): full NIP-34 git collaboration parity for amy git
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
2026-07-21 03:37:57 +00:00
Vitor Pamplona
da451145af Merge pull request #3653 from vitorpamplona/claude/notifications-pagination-n0qahe
Add infinite-scroll backward pagination for notification history
2026-07-20 20:10:05 -04:00
Claude
a5ac06ad6c Merge branch 'main' into claude/notifications-pagination-n0qahe
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
2026-07-21 00:00:28 +00:00
Claude
77908b2be0 refactor(notifications): extract history paging out of CardFeedView
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
2026-07-20 21:15:06 +00:00
Claude
05426d8c66 fix(notifications): bound eager fill + only the active feed drives paging
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
2026-07-20 21:07:30 +00:00
Claude
c8f43a1bd4 feat(notifications): auto-retry faulty relays + actionable relay detail
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
2026-07-20 20:36:43 +00:00
Claude
cc94ef9103 fix(notifications): retry stalled relays via per-relay sentinels
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
2026-07-20 20:14:30 +00:00
Claude
ad75344c3e feat(notifications): infinite-scroll paging via a look-ahead buffer
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
2026-07-20 19:48:27 +00:00
Vitor Pamplona
ef9a891974 Merge pull request #3652 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-20 15:17:43 -04:00
vitorpamplona
b2cfb5e7ee chore: sync Crowdin translations and seed translator npub placeholders 2026-07-20 19:14:54 +00:00
Vitor Pamplona
0601cfafbc Merge pull request #3651 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-20 15:12:11 -04:00
vitorpamplona
0214382d0f chore: sync Crowdin translations and seed translator npub placeholders 2026-07-20 18:19:06 +00:00
Vitor Pamplona
a0ab3ec66d Merge PR: feat(compose): resolve NIP-05 (incl. Namecoin .bit) in the @-mention popover
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>
2026-07-20 14:13:17 -04:00
Vitor Pamplona
15ce992b61 Merge pull request #3650 from vitorpamplona/fix/napplet-account-isolation-and-consent
fix: v1.13.0 pre-release QA — napplet account isolation, Concord authority, and 30 other fixes
2026-07-20 13:28:41 -04:00
Vitor Pamplona
8913af7a79 fix(concord)!: enforce CORD-04 rank gating on the Banlist fold
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>
2026-07-20 12:53:11 -04:00
Vitor Pamplona
ea6762b137 docs(concord): Banlist rank gap is a conformance bug, not a spec gap
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>
2026-07-20 12:05:21 -04:00
Vitor Pamplona
0ae6bc6698 fix(concord): rank-gate the Ban and Remove affordances
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>
2026-07-20 11:54:28 -04:00
Claude
ba8de9ba21 feat(notifications): paginate history by time with in-feed load markers
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
2026-07-20 15:53:32 +00:00
Vitor Pamplona
c15774e4e1 docs(qa): record the notifications deadlock and close two open findings
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>
2026-07-20 11:36:39 -04:00
Vitor Pamplona
4e7242a295 feat(concord): assign CORD-04 roles from the Members roster
`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>
2026-07-20 11:35:48 -04:00
Vitor Pamplona
97b861dd5e fix(notifications): remove the 7-day since floor that emptied the tab
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>
2026-07-20 11:34:07 -04:00
Vitor Pamplona
4ee0416a90 docs(qa): record v1.13.0 test coverage, open findings and recipes
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>
2026-07-20 10:56:21 -04:00
Vitor Pamplona
a040aa522c fix(perf): keep favicon and settings disk reads off the main thread
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>
2026-07-20 10:52:05 -04:00
Vitor Pamplona
4255ba3345 fix(browser): capture icons from sites that declare only an SVG favicon
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>
2026-07-20 10:09:24 -04:00
Vitor Pamplona
08bd5d9b4a fix(napplet): tightening an app's trust level drops its live grants
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>
2026-07-20 09:28:34 -04:00
Vitor Pamplona
b0668baeec fix(napplet): make revoking an app actually drop its live grants
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>
2026-07-20 09:13:37 -04:00
Vitor Pamplona
62440748a7 fix(concord): stop a rejected edition orphaning the honest ones after it
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>
2026-07-20 09:06:13 -04:00
davotoula
2d29f7ccda no empty methods
use constants instead of repeating strings
2026-07-20 13:58:06 +01:00
Vitor Pamplona
5f712b3a8a feat(concord): let a member leave a community
`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>
2026-07-20 08:57:52 -04:00
Vitor Pamplona
ab2f4d2da8 fix(nip29): split group-state REQs so relays stop rejecting them
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>
2026-07-20 00:18:13 -04:00
Vitor Pamplona
a87049e451 fix(chats): stop crashing when a relay's group directory is sorted
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>
2026-07-19 22:43:40 -04:00
Vitor Pamplona
894dc7d99b fix(location): don't self-select a location the user never picked
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>
2026-07-19 22:22:24 -04:00
Vitor Pamplona
717461f4d0 fix(chats): draw day headers above the message they introduce
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>
2026-07-19 22:14:00 -04:00
Vitor Pamplona
f2e42105f7 fix(podcast): stop rendering an episode description twice
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>
2026-07-19 22:07:37 -04:00
Vitor Pamplona
c6a6068486 fix(chats): stop showing ciphertext in DM previews; fix stuck npub names
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>
2026-07-19 22:01:19 -04:00
Vitor Pamplona
73d59a29bf fix(nip46): gate identity reads on pairing; make decrypt consent informed
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>
2026-07-19 21:28:05 -04:00
Vitor Pamplona
7792c42f1d fix(blossom): bound what a paid server can extract on a 402
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>
2026-07-19 21:03:20 -04:00
Vitor Pamplona
6045e28830 fix(cashu): validate a token's mint URL before contacting it
`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>
2026-07-19 21:02:59 -04:00
Vitor Pamplona
c84acddc0c fix(concord): renaming a private channel no longer makes it public
`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>
2026-07-19 20:59:44 -04:00
Vitor Pamplona
5c23f8490d fix(concord): stop destroying other clients' data in the community list
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>
2026-07-19 20:59:19 -04:00
Vitor Pamplona
f8d8a2b135 feat(concord): recover a membership stranded by a Refounding
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>
2026-07-19 20:36:52 -04:00
Vitor Pamplona
fac1bf5b5d feat(concord): refuse Control-Plane rollbacks with a version floor
`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>
2026-07-19 20:36:25 -04:00
Vitor Pamplona
30f4638954 fix(concord): re-key observed members on a Refounding
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>
2026-07-19 20:35:50 -04:00
Vitor Pamplona
0f53eb09a2 fix(concord): gate revokes on target rank; drop the owner rotation refusal
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>
2026-07-19 20:10:36 -04:00
Vitor Pamplona
c9c91a8c98 fix(concord): rank-gate role editions and harden rotation authority
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>
2026-07-19 20:00:50 -04:00
Vitor Pamplona
996b800da5 docs(changelog): correct claims the code does not support
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>
2026-07-19 19:52:52 -04:00
Vitor Pamplona
2ccd837f30 fix(napplet): sign as the account a surface was launched as
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>
2026-07-19 19:38:52 -04:00
Vitor Pamplona
c8be65a02e fix(concord): require consent for invite links, enforce expiry, fold the head
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>
2026-07-19 18:59:50 -04:00
Vitor Pamplona
153191e722 fix(nip46): stop auto-signing relay AUTH under the default policy
`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>
2026-07-19 18:59:33 -04:00
Vitor Pamplona
5fd8be64fd fix(podcast): clamp V4V fee splits so a feed cannot multiply payments
`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>
2026-07-19 18:59:18 -04:00
Vitor Pamplona
7f135a12ab fix(embed): rebuild pinned tabs when the account changes
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>
2026-07-19 18:59:04 -04:00
Vitor Pamplona
095b156953 fix(napplet): give each account its own WebView storage jar
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>
2026-07-19 18:58:47 -04:00
Vitor Pamplona
5bce87d76e fix(napplet): make the consent dialog say what a signature really does
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>
2026-07-19 18:57:57 -04:00
Vitor Pamplona
389b460f7a fix(napplet): disclose what connecting to an app pre-grants
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>
2026-07-19 18:57:12 -04:00
Vitor Pamplona
dbf15a2146 fix(napplet): scope applet grants per app and per account
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>
2026-07-19 18:56:58 -04:00
Vitor Pamplona
ee3b8786d4 Merge pull request #3648 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-19 15:15:02 -04:00
Vitor Pamplona
49b93db532 Merge pull request #3647 from vitorpamplona/claude/changelog-new-version-y1y331
docs(changelog): add v1.13.0 release notes
2026-07-19 15:13:29 -04:00
vitorpamplona
710c68d6aa chore: sync Crowdin translations and seed translator npub placeholders 2026-07-19 19:09:10 +00:00
Vitor Pamplona
824f9898de Merge pull request #3645 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-19 15:06:36 -04:00
Claude
fb730d7ea8 docs(changelog): add v1.13.0 release notes
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
2026-07-19 19:06:03 +00:00
vitorpamplona
fe761ab7f2 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-19 14:44:14 +00:00
Vitor Pamplona
768e19ff9b Merge pull request #3646 from vitorpamplona/claude/update-all-dependencies-h7vnw4
chore(deps): update dependencies across all modules
2026-07-19 10:41:30 -04:00
Claude
18c0c4291f chore(deps): update dependencies across all modules
Bump to latest stable (or latest within an existing pre-release track),
skipping stable→alpha/beta jumps for production safety.

- kotlin 2.4.0 → 2.4.10 (+ kotlinTest, compose compiler); ksp 2.3.9 → 2.3.10
- composeBom 2026.06.00 → 2026.06.01; composeRuntimeAnnotation 1.11.3 → 1.11.4
- firebaseBom 34.15.0 → 34.16.0; jacksonModuleKotlin 2.22.0 → 2.22.1
- kotlinxCollectionsImmutable 0.5.0 → 0.5.1; sqlite 2.6.2 → 2.7.0
- composemediaplayer 0.10.0 → 0.11.3; zoomable 2.12.0 → 2.13.0
- jlatexmath 1.4 → 1.5; jna 5.14.0 → 5.19.1 (nestsClient)
- benchmark 1.5.0-alpha06 → alpha07; genaiPrompt 1.0.0-beta2 → beta3
- bump DisableCacheInKotlinVersion guard 2_4_0 → 2_4_10 (iOS test workaround)

appfunctions held at alpha09 (appfunctions-service has no alpha10 published).
AGP 9.3.0, gms 4.5.0, kotlin stable, ktor, coil, media3, navigation, and other
androidx libs already at their newest stable; alpha-only newer releases skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TreeiMiHrhWce6PbnA5qWz
2026-07-19 14:06:37 +00:00
Vitor Pamplona
618664c45a Merge pull request #3644 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-19 09:46:56 -04:00
vitorpamplona
7b5e5f572b chore: sync Crowdin translations and seed translator npub placeholders 2026-07-19 02:42:47 +00:00
Vitor Pamplona
f875d2c397 Merge pull request #3643 from vitorpamplona/claude/nip29-location-geohash-ux-890wjz
Map-first geohash location UX: picker, teleport, follow, retarget
2026-07-18 22:39:58 -04:00
Claude
b94303a77a fix(location): audit fixes — stuck GPS spinner, main-thread geocode
- "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
2026-07-19 02:11:59 +00:00
Claude
a87d4688a8 fix(location): single location control on the geo-post screen
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
2026-07-19 01:50:02 +00:00
Claude
fb9ccbaad0 feat(location): follow a teleported place; retarget geo-posts
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
2026-07-19 01:25:24 +00:00
Vitor Pamplona
b801a6b138 Merge pull request #3639 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-18 21:13:04 -04:00
Claude
7c37a8691c feat(topnav): teleport to a place from the feed filter dialog
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
2026-07-19 00:55:43 +00:00
Claude
1445aea849 feat(location): open the picker at the user's location when permitted
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
2026-07-19 00:48:24 +00:00
vitorpamplona
6ba4ab0a4b chore: sync Crowdin translations and seed translator npub placeholders 2026-07-19 00:27:16 +00:00
Vitor Pamplona
035c50421c Merge pull request #3642 from vitorpamplona/claude/cli-module-deep-review-le4dsj
amy CLI overhaul: exit-code/JSON contract v1, per-command help, contract tests, docs, and thin-layer extractions
2026-07-18 20:24:28 -04:00
Claude
c6b2bad0a9 feat(location): calmer map, cell highlight, zoom on area size
- 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
2026-07-19 00:21:02 +00:00
Claude
4efb2cca98 fix(quartz,cli): audit fixes — bounded drains, no event loss, honest verdicts, false-reject traps
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
2026-07-19 00:17:43 +00:00
Claude
787fd99d1e fix(location): picker polish — open zoom, search pill, chip rows
- 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
2026-07-18 23:57:06 +00:00
Claude
b20efb3147 fix(location): map drag interception + wrap precision chips
- 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
2026-07-18 23:45:44 +00:00
Claude
78e641b3b2 fix(location): audit fixes for the geohash picker
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
2026-07-18 23:43:02 +00:00
Claude
962d1706dc fix(quartz): fetchAll-family timeouts are idle windows, not absolute deadlines
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
2026-07-18 23:39:25 +00:00
Claude
620fc465df feat(cli): publish results carry each relay's rejection reason; converge output shapes
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
2026-07-18 23:30:06 +00:00
Claude
fea9bcb8ea feat(composer): extend map location picker to all composers
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
2026-07-18 23:29:46 +00:00
Vitor Pamplona
43704772a7 Merge pull request #3641 from vitorpamplona/claude/nip29-group-load-perf-wz4yca
NIP-29 group chat: split state (always-on) from content (paginated)
2026-07-18 19:15:22 -04:00
Claude
8d3a47b8f8 refactor(cli): split GrapeRankCommand into graperank/ sub-files; TCP prober to quartz
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
2026-07-18 23:00:15 +00:00
Claude
82f041b1f5 docs(cli): reconcile README/ROADMAP/DEVELOPMENT/tests with reality
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
2026-07-18 22:59:37 +00:00
Claude
7b32ca1da3 feat(composer): add "pick on map" location to the short-note composer
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
2026-07-18 22:57:55 +00:00
Claude
6e664b2db6 refactor(cli): extract the drain loop to quartz accessories; split Context per domain
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
2026-07-18 22:55:56 +00:00
Claude
8658dcd512 refactor(geohash): reuse the shared picker in the Teleport screen
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
2026-07-18 22:36:47 +00:00
Vitor Pamplona
ad2c232868 build(tor): rebuild libarti_android.so for the SOCKS reply-code fix
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>
2026-07-18 18:35:25 -04:00
Vitor Pamplona
dd6e7e2852 fix(tor): map Arti errors to accurate SOCKS reply codes
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>
2026-07-18 18:35:25 -04:00
Vitor Pamplona
850c3e58e4 feat(relay): debug-only per-relay cold-start census
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>
2026-07-18 18:35:25 -04:00
Vitor Pamplona
e66550091d fix(relay): send an unresolvable host straight to the long backoff
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>
2026-07-18 18:35:25 -04:00
Vitor Pamplona
b1b6190d2f fix(relay): forgive reconnect backoff when the network or transport changes
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>
2026-07-18 18:35:25 -04:00
Vitor Pamplona
bff7592383 Merge pull request #3640 from vitorpamplona/claude/allsettingsscreen-reorder-9ov2tu
Reorder settings menu entries and update icons
2026-07-18 18:32:57 -04:00
Claude
11590b7081 feat(location): polish the geohash picker and generalize it
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
2026-07-18 21:21:57 +00:00
Claude
a4713c89c2 docs(cli): add RECIPES.md — task-shaped walkthroughs for the big subsystems
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
2026-07-18 21:13:23 +00:00
Claude
d8584a7935 feat: reorder App Settings entries and refine Home/Messages rows
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
2026-07-18 21:12:04 +00:00
Claude
c28c8af13f feat(nip29): map-based location picker for group creation
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
2026-07-18 21:07:44 +00:00
Claude
7ea6920679 refactor(nip65): move read/write-marker merge semantics from the CLI into quartz
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
2026-07-18 21:03:56 +00:00
Claude
80be7b909b test(cli): bare 'amy' exits 2; key generate rejects unknown flags
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 20:59:34 +00:00
Claude
55884f81eb refactor(marmot): extract the incoming-sync policy from the CLI into commons
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
2026-07-18 20:58:45 +00:00
Claude
7b5d9a0fe4 feat(cli): contract sweep for notes/nsite/offer/podcast/relay/search/zap verbs
Completes the module-wide contract sweep (N-Z families):
- rejectUnknown() everywhere Args is constructed
- publishGuard on single-event publishes (post, publish, profile edit,
  nsite/napplet publish, podcast + podcast20, relaygroup join/leave/
  message/invite/edit/put-user/remove-user)
- USAGE constants + route(help=...) / --help fast-paths; nsite/napplet
  publish/serve/list, offer discover/pay and zap --with are now
  documented in-binary; subscribe's USAGE pins the NDJSON stream shape
- notes post parses flags before positionals (flags may appear anywhere)
- search note: --kind canonical (--kinds kept as alias); search user
  default limit 20 -> 50
- --identifier accepted as alias of --d in nsite/napplet/podcast20
- relay dispatch gains a full USAGE (--help) documenting every noun

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 20:58:19 +00:00
Claude
11901df2c6 feat(cli): contract sweep for event/git/geochat/graperank/group/key/login verbs
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
2026-07-18 20:56:23 +00:00
Claude
471883a9f2 feat(cli): contract sweep for admin/await/blossom/bunker/concord/dm/cashu verbs
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
2026-07-18 20:56:09 +00:00
Claude
6bcae39b83 fix(cli): repair the exit-code and flag-parsing contract
- 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
2026-07-18 20:54:14 +00:00
Vitor Pamplona
7e3c01ab08 Merge pull request #3637 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-18 16:18:38 -04:00
Claude
b852a163c7 feat: paginate the NIP-29 group Threads tab with a backward history pager
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
2026-07-18 18:54:18 +00:00
Claude
3214095b99 fix: route stray NIP-29 group content to the host, not a phantom channel
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
2026-07-18 18:13:31 +00:00
vitorpamplona
c5f10c7518 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-18 18:06:39 +00:00
Vitor Pamplona
0d534a3149 Merge pull request #3638 from vitorpamplona/claude/post-image-render-shift-b4vx28
Fix media detection for malformed imeta MIME types
2026-07-18 14:04:00 -04:00
Claude
db5170a82e test: extend NIP-29 group-chat coverage to every assembler + relay integration
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
2026-07-18 17:54:38 +00:00
Claude
4653951b54 fix: recover media type from file extension when imeta MIME is malformed
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
2026-07-18 17:40:31 +00:00
Claude
399bef6bf7 test: cover NIP-29 group-chat filter shapes and can't-miss history paging
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
2026-07-18 17:13:56 +00:00
Vitor Pamplona
8ba0d0c317 Merge pull request #3633 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-18 12:57:53 -04:00
Vitor Pamplona
854c9907e2 Merge pull request #3636 from vitorpamplona/claude/strictmode-disk-read-violation-2s5q8e
Fix StrictMode DiskReadViolation in BootCompletedReceiver
2026-07-18 12:57:47 -04:00
Claude
4cf86ca030 docs(nip29): add Tier E — relay-behavior resilience + third-party conformance
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
2026-07-18 16:04:07 +00:00
Claude
c8362fbc18 docs(nip29): per-screen × per-assembler test plan for the group-chat refactor
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
2026-07-18 15:16:36 +00:00
Claude
20e86b8998 fix: move BootCompletedReceiver disk read off the main thread
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
2026-07-18 14:39:03 +00:00
Claude
ee5dc25414 refactor(nip29): finish the datasource rename — Open threads + consistent filter-builder names
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
2026-07-18 14:33:56 +00:00
vitorpamplona
9fb3603ed6 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-18 14:24:00 +00:00
Vitor Pamplona
2466132fb7 Merge pull request #3635 from vitorpamplona/claude/amethyst-blossom-client-p35sql
Add Blossom blob manager UI and BUD-07 payment handling
2026-07-18 10:21:08 -04:00
Claude
54b2b42d87 refactor(nip29): scope Card warmup to non-joined, rename the group-chat assemblers by scope+role
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
2026-07-18 13:57:44 +00:00
Claude
a25c5f9518 fix: harden Blossom sync concurrency and cancellation
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
2026-07-18 13:46:06 +00:00
Claude
84640a5890 perf(blossom): parallelize "sync all" across servers
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
2026-07-18 13:31:36 +00:00
Vitor Pamplona
bc3c3460c0 Merge pull request #3634 from davotoula/fix/graperank-flag-constants
Extract GrapeRank CLI flag names into constants
2026-07-18 08:11:37 -04:00
davotoula
ab6682d521 refactor(cli): extract GrapeRank CLI flag names into constants 2026-07-18 11:00:41 +01:00
davotoula
bb75ebf87c update cz, se, pt, de in amethyst 2026-07-18 10:16:46 +01:00
davotoula
314c5e41f2 update cz, se, pt, de in commons 2026-07-18 10:16:35 +01:00
davotoula
2f0c178eab Update translation skill for new commons strings 2026-07-18 10:16:18 +01:00
Claude
5d54a0ff90 refactor(fgs): extract FlowProgressForegroundService; add dataSync Blossom sync service
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
2026-07-18 04:33:10 +00:00
Claude
050ecaaf9f feat(nip29): split group chat into always-on state + live tail + history pager
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
2026-07-18 04:19:50 +00:00
Claude
af9d5a7278 docs(nip29): plan to split group chat into always-on state + paginated content
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
2026-07-18 04:19:25 +00:00
Claude
4845e95c9f chore(blossom): rename "My Blossom Data" to "My Blossom Files"
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
2026-07-18 03:59:37 +00:00
Claude
ecbe2c6704 feat(blossom): app-level "Sync all" with floating progress; rename to "My Blossom Data"
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
2026-07-18 03:57:58 +00:00
Claude
da633342bc feat(blossom): faster, more legible blob manager (load, progress, refresh)
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
2026-07-18 03:34:42 +00:00
Claude
6920dc6e82 fix(blossom): don't let mirroring corrupt upload progress or fail the upload
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
2026-07-18 03:05:37 +00:00
Claude
22396cf583 feat(blossom): redesign blob manager to match the app's modern UI
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
2026-07-18 02:32:51 +00:00
Claude
e87309e038 test(blossom): live interop smoke test for amy blossom (BUD-01/02/04/09)
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
2026-07-18 02:16:17 +00:00
Vitor Pamplona
4e53f26b38 Merge pull request #3630 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-17 22:10:07 -04:00
Claude
7dcf2afa0f perf(chats): make ChannelNewMessageViewModel.init idempotent across recompositions
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
2026-07-18 01:25:52 +00:00
Claude
9d8e07fbee perf(nip29): prefetch a joined group's history with a per-group since
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
2026-07-18 01:25:28 +00:00
Claude
9bc436b0c2 feat(blossom): BUD-07 confirm-then-pay for paid-server mirroring
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
2026-07-18 01:18:46 +00:00
vitorpamplona
69a5b3be00 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-18 01:09:34 +00:00
Vitor Pamplona
2cfac578fe Merge pull request #3631 from vitorpamplona/claude/concord-nip29-message-indicators-jwyvgt
Add unread indicators for NIP-29 relay groups and Concord channels
2026-07-17 21:06:55 -04:00
Vitor Pamplona
67e02618e2 Merge pull request #3632 from vitorpamplona/claude/settings-search-placement-n1wkqw
Move settings search field to top bar
2026-07-17 21:04:37 -04:00
Claude
4f661a501f feat(blossom): blob-manager per-file controls + clear 402 on upload
- 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
2026-07-18 01:04:35 +00:00
Claude
5ad3e143e4 fix: keep Concord community unread flow alive across Refounding + dedupe emissions
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
2026-07-18 00:55:30 +00:00
Claude
6c01ad2b10 feat(blossom): mobile upload controls — mirror + /media toggles, manager refresh
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
2026-07-18 00:07:30 +00:00
Claude
d0f2f08e19 fix(blossom): mirror and blob-manager use only the user's configured servers
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
2026-07-17 23:50:53 +00:00
Claude
ed4b851518 feat(settings): host the settings filter in the app bar
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
2026-07-17 23:50:25 +00:00
Vitor Pamplona
3e9da48c8a Merge pull request #3629 from vitorpamplona/claude/crowdin-ci-fix-lzm3ac
fix(ci): reclaim working-tree ownership after Crowdin Docker action
2026-07-17 19:45:47 -04:00
Claude
91765c56aa fix(ci): reclaim working-tree ownership after Crowdin Docker action
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
2026-07-17 23:39:44 +00:00
Claude
bb03cd2a3c feat(blossom): full-client protocol support across quartz, commons, CLI and Android
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
2026-07-17 23:36:41 +00:00
Claude
26b24c9125 feat: show unread dot for NIP-29 groups and Concord communities in Messages
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
2026-07-17 23:33:18 +00:00
Vitor Pamplona
cd5060e5dc Merge pull request #3626 from vitorpamplona/claude/graperank-algorithm-improvements-oco5ue
Add follower crawl and trusted-follower metrics to GrapeRank
2026-07-17 19:29:49 -04:00
Vitor Pamplona
bdbf12f8be Merge pull request #3628 from vitorpamplona/claude/mediaservers-settings-ui-design-gbq9m7
Redesign media servers UI with drag-reorder, health probes, and cards
2026-07-17 19:28:01 -04:00
Claude
699c2dc7e5 fix(graperank): import kotlinx.coroutines.IO for Kotlin/Native compile
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
2026-07-17 23:26:46 +00:00
Claude
d2e18cb18d fix(media-servers): trim the gap above the first section header
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
2026-07-17 23:26:34 +00:00
Claude
a99fc59de8 feat(media-servers): auto-save on every change; polish cache card & add flow
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
2026-07-17 23:21:02 +00:00
Vitor Pamplona
0ac31a7919 Merge pull request #3627 from vitorpamplona/claude/commons-crowdin-ci-acdrjg
chore: include commons Compose resources in Crowdin sync
2026-07-17 19:20:24 -04:00
Claude
7f99cbc3d6 fix(ci): include commons translations in the Crowdin PR
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.
2026-07-17 23:14:36 +00:00
Vitor Pamplona
451e27e38d Merge pull request #3625 from vitorpamplona/claude/event-renderers-commons-7pttym
refactor(commons): extract note-type UI rendering to commons
2026-07-17 19:04:41 -04:00
Claude
4d199ab28a fix(media-servers): probe /upload not root; polish rows into cards
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
2026-07-17 23:03:05 +00:00
Claude
33f4c648e9 fix(graperank): don't create the operator master for a read-only follower crawl
`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
2026-07-17 22:57:25 +00:00
Claude
0feb0072a1 feat(media-servers): redesign whole screen as one canvas, drop tabs
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
2026-07-17 22:53:13 +00:00
Claude
ee217a42dc perf(commons): remember derived values in the moved note cards
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
2026-07-17 22:50:09 +00:00
Claude
c4957ee8c0 fix(commons): base ColorScheme.isLight on background, not primary luminance
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
2026-07-17 22:50:09 +00:00
Claude
c18256a175 docs(plan): record note-ui-commons extraction progress + findings
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
2026-07-17 22:50:09 +00:00
Claude
14c9f99bac refactor(commons): extract NIP-52 calendar collection + RSVP cards to commons
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
2026-07-17 22:50:09 +00:00
Claude
fd4d9c10f7 refactor(commons): extract RelayDiscovery card to commons
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
2026-07-17 22:50:09 +00:00
Claude
60a226891c build(amethyst): read commons Compose resources; drop a duplicated string
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
2026-07-17 22:50:07 +00:00
Claude
62c2d7755d refactor(commons): extract PodcastValueSplits to commons
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
2026-07-17 22:50:06 +00:00
Claude
fb471a2e6b refactor(commons): extract Roadstr road-event card to commons
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
2026-07-17 22:49:36 +00:00
Claude
05f7da6814 refactor(commons): extract activity-card building blocks to commons
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
2026-07-17 22:49:35 +00:00
Claude
71d15bf916 refactor(commons): extract podcast badge/link/soundbite atoms to commons
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
2026-07-17 22:49:35 +00:00
Claude
3b96dc2f82 refactor(commons): extract PS1 memory-card save card to commons
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
2026-07-17 22:49:34 +00:00
Claude
a9ff6f5fbb refactor(commons): extract Birdstar Birdex/detection cards to commons
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
2026-07-17 22:48:33 +00:00
Claude
b2c78d6b76 refactor(commons): extract GitDiffView renderer to commons
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
2026-07-17 22:48:31 +00:00
Claude
a839ef226a refactor(commons): extract GitStatusPill to commons; wire commons i18n
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
2026-07-17 22:47:39 +00:00
Claude
870aeb4b99 refactor(commons): extract CodeSnippet + EcashMint renderers to commons
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
2026-07-17 22:47:37 +00:00
Claude
331dca79cd feat(graperank): run crawl/score without a personal account
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
2026-07-17 22:41:26 +00:00
Vitor Pamplona
90acafe389 Merge pull request #3624 from vitorpamplona/claude/relay-icons-reaction-gallery-x4uzmk
Move relay badges from author column to reaction gallery
2026-07-17 18:31:10 -04:00
Vitor Pamplona
23597ab883 Merge pull request #3623 from vitorpamplona/claude/messages-settings-toggles-s1qo9e
Add per-chat-type load toggles to Messages settings
2026-07-17 18:30:57 -04:00
Claude
ed9e02ac7f fix: reorder relay line last, align its icon, show expand button for relays
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
2026-07-17 22:20:49 +00:00
Vitor Pamplona
3316dfcd7a Merge pull request #3622 from vitorpamplona/claude/concord-epoch-backfill
feat(concord): backfill channel history across Refounding epochs
2026-07-17 18:04:16 -04:00
Vitor Pamplona
880a980f82 Merge pull request #3616 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-17 17:51:09 -04:00
vitorpamplona
cbcb8d42ba chore: sync Crowdin translations and seed translator npub placeholders 2026-07-17 21:49:24 +00:00
Vitor Pamplona
2fee4e800f Merge pull request #3620 from vitorpamplona/claude/medicaldata-jackson-kotlin-serialization-c15i39
Replace Jackson with kotlinx.serialization for FHIR parsing
2026-07-17 17:49:17 -04:00
Vitor Pamplona
1d5b08a128 docs(concord): mark epoch-walking backfill steps 1-3 done
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 17:48:45 -04:00
Vitor Pamplona
602c6a90b2 feat(concord): page channel history across epochs to the true start
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>
2026-07-17 17:48:45 -04:00
Vitor Pamplona
891e6ced91 feat(concord): backfill prior-epoch channel history from held roots
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>
2026-07-17 17:48:45 -04:00
Vitor Pamplona
60d492e2bc Merge pull request #3619 from vitorpamplona/claude/nip-84-tag-rendering-0o9owh
NIP-84: Support W3C TextQuoteSelector for highlight context
2026-07-17 17:47:18 -04:00
Claude
db586e30d3 feat: render NIP-84 highlights from web highlighter clients
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
2026-07-17 21:40:21 +00:00
Claude
40acd69781 refactor(media-servers): cache health probes, prune stale status, save tab
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
2026-07-17 21:33:26 +00:00
Claude
dc157e32d2 fix(graperank): page through ALL followers, not just the first limit
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
2026-07-17 21:32:16 +00:00
Claude
1daa871d95 refactor: move MiniFhir/MedicalData parsing from Jackson to Kotlin Serialization
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
2026-07-17 21:27:31 +00:00
Vitor Pamplona
3254b90b19 Merge pull request #3618 from vitorpamplona/claude/amy-nip46-bunker-concord-epoch-diag
fix(nip46): remote-signer pubKey is the user identity + amy Concord epoch tooling
2026-07-17 17:25:39 -04:00
Claude
76dd0a0f16 fix: size accepted-by-relays icons to match gallery author pictures
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
2026-07-17 21:11:34 +00:00
Vitor Pamplona
f0c21f3513 fix(nip46): remote-signer pubKey is the user identity, not the transport key
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>
2026-07-17 17:09:59 -04:00
Vitor Pamplona
a96dd12a49 docs(nip46): plan to fix remote-signer self-pubkey bug
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>
2026-07-17 17:00:41 -04:00
Vitor Pamplona
ac1ca888c7 feat(cli): amy concord import + prior-epoch history read
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>
2026-07-17 16:56:25 -04:00
Vitor Pamplona
28e63e3d92 fix(cli): resolve NIP-46 bunker identity via get_public_key
`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>
2026-07-17 16:56:07 -04:00
Claude
ede4fea1cd fix: don't render empty reaction gallery row without relays or reactions
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
2026-07-17 20:28:16 +00:00
Vitor Pamplona
56c99bb7b6 Merge pull request #3617 from vitorpamplona/claude/community-invite-nav-mr0qzv
Prevent re-joining Concord communities via old invite links
2026-07-17 16:20:36 -04:00
Claude
e3e85994f2 fix: drop Concord invite screen from back stack after forwarding to the community
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
2026-07-17 19:47:17 +00:00
Claude
7649c7f94d feat(messages): use PolicyCard selection cards for NIP-29/Concord display modes
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
2026-07-17 19:41:18 +00:00
Claude
5f65405b08 feat(graperank): add reverse follower crawl (amy graperank followers)
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
2026-07-17 19:34:06 +00:00
Claude
c8ff1bb18f feat(graperank): persist follower count and hop distance on trust cards
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
2026-07-17 19:34:06 +00:00
Claude
9717af28ee Merge remote-tracking branch 'origin/main' into claude/messages-settings-toggles-s1qo9e 2026-07-17 19:34:04 +00:00
Claude
f759ef6ca7 feat: modernize Media Servers settings with reorderable priority list
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
2026-07-17 19:33:43 +00:00
Claude
2ac9cc81f7 feat: skip re-join when redeeming a Concord invite for a community already joined
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
2026-07-17 19:33:43 +00:00
Claude
2f3c09c41d feat: move relay icons from author badge to reaction gallery
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
2026-07-17 19:33:04 +00:00
Vitor Pamplona
f380d92498 Merge pull request #3615 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-17 15:31:14 -04:00
Vitor Pamplona
d1f267a2a3 Merge pull request #3613 from vitorpamplona/feat/relay-req-refusal-suppression
feat(relay): stop re-sending REQs relays structurally refuse
2026-07-17 15:31:06 -04:00
davotoula
f28b6381e3 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-17 19:26:39 +00:00
David Kaspar
bf3e0df24d Merge pull request #3614 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-17 20:24:40 +01:00
davotoula
17c014ac1b chore: sync Crowdin translations and seed translator npub placeholders 2026-07-17 19:19:50 +00:00
davotoula
fd430ae429 update cz, se, pt, de 2026-07-17 20:16:55 +01:00
Vitor Pamplona
10d88afea3 feat(relay): stop re-sending REQs relays structurally refuse
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>
2026-07-17 14:58:03 -04:00
Claude
ae6e1e9c2d feat(messages): per-type load toggles for the Messages inbox
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
2026-07-17 16:22:20 +00:00
David Kaspar
cf4ea766aa Merge pull request #3608 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-17 17:22:17 +01:00
davotoula
e2128d2b6e chore: sync Crowdin translations and seed translator npub placeholders 2026-07-17 16:21:07 +00:00
davotoula
6dddb3b9c9 Code review:
- clarify no-op lifecycle callback comments
2026-07-17 16:57:27 +01:00
davotoula
ef7c10cef9 fix(sonar): address maintainability smells in resource-usage and CLI code
- ForegroundTracker: document empty ActivityLifecycleCallbacks overrides
- ResourceUsageReportAssembler: extract duplicated markdown table separator into a constant
- GroupMetadataCommands: use shared File.deleteOrWarn helper for temp-file cleanup
- fold private deleteOrWarn into shared commons helper
2026-07-17 16:57:05 +01:00
Vitor Pamplona
7b5ddd3637 Merge pull request #3612 from vitorpamplona/claude/amethyst-nip46-signer-njao2v
Add NIP-46 remote signer (bunker) support with consent UI
2026-07-17 11:37:04 -04:00
Claude
98c53ed907 docs(nip46): record the amy CLI interop gates in the signer checklist
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
2026-07-17 15:23:42 +00:00
Claude
13bc33ad70 feat(cli): gate amy bunker with --perms and interactive terminal approval
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
2026-07-17 15:18:38 +00:00
Claude
0f1ced8682 feat(cli): let amy login --nostrconnect request perms
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
2026-07-17 15:18:37 +00:00
Claude
1ebc4f0ada feat(nip46): informed consent for nostrconnect perms
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
2026-07-17 15:18:37 +00:00
Vitor Pamplona
5d9d75e2c4 fix(accounts): serialize Account construction so concurrent loaders can't build twins
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>
2026-07-17 15:18:36 +00:00
Claude
3a85a4f982 feat(nip46): per-account consent sheets + expandable per-item preview
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
2026-07-17 15:18:36 +00:00
Claude
5f723bf607 feat(nip46): show the signing account on each batched-consent row
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
2026-07-17 15:18:36 +00:00
Claude
62c82aef68 fix(nip46): two batched-consent races found in review
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
2026-07-17 15:18:36 +00:00
Claude
fd5556609f fix(nip46): audit fixes on the new signer code
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
2026-07-17 15:18:35 +00:00
Claude
1d8b7d7c8f feat(nip46): batched consent via concurrent request dispatch
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
2026-07-17 15:18:35 +00:00
Claude
a039f6adbb feat(nip46): security-icon trust picker + reconnect affordance
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
2026-07-17 15:18:35 +00:00
Claude
98c18a9fcf feat(nip46): honor nostrconnect perms + per-app live relay status
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
2026-07-17 15:18:35 +00:00
Claude
bb56bf6a73 feat(nip46): show a connected app's relays on its detail screen
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
2026-07-17 15:18:34 +00:00
Claude
e353467b6f feat(nip46): show the connected app's own icon when it declares one
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
2026-07-17 15:18:34 +00:00
Claude
0734fdb8de fix(nip46): show the same identity in the connected-app list and detail
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
2026-07-17 15:18:34 +00:00
Claude
052459567c feat(nip46): rename "Nostr Signer" to "Remote Signer"
Renames the user-facing title (drawer entry + screen top bar). Search keywords
already include "remote" and "nostr", so discoverability is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:34 +00:00
Claude
565342d7bf feat(nip46): drawer entry, dedicated apps screen, foreground surfacing, idle prune
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
2026-07-17 15:18:34 +00:00
Claude
2a1634bb07 feat(nip46): declutter the signer screen — bigger QR, drop noise
- 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
2026-07-17 15:18:33 +00:00
Claude
6151a2df25 fix(nip46): move allPolicies disk enumeration off the main thread
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
2026-07-17 15:18:32 +00:00
Claude
99f0c61f72 feat(nip46): preview the event as a signed note in the sign dialog
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
2026-07-17 15:18:32 +00:00
Claude
4fa41f1a67 feat(nip46): show which account is acting on the consent dialogs
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
2026-07-17 15:18:32 +00:00
Claude
00ec826e8b feat(nip46): refuse requests when the account can no longer sign
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
2026-07-17 15:18:32 +00:00
Claude
5fa8a0a2aa feat(nip46): persist serviced request ids so a restart never re-signs replays
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
2026-07-17 15:18:31 +00:00
Claude
9a0af15f36 fix(nip46): tighten stale-request window to 30s
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
2026-07-17 15:18:31 +00:00
Claude
cdf43db0eb fix(nip46): ignore stale/replayed sign requests by age
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
2026-07-17 15:18:31 +00:00
Claude
e69b59a255 test(nip46): verify a real-world Ditto kind-1 signs through the bunker
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
2026-07-17 15:18:31 +00:00
Claude
2976afc83b docs(nip46): record audit findings + known limitations in the checklist
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
2026-07-17 15:18:30 +00:00
Claude
31cd6c8bb7 fix(nip46): time-out the first-connect consent prompt; drop dead code
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
2026-07-17 15:18:30 +00:00
Claude
5c73a7c1f7 refactor(consent): move the shared signer-consent UI out of napplet
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
2026-07-17 15:18:30 +00:00
Claude
ac6697330e test(nip46): consent integration test + device verification checklist (Tier 4)
- 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
2026-07-17 15:18:29 +00:00
Claude
684ce26f63 feat(nip46): surface relay-connection health on the signer screen (Tier 3)
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
2026-07-17 15:18:29 +00:00
Claude
4998d7d460 feat(nip46): tailor the connected-app detail screen for remote signers (Tier 2)
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
2026-07-17 15:18:29 +00:00
Claude
f9d9691017 feat(nip46): activity feed + account clarity on the signer screen (Tier 2)
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
2026-07-17 15:18:28 +00:00
Claude
4676d176ec feat(nip46): live per-request + first-connect consent (Tier 1)
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
2026-07-17 15:18:28 +00:00
Claude
d8d185b928 feat(nip46): register nostrconnect:// deep link to the signer
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
2026-07-17 15:18:28 +00:00
Claude
3ed8779fff feat(nip46): scan nostrconnect:// from anywhere and un-gate the connect flow
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
2026-07-17 15:18:28 +00:00
Claude
cceac4478a feat(nip46): rotate the bunker transport key as the anti-spam remedy
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
2026-07-17 15:18:27 +00:00
Claude
2332384897 feat(nip46): use a dedicated transport key so the bunker doesn't reveal the user
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
2026-07-17 15:18:27 +00:00
Claude
922a5841d0 fix(nip46): make forgetting a client complete and immediate
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
2026-07-17 15:18:26 +00:00
Claude
cd3e1353e7 docs(nip46): correct stale coordinate format in KDoc
Two doc comments still described the pre-namespacing coordinate
`nip46:<clientPubKey>`; the actual key is `nip46:<signerPubKey>:<clientPubKey>`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:26 +00:00
Claude
c20cc2b514 refactor: move the generic signer-permission layer out of napplet/
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
2026-07-17 15:18:26 +00:00
Claude
d06b1c7f2c feat(nip46): rate-limit flooding clients + clean up on logout
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
2026-07-17 15:18:25 +00:00
Claude
fe4e881df6 fix(nip46): audit fixes — data race, cancellation, write amplification
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
2026-07-17 15:18:25 +00:00
Claude
994bdae2d1 feat(amethyst): keep the signer answering in the background
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
2026-07-17 15:18:24 +00:00
Claude
9667168a6a feat: persist connected NIP-46 client metadata + relays
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
2026-07-17 15:18:24 +00:00
Claude
dea711596e feat: namespace NIP-46 grants by signer + revoke on logout
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
2026-07-17 15:18:24 +00:00
Claude
d46ac987e8 feat(quartz): harden signer service, add logout + end-to-end loopback test
- 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
2026-07-17 15:18:23 +00:00
Claude
9f32f72a41 feat(amethyst): redesign the Nostr Signer screen around a scannable hero
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
2026-07-17 15:18:22 +00:00
Claude
c3a1762637 feat(amethyst): act as a NIP-46 signer for other apps
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
2026-07-17 15:18:21 +00:00
Claude
81aea57ddb feat(commons): ledger-backed NIP-46 authorizer for Connected Apps
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
2026-07-17 15:18:20 +00:00
Claude
dc0840264d refactor(cli): drive amy bunker through the shared quartz signer core
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
2026-07-17 15:18:20 +00:00
Claude
b1227fa7cb feat(quartz): add signer-side NIP-46 processor, service and URI codec
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
2026-07-17 15:18:20 +00:00
Vitor Pamplona
3d99f10058 Merge pull request #3611 from vitorpamplona/claude/search-settings-icon-expand-zxnm00
Refactor settings search field to use Material 3 TextField
2026-07-17 10:35:17 -04:00
Claude
6f86c035b0 feat: filter settings in place with a persistent search pill
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
2026-07-17 14:12:04 +00:00
Vitor Pamplona
86cecc7038 Merge pull request #3610 from vitorpamplona/claude/save-button-switcher-colors-7vnmm5
Fix onPrimary contrast and add color scheme preview tool
2026-07-17 10:00:17 -04:00
Vitor Pamplona
b39db9345d Merge pull request #3609 from vitorpamplona/claude/post-opening-issue-iyrmrs
Tolerate bare URL icons and keyless channels in CORD-05 invites
2026-07-17 09:54:42 -04:00
Claude
a3f42ee45b refactor(theme): converge filled controls on default colors, drop filledAccent
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
2026-07-17 13:52:39 +00:00
Claude
8b3dfafc20 chore(theme): trim color preview to the by-role view, taller
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
2026-07-17 13:37:32 +00:00
Claude
7d7fe0899e fix(concord): open CORD-05 invites with keyless channels and string icons
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
2026-07-17 13:33:37 +00:00
Claude
9c7cc8b971 fix(theme): contrast-pick onPrimary so filled buttons/FABs stop washing out
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
2026-07-17 13:11:42 +00:00
Claude
d11fb74e4f feat: use Material 3 DockedSearchBar for settings search
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
2026-07-17 13:04:48 +00:00
Vitor Pamplona
c9fbe07d37 Merge pull request #3607 from davotoula/fix/live-hls-behind-window-recovery
Keep live HLS streams playing; cache only proven on-demand HLS
2026-07-17 08:47:51 -04:00
David Kaspar
0479c8f3ec Merge pull request #3603 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-17 09:02:17 +01:00
davotoula
c7d4a72a34 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-17 07:57:11 +00:00
davotoula
6df7fbdd72 update cs,sv,de,pt 2026-07-17 08:51:02 +01:00
davotoula
018c00ffe5 fix(playback): keep live HLS playing; cache only proven on-demand HLS
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.
2026-07-17 08:22:36 +01:00
Claude
00ea0f0a01 feat(theme): keep the by-role color-pairs @Preview alongside old-vs-new
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
2026-07-17 04:18:30 +00:00
Claude
e581f9c6da feat: collapse settings search into an expandable top-bar icon
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
2026-07-17 04:17:29 +00:00
Claude
d76c803e1d feat(theme): old-vs-new styleguide @Preview for the color normalization
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
2026-07-17 04:03:03 +00:00
Claude
f937925fbf fix(theme): route new UI-Preferences toggle through AmethystSwitch
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
2026-07-17 04:03:01 +00:00
Claude
1e94e6ab29 Merge remote-tracking branch 'origin/main' into claude/save-button-switcher-colors-7vnmm5 2026-07-17 03:54:54 +00:00
Vitor Pamplona
f1162037f9 Merge pull request #3606 from vitorpamplona/claude/modernize-ui-preferences-56auhn
Redesign settings screen with segmented controls and sections
2026-07-16 23:54:05 -04:00
Claude
069376f2ac fix(amethyst): separate settings cards by tone, not a hard border
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
2026-07-17 03:46:40 +00:00
Claude
ef6c9bd5c4 fix(amethyst): compile errors in UI Preferences tiles
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
2026-07-17 02:42:55 +00:00
Claude
914d24c672 Merge remote-tracking branch 'origin/main' into claude/save-button-switcher-colors-7vnmm5 2026-07-17 02:22:47 +00:00
Claude
1430aeec97 feat(theme): @Preview of every fg/bg color pair, dark + light
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
2026-07-17 02:22:41 +00:00
Vitor Pamplona
95ed961c08 Merge pull request #3604 from vitorpamplona/claude/bitchat-ephemeral-interop-8epkek
Add geohash location chat support (Bitchat interop Phase 2)
2026-07-16 22:22:30 -04:00
Claude
010684c6ae chore(amethyst): full-screen @Preview for UI Preferences
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
2026-07-17 02:21:29 +00:00
Claude
5bb0140543 fix(amethyst): polish UI Preferences from the design audit
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
2026-07-17 02:21:29 +00:00
Claude
946303c2e5 feat(amethyst): make font tiles preview each option live
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
2026-07-17 02:21:29 +00:00
Claude
ece16e8373 feat(amethyst): modernize UI Preferences with grouped in-screen options
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
2026-07-17 02:21:29 +00:00
Vitor Pamplona
b8e2f7d314 Merge pull request #3605 from vitorpamplona/claude/agp-download-revert-utzlot
Pre-seed Gradle distribution in web sandbox to unblock ./gradlew
2026-07-16 22:20:24 -04:00
Claude
422a51cf33 style: fix import ordering in FollowingGeohashChatSubAssembler
spotless orders java.* after kotlinx.* in this project's ktlint layout;
move the ConcurrentHashMap import below the kotlinx imports.
2026-07-17 02:18:30 +00:00
Claude
acbd0f4ac8 refactor(hooks): drop the pre-push spotless infra-skip
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
2026-07-17 02:16:35 +00:00
Claude
e7f883f08b fix(hooks): seed Gradle distribution from a verified mirror in web sandbox
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
2026-07-17 02:11:56 +00:00
Claude
e2743ed0b6 fix: address pre-merge audit findings for geohash chat
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
2026-07-17 02:10:18 +00:00
Claude
bb38525fc6 feat(theme): deepen accent fill for Save button and switches on dark
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
2026-07-17 01:57:59 +00:00
Claude
fcf772d505 feat: persist the geohash nickname so it survives app restarts
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
2026-07-17 01:45:01 +00:00
Claude
d07bac7489 feat: geohash composer polish + hide the room's own cell in bubbles
- 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
2026-07-17 01:21:21 +00:00
Claude
bd6635b311 Revert "fix(hooks): treat Gradle distribution download failure as infra skip"
This reverts commit a2ee1880a6.
2026-07-17 00:40:27 +00:00
Claude
4c9f599a79 Merge remote-tracking branch 'origin/main' into claude/bitchat-ephemeral-interop-8epkek 2026-07-17 00:24:33 +00:00
Vitor Pamplona
bc34a4cf59 Merge pull request #3601 from vitorpamplona/claude/amethyst-mobile-colors-kgdfhn
Make theme colors follow selected accent, not fixed purple
2026-07-16 20:22:47 -04:00
Vitor Pamplona
c2b2be322d Merge branch 'main' into claude/amethyst-mobile-colors-kgdfhn 2026-07-16 20:15:27 -04:00
Claude
22a3bb31d5 Merge remote-tracking branch 'origin/main' into claude/bitchat-ephemeral-interop-8epkek 2026-07-17 00:10:58 +00:00
Vitor Pamplona
a094767aff Updates AGP 2026-07-16 20:03:18 -04:00
Claude
011b2ee862 fix: use the accent's deep tone for the Following shield
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
2026-07-16 23:58:41 +00:00
Claude
420fdfea53 Merge remote-tracking branch 'origin/main' into claude/bitchat-ephemeral-interop-8epkek
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
2026-07-16 23:52:28 +00:00
Vitor Pamplona
17e45d4e68 Merge pull request #3600 from vitorpamplona/claude/amethyst-note-ui-refactor-n36nvd
refactor: converge rich-text rendering across Android + Desktop into commons
2026-07-16 19:49:51 -04:00
Claude
2af730e5f7 feat: give the Following badge a vivid accent shield with a white figure
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
2026-07-16 23:46:29 +00:00
Claude
374fc3fc8b feat: nickname affordance under the anonymous geohash avatar
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
2026-07-16 23:44:26 +00:00
Vitor Pamplona
0d219e219c Merge pull request #3602 from vitorpamplona/claude/invite-not-working-x1mhrf
Improve Concord invite error messaging and Gradle infra detection
2026-07-16 19:44:05 -04:00
Claude
0fa1a26c26 fix(amethyst): restore no-preview suppression of invoice/withdraw UI
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
2026-07-16 23:42:26 +00:00
Claude
f11dcd74bc feat: collapse geohash composer identity into a tappable avatar
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
2026-07-16 23:38:31 +00:00
Claude
a2ee1880a6 fix(hooks): treat Gradle distribution download failure as infra skip
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
2026-07-16 23:38:14 +00:00
Claude
9eb3b75615 fix(concord): clarify invite failure copy for token/decrypt mismatch
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
2026-07-16 23:33:30 +00:00
Claude
8891e3a1a7 feat: match the Following badge to the FilledTonalButton palette
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
2026-07-16 23:33:01 +00:00
Claude
69177af390 feat: geohash chat polish — auto teleport, city names, relay list, own-name
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
2026-07-16 23:19:30 +00:00
Claude
dd03307ac3 fix: solid accent container tones + keep Following its brand purple
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
2026-07-16 22:59:40 +00:00
Claude
853d5e1b25 fix: address accent regressions — teal tonal buttons, FAB glyph, follow badge
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
2026-07-16 22:45:20 +00:00
Claude
c90353d944 refactor(amethyst): adopt the shared ClickableUrl for plain links
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
2026-07-16 22:43:58 +00:00
Claude
d43b9c121f feat: dedicated Location Channels list screen in the drawer
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
2026-07-16 22:32:45 +00:00
Claude
19f5065af4 refactor(commons): shared ClickableUrl/ClickableEmail; Desktop drops its duplicate
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
2026-07-16 22:24:27 +00:00
Claude
dddfa1d380 Merge remote-tracking branch 'origin/main' into claude/amethyst-mobile-colors-kgdfhn 2026-07-16 22:21:55 +00:00
Claude
9eb827fed6 feat(commons): route url/email/phone through the platform strategy
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
2026-07-16 22:12:28 +00:00
Claude
3bd490b58c feat: pin followed geohash channels to the bottom nav
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
2026-07-16 22:11:44 +00:00
Claude
901650f4a9 feat: make NIP-05 and image hash-verify symbols follow the accent
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
2026-07-16 21:56:29 +00:00
Claude
51f005d22f refactor(commons): unify the rich-text parser cache across Android + Desktop
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
2026-07-16 21:56:12 +00:00
Claude
2725a93569 fix: keep the liked heart and reposted check their semantic colours
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
2026-07-16 21:50:01 +00:00
Claude
269ae95b4b feat(desktop): render rich text through the shared core; delete the Desktop fork
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
2026-07-16 21:43:56 +00:00
Claude
067e3039ce feat(geohash-chat): polish the builder + teleport screens
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
2026-07-16 21:39:43 +00:00
Claude
479c20a2e6 refactor: make commons action icons tintable and follow the accent
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
2026-07-16 21:36:05 +00:00
Vitor Pamplona
6d9a7e8258 Merge pull request #3599 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-16 17:32:42 -04:00
Claude
435ad29f11 feat(commons): share hashtag icons + emoji; route Android RichTextViewer through the shared core
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
2026-07-16 21:30:54 +00:00
Claude
92c3e06d27 feat(geohash-chat): reuse the shared composer (EditFieldRow)
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
2026-07-16 21:20:21 +00:00
Claude
424160200b feat: show colored swatches in the accent-color picker
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
2026-07-16 21:13:32 +00:00
Claude
834dfc9fc6 feat(amethyst): Android adapter for the shared rich-text contract
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
2026-07-16 21:10:09 +00:00
Claude
e61b3fbebd refactor: drop dead purple constants and fix FAB glyph contrast
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
2026-07-16 21:08:28 +00:00
Claude
837d1ddf41 feat(geohash-chat): teleport marker + anonymous reaction undo
- 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
2026-07-16 20:59:54 +00:00
Claude
abdb3be021 refactor: route ad-hoc status colors through existing semantic tokens
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
2026-07-16 20:51:32 +00:00
Claude
c8deb0f227 feat(commons): prototype shared rich-text rendering contract
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
2026-07-16 20:45:58 +00:00
Claude
ec466d8bc0 feat: neutralize violet-tinted surface greys in the mobile theme
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
2026-07-16 20:32:21 +00:00
Claude
e9f0c601c2 feat: make unread-dot and selected-reaction highlight follow the accent
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
2026-07-16 19:33:01 +00:00
vitorpamplona
5c19017ef6 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-16 19:27:09 +00:00
Vitor Pamplona
9291c9770a Merge pull request #3597 from vitorpamplona/claude/limits-message-support-g5plrd
Add NIP-22 LIMITS message support with per-relay tracking
2026-07-16 15:24:50 -04:00
Claude
b5dc667ef9 refactor(geohash-chat): render the room through the shared chat screen
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
2026-07-16 19:01:26 +00:00
Claude
a068eaf721 docs(commons): add rich-text restructure as gating Phase 0
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
2026-07-16 18:49:57 +00:00
Claude
ccecc5edd4 style: spotlessApply on LocalPreferences after merging main
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
2026-07-16 18:49:12 +00:00
Claude
2a3a4437ca Merge remote-tracking branch 'origin/main' into claude/limits-message-support-g5plrd 2026-07-16 18:48:35 +00:00
Claude
0f07d63d7a docs(commons): read note lookups through the cache port, not lambdas
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
2026-07-16 18:47:31 +00:00
Vitor Pamplona
5f6fde339a Merge pull request #3598 from vitorpamplona/claude/pull-notification-toggle-ezy8l6
Add global master switch for always-on notification service
2026-07-16 14:39:48 -04:00
Claude
654dd2b1b3 docs(commons): review plan for extracting ui.note rendering to commons
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
2026-07-16 18:39:05 +00:00
Claude
452a1c8186 fix: harden LIMITS parsing and dedup, per audit
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
2026-07-16 18:37:35 +00:00
Claude
ae81c93c04 feat: global background-notification-service master + per-account participation
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
2026-07-16 18:37:28 +00:00
Claude
9b7aca77a6 docs: add relay LIMITS status + roadmap plan
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
2026-07-16 18:28:27 +00:00
Claude
041abf9e37 refactor: reconcile duplicate LIMITS code and rename client accessory
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
2026-07-16 17:21:00 +00:00
Claude
9ad936ea08 fix(geohash-chat): highlight own reactions under the per-cell identity
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
2026-07-16 16:27:50 +00:00
Claude
51e0479c29 feat: derive Material3 container colors from the selected accent
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
2026-07-16 16:11:31 +00:00
Claude
9daa0b3e48 feat: cache and expose relay LIMITS per relay
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
2026-07-16 15:39:04 +00:00
Claude
94c9dc7f77 feat: parse relay LIMITS message (NIP relay limits)
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
2026-07-16 15:23:25 +00:00
Vitor Pamplona
7143d81e15 Activating parallel sync from the new AGP 2026-07-16 10:03:30 -04:00
Claude
1a160b3490 fix(geohash-chat): anonymous-safe reactions + shared reply rendering
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
2026-07-16 13:57:18 +00:00
Vitor Pamplona
5085bf3f04 Merge pull request #3595 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-16 09:50:37 -04:00
vitorpamplona
9d0cc8ad56 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-16 13:40:26 +00:00
Vitor Pamplona
829994076f Merge pull request #3596 from vitorpamplona/claude/nip-29-compliance-review-ngzzaw
NIP-29: subgroups, custom roles, timeline refs, invite links + relay-signed hardening
2026-07-16 09:37:28 -04:00
Claude
37220306e9 fix(nip29): reject non-relay-signed group state; share one-tap invite links
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
2026-07-16 13:18:51 +00:00
Vitor Pamplona
3a112ed173 Merge pull request #3594 from nrobi144/fix/desktop-auth-tier1-coldboot-race
fix(desktop): auto-approve pending relay AUTH once the DM-inbox (kind:10050) set loads
2026-07-16 07:05:06 -04:00
nrobi144
d1ed9d071b fix(desktop): auto-approve pending AUTH once the DM-inbox relay set loads
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>
2026-07-16 13:31:18 +03:00
Claude
0e8239b22f fix(nip29): audit fixes — subgroup edit safety, subscription load, roles
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
2026-07-16 04:19:25 +00:00
Claude
c280c14b3c feat(nip29): subgroup authoring — parent-group picker in the edit form
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
2026-07-16 02:45:58 +00:00
Claude
357d28ad8a feat(geohash-chat): reactions, zaps and reply on messages
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
2026-07-16 02:44:50 +00:00
Claude
c236474f88 feat(nip29): timeline refs, custom roles, subgroup nav, naddr invites
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
2026-07-16 02:29:10 +00:00
Claude
67e5bbb1c7 fix(geohash-chat): right-align own messages in both posting modes
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
2026-07-16 02:11:44 +00:00
Vitor Pamplona
99f28ee1ca Merge pull request #3593 from vitorpamplona/claude/nip-51-compliance-review-vwhyvn
Add hashtag muting support via NIP-51
2026-07-15 22:07:00 -04:00
Claude
3ce9ddd696 refactor(geohash-chat): own the anonymous identity on Account
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
2026-07-16 02:02:09 +00:00
Claude
bea342a731 chore: remove dead kind:10011 GalleryListEvent
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
2026-07-16 02:00:44 +00:00
Vitor Pamplona
c39ebc5c93 Merge pull request #3591 from vitorpamplona/claude/relay-auth-request-repeat-0frbqp
Fix AUTH grant persistence for long relay URLs
2026-07-15 21:58:54 -04:00
Claude
956385843e refactor(geohash-chat): load room via shared channel feed + datasource
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
2026-07-16 01:58:45 +00:00
Claude
abc6732286 feat: support NIP-51 mute-list hashtag ("t") entries
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
2026-07-16 01:51:48 +00:00
Claude
e24eec9ca6 feat(nip29): subgroup hierarchy (parent/child) for relay groups
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
2026-07-16 01:51:40 +00:00
Claude
72a70ab635 test: make AUTH approval key test OS-independent
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
2026-07-16 01:38:54 +00:00
Vitor Pamplona
c0d816ad86 Merge pull request #3590 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-15 21:34:49 -04:00
vitorpamplona
01fbc34e5e chore: sync Crowdin translations and seed translator npub placeholders 2026-07-16 01:32:20 +00:00
Vitor Pamplona
67efac7972 Merge pull request #3592 from vitorpamplona/claude/amethyst-invite-link-hang-w0tagl
CORD-05: Distinguish invite redemption failures for better UX
2026-07-15 21:29:52 -04:00
Claude
b3fce19ff7 fix: persist desktop AUTH approvals for long relay URLs
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
2026-07-16 01:25:34 +00:00
Claude
a18b37e8ca feat(geohash-chat): opt-in "post as my real account"
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
2026-07-16 01:24:59 +00:00
Claude
ae9f4a0def fix(concord): resolve invite coordinate per CORD-05 (honor revocation)
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
2026-07-16 01:24:37 +00:00
Claude
a5c6eeaf03 feat(geohash-chat): map teleport + composer teleport toggle (Phase E)
- 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
2026-07-16 01:21:19 +00:00
Vitor Pamplona
615aa221e1 Merge pull request #3589 from vitorpamplona/claude/coroutine-dispatcher-access-fix-42chwt
Add IO dispatcher import to BlossomServerListState
2026-07-15 21:14:38 -04:00
Claude
57933dc64c feat(geohash-chat): Home "live near you" bubble (Phase D) + unify relay directory
- 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
2026-07-16 01:12:06 +00:00
Claude
6735c0b7a6 fix(commons): import kotlinx.coroutines.IO for native Dispatchers.IO access
`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
2026-07-16 01:10:31 +00:00
Claude
6657957677 feat(geohash-chat): native Messages rooms via LocalCache (Phase A+B)
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
2026-07-16 01:07:48 +00:00
Claude
389d750ccc fix(concord): don't hang forever on an unopenable invite link
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
2026-07-16 00:49:15 +00:00
Claude
fba41ecd8e feat(geohash-chat): builder + "location channel" chooser entry (Phase C)
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
2026-07-16 00:45:27 +00:00
Vitor Pamplona
f586656223 Merge pull request #3585 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-15 20:36:56 -04:00
Vitor Pamplona
a4931ce295 Merge pull request #3587 from vitorpamplona/claude/nip17-dm-relay-desktop-4shxdy
Desktop NIP-17: deliver DMs & reactions to the recipient's DM relays, downloaded up front
2026-07-15 20:36:42 -04:00
Claude
275df2ae9c fix(desktop): drain kind:10002 back-fill fully and close on EOSE
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
2026-07-16 00:31:18 +00:00
Claude
c688a3e256 docs: add geohash-list (kind 10081) management UI to the location-chats plan
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
2026-07-16 00:29:59 +00:00
Claude
a1b4d33c8f docs: plan to make location (geohash) chats first-class in Amethyst
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
2026-07-16 00:21:51 +00:00
Claude
2d311c9324 Merge remote-tracking branch 'origin/main' into claude/nip17-dm-relay-desktop-4shxdy
# Conflicts:
#	desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt
#	desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt
2026-07-16 00:20:28 +00:00
Claude
931236e719 feat(desktop): back-fill kind:10002 on profile load and read DM inbox from outbox relays
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
2026-07-16 00:15:28 +00:00
vitorpamplona
2b30a85dce chore: sync Crowdin translations and seed translator npub placeholders 2026-07-16 00:13:55 +00:00
Vitor Pamplona
c95270fca3 Merge pull request #3583 from vitorpamplona/claude/blossom-server-loading-sync-n1l3sw
Sync Blossom media server list (kind 10063) across clients
2026-07-15 20:11:48 -04:00
Claude
152fc76bc0 fix(desktop): fetch account config (incl. blossom) from outbox relays
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
2026-07-16 00:03:38 +00:00
Vitor Pamplona
13cbcf41d6 Merge pull request #3586 from vitorpamplona/claude/nip29-message-pinning-elzck9
NIP-29 message pinning: UI bar, moderation events, and relay group integration
2026-07-15 19:56:53 -04:00
Claude
c5ad932c1b feat(desktop): make DM relay-list prewarm viewport-driven, not a full sweep
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
2026-07-15 23:54:07 +00:00
Claude
a347889c57 fix(nip29): audit fixes for pinned-message jump and back-fill
- 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.
2026-07-15 23:52:20 +00:00
Claude
f727c226c0 refactor(desktop): provide blossom servers via LocalBlossomServers
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
2026-07-15 23:44:49 +00:00
Vitor Pamplona
6a28057890 Merge pull request #3584 from vitorpamplona/claude/chatcompose-timeago-padding-v0yypr
Skip empty reaction chip rows on messages without engagement
2026-07-15 19:36:31 -04:00
Vitor Pamplona
a906d4ee58 Merge pull request #3580 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-15 19:35:35 -04:00
Vitor Pamplona
f98799540b Merge pull request #3582 from vitorpamplona/claude/channel-list-enhancements-wktw8t
Concord: richer channel rows — previews, unread counts, facepiles, live typing
2026-07-15 19:35:23 -04:00
Claude
2eaaa270c3 refactor(desktop): read blossom servers from the account, not the cache
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
2026-07-15 22:51:57 +00:00
Claude
ca01e98d60 fix(chat): drop the dead footer gap on messages with no timestamp or chips
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
2026-07-15 22:34:31 +00:00
Vitor Pamplona
18f1d87492 fix: Concord unread badge + last-message previews ignore thread replies
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>
2026-07-15 18:24:45 -04:00
Claude
b8966049b8 fix: Concord channel rows stuck on "No messages yet" despite having messages
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
2026-07-15 21:59:25 +00:00
Claude
1896d77bd9 refactor(desktop): drop the global blossomServers pref, read per-account
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
2026-07-15 21:40:54 +00:00
vitorpamplona
65d16d185b chore: sync Crowdin translations and seed translator npub placeholders 2026-07-15 21:25:50 +00:00
Claude
9ee6f0dbde feat(nip29): message pinning for relay groups
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.
2026-07-15 21:24:37 +00:00
Claude
dbb8cc522e feat: make Concord channel rows feel alive — facepile, live typing, animated badge
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
2026-07-15 21:24:25 +00:00
Claude
7ecb1af13b feat: enrich Concord channel list rows with last message + unread counts
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
2026-07-15 21:24:25 +00:00
Vitor Pamplona
2be4929a97 Merge pull request #3581 from vitorpamplona/claude/kotlin-coroutines-opt-in-t940p3
Fix reactive state handling in Concord entry display
2026-07-15 17:23:21 -04:00
Claude
0d58d94ef4 fix: resolve CI lint error and coroutines opt-in warnings
- 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
2026-07-15 21:13:23 +00:00
Claude
5c858a3a29 feat(desktop): download NIP-17 DM relay lists proactively and back the User model
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
2026-07-15 20:59:10 +00:00
Claude
e322c939cc feat(desktop): load Blossom servers from kind 10063 like mobile
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
2026-07-15 20:56:57 +00:00
Claude
0ae8316e3b feat(geohash-chat): render messages through the shared chat bubble UI
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
2026-07-15 20:48:00 +00:00
Claude
8abfb56440 Merge remote-tracking branch 'origin/main' into claude/bitchat-ephemeral-interop-8epkek 2026-07-15 20:38:14 +00:00
Claude
49ab002d39 feat(geohash-chat): derive the anonymous identity from the account, unlinkably
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
2026-07-15 20:34:12 +00:00
Vitor Pamplona
000d01c44e Merge pull request #3579 from vitorpamplona/claude/modernize-chat-rendering-kigcsh
Modernize chat message rendering
2026-07-15 16:27:22 -04:00
Vitor Pamplona
d37e88f620 Merge pull request #3574 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-15 16:24:43 -04:00
vitorpamplona
6139923561 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-15 20:10:21 +00:00
Claude
284d7b608a Merge remote-tracking branch 'origin/main' into claude/modernize-chat-rendering-kigcsh
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt
2026-07-15 20:08:42 +00:00
Vitor Pamplona
efa74169b7 Merge pull request #3578 from vitorpamplona/claude/nip29-group-preloading-18kc3e
Prefetch recent group chat messages for list previews
2026-07-15 16:08:19 -04:00
Vitor Pamplona
48358603bc Merge pull request #3577 from vitorpamplona/claude/concord-image-display-issue-6qba0w
Extract imeta URL rendering logic to shared utility
2026-07-15 16:06:05 -04:00
Claude
56e3ab5b8b feat(concord): stamp the wrap's seen-on relays onto the decrypted rumor
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
2026-07-15 20:00:47 +00:00
Claude
00e5443247 fix(concord): only fill content from imeta URLs when content is blank
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
2026-07-15 19:53:36 +00:00
Claude
c740a346ae fix(chat): timestamp tap opens delivery (not format toggle); relays for Concord
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
2026-07-15 19:47:38 +00:00
Claude
bf9fb48205 fix(chat): make the timestamp a reliable "where from" tap target + chip padding
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
2026-07-15 19:34:08 +00:00
Claude
8c9c6e9e90 fix(nip29): preload recent group chat, not just the roster
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
2026-07-15 19:32:12 +00:00
Claude
3892ade0a3 fix(concord): render imeta-only images in the chat feed too
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
2026-07-15 19:26:13 +00:00
Claude
80ae26b4cf Merge remote-tracking branch 'origin/main' into claude/modernize-chat-rendering-kigcsh 2026-07-15 18:52:53 +00:00
Vitor Pamplona
21b98b3ebf Merge pull request #3576 from vitorpamplona/claude/bottom-nav-item-selection-xt85y9
Bottom nav: pin individual chats/groups + redesigned setup screen
2026-07-15 14:51:19 -04:00
Claude
029f012fe8 feat(nav): pinned group tabs behave as bottom-nav roots
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
2026-07-15 18:40:16 +00:00
Claude
7dc033ec79 fix(ui): show NIP-29 group names in the picker, not their ids
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
2026-07-15 18:33:19 +00:00
Claude
e1a9a5cd7e fix(ui): keep drag alive across swaps + equalize Add/Added pill height
- 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
2026-07-15 18:12:19 +00:00
Claude
8212e52a50 fix(ui): expand inner options straight down, not diagonally
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
2026-07-15 16:36:09 +00:00
Claude
0bbda1a92e docs: plan for Bitchat geohash chat interop (phase 1 shipped, DM follow-up)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
2026-07-15 16:18:03 +00:00
Claude
5a0f3c8ff1 feat(amethyst): in-app geohash location chat (Bitchat interop)
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
2026-07-15 16:17:54 +00:00
Claude
b6729bec00 feat(cli): amy geochat listen/send for Bitchat location-channel interop
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
2026-07-15 16:17:36 +00:00
Claude
ba93dd3564 feat(commons): geohash-relay directory for Bitchat location-channel routing
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
2026-07-15 16:17:23 +00:00
Claude
7df2e240ba feat(quartz): Bitchat geohash chat protocol (kind 20000/20001) + per-geohash identity
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
2026-07-15 16:17:08 +00:00
Claude
9180061655 feat(ui): drag-reorder inside the preview bar + real favicons
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
2026-07-15 16:09:01 +00:00
Claude
20fd3e416c Merge remote-tracking branch 'origin/main' into claude/modernize-chat-rendering-kigcsh 2026-07-15 16:03:31 +00:00
Vitor Pamplona
bfd8ef406c Merge pull request #3575 from vitorpamplona/claude/messages-fab-group-selection-mkddvi
Replace Messages FAB speed-dial with full-screen conversation chooser
2026-07-15 12:01:57 -04:00
Claude
f592cbc2ed fix: refine new-conversation chooser copy and audit fixes
- 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.
2026-07-15 15:47:53 +00:00
Claude
9892ba495b feat(ui): redesign the bottom-bar setup screen — live preview + tab chips
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
2026-07-15 15:33:29 +00:00
Claude
5e7fffe08e feat: lean, color-coded redesign of the new-conversation chooser
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.
2026-07-15 15:15:38 +00:00
Claude
7b93acee0c Merge remote-tracking branch 'origin/main' into claude/modernize-chat-rendering-kigcsh 2026-07-15 15:14:21 +00:00
Vitor Pamplona
7743f36ac6 Merge pull request #3573 from vitorpamplona/claude/concord-image-display-ou01vs
Support NIP-92 imeta attachments in chat messages
2026-07-15 11:11:41 -04:00
Claude
bb9f0c0cb3 fix(concord): render imeta-only chat images and send thumbhash
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
2026-07-15 14:54:48 +00:00
Claude
df313970b1 refactor: extract bottom-bar settings state, share the entry resolver, read-only picker
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
2026-07-15 14:53:20 +00:00
Claude
28fecb9c78 fix(chat): hide delivery ticks on old, untracked messages
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
2026-07-15 14:49:29 +00:00
Vitor Pamplona
7de5e75856 Merge pull request #3572 from vitorpamplona/claude/kotlin-compiler-warnings-g10py4
Replace nullable safe calls with immutable map operations
2026-07-15 10:48:30 -04:00
Claude
d20cbe0218 fix(chat): don't show an empty "Message Delivery" popup
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
2026-07-15 14:44:31 +00:00
Vitor Pamplona
c0f95a5139 Merge pull request #3571 from vitorpamplona/fix/strictmode-loopback-cleartext
fix(strictmode): stop CleartextNetworkViolation noise from the Tor SOCKS proxy
2026-07-15 10:41:37 -04:00
Claude
f8f8dd59c2 fix: use non-deprecated PersistentMap.putting()/removing() in RelayAuthenticator
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Myhus2x1c3BSWCtjenSmkf
2026-07-15 14:38:39 +00:00
Claude
cba2084a80 fix(chat): color the zap amount chip's number bitcoin orange
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
2026-07-15 14:37:27 +00:00
Vitor Pamplona
ac60b294ed fix(strictmode): stop CleartextNetworkViolation noise from the Tor SOCKS proxy
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>
2026-07-15 10:33:14 -04:00
Claude
ed0df81ccd feat: replace Messages FAB speed-dial with a full-screen conversation chooser
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
2026-07-15 14:31:39 +00:00
Claude
42563ef1de refactor(chat): retire the tap-to-expand "complete UI" detail row
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
2026-07-15 14:21:47 +00:00
Claude
7506ca599e fix: resolve Kotlin compiler warnings in commons and amethyst
- 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
2026-07-15 14:16:35 +00:00
Claude
d12093ec51 feat: pick individual chats/groups and favorites for the bottom nav bar
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
2026-07-15 13:57:55 +00:00
Vitor Pamplona
dfde5336f1 Merge pull request #3570 from vitorpamplona/claude/messages-label-styling-dbp4ov
feat: style Messages room-type labels as Concord-style pills
2026-07-15 09:57:27 -04:00
Claude
e2011056a2 fix(chat): mirror the timestamp to the inner edge on own bubbles
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
2026-07-15 13:57:13 +00:00
Claude
c00bd5bc27 feat: style Messages room-type labels as Concord-style pills
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".
2026-07-15 13:43:28 +00:00
Claude
6d107e6676 fix(chat): align the zap chip to the bubble border and show it while zapping
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
2026-07-15 13:32:39 +00:00
Vitor Pamplona
f59b1e5c52 Merge pull request #3567 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-15 09:20:12 -04:00
Vitor Pamplona
18e6e83a3a Merge pull request #3552 from nrobi144/feat/desktop-note-scheduling
feat(desktop): note scheduling + NIP-37 draft sync (shared scheduling in commons)
2026-07-15 09:20:03 -04:00
Claude
11acde747d feat(chat): compact two-stage long-press sheet with wrapping reactions
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
2026-07-15 12:58:02 +00:00
vitorpamplona
46f0a84f80 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-15 12:16:57 +00:00
Vitor Pamplona
7680dfbce6 Merge pull request #3568 from vitorpamplona/claude/marmot-group-icons-tlcwa1
Add MIP-01 v2 group avatar encryption and upload support
2026-07-15 08:14:25 -04:00
davotoula
73044746c8 update cs,sv,de,pt 2026-07-15 11:42:37 +01:00
nrobi144
3f56d177d8 feat(desktop): note scheduling + NIP-37 opt-in draft sync
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>
2026-07-15 11:26:12 +03:00
Claude
32d629cb26 test(geode): update RelayAuthenticator callers for the interactive flag
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
2026-07-15 05:06:17 +00:00
Claude
5069ff2c7a feat(chat): delivery ticks for own Concord channel messages
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
2026-07-15 04:55:34 +00:00
Claude
a2a3a60ee7 feat(chat): bring Concord moderation and minichat threads into the modern chat UI
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
2026-07-15 04:51:10 +00:00
Claude
ce727351b8 Merge remote-tracking branch 'origin/main' into claude/modernize-chat-rendering-kigcsh
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt
2026-07-15 04:39:47 +00:00
Vitor Pamplona
51607ff4cf Merge pull request #3566 from vitorpamplona/claude/concord-quartz-amethyst-plan-0oy779
Concord: end-to-end-encrypted communities (CORD-01…07) on Android + CLI
2026-07-14 23:14:36 -04:00
Claude
b0d18b2983 fix(concord): import kotlin.jvm.JvmInline so ConcordPermissions compiles on iOS
`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
2026-07-15 02:29:18 +00:00
Claude
a47fd206e6 Merge remote-tracking branch 'origin/main' into claude/concord-quartz-amethyst-plan-0oy779
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt
#	cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt
2026-07-15 02:12:18 +00:00
Vitor Pamplona
22c2305e93 Merge pull request #3565 from vitorpamplona/claude/public-chat-creation-fab-p9s72t
Add floating action button to create new public chats
2026-07-14 22:00:44 -04:00
Vitor Pamplona
984cafd1fb fix(concord): community title fallback + round channel-create FAB
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>
2026-07-14 22:00:04 -04:00
Vitor Pamplona
edbc910941 fix(concord): upload community icon/banner off the main thread
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>
2026-07-14 22:00:04 -04:00
Vitor Pamplona
5b54eb88a5 fix(concord): notify on reactions to my Concord messages
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>
2026-07-14 21:58:48 -04:00
Vitor Pamplona
08c1d1d539 fix(concord): show the quoted parent on kind-9 chat replies in NoteCompose
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>
2026-07-14 21:58:48 -04:00
Vitor Pamplona
4ec44ad241 feat(concord): harvest full member roster from bounded channel history
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>
2026-07-14 21:58:48 -04:00
Claude
90ab864362 test(marmot): make test_17 assert the image commit via a post-image message
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
2026-07-15 01:58:29 +00:00
Claude
a06cdd82fe feat(concord): rich, clickable relay rows in the community create/edit form
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
2026-07-15 01:54:32 +00:00
Claude
b37a0ae3bb feat: add FAB to create a public chat on the Public Chats screen
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
2026-07-15 01:44:21 +00:00
Claude
d0a817a607 style(concord): drop bold top-bar titles to match the app's default weight
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
2026-07-15 01:42:01 +00:00
Claude
e4c52a5553 test(marmot): refresh interop harness for restructured whitenoise-rs
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
2026-07-15 01:21:40 +00:00
Claude
22582d6a97 feat(cli): amy group set-image/clear-image + whitenoise interop regression test
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
2026-07-15 01:07:00 +00:00
Claude
3950323ba3 fix(concord): audit fixes — memory, folding, concurrency, notifications, UI
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
2026-07-15 00:37:13 +00:00
Claude
de2e71dad0 fix(marmot): match mdk/whitenoise MIP-01 v2 group-image scheme for interop
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
2026-07-15 00:32:32 +00:00
Claude
5753da7be3 fix: address Marmot group-icon audit findings (media_type, multi-server fetch, recomposition, placeholder)
- 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
2026-07-14 23:55:40 +00:00
Claude
b6a238b71a fix(concord): mute the community-name pill now that the logo is the avatar
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
2026-07-14 23:50:27 +00:00
Vitor Pamplona
20479ea0ef Merge pull request #3564 from vitorpamplona/claude/eager-cannon-ykmjry
Fix NoteHeaderFirstRowDensityPreview to use tryEmit for Flow
2026-07-14 19:40:52 -04:00
Claude
0acf5359c3 fix: avoid StateFlow.value write in composition in NoteHeader preview
Use featureSet.tryEmit(...) instead of assigning .value inside the
@Composable preview, matching the pattern used elsewhere (AppSettingsScreen).
Fixes the StateFlowValueCalledInComposition lint error.
2026-07-14 23:37:47 +00:00
Claude
c482af0578 feat(concord): custom-emoji autocomplete in the channel composer
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
2026-07-14 23:36:50 +00:00
Claude
7cd2be1c74 feat(concord): channel management + community banner & relay editing
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
2026-07-14 23:36:50 +00:00
Vitor Pamplona
89bb4063ae Merge pull request #3563 from vitorpamplona/claude/nip29-preload-startup-92ila9
Surface NIP-29 group replies and likes in notifications
2026-07-14 19:28:48 -04:00
Claude
339d7c10e7 feat: Marmot group icons — canonical encryption, feed display, metadata editing
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
2026-07-14 23:17:34 +00:00
Vitor Pamplona
d84555a27b fix(concord): real role names + full member roster (CORD-02 §5 / CORD-04)
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>
2026-07-14 19:13:47 -04:00
Claude
af8a95c44c feat(concord): add an Open channel action to the minichat thread
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
2026-07-14 23:11:42 +00:00
Claude
bf2b283cdc fix: keep NIP-29 group reactions in the group so likes notify
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
2026-07-14 23:06:18 +00:00
Claude
e8b7ad8f8b fix(concord): tapping a minichat reply opens its thread, not just the channel
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
2026-07-14 22:59:09 +00:00
Vitor Pamplona
dfc3c08f0b Merge pull request #3562 from vitorpamplona/claude/amy-graperank-commands-review-mofb8g
Refactor GrapeRank into separable pipeline stages with local persistence
2026-07-14 18:53:24 -04:00
Claude
86e29ed25a fix(concord): surface Concord replies/reactions on the Notifications tab
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
2026-07-14 22:46:52 +00:00
Claude
ccd4677f5d fix: render p-tagged NIP-29 group replies on the Notifications tab
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
2026-07-14 22:45:25 +00:00
Claude
f628783186 refactor(cli): rename amy wot -> amy fof (follows-of-follows)
`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
2026-07-14 22:43:44 +00:00
Vitor Pamplona
83c985819f fix(concord): render the decrypted community icon (local file:// avatar)
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>
2026-07-14 18:10:21 -04:00
Claude
1a8a4012a6 docs(cli): audit graperank docs against code; simplify the --help block
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
2026-07-14 21:44:26 +00:00
Claude
65eac0f359 refactor(cli): remove the graperank sync alias; reject non-64-hex user ids
`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
2026-07-14 21:33:49 +00:00
Claude
883365d6a5 feat(cli): graperank status verb, flag consistency, deprecate the sync alias
- 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
2026-07-14 21:18:36 +00:00
Vitor Pamplona
92f422be88 fix(concord): authority-gate the control-plane fold (CORD-04 §1)
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>
2026-07-14 17:12:49 -04:00
Vitor Pamplona
122403baf4 feat(cli): AUTH as the Concord plane stream key so amy can read planes
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>
2026-07-14 17:12:49 -04:00
Vitor Pamplona
a4d9087187 fix(concord): include the 32-zero id in the invite bundle key (CORD-05)
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>
2026-07-14 17:12:49 -04:00
Vitor Pamplona
8060dc0bb1 fix(concord): fold refounded community control plane for fresh joiners
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>
2026-07-14 17:12:49 -04:00
Claude
8b822029e2 feat(cli): graperank refresh rename, unregister verb, operator keys rename
- `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
2026-07-14 20:52:12 +00:00
Claude
a815614247 refactor(cli): move the relay census to amy relay probe
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
2026-07-14 20:42:17 +00:00
Claude
58e018c877 feat(cli): graperank scores always persist locally; add publish + rank verbs
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
2026-07-14 20:29:09 +00:00
Claude
6f63b33119 feat(concord): show the community pill on Concord notifications
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
2026-07-14 20:17:15 +00:00
Claude
24c2cfedc6 fix(concord): reply from Notifications/feed goes through the channel plane
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
2026-07-14 20:17:13 +00:00
Vitor Pamplona
9d5bd48164 Merge pull request #3561 from vitorpamplona/claude/nip-29-channel-icons-8h1dop
Warn users when relay group relay doesn't advertise NIP-29
2026-07-14 16:16:15 -04:00
Claude
18410a80fe feat: hide the reply quote when it targets the message directly above
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
2026-07-14 20:14:09 +00:00
Claude
64e0fa2a91 fix(concord): restore the Messages server/community chip's highlight
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
2026-07-14 20:08:20 +00:00
Claude
5507b1debf feat: bigger relay icons and NIP-29 support warnings on Find Groups
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
2026-07-14 20:06:20 +00:00
Claude
63f26b5cb4 feat: fall back to the relay's NIP-11 icon for NIP-29 rooms without a picture
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
2026-07-14 19:55:12 +00:00
Claude
e734d6400c feat(concord): send & receive encrypted image messages (Armada-compatible)
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
2026-07-14 19:44:56 +00:00
Vitor Pamplona
96c1a2ee2d Merge pull request #3555 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-14 11:43:37 -04:00
Claude
e231b0cf5b Merge remote-tracking branch 'origin/main' into claude/concord-quartz-amethyst-plan-0oy779 2026-07-14 15:25:40 +00:00
vitorpamplona
5e41a26678 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-14 15:22:01 +00:00
Vitor Pamplona
8fc841e50e Merge pull request #3559 from vitorpamplona/claude/notecompose-pow-pill-style-vr6hbt
Unify note-header markers into a pill / quiet-mark design system
2026-07-14 11:19:34 -04:00
Vitor Pamplona
596cb24de4 Merge pull request #3553 from vitorpamplona/claude/large-screen-layout-v5otrb
Adaptive large-screen layout: nav rail, permanent drawer, notification panel, reading-column cap
2026-07-14 11:19:10 -04:00
Claude
14d28dddad Merge remote-tracking branch 'origin/main' into claude/notecompose-pow-pill-style-vr6hbt
# Conflicts:
#	commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf
2026-07-14 15:00:16 +00:00
Vitor Pamplona
01c4ee1870 Merge pull request #3558 from vitorpamplona/claude/community-approval-parse-error-vneyon
Improve NIP-72 approval event parsing robustness
2026-07-14 10:56:19 -04:00
Vitor Pamplona
ee49c8a987 Merge pull request #3557 from vitorpamplona/claude/antispam-filter-null-pointer-hkx70i
Fix null pointer in AntiSpamFilter when LRU cache evicts duplicates
2026-07-14 10:55:29 -04:00
Vitor Pamplona
a915a03c32 Merge pull request #3556 from vitorpamplona/claude/left-drawer-banner-crop-bkxw9f
Fix profile banner image scaling from FillWidth to Crop
2026-07-14 10:54:18 -04:00
Claude
adb26df838 feat: tier-scaled navigation transitions for large screens
Full-width slide-from-end pushes read as disconnected on large screens:
the click comes from the docked drawer on the left and a whole 600dp+
column flies across the pane from the right. Keep one navigation
grammar (drill-in from end, modal from bottom, tab switches fade) and
scale the motion per tier: phones keep the existing full-width slides,
large screens get shared-axis moves — a 1/10-pane nudge plus fade — and
the screen behind a push fades with its slight scale so both aren't
visible mid-transition.

Transition specs run outside composition and can't read
LocalScreenLayout, so AppNavigation mirrors the tier into
NavTransitionTier for the spec lambdas, all of which live in
NavigationEffects' shared builders.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHSoAVvYioihaJeSumX8J7
2026-07-14 14:51:14 +00:00
Claude
cc03651460 fix: pad custom top bars by systemBars so desktop caption bars are respected
The Search and Browser top bars are plain layouts (not Material3
TopAppBars) padded with statusBarsPadding only, and DisappearingScaffold
used the same for its no-top-bar fallback. In a desktop-style window
(Waydroid/DeX freeform) the window's caption/title bar is a separate
inset that statusBars does not include — Material's own top bars pad by
systemBars and were fine, but these three drew underneath the title bar.
Pad by WindowInsets.systemBars top instead, which covers both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHSoAVvYioihaJeSumX8J7
2026-07-14 14:51:14 +00:00
Claude
bced238db4 fix: give the notification panel a Surface so its text follows the theme
The panel's Column sat in a bare Row with no Surface above it, so
LocalContentColor fell back to Color.Black and the header label was
invisible on the dark theme — the same trap DisappearingScaffold
documents for its own root. The Surface provides the container color
and onBackground content color for everything inside the panel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHSoAVvYioihaJeSumX8J7
2026-07-14 14:51:14 +00:00
Claude
6b6f0fd2ae feat: cap every screen to the reading-column width on wide panes
Capping only the feeds' contentPadding left top bars, list screens,
bookmarks and settings stretched across the whole center pane. Move the
cap up a level: every NavHost destination is wrapped in
CappedScreenContent (600dp, centered) through the shared route builders
in NavigationEffects, so each screen's entire surface — top bar, tabs,
content — shares one reading column, on all ~200 destinations at once.

Opt-outs at registration: Route.Message keeps the full pane for its
two-pane list/conversation split, and Browser/WebApp/NostrApp stay
full-pane so the warm EmbeddedTabLayer surfaces keep lining up.

This supersedes the LocalFeedSidePadding-based capping on Android: the
shell no longer provides side padding (CenterPane is a plain Box again)
and the now-dead overrides in MessagesTwoPane and NotificationSidePanel
are removed. The commons local stays, documented as the padding-based
alternative for hosts like a desktop reading column where gutters
should still scroll.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHSoAVvYioihaJeSumX8J7
2026-07-14 14:51:13 +00:00
Claude
c72024578e fix: harden the large-screen shell against runtime window-size changes
Fixes from an adversarially verified audit of the large-screen commit.
The two most serious bugs shared a root cause: the shell was correct at
any fixed size but mishandled the size CHANGING mid-session, which
foldables and multi-window make routine (MainActivity handles those
configChanges without recreation).

Bug fixes:
- Hoist the shell content into movableContentOf so crossing a layout
  tier (fold/unfold, rotate, resize) MOVES the NavHost subtree between
  shells instead of disposing it — screen state (drafts, pager tabs,
  expanded states, warm embedded tabs) now survives.
- DisappearingScaffold snaps bars back to visible when hiding gets
  disabled, so chrome scrolled away before a resize is no longer
  stranded off-screen with no reset path.
- ProfileScreen keeps WindowInsets.navigationBars instead of zeroing
  all content insets; with the bottom bar gone (large screens, and
  pushed entries on phones) content no longer underlaps the system bar.
- New TabReselectCoordinator: AppBottomBar registers each screen's
  re-tap handler even when the bar renders nothing, and the rail routes
  selected-item taps through it — restoring tap-current-tab-scrolls-to-
  top on the rail tier with the screens' existing logic.
- NotificationSidePanel now reuses the screen's SingleNotificationsBody
  (parameterized by scroll-state key), which restores WatchScrollToTop —
  previously the panel stranded scrolltoTopPending=true on the shared
  feed state, suppressing later send-to-top requests — and the inbox-
  relay warning header; it also honors split notifications by showing
  the Following feed when that setting is on.
- Entering the permanent-drawer tier snaps a stale Open drawerState to
  Closed, so returning to a modal tier no longer pops the drawer
  uninvited.
- MessagesTwoPane keys its TwoPane strategy on the width size class so
  the split fraction updates when the pane crosses 840dp in place.
- The drawer status editor calls onDone() after send/delete, so it can
  collapse back to the read-only bar in the docked drawer (and no
  longer waits for a drawer close in the modal one).
- The landscape auto-close drawer effect's inverted condition
  (close-only-when-already-closed, a pre-existing no-op) now closes an
  open drawer as intended.

Structure and performance:
- INav.isDrawerDocked models docked-ness explicitly: Nav.openDrawer()
  no-ops while docked, and consumers stop inferring from a DrawerState
  that never transitions.
- zonedDrawerSwipeIfModal wraps the edge-swipe modifier with the docked
  check so call sites can't forget it; TopBarNavigationIcon centralizes
  the back-arrow/avatar-or-nothing leading slot.
- The rail reuses AppBottomBar's entry icons (NotifiableIcon,
  FavoriteEntryIcon, rememberFavoriteIconModel) instead of duplicating
  them.
- MessagesScreen derives its pane size class via
  WindowSizeClass.calculateFromSize instead of restating the 600/840
  breakpoints.
- rememberFeedContentPadding folds the scaffold, baseline, and side
  paddings into one remember slot; the shell quantizes the feed side
  padding to 8dp steps so continuous resizes don't invalidate every
  feed per pixel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHSoAVvYioihaJeSumX8J7
2026-07-14 14:51:13 +00:00
Claude
8a34ae692f feat: adapt the app shell to large screens
Three layout tiers driven by the window width size class, published once
through LocalScreenLayout (ScreenLayout.kt):

- Compact (phones): unchanged — bottom bar + modal drawer.
- Medium (portrait tablets, unfolded foldables): the bottom bar is replaced
  by a left NavigationRail built from the same user-configured
  BottomBarEntry list (customization, pinned favorites and new-item dots
  carry over); the drawer stays modal behind the rail's avatar button.
- Expanded (landscape tablets, desktop windows): the drawer is permanently
  docked on the left (no ModalNavigationDrawer), and on windows >= 1200dp a
  docked notification panel renders the notifications card feed on the
  right, sharing last-read marking with the full screen. The panel hides
  while the Notifications screen itself is open.

Large screens also pin the chrome: DisappearingScaffold stops hiding the
top/bottom bars on scroll (and stops toggling the OS status bar), and
AppBottomBar renders nothing everywhere.

Feed content width is capped at 600dp inside wide center panes:
the shell measures the center pane and provides
(paneWidth - 600dp) / 2 via LocalFeedSidePadding (commons), which
rememberFeedContentPadding merges into every feed's contentPadding — the
scroll surface stays full-width so pull-to-refresh and edge scrolling keep
working. Panes that manage their own width (Messages two-pane, the
notification panel) override it back to 0.

Screen sweep: Messages now picks single/two-pane from its actual pane
width instead of the window size class; the Home/Messages pagers only
attach the drawer edge-swipe when a modal drawer exists; top-bar avatar
drawer-openers hide on large screens; FABs keep their bottom spacing
without the bar; the status editor in the drawer no longer cancels editing
when the drawer is permanent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHSoAVvYioihaJeSumX8J7
2026-07-14 14:50:29 +00:00
Vitor Pamplona
d1fb2ed42c Merge pull request #3551 from vitorpamplona/claude/mobile-battery-optimization-s7vw4a
Battery: background-usage fixes, 122-relay ping study, and an on-device resource-usage ledger
2026-07-14 10:48:54 -04:00
Vitor Pamplona
4fcae15bcb Merge pull request #3554 from vitorpamplona/claude/flatpak-desktop-release-ci-wfbcre
Add Flatpak bundle support to release CI + Flathub submission
2026-07-14 10:48:19 -04:00
Claude
103a3dfc23 fix: skip event parse of community approval contents that are not JSON objects
Clients in the wild publish kind-4550 approvals whose content is plain
text (e.g. braille ASCII art) or a custom non-event JSON wrapper instead
of the NIP-72 stringified approved event. containedPost() fed any
non-blank content straight into Event.fromJson, producing a
JsonParseException and a noisy logcat warning for every such approval.

Only attempt the parse when the trimmed content starts with '{', so
plain-text contents are skipped silently while genuinely malformed
event-looking JSON still logs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EXiy3Ajmr3vdrV83jZsUx6
2026-07-14 14:41:43 +00:00
Claude
621cdda2e8 fix: crop drawer banner instead of letterboxing short images
The left drawer banner used ContentScale.FillWidth inside a fixed
120dp-tall box, so banners wider than the box's aspect ratio left
empty bars above and below the image. ContentScale.Crop fills the
full banner height and crops the excess width instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BhQ1FCyoKCD1UeBT5mWy18
2026-07-14 14:33:35 +00:00
Claude
079417ba60 feat: add submission-ready Flathub manifest variant
New self-contained desktopApp/packaging/flatpak/flathub/ directory whose
contents are copied verbatim into the flathub per-app repo at submission
time (Flathub has no separate accounts — submission is a GitHub PR):

- manifest builds from the published GH Release tarball (pinned to
  v1.12.6 url + sha256 from the release asset digest) instead of the
  local type:dir tree CI uses — Flathub build servers must fetch all
  sources themselves
- x-checker-data (json type, is-main-source) on the archive source so
  flatpak-external-data-checker auto-PRs url/sha256 bumps and metainfo
  <release> entries on every new GitHub Release
- own metainfo copy carrying the permanent <releases> history Flathub
  requires (the CI variant keeps injecting its entry at build time);
  screenshots remain the documented submission blocker
- flathub.json restricting builds to x86_64 (no aarch64 tarball exists
  and jpackage cannot cross-compile one)

Verified locally: built the flathub manifest end-to-end from the real
v1.12.6 tarball (sha256 enforced by flatpak-builder), installed it, and
booted the actual app inside the sandbox under Xvfb — UI rendered and
the embedded Tor daemon spawned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYfNxhPZC3WRrn82Dm2Cb4
2026-07-14 14:32:47 +00:00
Claude
e684cb02a3 feat(ui): tap explainers for the PoW, private-rumor and expiration markers
Following the OTS pill's pattern, tapping now explains what the marker
means: PoW describes the mining difficulty as an anti-spam stamp
(pow_info_description), the private-rumor lock explains the unsigned
private delivery and its deniability (private_rumor_info_*), and the
expiration pill shows the request to delete plus the exact expiry
date/time (expiration_info_description).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5
2026-07-14 14:31:23 +00:00
Claude
079822ed9d fix: NPE in AntiSpamFilter.isSpam when the first duplicate was evicted from the LRU cache
The spam check can fire via the spamMessages record alone, after the
original event id/address has been evicted from the 2000-entry
recentEventIds/recentAddressables LruCache. In that case existingEvent /
existingAddress is null and building the njump link threw a
NullPointerException, aborting event consumption in LocalCache.

Fall back to the current event's link when the original is no longer
cached, and use setOfNotNull in logOffender so a null cache entry can't
sneak into the Spammer sets through the platform-type hole.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UGiCujAHWMbmGT89X77S4K
2026-07-14 14:30:40 +00:00
Claude
500de62841 fix(ui): explain pending OTS on tap, render the stamp glyph and pill icons larger
Tapping the pending OTS pill now shows a toast explaining that the
attestation is waiting to be stamped into the Bitcoin blockchain (new
ots_info_pending_description). The OpenTimestamps glyph gets a
near-full-em content box — its fine outline read much lighter than
Material's solid shapes at the standard 80..880 bounds — and HeaderPill
icons go from 11dp to 13dp.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5
2026-07-14 14:15:56 +00:00
Claude
7e78204336 feat: ship a Flatpak bundle of the desktop app on release CI
The linux-portable release leg now wraps the createReleaseDistributable
tree it already builds into a single-file Flatpak bundle and attaches it
to the GH Release as amethyst-desktop-<version>-linux-x64.flatpak.

Packaging fixes to the existing (previously unwired) Flathub manifest:
- add the missing 512x512 icon (copy of desktopApp icon.png) and install
  it under hicolor/512x512 instead of the never-present 256px path
- drop the openjdk module + sdk-extension: the jpackage tree bundles its
  own trimmed JRE, so /app/jre was pure bloat
- grant --socket=x11 instead of wayland/fallback-x11: Compose Desktop
  renders via AWT/skiko (X11-only on Linux, XWayland on Wayland), so
  fallback-x11 left the app socketless on Wayland sessions

CI wiring:
- install flatpak tooling + the freedesktop runtime/sdk (version greped
  from the manifest so the pin can't drift), retried like other fetches
- inject the AppStream <release> entry for the tagged version at build
  time (the checked-in metainfo deliberately carries none)
- flatpak-builder with --disable-rofiles-fuse (GH runners) and a
  --state-dir under desktopApp/build so the repo tree stays clean
- collect via the existing asset-name.sh contract (new flatpak ext)

Verified end-to-end locally: built the bundle from the manifest with a
stubbed jpackage tree, installed it, and ran the exported command inside
the sandbox (freedesktop 24.08, args forwarded through the wrapper).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYfNxhPZC3WRrn82Dm2Cb4
2026-07-14 14:06:37 +00:00
Claude
568aa9b005 fix(ui): use middle ellipsis in HeaderPill labels
Truncated pill labels (capped city names, relay hosts) keep their
distinguishing endings, matching the codebase convention for URLs and
relay links.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5
2026-07-14 13:49:08 +00:00
Claude
811d9cf802 feat(ui): icon-only fork mark, pencil edit mark, tighter expiration and location pills
- The fork marker drops "Forked from <name>" for a bare fork-right icon
  (new MaterialSymbols.ForkRight, U+EBAC); tapping still opens the
  original version. Font regenerated via subset.sh, which also re-baked
  the custom OpenTimestamps glyph, proving the custom-glyph pipeline
  survives regeneration.
- The edited mark becomes a pencil: bare pencil for the latest edit,
  pencil + "#2"/"original" only while cycling versions on tap.
- The expiration pill clamps beyond one year to "1y+" instead of
  switching to a full date.
- The location pill caps at 110dp and ellipsizes, so unbounded city
  names cannot squeeze the author's name out of the row.
- Remove the now-unused existed_since string from all 35 locale files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5
2026-07-14 13:46:12 +00:00
Claude
170bc7c121 fix(ui): restore full-size quiet marks, tighten the dotted timestamp, drop Boosted
Quiet marks go back to the row's regular text size in bold with 16dp
icons; the hashtag/community soft links lose their 12sp override too
(the smaller tier read as too small). A new TimeAgoStyle.DottedTight
renders "• 5m" without the leading space for rows whose spacedBy
already provides the gap, removing the double space before the
timestamp. The OTS pending pill shrinks to the stamp icon plus an
ellipsis (the words move to the content description). The Boosted mark
is removed entirely — from the Android header, the commons component,
and the desktop feed — since the repost context is already visible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5
2026-07-14 13:30:38 +00:00
Claude
41ef1d89b3 feat(ui): add the OpenTimestamps logo as a custom glyph and use it in the OTS pill
Bake the official OpenTimestamps stamp logo (traced from
opentimestamps/logo vector.svg; monochrome outline, tinted at render
time like every other glyph) into the Material Symbols subset font at
U+F8F0. New tools/material-symbols-subset/add_custom_glyphs.py converts
the traced SVGs in custom/ into TrueType glyphs and is invoked by
subset.sh after pyftsubset, so font regenerations keep them.

With the logo identifying the pill, drop the verbose "OTS:" prefix:
the pill now reads icon + "2y" (or icon + "Pending", new
R.string.pending).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5
2026-07-14 12:59:30 +00:00
Vitor Pamplona
efae1dec22 Adjustments 2026-07-14 08:21:44 -04:00
Claude
d29ee4e6a6 Merge remote-tracking branch 'origin/main' into claude/concord-quartz-amethyst-plan-0oy779 2026-07-14 04:27:53 +00:00
Claude
86da29fa78 fix(ui): mute the header pill wash, restore dotted timestamps, tighten menu gap
- HeaderPill's secondary-container background pulled too much attention;
  it now uses a faint onSurface wash (7%) with placeholderText content,
  so pills read as tappable metadata without competing with the note.
- Note-header timestamps go back to the original dotted format at the
  default size; the TimeAgo style/fontSize params are reverted.
- The timestamp + more-options pair renders unspaced again (the dot and
  the button's icon inset provide the separation), fixing the oversized
  gap the row-level spacedBy introduced before the 3-dot menu.
- The header preview now consumes its fabricated events as
  already-verified (they cannot pass id/sig checks, which left every row
  bare), gives the repost a parseable inner event, and fetches the draft
  through its AddressableNote (draft wraps are addressable events).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5
2026-07-14 01:26:33 +00:00
Vitor Pamplona
d043f4d143 Merge pull request #3549 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-13 20:42:21 -04:00
vitorpamplona
a335c0387c chore: sync Crowdin translations and seed translator npub placeholders 2026-07-14 00:39:07 +00:00
Vitor Pamplona
7c82c9cc17 Merge pull request #3550 from vitorpamplona/claude/1059-empty-content-filter-nge9qp
Decouple Android notification kinds from Desktop subscription list
2026-07-13 20:36:57 -04:00
Claude
7a30c18590 refactor: decouple Android NOTIFICATION_KINDS from desktop's subscription list
Android never uses NotificationKinds.SUBSCRIPTION_KINDS for relay
subscriptions — every kind in it already arrives via Android's own
datasources (FilterNotificationsToPubkey, the chat datasources, the
wallet assembler) or via local unwrapping (14/15). Spreading it into
NOTIFICATION_KINDS only coupled Android's display gate to a list whose
job is desktop's relay-filter/toast allow-list, which is exactly how
the kind-1059 wrap leaked into the Android notifications tab.

Restore NOTIFICATION_KINDS as an explicit flat set (identical content
to the previous commit's subtraction) and add
NotificationKindsContractTest as the drift tripwire: envelope kinds
(1059/21059) must never render on the Android tab, and every kind
desktop notifies on must be either displayable on Android or a known
envelope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011A6iSrJJHUoz686eDnCQMV
2026-07-14 00:19:01 +00:00
Vitor Pamplona
107777369f Merge pull request #3548 from vitorpamplona/claude/user-nicknames-contactcard-mglbz8
Nickname users via NIP-85 contact cards (encrypted petname + private note, custom emojis)
2026-07-13 20:16:09 -04:00
Claude
d2f02fbd32 style: double the nickname card's top margin, halve its bottom margin
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY
2026-07-13 23:52:13 +00:00
Claude
b2456f2051 revert: remove the 'What's using your battery' insights section
The relay-focused recommendations were built on a wrong premise: with the
outbox model, the number of open connections and the reconnect churn are
driven by the relays the user's FOLLOWS publish to, not by the user's own
relay list — so 'Edit relays' steered users at a lever that doesn't control
the number. Rather than ship a recommendations engine whose headline advice
is misleading, the section, the UsageInsights rules, their tests, and their
strings are removed. The measured data itself (rates tiles, cost cards,
subsystem bars, screen time) stays.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P
2026-07-13 23:42:56 +00:00
Claude
19bbfb3be4 fix: keep kind:1059 gift wraps out of the Android notifications tab
The desktop notifications redesign (ecedc4af) extracted the shared
NotificationKinds.SUBSCRIPTION_KINDS list from Android's
NotificationFeedFilter and rewired NOTIFICATION_KINDS to spread it. The
shared list includes GiftWrapEvent.KIND (1059) because it doubles as the
relay subscription filter (you must ask relays for wraps to receive
NIP-17 DMs) and because Desktop renders the wrap itself as a DM inbox
row. Android's display list never had 1059 before that commit, so the
delegation silently started rendering wrap envelopes in the
Notifications tab — with created_at randomized up to 2 days back per
NIP-59, producing misordered, undecryptable rows instead of routing DMs
to the chat screens.

Subtract GiftWrapEvent.KIND from the Android display set, restoring the
pre-extraction behavior (1059 was the only kind the delegation added).
SUBSCRIPTION_KINDS keeps 1059, so the desktop relay subscription, the
desktop OS-toast allow-list, and the desktop inbox DM rows are all
unaffected. Android push is likewise untouched: NotificationDispatcher
already excludes 1059/21059/13 and notifies on the unwrapped inner
event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011A6iSrJJHUoz686eDnCQMV
2026-07-13 23:37:27 +00:00
Claude
b76b37744e feat: nickname card on the user profile, above the real display name
The profile header no longer replaces the big display name with the petname.
Instead, when the account nicknamed the user (or kept a private note about
them), an outlined card renders above it — petname, divider, private summary —
with the standard Lock private marker in its top-right corner, since both
fields live NIP-44 encrypted in the account's contact card. Tapping the card
opens the shared nickname editor. The profile's own display name stays fully
visible underneath. Feeds, chats and mentions keep rendering the petname
instead of the display name.

To carry the summary into the UI, the commons PetName holder generalizes to
Nickname(petName?, summary?, tags), built when either field exists — so a
note-only card (no petname) now shows on the profile too, while the name
override everywhere else keys strictly off petName.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY
2026-07-13 23:33:05 +00:00
Claude
b17e4ddb86 fix(concord): persist the hub's per-community expand state across navigation
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
2026-07-13 23:21:02 +00:00
Claude
dab89bd781 fix(chat): don't re-render the minichat root as each reply's reply-to preview
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
2026-07-13 23:21:01 +00:00
Claude
bed5d93a12 feat(concord): typing indicators (CORD kind 23311)
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
2026-07-13 23:21:01 +00:00
Claude
ef3be70741 fix: reject kind:1059 gift wraps with empty content before caching
A gift wrap with an empty content string carries no NIP-44 ciphertext,
so it can never be unwrapped. Filtering it at LocalCache's ingestion
choke point (justConsumeInnerInner) rejects it before the Schnorr
signature check and before it takes a permanent cache slot, and keeps
GiftWrapEventHandler from retrying a doomed unwrap on every batch.

Locally stripped wraps (copyNoContent, used to drop the ciphertext
after a successful unwrap) are assigned directly to note.event and
never pass through justConsume, so they are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011A6iSrJJHUoz686eDnCQMV
2026-07-13 23:02:21 +00:00
Vitor Pamplona
8e30349cbd fix(concord): open a minichat reply in its thread, and resolve the plane from the reply
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>
2026-07-13 18:53:58 -04:00
Vitor Pamplona
07ad868de1 Merge remote-tracking branch 'upstream/main' into claude/concord-quartz-amethyst-plan-0oy779 2026-07-13 18:30:21 -04:00
Vitor Pamplona
77300578e9 Merge remote-tracking branch 'upstream/claude/concord-quartz-amethyst-plan-0oy779' into claude/concord-quartz-amethyst-plan-0oy779 2026-07-13 18:29:08 -04:00
Claude
b7df458071 feat(concord): true member count from the re-enabled Guestbook plane
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
2026-07-13 22:23:41 +00:00
Vitor Pamplona
5de55741a2 feat(concord): reuse the standard compressed/encrypted upload pipeline for community images
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>
2026-07-13 18:22:33 -04:00
Vitor Pamplona
66a5e111d6 fix(concord): eagerly backfill an open channel's history to a target window
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>
2026-07-13 18:22:32 -04:00
Vitor Pamplona
9f55794e06 fix(concord): only bump session revision on structural change, not per message
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>
2026-07-13 18:22:17 -04:00
Claude
1a1389e9ef feat(concord): tri-state channel expansion (closed / unread peek / all)
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
2026-07-13 22:13:43 +00:00
Claude
56d3d0681c feat(concord): drop the redundant rail and make the hub feel alive
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
2026-07-13 22:03:07 +00:00
Claude
a6da2ee69d fix: record contact-card EOSEs from the d-tag so card syncs are incremental
UserCardsSubAssembler.newEose read the filter's p-tags to stamp each target
user's card EOSE, but kind:30382 filters address targets in the d-tag — so
no per-user EOSE was ever recorded, groupByRelayPresence always classified
users as never-checked, and every filter update re-downloaded the visible
users' cards with since = null. Read the d-tag instead, and use DTag.TAG_NAME
on both the filter builder and the EOSE reader so the two keys cannot drift
apart again. Pre-existing on main; surfaced while verifying that card syncs
are incremental from the account's outbox relays.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY
2026-07-13 21:52:29 +00:00
Claude
070dd1b1d9 feat(concord): move the Concord hub from the FAB to the drawer + make it bottom-nav pinnable
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
2026-07-13 21:39:32 +00:00
Vitor Pamplona
413bf726fd feat(concord): author encrypted community icons (encrypt + Blossom upload)
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>
2026-07-13 17:32:56 -04:00
Claude
e8ecf429a2 refactor: move nickname policy, emoji autocomplete, filters and dialog to commons
Generalizes the remaining platform-bound pieces of the nickname feature so
the desktop app can adopt it:

- ContactCardsState.displayNameFlow/cachedDisplayName own the NIP-81 render
  policy (petname over profile name over npub); the Android observeUserName
  is now a thin wrapper around it
- EmojiSuggestionState.autocompleteInto completes the word under the cursor
  and closes the list — replaces the insert+reset pair copy-pasted in the 8
  composer ViewModels and the nickname dialog
- the kind:30382 filter builders move to commons relayClient/assemblers
  (cards about targets from trusted accounts, and the account's own cards by
  author), shared by the user watcher and the login subscription
- ShowEmojiSuggestionList moves to commons nip30CustomEmojis/ui using coil
  and commons string resources
- EditNicknameDialog moves to commons nip85TrustedAssertions/ui: it takes the
  ContactCardsState and an onSave callback, so each front end only wires its
  own publish path and menu entry; dialog strings move to commons resources

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY
2026-07-13 21:20:36 +00:00
Vitor Pamplona
28b93132a3 feat(concord): read + render CORD-02 §6 encrypted community icon/banner
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>
2026-07-13 17:16:12 -04:00
Vitor Pamplona
beaae90eb5 Merge pull request #3547 from vitorpamplona/claude/build-oom-investigation-ryd9u6
fix(ci): serialize :amethyst Kotlin compilations on CI to stop daemon OOMs
2026-07-13 16:50:39 -04:00
Claude
9d176edd1c fix: make usage insights accurate — impact ranking, honest attribution, missing rules
Accuracy fixes:
- Insights are ranked by an estimated-impact score (rough hours of extra
  radio/CPU activity, ranking only, never displayed) before the cut to
  three — declaration order no longer decides which consumer gets dropped.
- The notification insight states the measurement ('held connections open
  for N relay-hours in the background') instead of an unverified 'largest
  background cost' superlative, and only fires when the service's uptime
  covers at least half the background window — a service that ran 20
  minutes can no longer be blamed for 30 hours of background connections.
- When the measured drain split shows foreground use dominating (>=3x the
  background share with enough signal), an informational note leads the
  list saying most battery went to screen-on use — so nobody flips a
  setting expecting savings it cannot deliver.

Completeness — new rules, each with a deep link:
- PoW mining time -> compose settings (default difficulty), scored as
  full-tilt CPU.
- Reconnect churn -> relay list (a flaky relay burns a handshake plus a
  radio wake-up per retry) — distinct diagnosis from 'too many relays'.
- Push-processing wakelock time / process-start churn -> notification
  settings.

Count strings converted to plurals per res/CLAUDE.md. Insight test suite
extended to 15 cases covering the attribution gate, score ordering, the
honesty note, and each new rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P
2026-07-13 20:23:16 +00:00
Vitor Pamplona
975ccb9cf3 feat(concord): preload community control planes app-wide, like DMs
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>
2026-07-13 14:40:45 -04:00
Vitor Pamplona
95ab9e6528 fix(desktop,cli): match the new interactive auth-callback signature
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>
2026-07-13 14:14:30 -04:00
Claude
8a98ed3d15 feat: actionable usage insights, rates, cellular-first bars, Tor card, and per-screen time
The screen presented evidence and left the user to be the analyst; now it
draws the conclusions and links each one to the setting that acts on it:

- Insights: a UsageInsights rules layer (unit-tested, thresholds informed by
  the ping study and normalized per observed day) renders up to three
  plain-language recommendations, each deep-linking to notification
  settings, media settings, the relay list, or privacy options.
- Rates: battery %/hour in app (from the measured drain sampler), data/hour
  in app, and average simultaneous relay connections — totals aren't
  judgeable, rates are, and avg relays is directly actionable.
- Subsystem bars rank by CELLULAR bytes when any exist (with totals as
  secondary context) — Wi-Fi bytes are nearly free, so ranking by totals
  pointed users at the wrong feature.
- Built-in Tor gets the same cost card as the always-on service: uptime,
  bootstraps, and a link to privacy options.
- Per-screen time (screen.<Name>.ms): a ScreenTimeIntegrator fed by the nav
  controller's destination listener, gated on foreground. PRIVACY: only the
  route's base name is recorded — screenNameOf strips every navigation
  argument before the value leaves the navigation layer, so the ledger says
  'Profile', never whose profile. Shown as proportion bars and included in
  the report's summary table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P
2026-07-13 18:07:59 +00:00
Vitor Pamplona
04388adc02 Merge remote-tracking branch 'upstream/claude/concord-quartz-amethyst-plan-0oy779' into claude/concord-quartz-amethyst-plan-0oy779 2026-07-13 14:07:42 -04:00
Vitor Pamplona
2b4148ef19 feat(concord): scroll-back history pagination for channels
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>
2026-07-13 14:04:21 -04:00
Vitor Pamplona
691adb361c fix(concord): drop deleted-message ghost rows in channels
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>
2026-07-13 14:03:55 -04:00
Vitor Pamplona
0e485349e3 fix(relay-auth): re-authenticate on an auth-required CLOSED (NIP-42)
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>
2026-07-13 14:03:37 -04:00
Claude
899ada61e4 fix(ci): apply the :amethyst Kotlin compile limiter only on CI
The daemon OOM needs a cache-cold compile of several :amethyst variants in
one invocation, which only CI's combined test/lint/assembleBenchmark run
produces; local builds are incremental and usually build a single variant,
and have never shown the problem. Gate the maxParallelUsages=1 service on
the CI env var (always set on GitHub Actions) so local multi-variant builds
keep their parallelism.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ifbUGCADckUb3zaox9wBo
2026-07-13 18:00:28 +00:00
Claude
824dee6fb6 fix(concord): make the Messages-tab community chip reactive to the fold
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
2026-07-13 17:25:42 +00:00
Vitor Pamplona
43d0b8b2bc Merge remote-tracking branch 'upstream/main' into claude/concord-quartz-amethyst-plan-0oy779 2026-07-13 12:43:12 -04:00
Claude
53e8f8df09 feat: always-on service cost card with a link to notification settings
The uptime row told users the service ran, not what it cost — and the
setting to turn it off lives three screens away. The resource screen now
shows a dedicated card (only while the service has run this week) that
frames the cost in decision terms: running time and restarts, the relay
connections it held while the app was closed, and the measured background
battery drain — with a button straight to notification settings where the
delivery method can be changed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P
2026-07-13 16:30:07 +00:00
Claude
b2914fbc4b feat: count always-on notification service starts
Uptime alone can't distinguish a stable 24h service from one the watchdog
restarts every hour — each restart is a process wake plus a full round of
relay reconnects. Reported alongside uptime in the usage report.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P
2026-07-13 16:21:20 +00:00
Claude
bd4d80160e fix: resolve remaining audit findings in worker gates, ledger accuracy, and alert flow
- ScheduledPostWorkGate now treats PUBLISHING as pending work: claiming the
  last PENDING post no longer cancels the periodic worker mid-publish (which
  stranded the post in PUBLISHING with no recovery). ScheduledPostStore also
  releases stale PUBLISHING claims at initial load — a claim found on disk
  belongs to a dead process and is reset to PENDING for retry.
- CalendarReminderWorker decides its self-cancel on a FRESH cache snapshot,
  closing the race where an RSVP accepted mid-run was lost forever (the
  observer's KEEP schedule no-ops while the chain exists). A second observer
  on kind-31922/31923 re-arms the chain when the organizer reschedules the
  target event — the existing RSVP never re-fires observeNewEvents, so the
  moved appointment previously produced no reminder. The rescan is throttled
  (conflate + 30s) and gated on the feature being enabled.
- UsageCountingInterceptor skips loopback hosts: local Blossom cache hits
  (127.0.0.1) never touch the radio and were inflating mobile rx bytes,
  radio-burst estimates, and potentially the background-data alert.
- The usage alert's 7-day rate limit is now consumed when the user acts on
  the dialog (any button or dismissal) instead of before the first frame, so
  a config change or process death no longer burns the prompt budget on a
  dialog nobody saw.
- DEV_REPORT_PUBKEY is defined once in service/crashreports and imported by
  both the crash and resource-usage send paths.
- ResourceUsageScreen remembers its summaries keyed on the loaded data — the
  2s memory ticker was recomputing the full multi-day counter walk on every
  recomposition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P
2026-07-13 16:14:41 +00:00
Claude
1d385cae0f refactor(ui): move HeaderPill and QuietMark to commons/ui/note
The two note-header marker primitives are pure Compose with no Android
dependencies, so they belong in the shared layer where the desktop app
(and future iOS) can render the same design system. QuietMark's gray
comes from a new commons ColorScheme.placeholderText extension
(onSurface at 42%, matching the Android theme's value).

The thin app-side wrappers (BoostedMark, DisplayPoW, DisplayDraft,
DisplayOts, DisplayLocation, DisplayReward, ...) stay in amethyst: they
bind the primitives to Android string resources (with existing
translations that commons' compose-resources catalog does not have) and
to app state (LocalCache loaders, AccountViewModel, navigation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5
2026-07-13 16:04:36 +00:00
Claude
ed04c373b8 fix(ci): serialize :amethyst Kotlin compilations to stop daemon OOMs
CI runs die with java.lang.OutOfMemoryError (GC overhead limit exceeded /
Java heap space) inside the shared Kotlin compile daemon when two variant
compilations of :amethyst run at the same time — e.g. compilePlayDebugKotlin
and compilePlayBenchmarkKotlin in the test-and-build-android job, which
demands four :amethyst variants (unit tests, lint, assembleBenchmark) in one
invocation. KotlinCompile submits its work asynchronously via the Worker
API, so the project lock is released while compilation is still running in
the daemon and Gradle happily starts the next variant's compile task in
parallel; two full :amethyst codegen passes no longer fit the daemon's
-Xmx8g heap (see runs 29260711359 and 29248908870; removing Kover in #3539
helped but was not sufficient).

Register a no-op shared build service with maxParallelUsages = 1 and make
every :amethyst KotlinCompile task use it. The scheduler then runs this
module's Kotlin compilations one at a time — a task holding the service is
not complete until its async worker finishes — while all other work (other
modules' compilation, JVM tests, lint analysis, packaging) keeps running in
parallel. Verified with Worker-API probe tasks: unconstrained same-project
tasks start simultaneously; with usesService they run strictly one after
the other.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ifbUGCADckUb3zaox9wBo
2026-07-13 15:56:51 +00:00
Claude
aa34f9cb0e feat: track PoW, Tor, calls, decrypts, remote signs, and battery drain in the resource ledger
New counters for the app's remaining heavy battery consumers, all
transition-based so tracking never adds work to the activities measured:

- pow.ms/sessions: NIP-13 mining time from the PoW queue's isMining flow —
  the largest attributable CPU burner, previously an unattributed cpu.ms
  mystery
- tor.ms/starts: in-app Arti uptime from the raw TorService status (not
  TorManager.status, whose WhileSubscribed upstream would keep Tor's
  control flow alive if the ledger subscribed to it)
- service.alwayson.ms, call.ms/sessions, nests.ms/sessions: foreground
  service lifecycles for the always-on relay service, calls, and NIP-53
  audio rooms
- crypto.decrypt/encrypt.count+us and sign.local/nip46/nip55.count via a
  MeteringNostrSigner decorator wrapped inside NostrSignerWithClientTag at
  account load; crypto durations only metered for local-key signers so
  IPC/relay waits never poison the CPU numbers
- location.ms: GPS listening segments around LocationFlow collection
- battery.drain.fg/bg: measured percent-while-discharging sampled at each
  flush — the ground truth to correlate the other counters against

Ledger infrastructure hardening the new counters build on:

- all duration math now uses SystemClock.elapsedRealtime; wall-clock jumps
  (NTP, timezone) no longer fabricate or delete accounted time
- pre-flush-hook adds are drained in place and no longer re-arm the
  debounced flush, killing a self-perpetuating 30s wake-and-write loop
- drain is now AtomicLong.getAndSet(0) instead of remove+sum, closing a
  lost-update race that undercounted the hottest counters
- shared TimeSegmentIntegrator base extracted; relay/foreground integrators
  and the new SessionTimeIntegrator all use it
- @Volatile on LocalCache.verifyMeter (matching the onchainBackend
  precedent)

New counters surface in the usage screen (zero rows hidden), the NIP-17
report tables, and the per-day dump. 9 new unit tests including a
regression test for the flush loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P
2026-07-13 15:56:03 +00:00
Claude
3cba5d3b65 refactor(ui): drive header-marker previews through the real FirstUserInfoRow
Replace the hand-built preview rows and static stand-ins with the actual
FirstUserInfoRow over notes seeded into LocalCache (the ZapPollNote/Poll
preview pattern): metadata for usernames, a repost, an empty-sig rumor, a
draft wrap, a community post, and PoW ids with committed nonces plus
geohash/expiration tags. The geohash resolves through a seeded
CachedReversedGeoLocations entry and the OTS pill renders its real
pending branch via account.settings.pendingAttestations. Only the
followed-hashtag label and the confirmed OTS state stay out - both are
computed in effects that static previews never run, as documented in the
file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5
2026-07-13 15:53:03 +00:00
Claude
7852fcc65a docs: plan for chat feed fling-scroll performance
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
2026-07-13 15:51:22 +00:00
Claude
b81116865c feat(ui): add density previews for the note-header marker system
NoteHeaderFirstRowDensityPreview stacks first-row samples from bare
(username + time + options) to fully loaded (hashtag, edited, draft,
lock, pin, location/PoW/OTS/expiration pills, jump-to-parent) under a
long username, in dark and light themes. Uses the real QuietMark/
HeaderPill components with static stand-ins only for the data-loading
markers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5
2026-07-13 15:32:01 +00:00
Vitor Pamplona
b6560ab3f4 Merge pull request #3546 from vitorpamplona/claude/compose-chip-notification-toggle-6nmy6h
Replace remove-from-notify with mute/unmute bell toggle
2026-07-13 11:23:01 -04:00
Claude
b69304c4a8 fix: import EmojiSuggestionState from commons in ShowEmojiSuggestionList
ShowEmojiSuggestionList sat in the same package as EmojiSuggestionState, so
it referenced the class without an import — moving the class to commons broke
the Android compile. Caught by an honest verification run: earlier gradle
invocations were targeting a nonexistent :amethyst:compileDebugKotlin task
(the app builds flavored compilePlayDebugKotlin/compileFdroidDebugKotlin)
with the failure masked behind a pipe, so the last three commits had never
actually been compiled.

Verified at this commit: quartz nip85 tests (13/13), :commons:compileKotlinJvm,
:cli:compileKotlin, :amethyst:compilePlayDebugKotlin, :desktopApp:compileKotlin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY
2026-07-13 15:12:15 +00:00
Claude
fffeb78c2a fix(concord): decouple CORD-06 aux planes from the control/channel path
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
2026-07-13 14:55:29 +00:00
Claude
d1b3d9772a Merge remote-tracking branch 'origin/main' into claude/concord-quartz-amethyst-plan-0oy779 2026-07-13 13:03:48 +00:00
Claude
648e9f427a feat(concord): wire CORD-06 refounding for real member removal
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
2026-07-13 12:58:36 +00:00
Claude
ab4b0b984d refactor: audit nickname code — dedupe emoji helpers into commons
Review pass over the nickname/contact-card feature for reuse, simplicity and
performance:

- EmojiSuggestionState moves to commons next to EmojiPackState and now takes
  the EmojiPackState directly instead of the whole Android Account, so the
  desktop app can reuse the :shortcode: autocomplete
- the findEmoji helper that was copy-pasted in 8 composer ViewModels is gone;
  everyone calls the shared EmojiPackState.findEmojiTags, which now resolves
  codes through a map lookup instead of a linear scan per code
- ContactCardsState owns the emoji resolution for nickname saves (Account
  just publishes), takes EmojiPackState, and drops its unused scope param
- new synchronous cachedPetName path (decryption-cache read, no crypto) seeds
  observeUserName/observeUserPetName initial values, removing the flash of
  the real name before the flow's first emission
- nickname dialog prefill no longer clobbers text typed while a slow external
  signer decrypts the existing card

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY
2026-07-13 12:40:38 +00:00
Claude
2fefc3a409 feat(ui): unify note-header markers into a two-tier pill/quiet-mark system
Tier 1 (HeaderPill): tappable or verifiable metadata now renders as the
squared secondary-container chip — location, PoW, OTS (already pills),
plus expiration and the bounty reward (bolt icon + amount, tap opens the
pledge dialog; icon turns orange once the user has pledged).

Tier 2 (QuietMark, new): passive state markers — Boosted, Draft, Edited/
Original, pinned, private rumor — share one spec: 12sp medium gray text
with an optional 12dp icon. Draft is no longer a hardcoded English
literal (new R.string.draft).

Cross-cutting cleanups:
- Header rows (NoteCompose First/SecondUserInfoRow, ThreadFeedView) own
  spacing via Arrangement.spacedBy(5dp) instead of per-marker paddings.
- Hashtag/community/fork links use the existing lessImportantLink theme
  color instead of ad-hoc primary.copy(alpha = 0.52f) (fork was full
  primary, louder than everything else on the line).
- NIP-05 badge drops hardcoded Color.Yellow/Color.Red for theme-aware
  placeholderText/error.
- Note-header timestamps switch to the Short style at 12sp; other
  TimeAgo callers keep the dotted default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5
2026-07-13 12:28:59 +00:00
Claude
184f0bfde7 refactor: align ContactCardEvent with the nip88Polls class structure
Replicates the PollEvent layout: all tag parsing moves to TagArray extensions
in TagArrayExt.kt with the event accessors delegating to them, builder
extensions use addUnique for single-instance tags, and construction goes
through template-returning builders instead of methods that sign internally.

- build() returns an EventTemplate<ContactCardEvent> (the signer is only used
  to NIP-44 encrypt the private tags, matching TrustProviderListEvent);
  create() remains as the signer.sign(build(...)) convenience and now takes
  the emoji list directly
- updatePetNameAndSummary() returns an unsigned EventTemplate; callers sign
  it (ContactCardsState and tests updated)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY
2026-07-13 12:28:23 +00:00
Vitor Pamplona
9d79d3bc7f Merge pull request #3545 from vitorpamplona/claude/amethyst-video-repeat-limit-bq2ut7
Add AutoReplayLimiter to cap video auto-loops in feed
2026-07-13 08:11:28 -04:00
Claude
b282ac32af feat: turn Notify chips into bell mute-toggles listing all thread members
The composer's Notify row previously showed only the parent's p tags and
its author, each with an x that removed the user for good. It now lists
every thread member (authors along the reply chain plus the parent's
mentions), and each chip carries a bell instead: tapping it mutes the
notification for that user but keeps the chip — faded and with a
bell-off icon so the state is obvious — making it one tap to add them
back. Muted members are dropped from the outgoing event's p tags (and
from a private note's receivers), and drafts round-trip the muted state
by re-deriving thread members whose p tag the draft dropped.

The NIP-22 comment composer gets the same chip semantics; there the
mute is honored for the optional zap-sender tag, while the structural
root/reply scope tags stay as NIP-22 requires.

Adds the notifications_off glyph to the Material Symbols subset font.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013eK8mGPcuKYXNyhKVJ8Wg7
2026-07-13 08:48:02 +00:00
Claude
5761f37d0f feat: pause looping videos after 5 automatic plays
Feed videos run under REPEAT_MODE_ONE and looped forever. AutoReplayLimiter
counts each MEDIA_ITEM_TRANSITION_REASON_REPEAT as one completed play and
pauses the player when the 5th play finishes, leaving the video on its first
frame with the play button showing. Pressing play (or the feed mutex resuming
a video scrolled back into view) grants a fresh 5-play allowance, and pooled
players reset the count when they switch to a different media item.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N9RXiM42yqznwTVjw8tLZv
2026-07-13 01:58:10 +00:00
Claude
903f3d9dcd feat(ledger): catch-all HTTP metering, radio-burst estimator, media play time
Closes the gaps the "should we track the non-websocket client?" question
exposed, and adds the battery-estimation layer bytes alone can't provide:

- Counting moves from per-role wrappers to a single interceptor on the
  shared base client (OkHttpClientFactory), with role attribution via
  request tags (role wrappers now only tag, inserted outside the counter).
  Anything reaching the shared clients untagged lands in a new "other"
  subsystem instead of escaping the ledger — which the napplet broker did,
  and which Coil's image traffic did too: setImageLoader called
  getHttpClient directly, bypassing the metered okHttpClientForImage.
  The app's largest HTTP consumer was invisible; now it's tagged "image".

- Battery-relevant shape of the traffic, not just its size: per-subsystem
  request counts and active-transfer time, plus a radio-burst estimator
  (new HTTP activity after >10s of silence ~= one cellular radio
  ramp+tail). Scattered small requests cost far more energy than one
  continuous download of the same byte count; bursts/day makes that
  pattern visible and reportable.

- Media playback time (ExoPlayer isPlaying segments via a listener in
  ExoPlayerBuilder — every player in the app): decoder+screen+streaming
  at once, turning "video used 800 MB" into "800 MB over 2h" (normal)
  vs "over 10 min" (a bug).

All surfaced in the usage screen, the NIP-17 report, and UsageSummary;
20 ledger tests total (burst gap logic, play-time segments, and the
existing suites) all passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P
2026-07-13 01:54:38 +00:00
Claude
5976b7e466 Merge remote-tracking branch 'origin/main' into claude/concord-quartz-amethyst-plan-0oy779
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt
2026-07-13 01:44:54 +00:00
Claude
ebeecd1ee7 feat: custom emojis in contact card petnames
Nicknames can now use NIP-30 custom emojis: typing : in the nickname dialog
autocompletes from the account's emoji packs, and the emoji mappings for any
shortcode used are embedded in the card's NIP-44 encrypted content next to the
petname — so even the emoji set stays private. Renderers resolve the petname's
shortcodes against the card's decrypted tags instead of the profile's metadata
tags.

- quartz: updatePetNameAndSummary replaces the private emoji tag set wholesale
  and keeps it out of the public tags; round-trip test added
- commons: PetName(name, tags) holder with content equality, decryption cache
  returns the merged decrypted tag list, EmojiPackState.findEmojiTags resolves
  :codes: against the selected packs
- amethyst: Account embeds resolved emoji tags on save; all petname render
  sites pass the card tags to the WithEmoji composables; nickname dialog gets
  the : emoji autocomplete via EmojiSuggestionState

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY
2026-07-13 01:39:59 +00:00
Claude
718e2cba0b feat(ui): move PoW, OTS and location pills to the note header's first line
Restyle DisplayPoW, DisplayOts and DisplayLocation as compact squared
chips via a new shared HeaderPill composable, matching the relay
information pills in the NIP-29 chat headers (which now reuse the same
composable). Move the three pills from SecondUserInfoRow to
FirstUserInfoRow in NoteCompose, and shrink the secondary markers on
that line (boosted, hashtag, community, draft, edited, expiration,
pinned, private-rumor) to 12sp/smaller icons since the row now carries
more information.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5
2026-07-13 01:32:32 +00:00
Vitor Pamplona
a2e858d7b4 Merge pull request #3544 from vitorpamplona/claude/pr-note-button-sizing-732r64
Clean up Git card styling and fix duplicated subject in content
2026-07-12 21:28:11 -04:00
Claude
8ee90907ca feat: nickname users via NIP-85 contact cards signed by the account key
Adds petnames/nicknames per https://github.com/nostr-protocol/nips/pull/761,
reusing the kind:30382 contact card already used for WoT scores — a card only
acts as a nickname when signed by the account's main key. Petname and summary
are always stored in the card's NIP-44 encrypted content.

quartz:
- ContactCardEvent.updatePetNameAndSummary edits both fields in the encrypted
  private tags, strips stray public copies, preserves every other tag
- TagArray.petName()/summary() parsers for decrypted tag lists + tests

commons:
- ContactCardDecryptionCache: LRU NIP-44 decrypt cache for own cards
- ContactCardsState: account-scoped access to the account's own cards,
  petname flow per target user, create/update entry point

amethyst:
- Account.updateContactCardPetName publishes through the extended outbox
  relays (NIP-65 write + private outbox + local + broadcast) and the login
  subscription now downloads the account's own kind:30382s from its relays
- petname renders instead of the display name in usernames, profile header,
  chats and @mentions (observeUserPetName)
- Edit-nickname action + dialog on the profile actions menu

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY
2026-07-13 01:26:27 +00:00
Claude
ba0e6a0391 feat(chat): self-sufficient, reactive minichat + Concord screens; keep minichats off DMs
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
2026-07-13 01:21:44 +00:00
Vitor Pamplona
82fa652c19 Merge pull request #3543 from vitorpamplona/fix/inbox-nostr-wine-over-auth
fix(relay-auth): stop paid relays billing accounts that don't use them (inbox.nostr.wine over-AUTH)
2026-07-12 21:05:35 -04:00
Vitor Pamplona
6a528a742c fix(relay-auth): show a relay's payment NOTIFY only under the billed account
The relay NOTIFY (payment prompt) went into a process-wide NotifyRequestsCache
and DisplayNotifyMessages showed it under any account whose relay list contained
the relay — so a prompt billed to Amethyst surfaced under Vitor, and stale
entries re-appeared on every account switch.

A NOTIFY doesn't reliably name a pubkey, so instead of parsing the message we
correlate it with the AUTH that triggered it: a paid relay answers an
unauthorized AUTH with `OK <authEventId> false …` right before the NOTIFY, and we
signed that auth event. NotifyCoordinator remembers each auth event's signer,
maps the failing OK back to it, and files the NOTIFY into THAT account's own
Account.relayNotifications. Unattributable NOTIFYs are dropped. DisplayNotifyMessages
now reads only the current account's cache.

Also removes the now-unused app-wide RelayAuthPermissionStore singleton and wires
NotifyCoordinator with the pubkey -> Account lookup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:32:24 -04:00
Vitor Pamplona
0ec63958b8 fix(relay-auth): only AUTH a relay with accounts that actually use it
One shared NostrClient serves every logged-in account, so a relay's NIP-42 AUTH
challenge is not tied to any one of them. AuthCoordinator used to fold every
account's verdict into one decision and then sign with EVERY account (or a random
throwaway key), so a paid relay like inbox.nostr.wine billed and de-anonymized
accounts that never used it — including via a merged read filter that merely named
them, and via account switches / the background notification consumer.

Decide and sign PER ACCOUNT now: an account signs only if the relay is in its own
relay list, or it is publishing its own event there (RelayAuthFirstParty), AND its
own ledger verdict (Account.relayAuthLedger) allows it. A subscription merely
naming the account is NOT first-party — that is the merged-filter false positive.
The random-ephemeral-key fallback is removed. Own-inbox reads still qualify: the
relay serving them is by definition in the account's own list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:32:24 -04:00
Vitor Pamplona
f8416f4940 feat(relay-auth): per-account NIP-42 override store + state on Account
Per-relay ALLOW/DENY AUTH overrides (and the policy ledger that reads them)
lived in one process-wide DataStore keyed only by relay URL, so a DENY set for
one account silently applied to every logged-in account. Move them to a
per-account file under accounts/<pubkey>/ and warm-cache the overrides in memory
on the Account (RelayAuthPermissionCache) so an AUTH challenge is answered
without a disk read.

DataStoreRelayAuthPermissionStore now shares one DataStore per file path —
DataStore v1 forbids two live instances on one file, and loadAccount can build
the store more than once per account.

Introduces the per-account state holders wired up by the following commits:
Account.relayAuthPermissions, relayAuthLedger and relayNotifications.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:32:24 -04:00
Claude
57ecda7302 refactor: chat audit cleanup - shared action catalog and dedup
- 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
2026-07-12 22:33:58 +00:00
Claude
e41b089a26 fix: chat audit correctness and performance fixes
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
2026-07-12 22:21:17 +00:00
David Kaspar
c2714a2f9b Merge pull request #3542 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-12 23:18:17 +01:00
Claude
64d6c038a7 feat(ledger): redesign the usage screen — stat tiles, trend chart, proportion bars
The first version was a wall of identical label-value rows: correct but
unreadable at a glance. The redesign gives the screen visual hierarchy
and proportion:

- Headline 2x2 stat tiles for today (cellular, wi-fi, relay time, time
  in app) — the numbers that matter get the visual weight.
- A 7-day data-per-day trend chart (Vico, same line/area idioms and
  weekday axis as the notification summary chart) with a cellular/wi-fi
  legend. Color pairs validated for CVD separation and lightness on both
  surfaces: BitcoinOrange+RoyalBlue on light, #C77414+RoyalBlue on dark
  (a selected dark variant, not an automatic flip).
- Data-by-feature as ranked proportion bars, so "video dwarfs relays"
  is visible without reading numbers.
- Live memory as meters: the heap bar keeps the retired debug chip's
  green/amber/red thresholds; image caches as fill bars.
- Activity counters stay as compact rows under one section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P
2026-07-12 22:15:04 +00:00
Claude
fe780a4220 fix(concord,chat): address audit findings (mentions, re-index perf, auth coupling, cleanups)
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
2026-07-12 22:15:04 +00:00
Claude
716a7c82cb feat(ledger): live memory card on the usage screen; retire the debug-only chip
Moves the debug-build memory chip (home top bar) into the resource-usage
screen as a "Memory right now" card visible to everyone: app heap, native
heap, Coil RAM/disk cache fill, LocalCache note/profile/addressable/
chatroom counts, and the device memory class — refreshed every 2s only
while the screen is composed (same off-main collection the chip used,
DiskLruCache.size() contends with journal I/O).

The same snapshot is appended to the NIP-17 usage report (both from the
screen's send button and the high-consumption dialog), so a report now
also shows whether the device was under memory pressure at send time.

MemoryUsageChip.kt is deleted and the chip call removed from
UserDrawerSearchTopBar — the debug feature is retired from the home UI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P
2026-07-12 21:24:46 +00:00
Claude
f0f0056be4 feat(ledger): reconnect churn, process CPU, foreground time, verify metering
Extends the resource-usage ledger with the metrics that answer "what else
could be draining":

- Relay (re)connection + failed-dial counters per network/visibility via
  the existing RelayConnectionListener hook. Every completed connect paid
  a TCP+TLS handshake, so high daily counts are the reconnect-churn
  signature — new alert threshold at >5000 connects/day.
- Whole-process CPU time (Process.getElapsedCpuTime deltas sampled at
  ledger flush): the honest aggregate of parsing, crypto, coroutines and
  UI — one syscall per flush, nothing while idle. Answers whether CPU
  matters at all before any per-subsystem drill-down.
- Foreground time (time with UI visible): what display power scales with,
  and the denominator that makes every other per-day number interpretable.
- Signature-verification count + CPU time via a nullable meter hook on
  LocalCache.justVerify (wired like cache.onchainBackend), settling the
  "does Schnorr verify cost matter" question with data.

Deliberately not tracked (documented in the plan): per-screen time (route
names leak behavior patterns into a shareable report — needs its own
privacy pass), per-coroutine CPU (needs a thread registry; cpu.ms first),
and signing (user-action-rate, negligible).

All metrics surface in the usage screen, the NIP-17 report, and
UsageSummary; 4 new tests (18 total for the ledger).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P
2026-07-12 21:16:14 +00:00
Claude
e537412768 feat: open zaps and reactions in their own thread view
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
2026-07-12 21:01:47 +00:00
Claude
4324a8b8e4 feat(chat): extend dual-mode replies to public chats (NIP-28/NIP-29) — Phase 2
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
2026-07-12 20:57:39 +00:00
Claude
6415515a5e feat: resource-usage ledger — on-device battery/data accounting + NIP-17 reports
Passive, always-on counters (never transmitted automatically) that make
battery/data complaints diagnosable in production, where they actually
happen — see amethyst/plans/2026-07-12-resource-usage-ledger.md:

- Per-subsystem HTTP bytes (image/video/uploads/money/nip05/preview/push)
  via a counting interceptor wrapped per role in RoleBasedHttpClientBuilder,
  split by cellular/wifi and foreground/background.
- Relay traffic (RelayConnectionListener) and relay connection-time
  (Σ connections x time, integrated from connectedRelaysFlow with no
  timers) — the ping study's best single battery proxy.
- Notification wakelock held-time, background worker runs, and app
  process starts (WorkManager churn detector).
- Daily buckets persisted to one small JSON file (ScheduledPostStore
  idiom), pruned after 30 days; counters are sizes/durations/counts only,
  no URLs, relay names, or content.

Surfaces:
- Settings > "App resource usage": today / 7-day summaries and a
  data-by-feature breakdown, plus an explicit "send report via DM"
  button that prefills the NIP-17 composer to the developers (same
  pipeline, recipient, and 30-day expiration as crash reports — nothing
  sends until the user taps Send).
- High-consumption prompt: on app open, if the last day crossed
  conservative thresholds (>50 MB background cellular, >12 relay-hours
  backgrounded on cellular, >30 min notification wakelock, >75 process
  starts), an AlertDialog offers review-and-send — at most once every
  7 days, with a persistent "don't ask again".

14 unit tests cover the store, accountant (day rollover, live-merge,
pre-flush hooks), connection-time integrator (incl. the long-stable-
session case), and alert thresholds/rate limiting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P
2026-07-12 20:57:35 +00:00
Claude
e7ff2744a9 feat(chat): chat-styled minichat screen + route, chip opens it
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
2026-07-12 20:50:52 +00:00
Claude
025d63672f feat(chat): dual-mode replies (inline + minichat) — foundation, Concord composer, shared chip
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
2026-07-12 20:40:29 +00:00
davotoula
8e46714fca chore: sync Crowdin translations and seed translator npub placeholders 2026-07-12 20:06:46 +00:00
Claude
8065b045e4 fix(concord): build thread replies as kind-1111 NIP-22 comments, not kind-9 quotes
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
2026-07-12 20:06:20 +00:00
David Kaspar
a1f470f1ec Merge pull request #3539 from davotoula/fix/ci-android-oom-and-kover-optin
Remove Kover coverage plugin to fix runner OOM
2026-07-12 21:04:36 +01:00
Claude
a760d17eaa test(battery): behavioral tests for the keep-alive pause and worker gates
The ping-interval change shipped on reasoning and was overturned by
measurement; these tests give the remaining battery changes the same
scrutiny where the claims are about OUR code (deterministically
testable), instead of leaving them reasoning-only:

- NostrClientKeepAliveTest (quartz): a server-closed relay is redialed
  within one 60s sweep while active; ZERO dials happen while the client
  is inactive no matter how much time passes; and the sweep provably
  resumes after connect() — the failure mode a suspend/resume bug in
  the new isActiveFlow gate would cause.

- ScheduledPostWorkGate extracted from AppModules + tests: schedules on
  the first PENDING post, cancels when the last drains, re-schedules on
  publishNow-retry, and — the race that motivated the extraction —
  never emits a spurious cancel from the store flow's empty placeholder
  before the disk load completes.

- CalendarReminderWorker.couldStillFire extracted + tests against
  LocalCache: future target keeps the chain, past target lets it end,
  and an unresolved/start-less target keeps it alive so a reminder
  can't be lost while the target event is still being fetched.

The watchdog alarm change (wakeup -> non-wakeup) remains reasoning-only
by nature: it asserts Android OS alarm semantics on OEM devices, which
no JVM test can exercise; its blast radius is limited to opt-in
always-on users with two independent restart layers still active.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P
2026-07-12 19:32:20 +00:00
Claude
78e1d03eb0 feat: swipe-to-reply, send motion, and bubble press feedback
- 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
2026-07-12 19:21:40 +00:00
David Kaspar
8689a409db Merge branch 'main' into fix/ci-android-oom-and-kover-optin 2026-07-12 19:39:56 +01:00
Claude
4212f38219 revert(relays): keep the 120s WebSocket ping on mobile — measured, not guessed
Probed 122 production relays (top of 1,468 harvested from NIP-65 lists)
with idle connections and client pings at 55/110/120/180/240/300s.
Findings, detailed in amethyst/plans/2026-07-12-relay-ping-interval-study.md:

- 99/122 relays survive 13 min of total client-ping silence; 90 of those
  send their OWN pings every 30-70s, which OkHttp must answer — so the
  cellular radio's wake cadence is set by the relays, not by our ping
  interval. The battery saving of the 240s mobile interval was illusory.
- Idle-timeout tiers exist at ~60/~120/~240/~300/~600s, and pings only
  reliably reset a relay's timer when the interval is well below the
  tier: 240s pings drop relay.ditto.pub and the nostr1.com hosting tier;
  300s pings drop even relay.snort.social. 120s holds every tier >=240s.
- Lowering below 120s would only rescue a ~120s tier (6 relays) that
  already cycles today under 120s pings (110s/120s both fail; 55s works).

Net: 120s is the measured sweet spot; the mobile/wifi split is removed
and the constant now documents why.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P
2026-07-12 16:12:51 +00:00
davotoula
d7e163111a build: remove Kover coverage plugin
Reverts the Kover coverage aggregation (introduced in dca345212b) from every
module, the root aggregation block, the Sonar coverage import, the version
catalog, and BUILDING.md. With the plugin present the 16 GB CI runner OOM-killed
during the Android test+build job even with Kover opt-in disabled; removing it
entirely clears the OOM. No other CI changes — the diff versus main is exactly
the inverse of dca345212b.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:52:29 +01:00
Claude
cc18043bb3 feat: chat foundations - engagement details, delivery details, name colors, jumbo emoji
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
2026-07-12 15:45:30 +00:00
Claude
0bac5c7aca fix(concord): populate channel community metadata account-wide so the Messages chip shows
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
2026-07-12 15:33:58 +00:00
Claude
e9899f95cb feat: remove border from git event cards
Try git patch/issue/PR/repo cards without the 1dp outline so they read
like regular posts; the container keeps only the full-width column.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USiovhenaijgVEFFzASgfy
2026-07-12 15:20:55 +00:00
Claude
97c7fbfe80 fix: compact git-card action buttons and dedupe issue/PR subject heading
The NIP-34 status buttons (Mark merged / Close / Reopen) and the PR
View-changes button used default Material3 sizing, which reads oversized
inside a note card. They now use the same 32dp compact height, tighter
content padding and labelMedium text as the app-recommendation buttons.

Issue and PR cards also showed the title twice when the authoring client
duplicated the subject tag as a leading markdown heading in the content
(gitworkshop/ngit do this). The body renderer now strips that first
heading when it matches the subject already rendered as the card title.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USiovhenaijgVEFFzASgfy
2026-07-12 02:59:08 +00:00
Claude
bd42b1695b fix(concord): hydrate unsigned channel rumors so messages render (not a placeholder)
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
2026-07-12 02:32:17 +00:00
Claude
60475c10c0 feat(concord): NIP-42 AUTH as the derived plane stream key (CORD-01 §4b)
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
2026-07-12 01:21:58 +00:00
Claude
070e6242cd feat: unpack the full 3-dot menu into the chat long-press sheet
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
2026-07-12 00:08:51 +00:00
David Kaspar
c337c2353a Merge pull request #3540 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-11 23:06:21 +01:00
davotoula
82f1780b55 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-11 22:01:21 +00:00
David Kaspar
768523143c Merge pull request #3538 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-11 22:59:12 +01:00
Claude
3feb0049e6 perf: reduce background and mobile-data battery usage
Five targeted fixes for the biggest idle drains:

- NostrClient keep-alive sweep: the 60s reconnect loop kept ticking for
  the whole life of the process even after disconnect() (i.e. the entire
  time the app sat in the background). isActive is now a StateFlow and
  the loop suspends on it, so an inactive client schedules zero timers.

- Relay WebSocket pings on cellular: every ping on an idle connection
  promotes the radio out of its low-power state, per connection. The
  mobile-data client now pings every 240s instead of 120s (wifi keeps
  120s), staying under common carrier NAT idle timeouts.

- Always-on service watchdog: the 5-min health-check alarm no longer
  uses the _WAKEUP variant. Pulling the CPU out of sleep to restart a
  service that can't do useful network work on a sleeping device was
  pure cost; the alarm now fires as soon as the device is next awake,
  and the WorkManager + FCM/UnifiedPush layers still cover doze.

- ScheduledPostWorker: the 15-min periodic worker was enqueued forever
  for every user, waking (often cold-starting) the process with nothing
  to publish. It is now enqueued exactly while the store holds a PENDING
  post, driven by a store-flow observer in AppModules.

- CalendarReminderWorker: same unconditional 15-min periodic schedule,
  but worse — LocalCache is memory-only, so a WorkManager wake of a dead
  process can never find an RSVP to remind about. The worker is now
  scheduled when an ACCEPTED RSVP lands in LocalCache and cancels its
  own chain when nothing upcoming remains (or the feature is disabled).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P
2026-07-11 21:29:33 +00:00
Claude
0eff66c8c9 feat: unpack zap amount presets directly in the chat long-press sheet
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
2026-07-11 19:07:42 +00:00
davotoula
f2f8bdb765 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-11 16:13:04 +00:00
davotoula
0053edf2dd refactor: adopt androidx.core KTX helpers (Bitmap/Uri/SharedPreferences) 2026-07-11 17:00:40 +01:00
Claude
d7a31759cc feat: round raid and clip stream cards to match the chat design
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
2026-07-11 15:30:30 +00:00
Claude
90998ee838 fix(concord): import the 13302 list from the user's own relays too, not just stock
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
2026-07-11 15:25:33 +00:00
David Kaspar
56e4b1ec7d Merge pull request #3537 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-11 16:24:08 +01:00
Claude
3cac9077ec refactor(concord): delete ConcordKinds; kinds live on their owning classes
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
2026-07-11 15:12:32 +00:00
Claude
9c71034dd2 Merge remote-tracking branch 'origin/main' into claude/concord-quartz-amethyst-plan-0oy779 2026-07-11 15:04:57 +00:00
vitorpamplona
757f348a46 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-11 15:04:41 +00:00
Vitor Pamplona
00e02048ea Merge pull request #3536 from davotoula/feat/kover-coverage
Aggregate Kover coverage (for SonarQube import)
2026-07-11 11:02:23 -04:00
Claude
0015b39085 fix(concord): read/write the kind-13302 list in Armada's wire format
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
2026-07-11 14:56:30 +00:00
Claude
127d959c8d refactor(concord): community-list, control, invite-bundle events own their kind literals
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
2026-07-11 14:56:28 +00:00
Claude
ae6983a7a8 fix(concord): pasting an invite into Search opens the redeem flow, not a broken event screen
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
2026-07-11 14:56:26 +00:00
David Kaspar
4ceb8f78c2 Merge pull request #3532 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-11 15:38:48 +01:00
Vitor Pamplona
aff10df508 Merge pull request #3535 from vitorpamplona/claude/nostr-metadata-parse-errors-jmzlyl
fix: don't drop whole kind-0 profiles over one mistyped field
2026-07-11 10:38:07 -04:00
davotoula
dca345212b build: aggregate Kover coverage for SonarQube import
- Apply Kover (Apache-2.0, build-time only) to the KMP/JVM modules and aggregate at the root
- include amethyst playDebug in the aggregated Kover coverage
2026-07-11 15:37:40 +01:00
Claude
b68dd6ad16 feat: align live-chat zap cards with the new chat design
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
2026-07-11 14:34:54 +00:00
Claude
0e1a14a62d fix: don't drop whole kind-0 profiles over one mistyped field
Clients in the wild publish structurally wrong values in profile
metadata — e.g. "nip05":{} — and the strict field serializers made
JsonMapper throw, so contactMetaData() returned null and the entire
profile (name, picture, about…) was discarded.

Generalize the BirthdayTolerantSerializer precedent: string fields now
use TolerantStringSerializer (accepts any JSON primitive, ignores
objects/arrays/null) and the bot flag uses TolerantBooleanSerializer,
so a single malformed field is skipped instead of being fatal.

Non-JSON content (e.g. "Relay initialized") remains unrecoverable and
still parses to null; a test pins that it does so without throwing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSCGp6KgcnaepEMbxNroYe
2026-07-11 14:34:50 +00:00
davotoula
2edb22783c chore: sync Crowdin translations and seed translator npub placeholders 2026-07-11 14:32:20 +00:00
davotoula
d1e5a4ef83 update cs,sv,de,pt 2026-07-11 15:29:23 +01:00
Vitor Pamplona
a5f9a234c1 Merge pull request #3534 from vitorpamplona/claude/auth-permission-message-text-moco75
Clarify relay auth descriptions for profile and engagement data
2026-07-11 10:28:13 -04:00
Claude
5365311e7d fix: describe relay auth read permission as profile, posts and engagement
The READ_OUTBOX relay-auth prompt and the settings toggle described the
permission as only loading posts, but authenticating to a follow's relay
also downloads their profile information and post engagement. Update the
prompt title, consequence line, reason label and toggle description to
say so.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VmzSAtkFv29KWDzPVBbxS
2026-07-11 14:22:09 +00:00
Claude
8a6b658984 feat: render channel admin events as centered system messages
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
2026-07-11 14:04:03 +00:00
Vitor Pamplona
8e38ea6607 Merge pull request #3533 from vitorpamplona/claude/amethyst-miner-performance-0rw1dn
Parallelize PoW mining with multi-worker nonce search
2026-07-11 09:54:01 -04:00
Claude
5683c74629 perf(amethyst): mine on half the device's cores in the Android app
Each PoW job now races PoWPolicy.minerWorkers(cores) = cores/2 parallel
searches (at least 1) instead of a single thread, so a post on an 8-core
phone mines ~4x faster while the other half of the cores stays free for
the UI, and the total CPU budget matches the old 2-job x 1-thread pool.

- PoWPolicy.minerWorkers(availableProcessors) is the single definition of
  the per-job worker budget.
- PoWPublishQueue gains minerThreads (used by its built-in miner and
  exposed for custom mine lambdas); AppModules wires maxConcurrent = 1 +
  minerThreads = half the cores.
- PoWNostrSigner gains a workers param (default 1), covering reactions,
  reposts and reports via Account.miningSigner; the anonymous-post paths
  in the short-note and comment composers pass the same budget.
- Gift-wrap envelope mining stays single-threaded: the template
  conversion is a non-suspend hook inside the synchronous NIP-59 build.
- UI time estimates (settings picker, composer chip, broadcast banner)
  now benchmark at the queue's worker count via deviceHashesPerSecond(),
  and PoWEstimator caches one rate per worker count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhaF5scnAvhP9wr9R7bTWM
2026-07-11 12:13:32 +00:00
Claude
bc365c8ca2 refactor(concord): invite bundle (33301) gets its own addressable event
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
2026-07-11 12:00:42 +00:00
David Kaspar
9d4e2c3ea6 Merge pull request #3531 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-11 12:15:30 +01:00
davotoula
cc275ce62d chore: sync Crowdin translations and seed translator npub placeholders 2026-07-11 10:47:38 +00:00
davotoula
b8570797fe refactor: extract no-reachability-cache flag name into a constant 2026-07-11 11:44:21 +01:00
m
a1b7cad85c feat(compose): wire NIP-05 popover mentions to nostr:nprofile1…
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.
2026-07-11 20:35:16 +10:00
m
6ac8e475df feat(nip05): add Nip05Id.parseLenient for mention/text rendering
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.
2026-07-11 20:35:16 +10:00
davotoula
cf95b07855 fix: document intentionally empty default no-op bodies 2026-07-11 11:12:20 +01:00
Claude
cbeff0e8d8 docs(concord): mark chat + control refactor slices done in the plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-11 04:36:12 +00:00
Claude
dc2abc0e1c refactor(concord): control edition (3308) gets its own event package
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
2026-07-11 04:27:49 +00:00
Claude
5abb89435d feat: relay-acceptance delivery ticks on own chat messages
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
2026-07-11 04:07:15 +00:00
Claude
c2cbd602bc refactor(concord): chat plane reuses standard events + typed binding tags
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
2026-07-11 04:05:07 +00:00
Claude
ac98036a8e perf(pow): mine NIP-13 proof of work on all cores and drop per-hash allocations
The miner enumerates the nonce space deterministically (the random base is
overwritten before the first hash), so naively racing N copies of the search
duplicates the exact same candidate sequence N times. PoWMiner.mine() now
races workers over disjoint slices instead: each worker's nonce carries a
distinct fixed prefix while only the bytes after it are enumerated, making the
aggregate hash rate scale with cores (~3.8x on a 4-core box).

The hot loop also switches from sha256() to sha256Into() with a reused
32-byte buffer, so hashing no longer allocates per attempt.

amy wiring:
- `pow mine` and `post --pow` mine on all cores by default; `pow mine
  --threads N` overrides.
- `pow bench` measures the all-cores rate (what mining now uses, also the
  basis for expected_seconds) alongside a new hashes_per_second_single_core.
- PoWEstimator benchmarks with sha256Into to match the miner, and gains a
  workers overload that prices in cross-core contention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhaF5scnAvhP9wr9R7bTWM
2026-07-11 04:04:28 +00:00
Claude
f18ba03a48 fix(concord): store the kind-13302 list replaceably so the hub populates
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
2026-07-11 03:53:17 +00:00
Claude
5a2587c085 feat: show timestamp on the last message of each author run
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
2026-07-11 03:45:21 +00:00
Claude
34bf660455 feat: refine chat design per review feedback
- 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
2026-07-11 03:39:38 +00:00
Claude
ec51d4020c feat(concord): stock-relay list import + single-screen community/channel browser
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
2026-07-11 03:30:26 +00:00
Claude
09a944250a Merge remote-tracking branch 'origin/main' into claude/modernize-chat-rendering-kigcsh 2026-07-11 03:21:54 +00:00
Vitor Pamplona
b64c378a9d Merge pull request #3519 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-10 23:20:26 -04:00
Vitor Pamplona
5c8801082a Merge pull request #3513 from vitorpamplona/claude/amy-graperank-crawl-perf-jhmx0x
GrapeRank crawl: connect once, wait once — 38% faster at hop-3; hop-8 31% faster at +15% lists / +47% users
2026-07-10 23:19:58 -04:00
Vitor Pamplona
4cc88cc0ae Merge pull request #3529 from vitorpamplona/claude/powdisplay-notecompose-sizing-x8ua7i
Reduce PoW badge size for better UI balance
2026-07-10 23:19:34 -04:00
vitorpamplona
835f1e5e85 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-11 03:16:54 +00:00
Vitor Pamplona
d6c046249e Merge pull request #3528 from vitorpamplona/claude/fix-signed-events-reference-vhrzsa
Mark DM rooms as read when wraps are mined in background
2026-07-10 23:14:35 -04:00
Claude
f3084fcf79 docs(graperank): record communal-sweep hop-8 validation (160.7 min, +15.6% lists, +47% users)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-11 03:14:15 +00:00
Claude
9801f03ce4 fix: make the PoW pill in note headers a bit smaller
Shrinks the DisplayPoW icon and text from 12 to 10 (dp/sp) and trims
horizontal padding so the pill takes less space inline in NoteCompose
headers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PbpEL8rRBwLd2mgbAqBPvX
2026-07-11 03:06:24 +00:00
Claude
a3cdb06404 fix: resolve bad merge of broadcastPrivately overloads and DM read marker
The PoW publishing-queue branch split broadcastPrivately into a
NIP17Factory.Result overload delegating to a List<GiftWrapEvent> one,
while the DM-unread branch appended markDmRoomAsRead(signedEvents.msg)
to the old single body. The merge left that call inside the wraps
overload, where signedEvents does not exist, breaking compilation.

Move the read-marker call to the Result overload, and mark the room as
read on the PoW early-return path too (the rumor is signed inline there
and the wraps only publish after mining), so PoW-enabled accounts keep
the unread-clearing behavior from #1286/#1287.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1hu9zpyiuJo6yyoJs1vdw
2026-07-11 03:03:30 +00:00
Claude
feb5c0e7ce feat: modernize chat message rendering across all chat channels
- 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
2026-07-11 02:13:44 +00:00
Claude
c4657d482e feat(concord): invite card, deep links, and Messages group-by-community
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
2026-07-11 02:03:10 +00:00
Claude
8326f08f43 Merge remote-tracking branch 'origin/main' into claude/concord-quartz-amethyst-plan-0oy779 2026-07-11 01:51:00 +00:00
Claude
73c6bd6180 feat(concord): members roster + edit-metadata screens, redesigned create form
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
2026-07-11 01:50:49 +00:00
Vitor Pamplona
c889952e58 Merge pull request #3526 from vitorpamplona/claude/relay-nip29-groups-button-gadubu
Add NIP-29 relay groups button to relay information screen
2026-07-10 21:48:20 -04:00
Vitor Pamplona
e60003063b Merge pull request #3527 from vitorpamplona/claude/nip13-pow-publishing-queue-1takp1
Add NIP-13 Proof-of-Work mining queue with persistence and UI
2026-07-10 21:48:03 -04:00
Claude
c84cee8107 feat(pow): predictable mining progress — estimated-time bars and time-left labels
The mining banner's indeterminate bar becomes determinate: each mining job
gets its own bar that fills over the device's estimated duration for its
difficulty (elapsed / (2^bits / benchmarked hash rate)), with an
"≈ 10 minutes left" label next to the elapsed time. The nonce search is
memoryless, so past the mean the bar honestly falls back to the
indeterminate sweep with "any moment now" instead of parking at 100%.
Queued-only banners keep the shared activity sweep.

The mining notification mirrors this: single-job progress fills toward the
estimate with the time-left text, refreshed every 30 s while mining (the
queue only emits on state changes, not clock ticks).

The composer difficulty menu now prices each option on this device —
"24 bits · ≈ 45 seconds" — using the same cached benchmark, and the duration
formatting is shared (formatApproxDuration/formatTimeLeft) with the settings
estimate instead of being private to the settings screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-11 01:43:06 +00:00
Claude
195538e25f style: reduce relay header button row padding to 20dp
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012w9KridAbRpesbX2ytdjn4
2026-07-11 01:40:41 +00:00
Claude
39ffb0ed4d feat(pow): move composer PoW button before Subject; switch icon to Manufacturing
The per-post PoW button on the New Post screen now sits before the
Subject/Title toggle instead of last in the options row.

Every PoW surface (composer button, note chip, mining banner, settings tile,
relay-info minimum-PoW row) switches from the Bolt icon to Manufacturing
(gear, U+E726): Bolt is the app's zap/lightning glyph everywhere else, so a
Bolt PoW badge next to the zap-split/zapraiser/invoice buttons read as a
payment feature. Subset font regenerated for the new codepoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-11 01:33:54 +00:00
Claude
f17d3e942a feat: add Groups button to relay info screen for NIP-29 relays
Shows a button in the relay information header that opens the relay's
NIP-29 group list (RelayGroupServer route) when the relay's NIP-11
document advertises support for NIP-29. Also converts the header button
row to a FlowRow and marks button labels single-line so labels no
longer break mid-word (e.g. "Member/s") when the row runs out of
width.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012w9KridAbRpesbX2ytdjn4
2026-07-11 01:24:31 +00:00
Claude
0fae8b4966 fix(concord): route taps on a Concord message to its channel
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
2026-07-11 01:20:42 +00:00
Claude
80ece7c40f refactor(nip59): replace wrap PoW params with a template-conversion hook
GiftWrapEvent.create no longer takes powDifficulty/powIsActive — it takes a
single templateConversion hook ((template, ephemeralPubKey) -> template,
default identity) that runs on the finished wrap template right before the
ephemeral key signs it. The hook receives the ephemeral pubkey because the
NIP-01 id a nonce commits to includes it and the key never leaves create().

NIP17Factory forwards the same hook through wrapSeal/createWraps and the
create*NIP17 entry points, so quartz's NIP-59/NIP-17 code no longer imports
the NIP-13 miner at all; Account builds the mining closure at the call site.
Any future pre-sign wrap adjustment flows through the same seam.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-11 01:17:18 +00:00
Claude
9771d24cba fix(concord): fetch the kind-13302 community list at login
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
2026-07-11 01:13:46 +00:00
Claude
7ebbd2e46e fix(concord): round the create-community FAB to match app convention
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
2026-07-11 01:08:39 +00:00
Vitor Pamplona
0eb2beda22 Merge pull request #3525 from vitorpamplona/claude/dm-unread-own-messages-jl18pt
Fix DM read state: mark rooms as read when sending messages
2026-07-10 20:55:14 -04:00
Claude
6dd1e4d31d fix(pow): resolve audit findings — durability, coverage, perf and l10n
Durability & correctness:
- Keep the job entry + disk checkpoint alive until the publish continuation
  completes (was: dropped at mining-complete); failed publishes keep their
  checkpoint for restart retry and surface through a failures SharedFlow
- Move draft deletion into the publish continuations in every composer so a
  cancelled or process-killed mining job can't destroy the only copy of a post
- Persist gift-wrap mining: split NIP17Factory into createSeals (signer
  interaction, runs inline) + wrapSeal (pure CPU, runs on the queue), new
  REPLAY_WRAPS records restore pending DM wraps after process death
- Clamp synced NIP-78 difficulty (PoWPolicy.MAX_DIFFICULTY), require a sane
  target in PoWMiner, bound PoWRankEvaluator against short ids
- Reaction double-tap while mining now toggles (dedupeKey + cancelByKey)
  instead of publishing duplicates
- Restore checkpoints for every loaded account, not just the active one
- logOff purges the account's checkpoints and cancels its queued jobs
- Private notes: composer chip now gates on the gift-wrap kind and the
  per-post override reaches sendPrivateNote

Coverage:
- Public/live chat (kinds 42 + 1311) and voice replies (1244 + kind-1 audio
  replies) now route through the mining gate

Perf:
- FGS start() dedupes with a running flag; PendingIntents built once
- Banner 1 Hz clock only ticks while a job shows elapsed time
- PoWEstimator benchmark is single-flight behind a Mutex

UX / l10n:
- Post-mining failures toast with retry information
- Count strings converted to <plurals>; elapsed time via DateUtils; settings
  estimate uses localized units; shared powKindLabelRes replaces three
  duplicated kind→label maps; dead pow_chip_* strings removed
- CLI: pow mine lowercases the pubkey before mining (uppercase hex mined an
  id that never matches the signed event) and validates via quartz Hex

New queue tests: checkpoint lifetime, failure reporting, cancelByKey toggle,
per-owner cancellation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-11 00:31:05 +00:00
Claude
1cb930f941 Merge remote-tracking branch 'origin/main' into claude/concord-quartz-amethyst-plan-0oy779 2026-07-11 00:21:29 +00:00
Claude
139659bbda Merge remote-tracking branch 'origin/main' into claude/dm-unread-own-messages-jl18pt
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
2026-07-11 00:17:37 +00:00
Claude
b4fe6dffcd refactor(concord): move per-relay filter assembly to shared planner
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
2026-07-11 00:16:05 +00:00
Claude
bafa0cca9a feat(concord): invite dialog shows a QR code + share sheet
The minted invite link now renders as a scannable QR (QrCodeDrawer) with a
system Share action (ACTION_SEND chooser) and a Copy fallback, instead of just
copying raw text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-10 23:55:20 +00:00
Claude
3f475fd5eb feat(concord): rich community/channel rows + inbox community icon
- 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
2026-07-10 23:53:49 +00:00
Claude
507de32575 fix: harden own-message-counts-as-read against audit findings
Addresses the issues found reviewing the #1286/#1287 fix:

- Centralize the room read-marker route in privateChatLastReadRoute()
  and the authorship rule in chatMessageMarksRoomAsRead(); all writers
  and readers now share one implementation.
- Exempt notes-to-self rooms from the authorship rule — there the
  user's own messages are the content still to be seen.
- Mark own chat messages as read at the ingestion choke point
  (EventProcessor), covering every send path (including the play-flavor
  App Functions assistant) and own messages arriving from other devices
  at the persisted-marker level, not just visually.
- Advance the send-path marker to the newest known room message, so a
  quick-reply to a skew-ahead peer also clears the indicators.
- Revert the authorship guard on public-chat/ephemeral/marmot rows:
  in multi-party channels 'I posted last' does not imply the earlier
  backlog was seen, and no marker advances there to back the guard.
- Restore the new-items bubble for rooms whose newest item is an
  unsent draft — a draft still needs the user's attention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqN8cfqNdo1MAvLN4C9krm
2026-07-10 23:51:55 +00:00
Claude
f7ee2964db feat(concord): create screen uses the relay picker + icon field
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
2026-07-10 23:49:04 +00:00
Claude
bfc77e80f6 feat(concord): rich message composer (@mention search, inline mentions, reply preview)
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
2026-07-10 23:45:29 +00:00
Vitor Pamplona
57aaf69073 Merge pull request #3524 from vitorpamplona/claude/relay-icons-display-timing-7c3n7c
Fix relay attribution in gift-wrap chains and Marmot messages
2026-07-10 19:11:47 -04:00
Claude
f0f57a93ff fix: record relay attribution for repost and community-approval notes
Non-chat audit follow-up: RepostEvent, GenericRepostEvent, and
CommunityPostApprovalEvent consumes never called note.addRelay, so those
notes carried an empty relay list forever.

- CommunityPostApprovalEvent is badge-rendered directly in community feeds
  (BadgeBox has no repost-style indirection for it), so its relay icon row
  was always empty.
- Repost badges borrow the boosted note's relays in BadgeBox, but the
  repost note's own list feeds relayHintUrl()/relayUrls() for nevent hints
  when citing or sharing the repost — those fell back to author outbox
  guesses.

Add the standard attribution block (author.addRelayBeingUsed +
note.addRelay) before the duplicate check, matching consumeRegularEvent,
so both first arrivals and re-deliveries from other relays are recorded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEiq1NMK3q12KGQ2yYhPEp
2026-07-10 23:10:03 +00:00
Vitor Pamplona
eaf29a265d Merge pull request #3523 from vitorpamplona/claude/markdown-hashtag-fragments-9ya187
Support markdown hashtag links (#tag fragments)
2026-07-10 19:04:02 -04:00
Claude
f151cb74fc fix: drill relay attribution into Marmot kind-445 inner notes
Audit follow-up to the gift-wrap relay-icon fix: Marmot group chat rows
render the decrypted inner note (kind 9/7) via the standard chat feed with
RelayBadgesHorizontal, but nothing ever populated that note's relay list —
OK acceptances and relay deliveries all landed on the kind-445 envelope,
which has no link to its inner event.

- GroupEvent now implements HasInnerEvent (same @Transient @Volatile
  innerEventId pattern as GiftWrapEvent/SealedRumorEvent), so
  LocalCache.addRelayToNoteAndInners drills 445 -> inner for both OK
  confirmations and duplicate EVENT deliveries. RouteMaker is unaffected:
  it gates on the concrete wrap types before casting to HasInnerEvent.

- GroupEventHandler sets innerEventId at decrypt time and copies the
  envelope's accumulated relays down to the inner note (looked up by
  event.id, not the eventNote/publicNote params, which belong to the
  triggering event when replayed from retryPendingFor).

- sendMarmotGroupMessage sets innerEventId on the freshly built envelope
  before consuming/publishing, so acceptances for sent group messages
  reach the rendered note as soon as the inner note is indexed.

Also audited the remaining chat-rendered types: NIP-04 PrivateDmEvent,
ChatMessageEvent rumors, NIP-C7 ChatEvent, ChannelMessage/Ephemeral/
LiveActivities messages all route through consumeRegularEvent (duplicates
covered by the shared helper), and NIP-37 drafts render the wrap note
itself with markAsSeen covering the version/addressable pair.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEiq1NMK3q12KGQ2yYhPEp
2026-07-10 23:01:45 +00:00
Claude
b48cc2c117 Merge remote-tracking branch 'origin/main' into claude/concord-quartz-amethyst-plan-0oy779
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt
2026-07-10 22:53:52 +00:00
Claude
962146bd60 perf(graperank): round-wide communal sweep replaces per-batch backbone re-fan
Retry rounds were the dominant tail cost of deep crawls (rounds 8-10 of a
hop-8 run burned ~80 min for +6.6k lists): every 256-user batch with
attempt>0 or no-outbox users fanned the SAME ~40 backbone+fallback relays,
queueing thousands of small REQs against their 16-permit gates — pure
hot-relay head-of-line blocking.

Restructured:
- routeByOutbox now routes ONLY own-signal relays (write relays; hints when
  no outbox) and flags no-outbox/retry users into a communal set.
- New Phase C communalSweep asks the shared relays ONCE per round for all
  flagged users in author-chunked units — same (relay, user) coverage,
  ~8x fewer REQs — and records answered-empty pairs so each round shrinks.
- Attempt accounting moves to Phase C, after the round's full coverage
  (own relays + shared set) has run.
- shardedSweep now also records answered-empty (user, relay) pairs, so the
  communal sweep never re-asks a pair the shard pass already proved empty.
- Fixpoint recovery dedup: recoverStragglersFromAggregators skips users a
  prior pass already asked the aggregators for (each repeat pass cost ~4 min
  of store scans + REQs at 470k stragglers for zero new answers).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 22:50:08 +00:00
Vitor Pamplona
84d16348df Merge pull request #3520 from vitorpamplona/claude/auth-permission-architecture-weyn9x
NIP-42 relay auth: contextual "why", per-situation trust, and prompts
2026-07-10 18:48:54 -04:00
Claude
81dc4449bd feat(cli): NIP-13 for amy — post --pow and the pow verb group
- `amy notes post TEXT --pow BITS [--pow-timeout SECS]` mines the note
  pre-signature via quartz's PoWMiner (blocking — the CLI process is the
  job), exits 124 on timeout with nothing published, and adds additive
  --json keys: pow, pow_target, pow_millis.
- `amy pow check EVENT-JSON|-` reports actual_bits, committed_target,
  has_commitment and effective_pow (capped at the commitment per
  NIP-13's anti-lucky-spam rule) plus id+sig validity.
- `amy pow mine --target N [--pubkey HEX] [--timeout SECS] TEMPLATE|-`
  mines an unsigned template for any pubkey — NIP-13's delegated PoW:
  ids don't commit to signatures, so a headless box can mine for a
  phone and hand the template back for signing.
- `amy pow bench` prints the machine's hash rate and expected seconds
  at 16/20/24/28 bits (commons PoWEstimator).
- cli/tests/pow/pow-headless.sh: relay-free harness covering bench,
  mine (commitment shape + 124 timeout), and the delegated round trip
  (mine → sign via `amy event` → pow check ≥ target). 6/6 passing.

No logic added to cli/ — thin assembly over quartz nip13Pow + commons
PoWEstimator per the CLI architecture rules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-10 22:47:05 +00:00
Claude
8d528ea01c revert(relayauth): drop the "couldn't deliver your event" toast
The give-up toast fired per-relay, but Nostr publishes each event to several
relays (NIP-65 outbox), so one relay rejecting an event that reached the others
produced a misleading "couldn't deliver" popup. It also named no event and fired
mid-scroll on reconnect-driven re-pumps, so it read as random noise. Remove the
toast and its subscription/strings.

The low-level quartz onEventGaveUp signal stays (tested, no UI consumer) as a
primitive for a future per-message send-status indicator, which is the right
surface for delivery failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:45:47 +00:00
Claude
21c6e60e18 feat(concord): mobile "Make admin" / role toggle
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
2026-07-10 22:41:14 +00:00
Claude
d1ba04ad52 fix(relayauth): pre-merge audit — auth retry budget + lost-prompt window
Two bugs found in a pre-merge audit:

- quartz PoolEventOutboxState: auth-required NAKs only spared the `responses`
  budget, but `tries` (grown by every send/re-pump and NOT auth-aware) still
  accumulated across reconnects, so a slow/flapping AUTH handshake could exhaust
  Tries.isDone() and drop the event — with a spurious onEventGaveUp — before AUTH
  landed. Now an auth-required NAK resets the relay's retry budget (it responded,
  so it's up and just wants auth). Regression test added.

- RelayAuthPromptBus used a replay=0 SharedFlow, so a challenge that resolved to
  ASK before RelayAuthPromptHost subscribed (cold start / account switch) was
  dropped and the auth coroutine stalled the full timeout then DISMISSed. Add
  replay so late subscribers recover pending prompts (the host already filters
  resolved ones). Regression test added.

Also record the as-built design (Always/Never/Custom + toggles, venues, give-up
toast, known deny-relay-outbox limitation) in the plan doc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:13 +00:00
Claude
50d942cfce style(relayauth): use the settings design system for the toggle and relay blocks
Adopt the same componentized UI as the Security Filters screen for blocks 2 and
3 (block 1, the Always/Never/Custom mode selector, stays as the policy cards):

- The Custom toggles are now a SettingsSection card of SettingsSwitchTiles
  (leading colored icon box, title + description, inset dividers) — reusing the
  shared settings components instead of bespoke rows.
- The per-relay list is now a grouped rounded card (surfaceContainerLow) with
  the same header style. Kept lazy: each row is its own LazyColumn item that
  clips the card's top/bottom corners on the first/last row so contiguous rows
  read as one card, preserving the earlier perf fix.
- Section headers unified to the SettingsSection primary-colored style.

Toggle icons: Dns (my relays), Download (read follows), Mail (message follows),
Public (message strangers).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:13 +00:00
Claude
0f8b276af7 perf(relayauth): lazily render the per-relay list; fix trailing divider
The per-relay list was a non-lazy Column + forEach, so every relay row composed
on first frame — each building a NIP-11 relay icon (robohash generation is
CPU-heavy) plus up to three avatars. With many authenticated relays that is a
lot of synchronous main-thread work, making the screen slow to open. Move the
whole screen to a LazyColumn so only the visible rows (and their icon/avatar
loads) compose.

Also render the per-relay divider only between rows (keyed itemsIndexed, skip
index 0) so there's no full-width divider trailing after the last row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:13 +00:00
Claude
a16a9f4522 style(relayauth): match the settings-screen house layout
Drop the ad-hoc cards/thin-divider layout and adopt the same structure as the
other settings screens: small primary-colored SectionHeaders, 4dp dividers
between sections, and plain padded rows instead of surfaceVariant blocks.

- The Custom toggles are now standard settings switch rows (24dp inset, 16sp
  title / 13sp description) under a "What to log in to" header, not a card.
- The per-relay list is now icon-led rows separated by dividers (like the app's
  other relay lists) instead of chunky cards; tapping a row still opens the
  relay's NIP-11 info.
- Section labels reworded: "When to authenticate" and "What to log in to".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:13 +00:00
Claude
3a96554c8d feat(relayauth): replace the policy list with Always / Never / Custom + toggles
Restructure the global auth control into a top-level mode — Always authenticate,
Never authenticate, or Custom — where Custom reveals independent per-situation
toggles instead of the confusing single sub-toggle:

- My relays and venues (own relays + joined/subscribed/favorited venues) — on
- Read posts from people I follow — on
- Message people I follow (DMs, replies, notifications) — on
- Message anyone / strangers — off by default (you're asked each time instead)

RelayAuthPolicy is now {ALWAYS, NEVER, CUSTOM}. The resolver takes a
RelayAuthCustomToggles plus split serves-facts (followed-read, followed-write,
stranger-write, own-relay, venue) and, under CUSTOM, allows if any enabled
category matches — else falls through to a prompt. There is deliberately no
"read strangers' posts" category, so that always prompts.

Account settings replace the single delivery flag with four persisted booleans
(default policy CUSTOM; no migration, unreleased). The contextual DM/notification
prompt button now switches to CUSTOM and enables both message toggles. Resolver
tests rewritten per-toggle, including that reading a stranger is never
auto-allowed. Old IF_IN_MY_LIST / TRUSTED_FOLLOWS policies and their strings are
removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:13 +00:00
Claude
9ca1ba2f5c feat(relayauth): reframe follows trust as relationship vs. message delivery
Restructure the two TRUSTED_FOLLOWS controls so they read as independent ideas
instead of a confusing read/write sub-toggle:

- The "My relays and people I follow" policy now trusts a followed user as any
  counterparty — reading their posts AND reaching them (DM/notification) — plus
  your own relays and joined venues. Reading your follows is no longer gated.
- The sub-toggle is repurposed to "Also log in to deliver my messages": trust a
  relay to send DMs, replies or notifications to anyone you're talking to, even
  people you don't follow.

Resolver: replace servesFollowed{Write,Read}Counterparty with a single
servesFollowedCounterparty, add servesWriteCounterparty (an inbox of anyone
you're messaging), and gate the latter behind the new
messageDeliveryTrustEnabled input. Rename the account setting
relayAuthTrustFollowsForReads -> relayAuthTrustMessageDelivery (+ pref key;
no migration needed, unreleased). The contextual prompt button moves from
read-post prompts to DM/notification prompts and now enables message delivery.
Resolver tests updated for the new inputs, including that the delivery toggle
is write-only and never auto-allows reading a stranger.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:13 +00:00
Claude
3e6cda8407 feat(relayauth): modernize the per-relay list to match the app's relay rows
Restore the "Per-relay overrides" heading and rebuild each relay card using the
same conventions as the other relay screens: the relay's NIP-11 icon (robohash
fallback), the shortened displayUrl instead of the raw URL, last-used as a
subtitle under the name, and the whole card tappable to open the relay's
NIP-11 info screen (Route.RelayInfo). The Allow/Deny chip and Forget stay on
the trailing edge; the served-people facepile sits on its own row below.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:13 +00:00
Claude
7c58ea31fd feat(relayauth): merge override + rationale into one per-relay list
Fold the separate "Why you're logged into these relays" section into the
per-relay list so each relay is one card: URL, an Allow/Deny chip, a facepile
of the people it serves (3 avatars + "+N"), and when it was last used.

Collapse the two removal actions (the override "X" and the rationale "Forget")
into a single Forget that clears both the stored decision and the recorded
reason, so the relay drops off the list — this removes the earlier ambiguity
about what each button did. The Allow/Deny chip now also shows for relays
allowed by policy (no explicit override), so they can be blocked from here too.

Also clarify the confusing read-trust sub-toggle copy so it reads as the
read-side extension of the write-only follows policy ("On its own, the option
above only logs in to send messages or notifications… turn this on to also log
in when downloading their posts"). Drop the three now-unused strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:12 +00:00
Claude
44462ecce1 fix(relayauth): reword follow-trust button to "Always allow my follows' relays"
Shorter and clearer than "Always allow relays from my follows".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:12 +00:00
Claude
e9dad44131 fix(relayauth): shorten the follow-trust button label
"Always allow relays of people I follow" overflowed the button; shorten to
"Always allow relays from my follows".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:12 +00:00
Claude
620eeadc2b feat(relayauth): one-tap "trust my follows' relays" on read-post prompts
On a "Load posts from <user> and others" (READ_OUTBOX) auth prompt, add a
button that turns on the broad rule instead of allowing one relay at a time:
it sets the policy to TRUSTED_FOLLOWS and enables the read-trust sub-toggle,
so every relay serving people the user follows auto-authenticates for reads
and these prompts stop appearing. Read-trust only applies under
TRUSTED_FOLLOWS, so the button sets both to keep its promise on any prior
policy, then authenticates the current connection once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:12 +00:00
Claude
672fb931c8 test: integration-test NIP-17 DM delivery through an auth-required relay
Drive a real NostrClient against an in-process geode relay running
FullAuthPolicy, publishing a NIP-17 gift wrap through the PoolEventOutbox
retry queue. The first EVENT races ahead of AUTH and is rejected
`auth-required`; a RelayAuthenticator answers the challenge and the
still-pending wrap is resent on the post-AUTH resync and stored. This is
the integration counterpart to PoolEventOutboxAuthTest and exercises the
"auth-required must not burn the retry budget" fix end-to-end. A control
test (no authenticator) proves the relay genuinely gates, so the delivery
assertion isn't vacuous.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:12 +00:00
Claude
893b3c7140 test: cover DataStoreRelayAuthPermissionStore round-trips and pruning
Add JVM unit tests (real PreferenceDataStore on a per-test temp dir, no
Robolectric) covering: decision round-trip and hashed-key reverse lookup;
recordUse counterparty merge by kind, the 64-counterparty cap, idempotence,
and that a new counterparty always persists despite the write throttle; and
the reverse-lookup pruning contract — clearRationale/clearDecision prune the
shared url key only when nothing else references the relay, keeping it when a
decision or rationale still remains.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:12 +00:00
Claude
e959acb215 refactor: extract testable AuthDecisionResolver from AuthCoordinator
The verdict-folding policy (no-ledgers auto-allow, any-ALLOW short-circuit,
ASK→prompt→remember Allow/Block) was inlined in AuthCoordinator's signing
lambda, untestable without the relay client and signers. Move it to a pure
suspend AuthDecisionResolver.resolve(verdicts, prompt) returning an Outcome
(shouldAuth + optional remembered override), and cover every branch with
unit tests, including that ASK never prompts when an account already allows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:12 +00:00
Claude
72f1f72ea7 refactor: share user-load and reason-string helpers across relay-auth UI
The auth prompt dialog (RelayAuthPromptHost) and the relay-auth settings
screen each carried an identical copy of a pubkey→User loader and the
AuthPurposeKind→reason-string mapping. Extract both into
RelayAuthComposeHelpers (LoadRelayAuthUser, relayAuthReasonRes) and point
both call sites at the shared versions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:12 +00:00
Claude
a8fe2eee92 refactor: use quartz typed tag parsers in auth purpose derivation
Replace hand-rolled tag-index parsing in RelayAuthPurposeDeriver with
quartz's typed parsers: PTag.parseKey, ATag.parseAddress (filtered by
community/live-activity venue kinds), MarkedETag.parseRoot / ETag.parseId
for channel roots, and Address.parse for filter `a`-tags. Derive the
venue owner pubkey via Address.parse(...).pubKeyHex instead of splitting
the address string by hand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:11 +00:00
Claude
95e3a8a54d fix(relayauth): load and observe venue names in the auth prompt
rememberVenueLabel was a non-loading cache snapshot, so a venue not yet
in LocalCache showed its id and never fetched or updated. Bring it to
parity with the person path (checkGetOrCreateUser + observeUserInfo):
get-or-create the public chat / live activity channel and observeChannel
it, which subscribes to relays for the metadata and recomposes when the
title arrives. Communities keep the NIP-72 d-identifier (no fetch needed).

Audit of the rest confirmed correct: person names (rememberDisplayName /
LoadUser / UsernameDisplay) already load + observe; the trust predicates
read .value of Eagerly-shared StateFlows (publicChatList/communityList/
allFollows), which is the right decision-time snapshot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:11 +00:00
Claude
ffe9637c8d feat(relayauth): resolve real venue names in the auth prompt
The venue prompt said generic "this room"; now it names the actual place.
rememberVenueLabel resolves a public chat channel's title (by event id),
a live activity's title (by address), or a community's d-identifier (its
NIP-72 name), falling back to the address d-tag / short id when the venue
isn't cached. So the prompt reads "Post to nostr-dev?" / "Open <stream
title>?" with a matching consequence ("…your message to nostr-dev won't
be posted."), reusing the same who-slot as the person purposes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:11 +00:00
Claude
3526a14ebe feat(relayauth): venue-aware auth for public chats, communities & live streams
The purpose model was person-centric, so an auth-required NIP-28 channel,
NIP-72 community, or NIP-53 live stream broke: reads filter by #e/#a with
no authors, so nothing was attributable -> silent DENY (chat wouldn't
load); top-level posts (no p tags) also silently failed; replies were
mis-attributed as "notify" and trusted on the wrong signal.

Add venue purposes (POST_VENUE / READ_VENUE) carrying the venue id
(channel event-id, or community/live `kind:pubkey:dTag` address). The
deriver recognizes kind-42 channel posts (root `e`), community/live posts
and reads (`#a` 34550/30311), and channel reads (`#e`). Venues are trusted
under TRUSTED_FOLLOWS when you've joined them (publicChatList /
communityList) or their owner — the pubkey in the address, e.g. a live
stream's host — is someone you follow; trusted venues auto-auth for both
reading and posting.

Safety net: any active use we still can't attribute yields an OTHER
purpose so the relay is prompted about instead of failing silently.
Prompt copy gains venue-aware titles/consequences ("Post to this room?",
"If you don't, your message won't be posted."). Default policy already
TRUSTED_FOLLOWS, so joined venues just work. Unit-tested across resolver
and deriver.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:11 +00:00
Claude
eea072ec42 feat(relayauth): follow-trust default + action-aware prompt copy
User-perspective fixes so the auth prompt stops feeling like "why does
this app keep asking me to log into things":

1. Default policy is now TRUSTED_FOLLOWS (new installs only; a persisted
   choice is untouched). Messaging/notifying people you follow just works;
   only strangers prompt.
2/3. The prompt is reframed around what the user was doing and its
   consequence, not "log in":
   - Title is action-aware: "Send your message to Alice?" / "Notify …?" /
     "Load posts from …?", resolving the counterparty's display name.
   - The message states the real tradeoff (the relay confirms it's you;
     its operator sees which account you are) instead of "log in".
   - A purpose-specific, error-tinted consequence line — "If you don't,
     your message to Alice won't be delivered." — so Block/Dismiss no
     longer silently break the exact thing the user was trying to do.
   Per-purpose reason labels now show only when a challenge spans multiple
   purposes (the title carries the single-purpose case).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:11 +00:00
Claude
af0274bb24 style(relayauth): modernize the NIP-42 auth prompt dialog
Light design pass on the ASK dialog (the weakest surface):
- Add the M3 AlertDialog icon slot with a primary-tinted shield, so it
  reads as a security decision.
- Give the actions a real hierarchy: filled "Allow once", tonal "Always
  allow this relay", and an error-tinted text "Block this relay" (it was
  three same-emphasis buttons before).
- Show the relay in a rounded surfaceVariant chip instead of raw bold text.
- Keep named avatar rows for ≤2 counterparties (the common DM case) and
  collapse larger sets into an overlapping avatar facepile with a "+N"
  overflow badge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:11 +00:00
Claude
cface58e15 perf(relayauth): lazy auth context + prune stale auth prompts
Audit follow-ups #3 and #5:
- AuthCoordinator builds the RelayAuthContext lazily, so the no-ledgers
  auto-allow path no longer pays for activeOutboxEvents/activeRequests +
  purpose derivation on every challenge.
- RelayAuthPromptBus now completes the deferred with DISMISS on timeout
  (it previously returned DISMISS without resolving it), and RelayAuthPrompt
  exposes isResolved/onResolved. RelayAuthPromptHost drops a prompt as soon
  as it resolves by any path (answered, answered elsewhere, or timed out)
  and skips any already-resolved prompt, so a stale dialog is never shown
  for a relay whose auth already gave up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:11 +00:00
Claude
fb30982f92 perf(relayauth): bound grant rationale and throttle its writes
Audit fixes for the grant-rationale path:
- Bound counterparties per relay+purpose in the store (cap 64). An outbox
  relay can serve a large slice of the follow list; recording every author
  bloated the DataStore value and, since recordGrant stores every purpose
  that has counterparties, this happened even when the grant was for a DM.
- Throttle recordUse: auth is re-granted on every reconnect, so skip the
  DataStore write unless a new counterparty appeared or the last-used
  timestamp is stale (>5 min), instead of writing on every grant.
- Cap the settings rationale rows at 8 (+ "…and others"); they render in a
  plain non-lazy scroll column, so an unbounded set could compose hundreds
  of user rows.

The live follow-trust decision still sees the full counterparty set (the
cap is storage/display only), so trust verdicts are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:11 +00:00
Claude
c29374f089 feat(relayauth): surface failed sends + per-relay forget/last-used
Consume the new give-up signal: RelayPublishFailureToastSubscription
(hosted in LoggedInPage) listens for onEventGaveUp and toasts "couldn't
deliver to <relay>", so a dropped send is visible instead of silent.

Rationale polish in the auth settings screen: each relay card now shows
"Last used N ago" and a Forget button that clears both the ALLOW/DENY
override and the accumulated rationale for that relay. The store gains
clearRationale + allLastUsed (default-implemented on the interface) and
records a last-used timestamp on each grant; clearDecision/clearRationale
now prune the shared url key only when a relay has neither an override
nor rationale left, so a partial clear never orphans the reverse-lookup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:10 +00:00
Claude
9179bd9d8d feat(quartz): signal when an outgoing event exhausts its retry budget
Instead of silently dropping an event once its per-relay retry budget is
spent, PoolEventOutbox now reports it: PoolEventOutboxState.newTry returns
whether the attempt gave up on the relay, PoolEventOutbox.onSent surfaces
the dropped event, and NostrClient notifies a new (default no-op, so
non-breaking) RelayConnectionListener.onEventGaveUp(relay, event). Lets a
host surface a failed delivery rather than lose it silently; the event may
still be pending on other relays. Unit-tested via the outbox try budget.

Note: timed retry backoff (the other half of this item) is intentionally
deferred — applied in the shared syncState path it would also delay the
post-auth resend and regress the auth-required fix, so it needs
trigger-aware handling designed separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:10 +00:00
Claude
b1b7a4e7c3 fix(relayauth): exclude event author from NOTIFY_INBOX counterparties
A pending non-gift-wrap event's `p` tags are the people it notifies, but
some events self-p-tag the author. Drop the author's own key so the auth
reason and follow-trust check reflect who is actually being notified, not
the sender. (Own-relay over-attribution is already handled upstream by the
isInMyRelayList allow.) Adds a regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:10 +00:00
Claude
39dc053bf7 feat(relayauth): user toggle for trusting follows' outboxes on reads
Add relayAuthTrustFollowsForReads (local AccountSettings flag, persisted
via LocalPreferences, default off) and wire it into the ledger's
readTrustEnabled input. The relay-auth settings screen shows a switch —
"Also trust when reading their posts" — under the Trusted-follows policy,
so downloading a followed author's outbox auto-authenticates only when
the user opts in; writes (DMs/notifications) remain auto-trusted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:10 +00:00
Claude
70b23b6999 feat(relayauth): remember and show why each relay was granted
Extend RelayAuthPermissionStore with grant rationale (purpose ->
counterparty pubkeys), default no-op so other implementers are
unaffected; DataStoreRelayAuthPermissionStore persists it per relay,
merging counterparties across grants. AuthCoordinator records the
rationale via ledger.recordGrant whenever it authenticates (auto-allow,
override, or approved prompt). The relay-auth settings screen adds a
"Why you're logged in to these relays" section: per relay, purpose-
grouped rows ("Send your private message to:", "Download posts from:")
with each counterparty's avatar + name. Unit-tested: grouping, cross-
grant merge, and that counterparty-less purposes aren't recorded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:10 +00:00
Claude
e128a7834b feat(relayauth): interactive "log in to this relay?" prompt dialog
RelayAuthPromptHost collects RelayAuthPromptBus.prompts app-wide (hosted
in LoggedInPage next to RelayAuthSubscription) and shows one dialog at a
time when a NIP-42 challenge resolves to ASK. The dialog explains why the
relay wants auth — grouped by purpose (send DM / notify / download posts)
with each counterparty's avatar + name — and offers Allow once / Always
allow this relay / Block this relay. Dismissing answers DISMISS, matching
the bus timeout fallback so a connection never blocks on the UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:10 +00:00
Claude
deb0abb982 docs: state the auth-required outbox fix narrowly
Clarify that resend-after-auth already worked for the common single-round
case; the fix only prevents auth-required NAKs from consuming the per-relay
retry budget, which could evict the saved event across repeated rounds
(slow signer / reconnect churn / re-challenge) at the moment syncFilters
tries to redeliver it post-auth.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:09 +00:00
Claude
e857e1c8e8 feat(relayauth): suspendable ASK prompt mechanism for NIP-42 auth
Add RelayAuthPromptBus: when a challenge resolves to ASK, the auth
coroutine calls requestDecision() and suspends until the UI answers via
a SharedFlow<RelayAuthPrompt> + CompletableDeferred. Concurrent
challenges for the same relay share one prompt (no double-asking) and an
unanswered prompt times out to DISMISS so a connection never hangs.

AuthCoordinator now consults the prompt on ASK and acts on the choice:
ALLOW_ONCE signs once; ALWAYS_ALLOW / BLOCK persist a per-relay override
via the ledger and then sign / skip; DISMISS skips. Exposes promptBus so
a Composable can host the dialog (the visual layer is the next step).
Unit-tested: choice delivery, same-relay dedup, and timeout-to-DISMISS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:09 +00:00
Claude
c036069439 feat(relayauth): wire purpose-aware resolver into the live auth path
AuthCoordinator now reconstructs the challenge purpose from what the
client is doing with the relay — pending outgoing events + active
subscription filters — via the new pure RelayAuthPurposeDeriver
(gift wrap => SEND_DM, p-tagged event => NOTIFY_INBOX, filter authors
=> READ_OUTBOX), and asks each account's ledger for a verdict through
RelayAuthResolver instead of the old URL-only ALLOW/DENY.

RelayAuthPermissionLedger gains context-aware decide(RelayAuthContext)
plus isBlocked / isFollowed / readTrustEnabled inputs; the live wiring in
AccountDataSourceSubscription splits the old combined check into separate
blocked-list and trusted-list predicates and feeds follow membership from
allFollows (any follow list). TRUSTED_FOLLOWS now auto-auths for relays
serving a followed DM/notification counterparty. ASK is treated as
"don't auth yet" pending the interactive prompt (next step).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:09 +00:00
Claude
38b2e65362 feat(quartz): expose pending outbox events per relay for auth context
Add INostrClient.activeOutboxEvents(url) (backed by
PoolEventOutbox.activeOutboxEventsFor) returning the full events still
pending delivery to a relay, not just their ids like activeOutboxCache.
This lets a host explain *why* a relay is being authenticated with —
e.g. a pending kind-1059 gift wrap means we're sending a DM to its
recipient — by inspecting kind/tags. Combined with the existing
activeRequests(url) filters, it is the generic challenge context the
NIP-42 decision hook needs. Updates the INostrClient test fakes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:09 +00:00
Claude
4bd73596e3 feat(relayauth): add purpose-aware AUTH policy core + TRUSTED_FOLLOWS
Introduce the platform-neutral decision core for contextual NIP-42 auth
in commons/relayauth:
- AuthPurpose / AuthPurposeKind / RelayAuthContext describe *why* a relay
  wants auth (send DM, deliver notification, read outbox, own relay).
- RelayAuthVerdict adds an ASK outcome (runtime-only; never persisted,
  unlike the two-value RelayAuthDecision override).
- RelayAuthResolver is a pure, unit-tested precedence ladder: blocked
  list > per-relay override > policy > ASK-if-attributable-else-DENY.
- New TRUSTED_FOLLOWS policy: auto-auth for relays serving a followed
  counterparty on a write purpose (DMs/notifications), and — behind a
  read sub-toggle — read purposes; strangers fall through to ASK.

Wires the new enum value through the existing settings screen (new
option + strings, reusing the Group symbol) and the URL-only ledger path
(degrades to the my-list check until challenge context is plumbed).
Live prompt UI, grant-rationale persistence, and quartz challenge-context
plumbing are follow-up steps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:09 +00:00
Claude
9c618f9ba1 fix(quartz): don't drop outgoing events on NIP-42 auth-required NAKs
PoolEventOutboxState treated an `auth-required` OK-false the same as any
transient failure, recording it against the per-relay Tries budget
(responses > 2 drops the event on the next send attempt). Relays that
NAK every unauthenticated EVENT could therefore exhaust the budget and
drop the message before the AUTH handshake completed — the event was
gone by the time syncFilters re-sent it after the auth OK.

Treat `auth-required` as a deferred state instead: keep the relay in
relaysRemaining and record no failure, so the existing
syncFilters-after-auth path redelivers it. Mirrors the behavior already
present in StandaloneRelayClient. Terminal rejections (invalid/pow/
replaced/deleted) and ordinary transient errors are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:09 +00:00
Claude
6c9259fea0 docs: split AUTH permission plan into quartz mechanism vs amethyst policy
Account for the existing (rudimentary) outgoing-event retry in
PoolEventOutbox: syncFilters already re-sends EVENTs after an auth OK,
but auth-required is wrongly treated as a burned retry (hard >2/>3 cap,
no backoff, silent drop) — a race that can drop a message before AUTH
completes. Recommend fixing the resend/retry mechanism generically in
quartz (port StandaloneRelayClient's auth-required handling, add backoff
+ terminal give-up signal, enrich the injected auth-decision hook with
pending-event/active-filter context) and keeping only policy, purpose
derivation, ASK prompt, and grant-rationale UI in amethyst.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:09 +00:00
Claude
3570ebb014 docs: plan for contextual NIP-42 AUTH permissions
Design doc for asking the user *why* an auth is requested (send DM,
deliver notification, read outbox), a follow-based auto-trust policy
mode, an ASK decision state, and per-relay grant rationale shown in the
auth settings screen. Reuses the existing RelayAuthenticator /
AuthCoordinator / RelayAuthPermissionLedger stack.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:08 +00:00
Claude
6d4262bc2a feat: route [text](#hashtag) markdown links to the hashtag feed (#1564)
Markdown links whose target is a bare hashtag fragment, e.g.
[Nostr Multiplayer Games](#NostrMultiplayerGames), did nothing when
tapped: uriToRoute had no branch for them, so the tap fell through to
the system uri handler, which silently fails on a bare fragment.

- uriToRoute now resolves a bare #tag target to Route.Hashtag, reusing
  RichTextParser.hashTagsPattern so tag validity matches how #hashtags
  linkify in plain text (including unicode tags). Full URLs with
  anchors are unaffected.
- MarkdownMediaRenderer rewrites the link destination to
  nostr:hashtag?id=<tag> (mirroring renderHashtag) while keeping the
  custom link text, so the tap flows through the same route string as
  a plain #hashtag.

Read side only: the composer still never emits this syntax.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D382kMNdcmMLZrwKeZAPTY
2026-07-10 22:36:52 +00:00
Claude
906744d32a fix: treat DM rooms whose newest message is my own as read (#1286, #1287)
Sending a reply — from this device or from another one via the
self-addressed gift wrap — now counts as having read the conversation:

- Account.broadcastPrivately / sendNip04PrivateMessage advance the
  room's local read marker to the sent message, so the Messages tab dot
  and the room's new-items bubble clear immediately on send.
- unreadPrivateChatRoute and the room/channel/marmot rows in
  ChatroomHeaderCompose additionally ignore newest messages authored by
  the logged-in user, covering own messages that arrive from other
  devices before any local marker exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqN8cfqNdo1MAvLN4C9krm
2026-07-10 22:34:13 +00:00
Claude
1cffd8db18 fix: propagate relay acceptances into gift-wrap chains for chat relay icons
Chat rows render the inner rumor note of a NIP-17 message, but relay
attribution only landed there through narrow windows, so accepted relays
often never showed as icons:

- Duplicate deliveries were stranded on the wrap: a gift wrap re-delivered
  by a second relay hit the duplicate branch of consumeRegularEvent, which
  tagged the outer wrap note only and never re-processed the event. Extract
  the OK-path drilling (wrap -> seal -> rumor) into
  LocalCache.addRelayToNoteAndInners and call it from both the OK
  confirmation path (markAsSeen) and the duplicate EVENT path, replacing
  CacheClientConnector's private copy.

- Cross-thread visibility: Note.event, Note.relays, Note.flowSet and the
  innerEventId of GiftWrapEvent/SealedRumorEvent are written by decrypt/index
  coroutines and read lock-free on relay socket threads; a stale read parks
  an acceptance on the outer envelope permanently. Mark them @Volatile.

- Orphaned UI flows: RenderClosedRelayList/RenderAllRelayList and
  createMustShowExpandButtonFlows captured note.flow().relays.stateFlow once
  in remember/stateIn; MemoryTrimmingService.cleanObservers destroys the
  unobserved NoteFlowSet while the lifecycle is stopped, so resumed rows
  never saw another relay update. Wrap in a cold flow that re-resolves
  flow() on every collection start.

- Indexing latency: sent DMs waited for the ~1s newEventBundles batcher
  before the self-wrap was unwrapped and the message reached the chatroom,
  parking early OKs on the wrap. broadcastPrivately and
  sendNip04PrivateMessage now run the EventProcessor on the freshly
  consumed note immediately; the batched re-delivery is idempotent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEiq1NMK3q12KGQ2yYhPEp
2026-07-10 22:29:41 +00:00
Claude
97fc801294 fix: NIP-13 compliance — refresh created_at at mining start, clean nonce tag
Findings from a full NIP-13 review:

- The spec recommends updating created_at while mining. Queue jobs now
  re-stamp the template to "now" when a worker picks them up (a post can
  wait behind other jobs, and a job restored after process death could be
  hours old); the restorer does the same. Scheduled posts are exempt —
  their future created_at is intentional. Anonymous posts re-stamp before
  mining against the throwaway key.
- PoWTag.assemble(nonce, null) serialized the literal string "null" as
  the third tag entry; a missing commitment now omits the entry entirely.
- New tests: PoWTagTest pins the NIP-13 example tag shape and the
  no-commitment round trip; a queue test asserts the created_at
  re-stamp at mining start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-10 22:22:04 +00:00
Claude
f56edf7986 docs(graperank): record fixpoint validation (hop-8: 796k users, deepest run)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 22:20:37 +00:00
Claude
6aa3e0e1b9 feat(concord): mobile moderation — ban action + read-time ban enforcement
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
2026-07-10 22:14:07 +00:00
Vitor Pamplona
4d16d6e38f Merge pull request #3522 from vitorpamplona/claude/richtext-trailing-whitespace-85eudl
Strip trailing whitespace from rich text parser output
2026-07-10 18:11:21 -04:00
Claude
24ea909221 fix: remove trailing whitespace and newlines when rendering RichText
Trailing spaces and newlines in a note's content produced empty trailing
paragraphs after the line split, each rendered as a blank FlowRow line
between the last visible word and the end of the component. Trim the
content's tail before segmenting, and return no paragraphs for
whitespace-only content.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZET7ig8MvR5vSCSxvcURi
2026-07-10 22:09:11 +00:00
Vitor Pamplona
581fded5f5 Merge pull request #3521 from vitorpamplona/claude/brainstorm-world-client-033mhn
Update Brainstorm web app icon URL
2026-07-10 18:06:24 -04:00
Claude
b83ef20d61 feat(cli): amy concord roles/role/grant/ban/unban (CORD-04 moderation)
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
2026-07-10 22:00:12 +00:00
Claude
441413ea95 feat(concord): roles & moderation write path (CORD-04)
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
2026-07-10 21:56:06 +00:00
Claude
96965305cf fix: use Brainstorm's real apple-touch-icon in browser suggestions
https://brainstorm.world/brainstorm.svg 404s (the SPA serves its HTML
not-found page with a 200), so the Discover row fell back to the globe
glyph. Point at the site's declared 180x180 apple-touch-icon.png instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EzQhmuFNULgiP31XqMRpJP
2026-07-10 21:53:49 +00:00
Claude
ba98f8c232 fix: never mine the inner kind-7 of gift-wrapped reactions
Reactions to NIP-17 groups and unsealed rumors are gift-wrapped: the
inner reaction only travels as ciphertext, so relays can never PoW-filter
it and mining it was pure battery waste. Account.reactTo now detects
private targets up front and routes them straight to the plain signer,
skipping the mining queue. DM/private-note/file wraps were already
correct (only the kind-1059 envelope is mined, never the seal or rumor).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-10 21:43:00 +00:00
Claude
3895dbe115 feat(concord): create-community + invite UI, Concord hub
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
2026-07-10 21:41:16 +00:00
Claude
6ef1caef77 feat: polish the PoW surfaces — icon chip, animated banner, time estimate, note pill
- Composer chip: replaced the text button with a bolt icon carrying the
  difficulty as a small badge (primary-tinted when active, dimmed when
  off), matching the layered-icon language of the rest of the options
  row; the current choice is bolded in the override menu.
- Mining banner: pulsing bolt while the nonce search runs, per-job
  elapsed time driven by a 1 Hz clock ("Note • mining at 20 bits • 12s"),
  and proper close IconButtons instead of a text "×". PoWJobState now
  carries miningStartedAt for the elapsed display.
- Settings: a live "≈ 45s per post on this device" estimate under the
  difficulty picker, from a one-shot cached sha256 benchmark
  (PoWEstimator in commons) and the 2^d expected-attempts mean.
- Received notes: DisplayPoW's plain "PoW-24" text is now a compact
  secondaryContainer pill (bolt + bits) used by NoteCompose, thread view
  and chat messages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-10 21:22:27 +00:00
Claude
e85de73e60 feat(concord): plane-wrapped reactions and replies (outbound)
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
2026-07-10 21:20:07 +00:00
Claude
35647d0a5c feat: persist the PoW queue and shield it with a shortService foreground service
Backgrounding the app no longer pauses or loses mining:

- commons: PoWPublishQueue checkpoints template jobs through a
  PoWJobPersistence hook (saved on enqueue, removed on finish/cancel,
  deduped by id so restore is idempotent) and fires onQueueActive on
  every enqueue. PoWReplay flattens the live continuation into a
  PersistedPoWJob record (broadcast / to-relays / schedule) that can be
  replayed headlessly. Opaque enqueueWork jobs (reactions, reposts, gift
  wraps) and anonymous posts stay in-memory on purpose — a throwaway key
  and its content must never touch disk.
- amethyst: PowJobStore (atomic-rename JSON file, single-lane writer,
  3-day staleness purge) and PowJobRestorer, which re-enqueues an
  account's checkpointed jobs on login and finishes them via the replay
  descriptor. Composers now pass the matching PoWReplay for the public,
  group-thread and scheduled paths.
- PowMiningForegroundService: a shortService-type FGS started on every
  enqueue (~3 min guaranteed budget, no special permission) that keeps
  the process out of the cached-apps freezer while mining. Stops itself
  when the queue drains; on onTimeout it exits cleanly since the jobs
  are checkpointed. The notification is a live progress card
  (NotificationCompat.ProgressStyle): one track segment per post filling
  as jobs finish, indeterminate for a single post, per-kind text, a
  cancel-all action, and Live Updates rendering on Android 16+.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-10 21:08:51 +00:00
Claude
65502c7cda refactor(concord): land decrypted messages in LocalCache as real Notes
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
2026-07-10 20:56:32 +00:00
Vitor Pamplona
193a5559bd Merge pull request #3516 from vitorpamplona/claude/amethyst-apk-cert-docs-dy2pzo
docs: Add APK signature verification instructions
2026-07-10 16:34:04 -04:00
Claude
ac5223b956 feat: mine scheduled posts through the PoW queue
The scheduled branch re-stamps the template with created_at = the future
publish time and the worker publishes the stored signed JSON verbatim,
so a nonce mined at compose time commits to that future created_at and
remains valid when the post goes out. The sign+store step moves into the
mining continuation (storeScheduledPost), keeping the composer
fire-and-forget for scheduled posts too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-10 20:31:41 +00:00
Vitor Pamplona
c06c6523b5 Merge pull request #3518 from vitorpamplona/claude/amethyst-trim-post-whitespace-t9xi5m
Trim message text before tagging in post ViewModels
2026-07-10 16:25:54 -04:00
Vitor Pamplona
0f89fdb7e1 Merge pull request #3517 from vitorpamplona/claude/amethyst-login-theme-toggle-5af214
Persist login UI state across configuration changes
2026-07-10 16:25:39 -04:00
Claude
8988c98308 feat: add NIP-13 proof-of-work publishing with a fire-and-forget mining queue
Implements user-facing PoW publishing (#3317) on top of the existing
quartz miner:

- quartz: PoWMiner.run gains a cooperative isActive cancellation hook
  (checked every ~VALID_BYTES^2 hashes); PoWNostrSigner decorator mines
  kind-scoped templates pre-signature so it composes with every signer;
  GiftWrapEvent.create/NIP17Factory can mine the outer ephemeral-key
  wrap (never the seal/rumor); NostrSignerWithClientTag exposes
  prepareTags so mining runs over the final tag set.
- commons: PoWPublishQueue (FIFO, capped worker pool on
  Dispatchers.Default, per-job cancel, in-memory only — unmined posts
  are lost on process death, logged) and PoWPolicy (kind-group
  categories with a hardcoded NEVER list: auth, zap requests, NWC and
  bunker RPC, HTTP/Blossom auth, drafts, metadata and lists, OTS).
- amethyst: per-account synced settings (difficulty Off/16/20/24/28 or
  custom, per-category checklist) in Compose Settings; Post enqueues the
  template and returns immediately; reactions, reposts, reports, private
  notes, DMs and long-form route through the same shouldMine gate at
  their existing choke points; per-post PoW override chip in the
  composer options row; "Mining proof of work… (N in queue)" phase with
  per-job cancel in the broadcast banner.

Scheduled posts and anonymous posts mine against the correct key
(scheduled posts skip mining in v1); the client tag is applied to the
template before mining so signing never invalidates the nonce.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-10 20:24:45 +00:00
Claude
a509d41358 fix: keep login/signup page choice across uiMode recreation (#1097)
Toggling the system dark/light theme recreates MainActivity (uiMode is
not in configChanges), and the login/signup page choice was held in a
plain remember, so the UI reset from Sign-Up back to Login. Switch it to
rememberSaveable so the choice survives recreation. Same fix for the
password-visibility toggle in LoginScreen.

https://github.com/vitorpamplona/amethyst/issues/1097

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J74bRJ9H1KFx4vTo9wpez3
2026-07-10 20:11:47 +00:00
Claude
03cb5e4123 fix: trim leading/trailing whitespace from composed posts (#349)
Trim the composer text once, where it is handed to NewMessageTagger, so
every event kind built from tagger.message (short note, poll, zap poll,
NIP-29 thread, NIP-22 comment) publishes without stray leading/trailing
whitespace and mention rewriting operates on the exact string that gets
published. The now-redundant per-call trim on the NIP-29 thread build is
removed. Whitespace-only messages are still rejected by the existing
isNotBlank gates before a template is ever built.

https://github.com/vitorpamplona/amethyst/issues/349

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1V6v7aYBE8B5PhNZMNs3o
2026-07-10 20:11:08 +00:00
Claude
412ec16905 docs: publish APK signing certificate SHA-256 for verification (#972)
Adds a 'Verifying the APK signature' section to the README so users
sideloading via Obtainium/AppVerifier/GitHub Releases can check the
signing certificate of their download.

The fingerprint was COMPUTED (not transcribed) from official release
artifacts of the latest release, v1.12.6:

- amethyst-fdroid-arm64-v8a-v1.12.6.apk
  https://github.com/vitorpamplona/amethyst/releases/download/v1.12.6/amethyst-fdroid-arm64-v8a-v1.12.6.apk
  (file SHA-256 ed95fdb39d668ad2100ca6ff90f21617537790c8db47850f36ca36f75bc7aeea,
  matching the GitHub release asset digest)
- amethyst-googleplay-arm64-v8a-v1.12.6.apk
  https://github.com/vitorpamplona/amethyst/releases/download/v1.12.6/amethyst-googleplay-arm64-v8a-v1.12.6.apk
  (file SHA-256 dacc6db1ba14c914504c6629f8fc039de0918a2ea9d35483915b4721391c3b19,
  matching the GitHub release asset digest)

Extracted with 'apksigner verify --print-certs' (build-tools 37.0.0)
and cross-checked with 'keytool -printcert -jarfile'. Both flavors are
signed with the identical certificate (CN=Vitor Pamplona, O=Amethyst
Labs), consistent with create-release.yml signing both flavors with
the same SIGNING_KEY secret:

  SHA-256: c2d0aa86bcb6b62090561a41bbe336e98b78c2d0210a498dc885f28e1348cf17

Fixes #972

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148shLViyE1KjFc69J6xYux
2026-07-10 20:02:35 +00:00
Claude
8f0cbc77cf feat(concord): tappable Concord invite links in note content
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
2026-07-10 20:00:32 +00:00
Claude
9964423ebc feat(concord): create, mint-invite, and deep-link join flows
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
2026-07-10 19:56:19 +00:00
Claude
1e5d062e32 feat(concord): chat + channel-list screens, send path, nav routes
- 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
2026-07-10 19:51:55 +00:00
Claude
285317b0c2 feat(concord): surface Concord Channels on the Messages screen
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
2026-07-10 19:44:26 +00:00
Claude
f450d03110 feat(concord): mount live channel subscription + LocalCache index refresh
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
2026-07-10 19:40:15 +00:00
Claude
8f34f5f7d2 feat(concord): wire ConcordSessionManager into Account + giftwrap route
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
2026-07-10 19:38:10 +00:00
Claude
faa9aa732e feat(concord): add ConcordSessionRegistry account-wide coordinator
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
2026-07-10 19:30:55 +00:00
Claude
53c3a92288 feat(concord): add ConcordCommunitySession read-model
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
2026-07-10 19:23:43 +00:00
Claude
6178a4e3c6 feat(concord): add ConcordPlaneRegistry (route + decrypt inbound wraps)
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
2026-07-10 18:51:08 +00:00
Claude
a1ae4220e9 feat(concord): add subscription planner (warm all joined planes)
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
2026-07-10 18:48:12 +00:00
Claude
e560df6edc feat(concord): add ConcordChannel model + LocalCache index
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
2026-07-10 18:25:52 +00:00
Claude
0dcfe9cfb5 fix(graperank): crawl what the terminal recovery discovers (fixpoint loop)
An A/B rerun of the full-depth crawl exposed a completeness cliff: deep-hop
coverage was hostage to WHEN a contact list arrived. Lists fetched in-round
expand the frontier and get their follows crawled next round; the same lists
recovered by the terminal aggregator pass were counted but never crawled -
the rounds were already over. One weak backbone hour at round 5 shifted ~30k
lists from in-round to the terminal pass and silently amputated ~220k
hop-6/7 users (621k discovered vs 394k on otherwise-identical runs, while
total lists differed only 7.5%).

Extract the round loop into runRounds() and iterate rounds + aggregator
recovery to a fixpoint: while a recovery pass reveals new in-budget pending
users, resume rounds and recover again. Converges because each straggler
folds at most once and the hop budget bounds depth; maxRounds still
backstops. The warm pool stays up through recovery so resumed rounds start
on warm sockets, and a report-deletions top-up runs only when extra rounds
actually happened.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 18:20:40 +00:00
Claude
22e131e51c feat(concord): wire ConcordChannelListState into Account (kind-13302 home base)
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
2026-07-10 18:11:17 +00:00
Vitor Pamplona
16711caec0 Merge pull request #3515 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-10 13:57:30 -04:00
Claude
88732d507c feat(concord): make joined-list a registered Event (13302) for cache/account use
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
2026-07-10 17:36:49 +00:00
Claude
0306633eab docs(concord): document per-account persistence + subscription model
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
2026-07-10 17:23:29 +00:00
Claude
4672b60c9d feat(concord): add mobile foundation enums (membership + view mode)
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
2026-07-10 17:19:36 +00:00
Claude
b816842e5d docs(concord): add mobile integration plan mirroring NIP-29 relay groups
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
2026-07-10 17:17:28 +00:00
Claude
743658433f Merge remote-tracking branch 'origin/main' into claude/amy-graperank-crawl-perf-jhmx0x
# Conflicts:
#	cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt
2026-07-10 17:12:10 +00:00
vitorpamplona
085b4d7dc0 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-10 17:06:25 +00:00
Claude
51494d5330 Merge remote-tracking branch 'origin/main' into claude/concord-quartz-amethyst-plan-0oy779 2026-07-10 17:05:56 +00:00
Vitor Pamplona
525d3ef189 Merge pull request #3514 from vitorpamplona/claude/armada-nip29-integration-lwqard
Relay Groups: NIP-29 relay-based group chat (Armada interop)
2026-07-10 13:03:48 -04:00
Claude
f946e1aaf4 fix: use the Topic icon for the composer's subject toggle
The subject/title toggle used the Article (document-lines) glyph, which reads as
"body text". Switch to Topic — a clearer signifier for a subject/title line.
Codepoint already present in MaterialSymbols, so no font-subset regen needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 16:50:23 +00:00
Claude
003142122b fix: don't drop NIP-11 waiters on a stuck Loading marker
The relay-signed NIP-29 gate reads each relay's NIP-11 `self` key from the
cache. Nip11CachedRetriever.loadRelayInfo treated a valid `Loading` marker as
"a fetch is already in flight, just wait" — but it dropped the caller's
callback entirely and had no way to notify it when the fetch finished.

That is a lost-wakeup: if the coroutine that started the fetch is cancelled
mid-flight (e.g. the discovery screen that launched the warm-up leaves
composition when you tap a relay chip), the `Loading` marker is left behind,
valid for a full hour. The relay's on-group-list screen then mounts, sees the
stuck `Loading`, waits forever, and never receives the doc — so its NIP-11
looks empty (no `self`), the self-key gate rejects every 39000, and a group
you can plainly see under the "Mine" filter (which bypasses the gate) vanishes
on the relay page.

Re-fetch on `Loading` instead of silently waiting: the fetch is cheap, dedups
at the HTTP layer, and guarantees this caller is notified. Error caching is
unchanged (still avoids hammering a genuinely broken relay).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 16:27:02 +00:00
Claude
af0e9c512e fix: warm host-relay NIP-11 in discovery so All-Follows shows their groups
Companion to the All-Follows roster fix: the discovery screen warmed NIP-11
only for the outbox `candidateRelays`, but a group's relay-signed 39000 lives
on its HOST relay. In All-Follows the subassembler now probes those host
relays (joined kind-10009 + favorited kind-10012) for follow rosters, so their
39000s land in the cache — but the self-key gate (isRelaySignedRelayGroup)
then read an unwarmed, empty NIP-11 (self=null) and dropped every one of them.

That is why the groups only appeared after bouncing through Global (whose
relay set happens to include the host relay, warming it as a side effect).
Warm the joined + favorited host relays alongside the outbox candidates so
the gate is a cache hit with the relay's `self` key present.

Verified against wss://basspistol.org: NIP-11 advertises nip-29 with
self=afd7da3f…, all six 39000s are signed by that self key, and
`amy relaygroup browse` returns all six with unverified_dropped=0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 16:15:28 +00:00
Claude
4d81d298ec fix: load follow-participated relay groups in All-Follows without Global
The All-Follows / Authors discovery filter sets resolve their relays via
the outbox model (each follow's own publish relays), but a NIP-29 roster
(kind-39001 admins / 39002 members) lives ONLY on the group's host relay.
So a follow who is an admin or member of a group never surfaced in
All-Follows until the user bounced through Global — which pulls the whole
directory — and back.

Additionally query the follows as `#p` against the group-host relays we
already know about (joined via kind-10009 + favorited via kind-10012),
minus the relays the outbox filter already covers, and re-assemble when
either list changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 16:08:21 +00:00
Claude
babdea67ab feat: list joined groups' relays as filter chips in Relay Groups discovery
You had to Favorite a relay (kind-10012) before it appeared as a chip in the
Relay Groups top-nav filter — even for relays you already have groups on. Now the
host relay of every group in your joined list (kind-10009) also shows up as a
relay chip, so you can browse the other groups on those relays without favoriting
them first.

Added only to the relay-groups discovery catalog (not git/podcasts/etc.), deduped
against relays already present as favorites. Selecting one resolves to that
relay's groups (filtered by the relay-signed self-key check as usual).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 15:51:51 +00:00
Claude
1614a01ae4 refactor(dns): one resolver everywhere — promote SurgeDns to quartz, fix its blind spots, drop CachingDns
CachingDns duplicated 20% of what the app's SurgeDns already did better
(stale-while-revalidate, single-flight, jittered 24-48h positive TTL,
persistence, poison filtering). Consolidate on SurgeDns and fix what the
verification pass found along the way:

- move SurgeDns + SurgeDnsStore (+ their 41 tests) to quartz jvmAndroid so
  the CLI can share them; delete CachingDns
- negative TTL 10s -> 10min (class default): a dead domain burns 10-30s of
  getaddrinfo per re-dial and both the relay pool and a crawl re-dial dead
  hosts continuously; the relay pool's own backoff already reaches 5min
- stop the call-failure listeners from erasing negative entries they were
  just written from: callFailed caused by UnknownHostException must not
  invalidate (it deleted every negative entry milliseconds after creation,
  silently defeating the negative TTL entirely). The invalidation remains
  for its real purpose - stale positives with rotated IPs
- new SurgeDns.staleAll(): soft-expire everything WITHOUT discarding -
  positives serve stale and revalidate in the background on next use,
  negatives re-try on first touch. Wired to network-identity changes in
  AppModules (same trigger as Tor's onNetworkChange), replacing nothing:
  the full-clear invalidate() was never actually called on network change
- amy: SurgeDns wired into the CLI OkHttp client, snapshot persisted at
  ~/.amy/shared/dns-cache.bin across runs (best-effort load/save)

Verified: all SurgeDns/SurgeDnsStore tests pass in the new location incl.
two new staleAll tests; :amethyst compiles (main + unit tests); cli suite
green. Caveat recorded in the plan doc: behind an HTTP CONNECT proxy OkHttp
never consults the client resolver (hostname goes to the proxy), so this
layer pays off on direct-connect deployments only - which also means the
branch's A/B numbers never depended on it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 15:51:32 +00:00
Claude
fa0883f4a0 perf: warm NIP-29 relays' NIP-11 in parallel + pre-warm joined groups
The relay-signed group check reads each host relay's NIP-11 `self` from cache,
but the discovery screen was warming those docs SERIALLY — Nip11Retriever awaits
each HTTP fetch, so N relays meant N sequential round-trips and one slow or
unreachable relay stalled every group behind it until its socket timeout. That's
why groups trickled in.

- Add WarmNip11(relays): fans the fetches out, one coroutine each, so the wait is
  the slowest single fetch instead of the sum. Discovery now uses it and
  re-invalidates the feed as each doc lands (via a version counter).
- Pre-warm NIP-11 for joined groups' host relays from the Messages tab
  (WarmJoinedRelayGroupNip11), so by the time one surfaces in discovery the
  answer is a cache hit. NIP-11 stays cached for an hour.

Note: the Messages tab itself never needed this — joined ("My Groups") rows are
authoritative from the kind-10009 list and were never gated on NIP-11.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 15:43:20 +00:00
Claude
0f01dc0c12 feat: verify NIP-29 group metadata is signed by the relay's own key
NIP-29 metadata/roster events (39000-39003) "are addressable events signed by
the relay keypair directly ... as stated by the NIP-11 `self` pubkey", and
"relays shouldn't accept these events if they're signed by anyone else". So the
authoritative test for a genuine group is `39000.author == relay.self` — which
also rejects a stray user-published 39000 even on a real NIP-29 relay, something
the earlier supported_nips heuristic could not.

Add `isRelaySignedRelayGroup(channel)`: strict `author == self` when the relay
publishes `self`, falling back to `supported_nips ∋ 29` when it omits `self`, and
false when it has neither. Apply it at the surfaces that show unsolicited groups:

- Discovery feed: replace the relay-level supported_nips filter with the
  per-channel self-key check in matches(); the screen now warms each candidate
  relay's NIP-11 and re-invalidates the feed as each doc resolves.
- On-relay group list: filter to relay-signed groups, warming that relay's
  NIP-11 so genuine groups fill in and fakes stay hidden.
- CLI `relaygroup browse`/`info`: fetch the relay's NIP-11 (new
  Context.relayInfo) and drop 39xxx not signed by `self`; browse reports the
  dropped count.

Explicit user actions (a received invite link, opening an naddr) are left
untouched — hiding those would be user-hostile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 15:18:13 +00:00
Claude
85ad70521b feat(graperank): rebuild --diagnose around where the wall time actually hides
Every hard diagnosis this branch needed lived in blind spots of the old
output — waits that happen before its timers start and stages it never
delimited. Close them:

- permit/rate-gate wait ledger per relay (queueing for a limiter slot
  happens BEFORE the drain timer starts; total + worst offender always,
  top-10 queue table under --diagnose)
- [batch-walls] per round: p50/p90/max of Phase-B batch walls vs the fast
  window, naming the last-resolving unit of the slowest batches - the
  direct measure of pre-drain queueing
- store path split: insert wall now labeled as mutex-wait-inclusive with
  an uncontended-writes estimate (solo batches), plus a read counter
  (351 queries/724ms at hop-2) that makes point-query pathologies visible
- ticker gains visited/s + batches/s so retry rounds over list-less users
  read as "working, zero yield" instead of "stalled" (the done-rate hid
  this for hours)
- per-round limiter demotion counts; stage durations for aggregator
  recovery and report deletions
- [slow-relay] lines deduped to 3 samples per authority with 3-author
  samples (a hop-8 log was ~1MB of repeats; telemetry keeps full counts)
  and the redundant per-drain parked line removed

Smoke-tested live on a hop-2 --diagnose crawl; all suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 14:43:35 +00:00
Claude
188ba3c609 feat: discovery only shows groups from relays that advertise NIP-29
Stray kind-39000 events published by ordinary users to general relays (e.g.
nostr.wine) were surfacing in the Relay Groups discovery feed as joinable groups
that have no roster and no chat and can't actually be joined — because those
relays don't run NIP-29, they just store the fake metadata like any addressable
event.

A relay that truly runs NIP-29 rejects user-authored 39xxx, so on such a relay
every 39000 is relay-signed and genuine. Gate discovery on that: restrict the
per-relay constraint set to relays whose NIP-11 `supported_nips` advertises 29.
This is the single point both the match test and the REQ-driven feed read, so
non-advertising relays drop out wholesale. The discovery screen warms each
candidate relay's NIP-11 and re-invalidates the feed as support resolves (a
relay whose NIP-11 lands after its 39000s would otherwise stay hidden until a
manual refresh). "My Groups" (the kind-10009 joined list) is unaffected.

Trade-off: a relay that runs NIP-29 but doesn't publish NIP-11 (or omits 29 from
its list) is hidden from discovery until it advertises.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 14:32:30 +00:00
Claude
9a5a278b01 feat: block NIP-29 group creation on relays that don't advertise it
The reported "title/image didn't save, group shows as a bare hex, and I'm asked
to join my own group" all come from creating a group on a relay that isn't
running NIP-29: it stores the 9007/9002 as ordinary events but never creates the
group, emits 39000/39001/39002 metadata, or makes the creator an admin.

Gate the create screen on the relay advertising NIP-29 in its NIP-11
`supported_nips`: a tri-state check (checking / unsupported / supported) disables
the Create button until support is confirmed and shows an explanatory warning
banner when the relay is confirmed to lack it. Editing is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 13:13:30 +00:00
Claude
a2fdca08cf docs(graperank): record sub-cap rejection + cold hop-5 validation in plan
Per-relay sub cap 32 made the hop-3 crawl 52% slower with slightly fewer
lists (27 relays demoted us vs 5 at cap 16) - the Phase-B plateau is
relay-side service rate, so 16 stays the default and AMY_RELAY_SUB_CAP
remains an experiment hatch. Final validation: cold hop-5 completed in
85.4 min with 203,903 contact lists / 391,549 users / 626,599 events -
the workload that never finished before this branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 06:19:18 +00:00
Claude
2d58afefe6 feat(amy): AMY_RELAY_SUB_CAP env override for the per-relay sub cap
Phase-B throughput plateaus at ~100-150 users/s at every crawl scale while
CPU/FDs/network sit idle, pointing at the per-relay 16-permit gates on the
hot backbone/fallback relays every batch touches. The 16-vs-100 benchmark
that chose the default predates the multithreaded-crawl fix, so expose the
starting cap for A/B runs; the NOTICE/CLOSED demotion ladder still protects
relays that push back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 05:13:56 +00:00
Claude
3a4b4d5c27 perf(graperank): default drainConcurrency 24 -> 48; record A/B results
Cold hop-3 A/B (fresh store per leg, same observer): 48 workers beat 24
twice at identical contact-list counts (579s->559s and 474s->396s). The
old result that made 64 look 2x slower predated the multithreaded
dispatcher fix - more coroutines on one starved event-loop thread. Plan
doc gains the full A/B table (kept: background-park degating, sweep
dedup, tail overlap, dc48; rejected: 5s fast window).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 04:41:57 +00:00
Claude
e2afe23e52 perf(graperank): overlap the finishing tail instead of serializing it
Timestamped hop-3 logs split the 373s tail three ways: 222s blocking
convergence on parkedInFlight (whose late lists provably cannot fold once
pending is empty - parked filters only ever asked for in-budget users and
they are all done; the events still stream into the store), 14s of
aggregator recovery, and 137s of report-deletion fetching serialized
behind it. Convergence now breaks as soon as the frontier is empty; the
aggregator pass and report deletions run concurrently; and the aggregator
folds the lateHarvest trickle until one full park window of silence
instead of inheriting every background park's lifetime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 04:24:26 +00:00
Claude
297244813a feat(amy): stamp graperank crawl log lines with elapsed seconds
A saved crawl log couldn't attribute wall time to the finishing tail
(aggregator recovery vs report deletions vs parked drains) without
external timestamps. Prefix every crawl progress line with [t+SSSs].

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 03:55:48 +00:00
Claude
f334402034 perf(graperank): don't re-shard-sweep the same stragglers every round
Straggler rounds re-offered the identical missing set to the identical
top-relay backbone each round, re-paying the rotation barriers (~20s of
Phase A per round) for ~zero new lists. Track swept users and only sweep
newcomers - but only once the backbone is at full shard width, so users
swept against round 2's proto-backbone still get a real sweep when the
top-10 exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 03:48:20 +00:00
Claude
91835993f6 perf(graperank): stop background sweeps' parks from gating convergence
A hop-3 crawl spent ~500s of its 594s total in the finishing tail waiting
for parkedInFlight to hit zero - and the spike (1791 parked units) was the
fire-and-forget Tier-2 relay-list sweeps, whose whole point was not to
block the crawl. They only gated it because a parked unit persisted its
events at park END, so cancelling early would have lost them.

Fix in two parts: the park loop now persists incrementally (drains + stores
the queued chunk on every activity ping, plus a NonCancellable final drain),
so cancelling a park loses nothing already delivered; and drainGated grew a
background flag - Tier-2 sweep parks (and their capped-page paginations)
no longer count toward parkedInFlight and are simply cancelled at crawl
end. Round-critical parks (outbox drains, sharded sweep, aggregator
recovery) still gate convergence exactly as before. paginateIfCapped takes
(pageSize, oldest) since the incremental path no longer retains the page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 03:31:11 +00:00
Claude
720f76ed97 perf(graperank): batch store contact-list reads instead of per-user point queries
Once the crawl went multithreaded, the store became the visible Phase-A
bottleneck: harvestFromStore, shardedSweep's missing-check, the Phase-B
consumer, and the aggregator straggler scan each issued one contactsOf
point query per user (~8ms each on a multi-GB store under concurrent
writers) - 100k-user folds burned minutes serially, and the aggregator
pass over 300k discovered users would have burned ~40 on its own. Add
latestContactsFor (one chunked author query per 300 users, newest
created_at per author wins) and route all four paths through it;
harvestFromStore folds chunk-by-chunk so peak memory stays one chunk.
Phase A now folds the store first and shardedSweep trusts `done` instead
of re-checking per author.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 03:14:12 +00:00
Claude
e595d42c49 perf(graperank): run the crawl on Dispatchers.IO, not the caller's loop
The CLI calls crawl() from runBlocking's single-threaded event loop, so
Phase B's thousands of concurrent drain-unit coroutines (timeout timers,
channels, REQ JSON encoding, signature verifies, SQLite writes) all queued
on ONE thread: timers fired late, batch walls inflated ~5x, and the
--diagnose ticker showed 24/24 workers pinned while completing ~1 user/s
with one core pegged and three idle. It also explains why the old
64-worker A/B ran slower - more coroutines on the same thread. Hop the
whole run onto Dispatchers.IO inside the crawler so every front end gets
real parallelism; IO (not Default) because the store's blocking SQLite
calls must not starve the cores-sized pool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 03:02:51 +00:00
Claude
f912aea004 fix(amy): reserve 'operator' so machine keys don't break account auto-select
Any graperank crawl/probe now creates ~/.amy/operator/ (the reachability
cache signs with the operator monitor key), and listAccounts counted it as
an account - so a single-account user's very first crawl left every later
command failing with "multiple accounts (operator, <name>)". Add it to
RESERVED_NAMES alongside shared/current. Hit for real while network-testing
the connect-storm changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 01:45:17 +00:00
Claude
2bad6779ce perf(graperank): mass pre-connect, cached DNS, and an amy relay census probe
Most of a from-scratch crawl's wall clock was connection setup, re-paid
serially: the relay pool tears down a relay's socket ~300ms after its last
drain unsubscribes, OkHttp allowed only 256 concurrent WS handshakes, and
every dial re-ran an uncached blocking getaddrinfo (10-30s per dead domain,
once per per-user path URL of the same host). Open the connections once, in
parallel, and keep them:

- GrapeRankCrawler: the warm-pool trick (never-matching REQ that only holds
  the socket) now covers the whole candidate universe instead of the top 20 -
  seeded at crawl start from the reachability cache's live set (one parallel
  connection storm, Config.knownLiveRelays) and refreshed each round with
  newly learned outbox relays, capped by Config.preconnectCap (FD-budget
  aware, --preconnect-cap / --no-preconnect).
- CachingDns (quartz jvmAndroid): 10-min positive + negative DNS cache with
  in-flight per-host dedup; dead domains fail in microseconds instead of
  re-burning resolver timeouts, path URLs of one host resolve once.
- cli Context: dispatcher and pre-connect caps derived from the process's
  open-files limit (UnixOperatingSystemMXBean), warning when ulimit is low.
- amy graperank probe: relay census - mass-connects every relay the store
  knows (kind:10002 universe deduped per authority + cached verdicts) in
  waves, records live/dead with real measured rtt-open into the NIP-66
  reachability cache (RelayProber + RelayReachabilityStore.recordProbed),
  so the next crawl skips dead relays and waits once for the slow-but-alive.

Single-server limits (FDs, ephemeral ports, DNS, threads, conntrack) and the
design are documented in quartz/plans/2026-07-10-graperank-connect-storm.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zEYRGKF943RgLaHTViJaB
2026-07-10 01:13:23 +00:00
Claude
567cc3f416 style: match the composer subject/title field to the new-DM To field
Restyle the ShortNotePostScreen subject/title input to the inline-label +
borderless ThinPaddingTextField + hairline-divider look used by the new-DM
composer's "To"/"Subject" rows, instead of a boxed OutlinedTextField. The field
now backs onto a TextFieldState (like the DM composer) rather than a String.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 00:29:41 +00:00
Claude
d650c184de feat: require the title for NIP-29 group threads (no text fallback)
The group-thread composer now treats the subject/title field as mandatory: the
field is always shown (no toggle to hide it), canPost() blocks until it's filled,
and createTemplate uses it verbatim as the kind-11 title — dropping the previous
first-line-of-the-body fallback. Plain kind-1 notes keep the optional subject.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 00:21:25 +00:00
Claude
bf288711a2 feat: optional subject/title field in the ShortNotePostScreen composer
Add a subject line toggled from the composer's bottom row. On a kind-1 note it
becomes a NIP-14 `subject` tag; on a NIP-29 kind-11 group thread it becomes the
`title` (superseding the first-line-as-title heuristic — that stays as the
fallback when the field is left empty). The field is auto-shown for group
threads and labelled "Title" there, "Subject" otherwise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 00:14:05 +00:00
Claude
cb90362bae feat: warm each group's recent messages on a relay's channel list
Opening a group from the relay's channel list used to start its chat from a cold
load. Mount the existing RelayGroupWarmupSubscription on every visible card
(content-only — the directory subscription already streams metadata), so a tap
lands on already-cached messages.

Add a contentLimit to the warmup (default 50, unchanged for discovery's "50+"
signal); the channel list passes ~10 — a first screen's worth. Bounded to
visible rows by the LazyColumn and released as they scroll off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-10 00:13:53 +00:00
Claude
dd9623a16e feat(concord): add amy concord CLI (create/join/send/read/invite)
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
2026-07-09 23:43:50 +00:00
Claude
1ecc97de76 feat: compose NIP-29 group threads in the full ShortNotePostScreen
The group "new thread" FAB opened a cramped title+body screen. Point it at the
rich ShortNotePostScreen instead (attachments, emoji, previews, markdown), and
teach that composer to emit a kind-11 group thread when opened for a group.

- Route.NewShortNote gains groupThreadId + groupThreadRelayUrl; the group Threads
  FAB navigates there.
- ShortNotePostViewModel.setGroupThread arms a group-thread mode: createTemplate
  builds a kind-11 ThreadEvent (title = first line, body = the rest, `h` scope),
  and sendPostSync publishes it ONLY to the group's host relay via
  signAndSendPrivatelyOrBroadcast — bypassing the outbox/private/scheduled paths.
- The poll, private-note and scheduling toggles are hidden in group-thread mode
  (they don't apply / would break the host-relay pin).
- Delete the now-unused RelayGroupNewThreadScreen and its route/nav dispatch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 23:35:40 +00:00
Claude
587387ba96 feat(concord): add commons ConcordActions (CLI-safe business layer)
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
2026-07-09 23:34:15 +00:00
Claude
6292ceb803 feat: grouped-by-relay Messages view + a Messages settings screen
Move the NIP-29 inline/by-relay toggle off the top of the Messages tab (where it
sat clipped behind the tab row) into a new Settings › Messages screen, and make
"by relay" actually mean something in the feed.

- New MessagesSettingsScreen (Route + SettingsCatalog entry + nav) with a
  radio choice: show each joined group inline, or collapse each relay's groups
  into one row. Removes the pinned SegmentedButton + above-pager server list
  from both the single- and two-pane layouts; deletes the now-dead
  RelayGroupViewModeToggle and RelayGroupServerList composables.
- GROUPED mode now weaves one row PER HOST RELAY (never duplicated) into the
  Messages feed, positioned at that relay's newest group message so it
  interleaves with DMs by recency and shows the last message. Backed by a
  synthetic RelayGroupServerRoomNote whose createdAt mirrors the newest message;
  ChatroomListKnownFeedFilter builds/updates it in feed(), applyFilter and
  updateListWith, keyed by relay url. A view-mode change forces a feed rebuild.
- Revert the moot kind-7 render guard: the data-layer content filter already
  keeps reactions out of the row, so the ChatroomEntry fallback stays simple
  (the shared Event.isGroupChatContent helper remains, used by the feed filter).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 23:17:19 +00:00
Claude
d4e71881e8 fix: sign a q quote-tag when replying to a kind-9 group message
The NIP-29 group composer always built replies with ChatEvent.build and ignored
replyTo entirely, so a reply to a kind-9 message quoted its parent in the UI but
the signed event carried no NIP-18 `q` tag (and no `p` notify to the author) —
unlike the public-chat and live-activity paths, which use `.reply(...)`.

Route group replies through ChatEvent.reply when replyTo is set: it emits the
`q` quote tag, and we add a `p` tag to the parent's author.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 23:17:03 +00:00
Claude
46a2652fc5 feat(concord): add direct invites (kind 3313)
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
2026-07-09 23:11:22 +00:00
Claude
5ea59ca137 feat(concord): add voice presence and blind-broker token (CORD-07)
- 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
2026-07-09 23:08:07 +00:00
Claude
5669414de7 feat(concord): add invite bundle (33301) and full join flow
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
2026-07-09 23:04:46 +00:00
Claude
f42f587ade feat(concord): add community creation factory and entity coordinates
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
2026-07-09 22:53:13 +00:00
Claude
b96960db88 feat(concord): add private joined-communities list (kind 13302)
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
2026-07-09 22:48:46 +00:00
Claude
7c7608913d fix: never render a group reaction as the Messages row content
The Messages-list feed already filtered kind-9/1068/11/1111 content when
selecting a group's representative note, but the ChatroomEntry render fallback
still rendered ANY group-scoped note — including a kind-7 reaction lingering in
the in-memory list — as the group row, using the reaction's content and time.

Add a shared quartz helper `Event.isGroupChatContent()` and use it in both
places: the feed filter and the render fallback. A non-content group-scoped note
now falls back to the channel placeholder ("No messages yet") instead of showing
the reaction as the room's last message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 22:39:15 +00:00
Claude
b0e3594eaa feat(concord): add rekey distribution (CORD-06)
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
2026-07-09 22:20:51 +00:00
Claude
b36daf80f6 feat: notify on reactions/replies to my messages in joined groups
Notification events were only fetched from my inbox relays (#p=me), but NIP-29
group activity — a reaction or reply to my message — lives on the group's HOST
relay, so it never surfaced in Notifications until I opened the group (which
subscribes to the group's content directly).

Extend the notifications subscription to also poll each joined group's host relay
for events that tag me, scoped by `#h` to my joined groups (new
filterGroupNotificationsToPubkey over reaction/reply/repost/zap/report kinds).
Re-subscribe when the joined-group list changes so a newly-joined group's relay
is added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 22:15:54 +00:00
Vitor Pamplona
268a9ff33e Merge pull request #3512 from vitorpamplona/claude/nostr-blocked-relays-review-7m5o3t
Enforce blocked relays centrally via decorator client
2026-07-09 18:12:23 -04:00
Claude
5425b09fef fix: don't treat a group reaction as the group's latest message
filterRelevantRelayGroupMessages picked ANY group-scoped note (isGroupScoped()
= carries the group's `h` tag), so a reaction (kind 7) to my message became the
group's "last message" on the Messages tab — a wrong row that the chat renderer
can't display. Whitelist actual chat content (kind 9 chat / 1068 poll / 11
thread / 1111 comment) and reject reactions, deletions, labels, etc. Apply the
same guard to the feed()'s channel-notes scan as defense in depth.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 22:11:44 +00:00
Claude
06fa80da65 feat(concord): add invite-link codec and bundle key (CORD-05)
- 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
2026-07-09 22:06:34 +00:00
Claude
982cbb6a21 feat(concord): add community-state fold and guestbook plane
- 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
2026-07-09 22:02:54 +00:00
Claude
d51d8e6d71 feat(concord): add channel key derivation and chat message binding
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
2026-07-09 21:59:05 +00:00
Claude
71db306f8d feat(concord): add owner-rooted authority resolver
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
2026-07-09 21:56:09 +00:00
Claude
c865d0f4eb feat(concord): add control edition parsing and chain folding
- 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
2026-07-09 21:51:35 +00:00
Claude
ace7a7f476 fix(relay): enforce blocked relays centrally on every REQ/COUNT/publish
Relay targeting is fully distributed: every feed, loader, finder and
broadcast path builds its own relay set and hands it to the shared
INostrClient. Only the follow-outbox flows and the top-nav feed filters
subtracted the NIP-51 kind:10006 blocked list, so blocked relays still
leaked in through the event/thread loaders (FilterMissingEvents /
FilterMissingAddressables), the user-metadata finder
(pickRelaysToLoadUsers), channel finder, DM targeting, the one-shot
fetch helpers, and the publish path (Account.computeRelayListToBroadcast)
— none of which consulted the blocked set.

Add BlockedRelayFilteringClient, a thin INostrClient decorator that
strips the active account's blocked relays from subscribe, count and
publish right before they reach the pool. Because the one-shot fetch
helpers route through subscribe/count, wrapping the client covers them
too. The blocked set is read per-call so account switches and list
edits apply with nothing to invalidate.

Wire it around the shared app client (blocked set from the logged-in
account) and around the per-account crawl client used by Event Sync and
Cashu discovery. Add commonTest coverage for the filtering, pass-through,
fully-blocked, and per-call-read behaviors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNMPdC2eGUwTrt3XkQefuf
2026-07-09 21:50:55 +00:00
Claude
0366d6d584 fix: render group messages on the Messages tab even when unattached
A NIP-29 group message whose note isn't attached to its RelayGroupChannel (its
gatherer never registered — loaded before the channel existed, or via a path
that skips attach) hit ChatroomEntry's when(event) with no matching case and fell
to `else -> BlankNote()`: a white, non-clickable row where the group should be.
The inGatherers check only covers attached notes.

Handle group-scoped events explicitly before the when: resolve the group from the
event's `h` tag + the note's provenance relay and render RelayGroupRoomCompose,
so the row shows the group and taps through to the chat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 21:48:28 +00:00
Claude
2eb7ff2ef2 Merge remote-tracking branch 'origin/main' into claude/armada-nip29-integration-lwqard
# Conflicts:
#	cli/tests/.gitignore
2026-07-09 21:48:04 +00:00
Claude
828792eb40 feat(concord): add kind registry, control entity kinds, permission bitfield
- 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
2026-07-09 21:46:40 +00:00
Claude
30b6d70cfc feat(concord): add stream envelope (wrap/seal/rumor) layer
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
2026-07-09 21:41:08 +00:00
Claude
ade45c3326 feat(concord): add Concord key-derivation crypto foundation
Introduce the quartz `concord/crypto` package implementing the interoperable
key-derivation core of the Concord protocol (encrypted, serverless communities
on Nostr), pinned to the Concord v2 reference client (Soapbox Armada) for wire
compatibility:

- ConcordLabels: frozen HKDF domain-separation labels (CORD-01..07)
- ConcordKeyDerivation: buildInfo layout, hkdf32, scalar-normalizing
  deriveSecretKey, groupKey (plane/channel address + self-ECDH conv key),
  communityId, voice keys, and rekey recipient locator
- GroupKey: plane key result (secret key, x-only address, conversation key)
- EditionHash: domain-separated, length-prefixed edition-chain hash (CORD-04)

Reuses the in-tree Hkdf, Nip44v2 self-ECDH, KeyPair and Secp256k1Instance
primitives. Adds property-based tests (determinism, distinctness across
label/id/epoch/secret, self-ECDH round-trip, genesis-vs-zero-prev chain,
verbatim-content hashing). All green on :quartz:jvmTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
2026-07-09 21:24:59 +00:00
Claude
a645688baf test+polish: verify NIP-29 placeholder gatherer; show "No messages yet"
Add a regression test proving RelayGroupChannel.placeholderNote() carries the
channel as a gatherer and is cached/stable — this is what lets the Messages row
renderer resolve the event-less placeholder back to the group (the prior fix).

Also give the empty-group placeholder row a visible "No messages yet" second
line instead of blank content, matching the Marmot-group row, so a just-joined
group with no messages reads clearly rather than looking like an empty item.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 21:21:59 +00:00
davotoula
1e05a086ad fix: suppress RestrictedApi false positive on NappletHostActivity.dispatchKeyEvent
Activity.dispatchKeyEvent is a public framework hook; lint flags the
override only because androidx.core's intermediate override carries a
library-group @RestrictTo. Scoped to the method so the check stays live
for genuine restricted-API use. Makes :nappletHost:lintDebug pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HLfwhTdf72qFPnmPRYnzqu
2026-07-09 22:14:57 +01:00
Vitor Pamplona
f1419377b7 Merge pull request #3511 from vitorpamplona/claude/graperank-sync-crawl-1n05im
graperank: relay reachability cache (NIP-66), aggregator kind:3 recovery, and outbox-discovery dedup
2026-07-09 17:00:09 -04:00
Claude
7c581edddc fix: render the NIP-29 empty-group placeholder row instead of a blank
The Messages placeholder guard in ChatroomHeaderCompose only recognized Marmot
placeholders, so a just-joined NIP-29 relay group (an event-less placeholder note
carrying a RelayGroupChannel gatherer) fell through to the wait-for-event branch
and rendered BlankNote() — a white gap where the group row should be. Recognize
a RelayGroupChannel gatherer too so it routes to the group row renderer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 20:56:15 +00:00
Claude
cc17b29bc0 fix(graperank): stop dropping events, un-evict live-but-slow hosts, re-sweep new relays
Addresses correctness/perf issues found in the crawler + reachability audit:

- deadHosts permanent eviction (#1): an authority that accrued timeoutEvictStrikes
  before its first EOSE was evicted forever — clearTimeoutStrikes only zeroed the
  counter and could not un-evict, contradicting the "a host that ever produces is
  never evicted" invariant. Add a producedHosts set that isDead() consults, so a
  proven-productive authority is never treated as dead even if a concurrent strike
  from the 24-worker fan-out raced it into deadHosts.

- Parking-disabled event loss (#2): when parking is off (no bgScope, or
  parkTimeoutMs <= timeoutMs), a relay that streamed events but didn't EOSE in the
  fast window had its buffer dropped without persist() and reported count 0. Drain,
  persist, and return those events like the other two branches; strike only when
  nothing was delivered.

- Wide-sweep over-narrowing (#4): relayListDiscoverySwept excluded an already-swept
  straggler from the wide pass even though the wide net grows each round, so a 10002
  hosted only on a later-learned relay was never fetched. Gate the wide pass on the
  asked-relay set (wideRelaysSwept) instead: new users get the full net, older
  stragglers get only newly-appeared relays, no (user, relay) pair asked twice.

- Onion detection (#10): replace loose relay.url.contains(".onion") with
  RelayUrlNormalizer.isOnion() in isDead() and networkTypeOf(), fixing the
  foo.onionfake.com false positive and the store/crawler disagreement.

- rtt-open=0 semantics (#9): document that the crawler's reachable records use
  rtt-open purely as a liveness flag (0 = latency not probed), not a real 0 ms
  measurement, and must not be published as authoritative latency data.

deadHosts is deliberately still NOT persisted to the 24h reachability cache (#8):
a timeout eviction means "too slow under our fan-out this run", not "proven
unreachable", so persisting it would blacklist slow-but-live hubs across runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-09 20:43:38 +00:00
Claude
b935995fe6 fix: open NIP-29 group posts in the group chat, not a thread view
Tapping a NIP-29 message in Notifications (or the feed) fell through routeFor's
`else -> Route.Note`, opening the generic thread view instead of the group chat —
unlike every other chat NIP. Mirror the Marmot-group path: when a note is
attached to a RelayGroupChannel gatherer, route to Route.RelayGroup (the channel
carries the host relay). Add an `h`-tag + provenance-relay fallback for a
group-scoped note that isn't attached to a channel yet, so it still opens the
chat rather than a thread.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 20:24:39 +00:00
Claude
dae4ade55a fix: show a just-joined NIP-29 group on the Messages tab immediately
The Messages tab (INLINE mode) mapped each joined group to its newest cached
kind-9 message and dropped it when none existed. Since the joined-groups
subscription only fetches roster kinds (39000/1/2), not chat, a group you just
joined stayed invisible until you opened it (loading messages) or posted — unlike
Marmot groups, which already fall back to a placeholder row.

Mirror the Marmot pattern for relay groups:
 - RelayGroupChannel.placeholderNote(): a cached synthetic note that adds the
   channel as a gatherer, so the existing Messages row renderer resolves it back
   to the group (RelayGroupRoomCompose already handles a null-event note).
 - ChatroomListKnownFeedFilter.feed(): fall back to placeholderNote() when the
   group has no loaded message.
 - AccountFeedContentStates: rebuild dmKnown when relayGroupList (kind 10009)
   changes — join/leave doesn't flow through newEventBundles, so without this the
   placeholder wouldn't appear until a later event.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 20:22:00 +00:00
Claude
2ffd64c170 refactor: rename relay-group REQ assemblers by when they run
The assembler names didn't say when each is active — most confusingly, a
"Threads" assembler with no matching "chat" one, because group chat (kind-9) is
served by the shared `channel` assembler, not a group-specific one. Rename the
four group-specific families for their surface, and document that chat has no
dedicated assembler:

  relayGroupDirectory  -> relayGroupsOnRelay        (browsing one relay's channels)
  relayGroupRoster     -> relayGroupMyJoinedGroups  (metadata+rosters of joined groups)
  relayGroupThreads    -> relayGroupThreadFeed      (a group's forum-threads tab)
  relayGroupPreview    -> relayGroupWarmup          (prefetch before a group opens)

Each family's FilterAssembler / QueryState / SubAssembler / Subscription + file
renamed to match. relayGroupsDiscovery is left as-is: it already names the
Discover feed and shares its token namespace with the screen/DAL/settings, so a
rename would either collide with RelayGroupDiscoveryFeedFilter or corrupt those.
Pure rename; no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 20:05:43 +00:00
Claude
aa5c8f0491 revert(relay): drop FrameDispatchStats — per-frame cost on the shared WS hot path
FrameDispatchStats stamped a ValueTimeMark on every relay frame and recorded a
contended atomic per frame in BasicOkHttpWebSocket — the WebSocket layer used by
the whole app, unconditionally, forever — to answer a one-time question that only
graperank --diagnose read. It served its purpose (proved the our-side dispatch
lag is ~200ms mean and the EOSE-wait is dominantly relay-side, so the crawler is
network-bound), but the ongoing per-frame Pair allocation + atomic contention on
every client's relay traffic isn't worth carrying. Revert the channel back to
Channel<String> and delete the stats holder. The diagnose-gated saturation ticker
and per-drain latency breakdown stay — they're crawler-local, off the hot path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-09 19:52:42 +00:00
Claude
23c93e43f9 feat: gate the group chat composer on membership
NIP-29 relays reject kind-9 writes from non-members, so typing in a group you
haven't joined only earns a silent relay rejection. Show the composer only when
the relay-signed roster (39001/39002) lists me as a member/mod/admin — the same
boundary the threads FAB already uses — collecting the channel metadata flow so
it appears the instant my join is accepted. Otherwise replace it with a notice:
"Join this group to send messages" (open) or an invite-only explanation (closed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 19:14:14 +00:00
Claude
525a615b51 fix: circular FAB on the relay group list + threads screens
The "groups on this relay" and group-threads screens used a bare
FloatingActionButton, which renders as Material 3's default rounded-square shape.
Every other new-post FAB in the app is circular (shape = CircleShape); match it
so the group FABs read the same as the rest of the app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 19:08:42 +00:00
Claude
2527364a61 fix: load the kind-10009 groups list at login (consume + REQ)
The user's NIP-51 "simple groups" list (kind 10009 — joined NIP-29 groups +
servers) was neither consumed nor requested at login:

- LocalCache had no dispatch branch for SimpleGroupListEvent, so an arriving
  10009 fell through to the "Event Not Supported" else and was dropped —
  RelayGroupListState, which reads it from the addressable cache, could never
  populate from the network (only from the on-device offline backup).
- The account-info assemblers (filterAccountInfoAndListsFromKey /
  filterBasicAccountInfoFromKeys) never REQd kind 10009 alongside the sibling
  NIP-51 lists, so a fresh sign-in never fetched it.

Add the consume branch (consumeBaseReplaceable, like every sibling list) and
include SimpleGroupListEvent.KIND in both assemblers, so "My Groups" and group
memberships resolve from the start of login without opening the groups screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 19:08:27 +00:00
Claude
1f01407442 Revert "perf(graperank): adaptive idle-EOSE cutoff (--eose-idle-ms)"
This reverts commit e02f00384a.
2026-07-09 18:52:50 +00:00
Claude
4fca9ae25e feat: message-count + follows-in-group on discovery cards; split relay chip
Three group-discovery card changes:

- Reactive loaded-message count. The preview subscription streams the group's
  recent kind-9 chats into the channel note cache; the card now shows that count
  on the stats line ("12 members · 50+ messages"), updating live as messages
  arrive. Bumped the preview page 15 -> 50 so an active chat reads as "50+"
  (also a better warm-up); the display caps at DISCOVERY_MESSAGE_CAP.
- People-you-follow social proof. RelayGroupChannel.participatingFollows()
  intersects the relay-signed roster with the kind-3 follow set; the card shows
  an overlapping face pile + "%d people you follow" caption when non-empty.
- Split the relay chip's tap targets. Tapping the chip body now opens that
  relay's full group list (Route.RelayGroupServer); only the star toggles the
  relay favorite. Previously the whole chip favorited, which was easy to hit by
  accident.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 18:24:36 +00:00
Claude
e02f00384a perf(graperank): adaptive idle-EOSE cutoff (--eose-idle-ms)
Measured: relays deliver their events in ~0.6s then sit ~4.6s (86% of drain wall)
before sending EOSE — mostly relay-side (our pipeline adds only ~200ms). So instead
of waiting the full 10s fast window then parking, close a drain that has delivered
>=1 event and then gone silent for eoseIdleMs, treating it as complete ("eose-idle").

awaitTerminalOrQuiescent: the idle timer arms only AFTER the first event, so a relay
merely slow to answer still gets the full timeoutMs and is never cut prematurely; a
still-streaming relay keeps resetting the window. eose-idle paginates if the page was
capped and clears timeout strikes (it delivered), but joins notAnswered (no clean
EOSE, so its missing authors are retried elsewhere). Off by default (eoseIdleMs=0),
CLI --eose-idle-ms, so it can be A/B'd against the plain fast window.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-09 18:19:43 +00:00
Claude
a5c2a8b2c1 revert(relay): frame dispatch back to Dispatchers.IO — dedicated pool regressed
The dedicated frame-dispatch pool (ea1093ad) made dispatch lag WORSE, not better:
mean 200ms→460ms, max 3.5s→5.5s, frames>1s 43k→76k. The pool was sized cores*2
(=8 here) vs Dispatchers.IO's 64 threads, so it cut frame-processing parallelism
~8x. Lesson: the our-side lag is dominated by per-connection serial decode
throughput / thread count, NOT cross-contention with the store's IO writes — the
experiment ruled that hypothesis out. Reverting to shared IO; keep FrameDispatchStats.
EOSE-wait is confirmed dominantly relay-side (200ms our-mean vs ~5s eose-wait).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-09 18:19:43 +00:00
Claude
ea1093adaf perf(relay): isolate WS frame dispatch onto a dedicated pool, off Dispatchers.IO
Measured on a GrapeRank crawl, frame decode/dispatch (per-connection consumer
coroutines) ran on the shared Dispatchers.IO — the same pool that runs the store's
blocking SQLite inserts. During event floods, frame coroutines queued behind those
inserts: mean 200ms and up to 3.5s of dispatch lag, with 43k frames waiting >1s in
our pipeline. That lag also skews the relay-idle/EOSE timing the crawler reads.

Give frame processing its own daemon thread pool (sized to a small multiple of
cores; decode is light + CPU-bound), shared across all connections. Frame delivery
stays prompt regardless of what the IO pool is doing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-09 17:56:22 +00:00
Claude
c253a5aa12 feat: relay autocomplete on the Find Groups browse field
The browse-a-relay field was a plain text box. Wire it to the same relay
autocomplete every other relay field uses (RelaySuggestionState +
ShowRelaySuggestionList over LocalCache.relayHints): as you type, a popup lists
matching known relays, and tapping one opens that relay's group directory
directly. The manual paste-and-Go path and the your-relays / popular sections
are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 17:45:55 +00:00
Claude
4c20522d78 feat(graperank): frame-dispatch-lag metric to attribute EOSE-wait
Adds FrameDispatchStats: the lag between a relay frame arriving on the OkHttp
reader thread and our per-connection consumer coroutine (on shared Dispatchers.IO)
pulling it off the channel — pure our-side pipeline delay, relay send-timing
excluded. BasicOkHttpWebSocket stamps arrival before enqueue and records the lag
on dequeue; the crawler resets it at start and dumps it in the --diagnose summary.

Answers whether a drain's 5s gap between the relay's last event and its EOSE is
the relay being slow to SEND eose (low dispatch-lag) or our IO pipeline backing up
so the already-arrived eose frame sits queued (high dispatch-lag).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-09 17:24:35 +00:00
Claude
a545151a34 fix: reject non-32-byte pubkeys when decoding npub/nprofile
A Nostr pubkey is x-only, exactly 32 bytes, but NPub.parse/NProfile.parse never
checked the length — they hex-encoded whatever bytes the bech32/TLV carried. A
malformed npub/nprofile that some clients encode with the full 33-byte COMPRESSED
secp256k1 key (0x02/0x03 prefix) therefore round-tripped its 66-char hex straight
into a `p`/`q` tag via the quote/mention path, and a strict relay (relay29 /
pyramid.fiatjaf.com) rejected the whole group message:

  blocked: schema validation failed: tag[..]: invalid pubkey value
  '02977dcf…c3402' ... pubkey should be 64-char hex

We never generate compressed keys ourselves (Nip01Crypto.pubKeyCreate strips the
prefix byte); this is purely inbound malformed input. Enforce the 32-byte length
at the decode boundary so the bad entity never becomes a mention/quote tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 17:24:03 +00:00
Claude
fdd0788e7f feat(graperank): saturation + latency instrumentation (--diagnose)
Answers "are we resource-bound or waiting on relays" without a profiler:
- progress ticker gains "Nw/CAPw" (drain workers busy vs drainConcurrency) and
  "N rl" (rate-limit responses so far) — a rarely-full pool means the producer or
  the relays are the limit, not concurrency; a climbing rl count is the external
  ceiling that made concurrency 60 backfire.
- crawl-end "latency breakdown": splits each drain's wall into time-to-first-event
  vs EOSE-wait-AFTER-the-relay's-last-event, and reports the % of drain wall spent
  waiting for EOSE after the relay was already done, how many drains blew the fast
  window and parked, and total rate-limit hits. A high EOSE-wait % is the direct
  case for a shorter/adaptive fast window over more concurrency.

All gated on config.diagnose; zero cost on a normal run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-09 16:51:28 +00:00
Claude
cc048c7ad1 feat: show the host relay as a starred chip on group discovery cards
The standalone star icon sat right next to the group's Join button, so it read
as a second way to act on the group when it actually favorites the host RELAY.
Pull the relay out of the member-count line into its own tappable chip with the
star inside it — favorited relays fill primary, unfavorited show a tonal outline
— so the two scopes are visually distinct: the chip favorites the relay (and
surfaces its groups under the relay filter), the button joins the group.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 16:42:40 +00:00
Claude
99e9708a3b fix(graperank): flush only observed relays; rename to GrapeRankCrawler
Two changes:

1. The reachability flush re-wrote the SEEDED known-dead relays with a fresh
   created_at every run, refreshing their TTL without a re-probe — so a relay
   marked dead once (and thereafter skipped, never re-dialed) would stay
   blacklisted forever as long as crawls kept running, defeating the TTL's
   re-probe. Stats.deadRelays now reports only relays actually dialed this run
   (deadRelays - knownDeadRelays); seeded records keep their original timestamp
   and age out on schedule so the next run re-probes them.

2. Rename GrapeRankDataCrawler -> GrapeRankCrawler (file + all references).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-09 16:40:54 +00:00
Claude
6cca872f80 fix: refresh the groups feed when the resolved relay set catches up
Flipping the top-nav filter A->B showed A's groups until a manual pull-to-
refresh. The selected list flips synchronously, but the per-relay set the feed
filters on (liveRelayGroupsDiscoveryFollowListsPerRelay) resolves a frame later
via the async outbox loader. feedKey() keyed only on the list code, so the first
refresh ran against the stale (A) set and the catch-up emission — same list code
— was swallowed by checkKeysInvalidateDataAndSendToTop's key-unchanged guard,
freezing the feed on A.

Fold the resolved discriminator into feedKey(): the joined ids for "My Groups",
the per-relay constraints otherwise (both content-hashed via data classes). The
key now moves when the resolution lands, so the refresh fires and the feed
follows the selection without a manual pull.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 16:38:17 +00:00
Claude
8ced11b6b0 feat(graperank): share dead-relay knowledge via the NIP-66 reachability cache
Wire RelayReachabilityStore into the crawler and the WoT updater so liveness is
shared across procedures and runs instead of each rediscovering dead relays.

- OperatorKeys.monitorKey(): a dedicated machine monitor identity derived from the
  operator master (domain "relay-monitor:"), independent of any account — the
  30166 records are published under this, not the observer key.
- Context.reachability: a RelayReachabilityStore over the shared store, signed by
  the monitor key.
- Crawler: Config.knownDeadRelays seeds deadRelays before the run; Stats now
  returns the final dead/live sets. GrapeRankCommand seeds from snapshot().dead
  and flushes the crawl's verdicts back via reachability.record().
- Updater: Config.knownDead skips proven-dead relays from the reconcile plan — a
  dead relay cannot serve its authors, so reconciling it only burns a timeout.
  Live author-advertised relays are always synced.

All behind --no-reachability-cache. TTL'd (24h), so a recovered relay is retried
once its record ages out — a "skip for now", never a permanent ignore, keeping
the outbox rule that every live advertised relay is tried.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-09 16:30:45 +00:00
Claude
b5e0ef53e2 feat(nip66): RelayReachabilityStore — dead-relay cache backed by kind:30166
A durable, shareable relay-reachability cache backed by the EventStore as NIP-66
kind:30166 Relay Discovery events, so the crawler, the WoT updater, and future
runs share liveness knowledge instead of each rediscovering dead relays from an
in-memory set wiped at process exit.

- 30166 is addressable by its d-tag (relay URL) → one replaceable status slot per
  (monitor, relay), with created_at giving a free TTL.
- Reachable → 30166 with rtt-open; dead → 30166 without (NIP-66 has no explicit
  offline field; liveness is inferred from a fresh successful open). Live wins
  over dead within the TTL, so third-party monitors' 30166 can be ingested.
- snapshot() loads the fresh set once (not a per-request hot-path query); record()
  flushes a run's findings. A relay is only skipped for the TTL, never permanently
  — consistent with the outbox rule that every advertised write relay is tried.

Reuses the existing RelayDiscoveryEvent. jvmTest covers record/reload,
live-overrides-dead, TTL expiry, and .onion→Tor network tagging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-09 16:23:20 +00:00
Claude
3529a71052 fix: send a discovery REQ when a relay chip is selected
The discovery filter spinner offers a favorite-relay chip (TopFilter.Relay),
but makeRelayGroupsDiscoveryFilter had no branch for RelayTopNavPerRelayFilterSet
— it fell through to `else -> emptyList()`, so selecting a relay sent no REQ and
the feed stayed empty even though the dal's toGroupConstraints() already mapped
that filter to AllGroups-on-that-relay. Add filterRelayGroupsByRelay (the same
whole-directory pull as Global, scoped to the one relay) and wire the dispatch
branch, restoring parity between the two per-type tables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 16:22:36 +00:00
Claude
4265d7a00b feat: consume the remaining NIP-29 event kinds in LocalCache
Only 39000/39001/39002 (metadata + rosters) and the group-scoped content
kinds were consumed; every other registered NIP-29 kind deserialized fine but
fell through to the "Event Not Supported" else branch and was dropped.

Add explicit branches for the whole family:
 - 39003 SupportedRoles and 39004 GroupParticipants are relay-signed
   addressables — durable group state alongside 39000/1/2 — so they're stored
   replaceably (consumeBaseReplaceable).
 - the 9xxx moderation actions (put/remove user, edit/delete metadata, create/
   delete group, create invite) and 9021/9022 join/leave requests are regular
   one-shot events the relay is authoritative for; store them via
   consumeRegularEvent so they're queryable and no longer warn, without acting
   on them client-side.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 15:30:51 +00:00
Vitor Pamplona
d13af401a0 Merge pull request #3510 from vitorpamplona/claude/kotlin-ios-simulator-compile-vbkx46
Replace synchronized with Mutex in InMemoryAuthApprovalStore
2026-07-09 11:27:01 -04:00
Claude
a4679da0a9 refactor: move GroupDiscoveryConstraint matcher to commons
The sealed GroupDiscoveryConstraint matcher (AllGroups/ByPeople/ByHashtags/
ByGeohashes/AnyOf) is pure over RelayGroupChannel + HexKey with no LocalCache,
eose-manager, or topNavFeeds dependency, so it belongs in :commons alongside
RelayGroupChannel where Desktop/CLI can reuse it. The amethyst dal keeps only
the platform-specific toGroupConstraints() mapping from the Android top-nav
filter set onto the shared matcher.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 15:25:03 +00:00
Claude
69204f16cb fix: use Mutex instead of JVM-only synchronized in AuthApprovalPolicy
The synchronized intrinsic is only available on JVM/Android, breaking the
iOS (compileKotlinIosSimulatorArm64) build in commonMain. Replace the Any()
lock with a kotlinx.coroutines Mutex + withLock, which is KMP-common and
safe here since all three store methods are already suspend functions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A918G2Ks9i9LXRuVoNgm7R
2026-07-09 15:19:01 +00:00
Claude
ec3eff83d8 fix: "My Groups" shows joined list ∪ roster memberships
"Mine" only listed groups where the relay-signed roster (39001/39002) already
had me as an admin/member — so a group I just joined (or an open group I post
in without being rostered) wouldn't appear until the relay caught up. It now
shows the UNION of:
 - my kind-10009 joined list (authoritative, immediate), and
 - groups whose roster lists me as an admin/member.

The screen re-scans when the joined list changes (join/leave), so a newly
joined group appears right away.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 14:58:49 +00:00
Claude
a44007bb95 fix: order the "Mine" chip last in the Relay Groups filter, like other feeds
The joined-groups ("Mine") entry was placed first in the discovery filter
dropdown; every other feed lists it last in the base group (after Global).
Match that ordering so the top-nav popup is consistent across screens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 14:54:23 +00:00
Claude
34bbb7b727 fix: robohash crash on short ids + standard Relay Groups top bar/layout
- RobohashAssembler read up to hash[10] but only required the input to be
  >10 chars; a 16-char NIP-29 group id is valid hex that decodes to 8 bytes,
  so hash[8] threw ArrayIndexOutOfBoundsException and crashed the discovery
  feed. Require >=22 hex chars (>=11 bytes) before decoding; anything shorter
  falls back to sha256 (32 bytes). Latent crash for any short hex seed.
- The discovery screen used a hand-rolled ShorterTopAppBar (dropping the
  standard search icon + memory chip) to fit a browse action. Switched to the
  shared UserDrawerSearchTopBar like every other top-level feed; the
  browse-a-relay action moved to the FAB (DisappearingScaffold.floatingButton).
- Removed the extra Column(Modifier.padding(padding)) wrapper that double-
  applied the top-bar inset (rememberFeedContentPadding already accounts for
  it) — that was the large blank block above the first card.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 14:47:04 +00:00
Claude
c97e697ef0 refactor: unify Relay Groups onto the discovery feed
The top-level "Relay Groups" tab was a thin server-list home screen while a
separate "Find Groups" discovery feed did the real work. They're now one screen:
the discovery feed IS the Relay Groups tab, defaulting to a "My Groups" filter.

- Route.RelayGroups now renders the discovery feed (top-level DisappearingScaffold
  + AppBottomBar + drawer top bar with the filter spinner and browse action).
- "My Groups" (TopFilter.Mine) lists the groups you've joined. These live on their
  host relays (kind 10009), not your outbox, so the filter scans the cache for
  groups where you're the relay-key / an admin / a member; the joined rosters are
  kept live by RelayGroupRosterSubscription mounted on the screen.
- Per-relay "server" browsing is still available via the relay chips in the filter;
  the grouped server rail still shows in the Messages tab (GROUPED mode).
- Default discovery filter is now Mine (was Global); the "Mine" chip is back in the
  route list.
- Deleted RelayGroupsHomeScreen and the redundant Route.RelayGroupDiscovery; the
  Messages "Find groups" row and everything else point at Route.RelayGroups.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 14:31:02 +00:00
Claude
7ee962ce21 Merge remote-tracking branch 'origin/main' into claude/graperank-sync-crawl-1n05im 2026-07-09 14:00:26 +00:00
Vitor Pamplona
21ef7af3fe Merge pull request #3509 from vitorpamplona/claude/pictures-feed-title-order-9ob0du
fix(pictures): show title/caption before reactions row
2026-07-09 09:59:14 -04:00
Vitor Pamplona
eaf100bd79 Merge pull request #3508 from nrobi144/feat/desktop-dm-reliability
feat(desktop): NIP-17 DM reliability — AUTH banner, strict inbox resolution, relay hints
2026-07-09 08:51:20 -04:00
nrobi144
fe96311d93 test: record 2026-07-09 live run results + fix macOS prefs path in sheet
- Correct the AUTH-approval persistence check: this JVM uses
  MacOSXPreferences (~/Library/Preferences/com.vitorpamplona.amethyst.plist),
  not ~/.java/.userPrefs. Updated T3.b/c/d to read it via plutil.
- Record session results (T1,T2,T3a,T3c,T6,T6b,T8,T12 pass) and the three
  bugs found+fixed during the run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 15:43:03 +03:00
nrobi144
5a8e8828b1 fix(desktop): actually wire the NIP-17 p-tag relay hint into the send path
The relay-hint plumbing existed end to end in quartz (NIP17Factory ->
GiftWrapEvent.create(recipientRelayHint), with GiftWrapRelayHintTest), but
DesktopIAccount called createMessageNIP17/createEncryptedFileNIP17 without
passing recipientRelayHints. The default {null} lambda meant every outgoing
gift wrap shipped a 2-element ["p", pubkey] tag — the hint feature was dead
in production. Manual testing (T8) caught this: wraps on the recipient's
inbox relay had no third element.

Pre-resolve each recipient's primary DM inbox relay (first entry of their
kind:10050, order-preserving) and pass it as the hint, yielding
["p", pubkey, "wss://primary-relay/"]. Recipients with no resolvable
kind:10050 map to null and keep the 2-element shape.

Adds resolveDmInboxRelaysStrictOrdered (order-preserving) as the basis for
both the target-relay set and the primary-relay hint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 15:39:21 +03:00
nrobi144
b62a74f2ad fix(desktop): make bech32 npub selectable in new-DM picker without cached metadata
The new-conversation picker rendered a pasted npub as a non-clickable
Surface whenever getUserIfExists returned null — i.e. for any recipient
whose kind:0 metadata the local cache hadn't seen. The npub showed in the
results list but couldn't be selected, so you couldn't start a DM to
anyone new by npub.

A DM recipient is identified purely by pubkey; metadata is not required to
open a conversation. Use getOrCreateUser so a valid npub always resolves to
a selectable UserSearchCard, keeping the non-clickable fallback only for
keys that can't be resolved at all.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 15:22:23 +03:00
davotoula
9731185d49 docs: document importing Android Lint reports into local Sonar analysis
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HLfwhTdf72qFPnmPRYnzqu
2026-07-09 13:21:28 +01:00
Claude
f1b5be9fdf perf(graperank): stop re-discovering outboxes; co-fetch kind:3 on indexers only
Round-8 profiling showed the crawl re-querying the same never-had-a-10002
users' outboxes every round they recirculated — ~144k slow kind:10002 drains
(p50 17.4s) against a static discovery set, dragging the round to ~18 users/s.

1. ensureRelayLists guards with `relayListDiscoverySwept`: each user's outbox
   discovery runs once. The discovery relay set is static, so a second sweep of
   a user still lacking a 10002 cannot find one the first missed.

2. The discovery REQ to the bounded INDEXER set co-fetches [10002, 3]: the
   outbox lookup already pays the round-trip and an indexer holding a user's
   10002 often holds their kind:3, so we harvest the contact list as a cheap
   byproduct. The wide "every live relay" completeness sweep stays 10002-ONLY —
   co-fetching kind:3 across thousands of relays downloaded the same big contact
   lists repeatedly and inflated the fire-and-forget bgScope sweep the finishing
   drain waits on (measured +260s at hop-3; the indexer-only co-fetch keeps
   coverage flat at baseline speed).

3. harvestFromStore folds any already-stored kind:3 into the graph at Phase-A
   time so Phase B never re-drains a list we hold (also speeds re-runs).

Verified same-session hop-3: pre-fix 685s / narrowed 690s / wide-co-fetch 945s,
coverage 91.74% across all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-09 11:53:31 +00:00
davotoula
94a966df80 refactor: share the event-header SELECT column list in FullTextSearchModule 2026-07-09 12:32:37 +01:00
nrobi144
abe4668fd8 test: add T6b strict kind:10050 non-leak scenario; refresh sheet header
Covers the review fix — recipient with NIP-65 read relays but no
kind:10050 must be treated as unreachable, not routed to the read relays.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 07:56:42 +03:00
nrobi144
9e707da2b1 fix(desktop): use strict kind:10050 in DM inbox resolver LocalCache fast-path
The indexer fan-out already used lists.dmInbox (strict), but the
LocalCache fast-path in DmInboxRelayResolver.resolve() — and the
no-resolver fallback in DesktopIAccount — went through the lenient
User.dmInboxRelays(), which falls back to NIP-65 read relays (kind:10002)
when the recipient has no kind:10050.

Because that fast-path returns first and short-circuits the strict
indexer lookup, a recipient with NIP-65 read relays but no published
DM-inbox would get gift wraps published to relays they never designated
for DMs — re-introducing the metadata leak (recipient pubkey + send
timing) the P0 fix was meant to close. LocalCache commonly holds
kind:10002 but not kind:10050, so this path fired often.

Switch both LocalCache lookups to dmInboxRelaysStrict().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 07:45:34 +03:00
nrobi144
3f0ad05b58 fix(commons): guard RelayLatencyTracker.sweep against ConcurrentModificationException
The per-relay pending maps in RelayLatencyTracker are
Collections.synchronizedMap(LinkedHashMap): individual read/write
ops are thread-safe, but per the synchronizedMap javadoc iteration
is NOT — callers MUST hold the map's monitor while walking its
views. sweep() was iterating directly, so any network-dispatcher
mutation (adding a pending REQ, receiving an OK) during a sweep
would throw ConcurrentModificationException on AWT-EventQueue-0,
killing the Compose renderer while coroutine work kept running.

Pre-existing bug, documented in memory
desktop_relay_health_cme_crash. Ordinarily "not our problem", but
it's actively blocking manual T3 testing of this branch's AUTH
approval banner: adding any new relay triggers a
RelayHealthStore.reclassify sweep, so testers can't get a banner
render in without hitting the crash. Fix it here so the branch is
actually testable end-to-end.

Wrap both iteration loops in synchronized(pending) blocks. Sweep is
O(pending) with typically single-digit entries per relay, so the
hold time is negligible and the network dispatcher just briefly
waits.

Reproduced during manual T3 testing 2026-07-06 when adding
wss://pyramid.fiatjaf.com. Stack: RelayLatencyTracker.sweep:182 →
RelayHealthStore$reclassify$flagged$1.invokeSuspend:268.
2026-07-09 07:33:43 +03:00
nrobi144
8c844d76db fix(desktop): use strict kind:10050 for tier-1 AUTH classification
DesktopAuthCoordinator.selfApprovedRelaysFor was calling the lenient
User.dmInboxRelays() helper, whose NIP-65-read fallback silently
expanded the tier-1 auto-allow set to include every relay in the
user's read markers. That defeated the tier-2 prompt for any AUTH-
required relay the user happened to have in NIP-65 — such as
wss://pyramid.fiatjaf.com, which never surfaced a banner during
manual testing because the coordinator was auto-signing it as
tier-1.

Switch to dmInboxRelaysStrict() (added in the earlier pre-send
alignment fix) so tier-1 is truly kind:10050 only. The KDoc already
promised strictness ("write/read relays are NOT included") — this
just makes the code match.

Surfaced during manual T3 testing 2026-07-06 with an account whose
NIP-65 outbox included pyramid.fiatjaf.com.
2026-07-09 07:33:43 +03:00
nrobi144
33417d8a58 test: unpack testing sheet into linear playbook
Rewrites the manual testing sheet so every step is an executable action
or a specific observation:

- Numbered steps within each test — no jumping between reference
  sections. "Click X", "run Y in a terminal", "watch for Z".
- Each observation records YES/NO/SKIPPED for the sign-off matrix.
- Setup section spells out the wipe-preferences command and the
  post-restart smoke check.
- T3 broken into T3.a/b/c/d for each button and its persistence
  check separately (previously bundled T3.1–T3.10 was too dense).
- T6/T7 include exact tcpdump/tshark commands for the security
  observations that require packet capture.
- T13 shortened to a sanity re-check (already verified).
- Sign-off table lists every test with a checkbox.

Purpose: give the tester a self-contained document they can follow
top to bottom in ~40 min without cross-referencing other sheets.
2026-07-09 07:33:43 +03:00
nrobi144
0fc9b8a87d test: consolidate manual testing sheet, add T13 pre-send fix coverage
Updates the manual walkthrough to reflect the full state of the branch
after rebase + banner padding fix + pre-send validation fix:

- Marks what's already verified (T1 startup wiring, T5 lifecycle,
  T6 P0 security via upstream UI, T13 pre-send fix).
- Adds T13 covering the alignment + resolver-probe fix from
  a139c0fb17 with three sub-scenarios (strict alignment, resolver
  unblock, and the real 2-user bootstrap flow).
- Documents the known pre-existing issues surfaced during testing
  (RelayLatencyTracker CME, CompressionQuality stale-daemon) so
  they don't get filed as regressions of ours.
- Adds a summary table pointing to the automated sheet for the
  Phase A greens.
- Explicit out-of-scope list mirroring the deepening synthesis.

No code changes — documentation only.
2026-07-09 07:33:43 +03:00
nrobi144
2061ef9e37 fix(desktop): align pre-send DM validation with strict NIP-17 semantics + wire resolver fan-out
Two related fixes that surface the same class of bug: the pre-send
"Recipient has no DM relay list" warning could disagree with the
actual send path, causing sends that either fail after the user
clicks Send, or block sends the user would have expected to work.

(a) STRICT ALIGNMENT — User.dmInboxRelays() (commons) is the lenient
    "give me a delivery target for a DM" helper: it returns kind:10050
    if present else the NIP-65 read marker (kind:10002). The send path
    (DesktopIAccount.resolveDmInboxRelaysStrict → DmInboxRelayResolver)
    uses the strict variant that returns kind:10050 only, because NIP-17
    §Publishing mandates delivery to the recipient's kind:10050
    exclusively — routing a wrap through a NIP-65 read relay leaks the
    conversation metadata to a relay the recipient did not designate
    for DMs.

    Add User.dmInboxRelaysStrict() as the kind:10050-only accessor and
    switch ChatNewMessageState.updateRecipientRelayStatus() to it so the
    UI's "can we deliver" check matches what the send path actually
    enforces.

(b) RESOLVER PROBE — pre-send validation was cache-only: if a peer's
    kind:10050 hadn't landed in LocalCache yet (e.g. the peer just
    published, or their event sits on an indexer relay we don't
    subscribe to), the UI reported them unreachable and blocked send
    even though the send path's DmInboxRelayResolver would have found
    them via indexer fan-out.

    Add an optional `dmInboxResolver: suspend (HexKey) -> List<...>?`
    callback to ChatNewMessageState. On a synchronous cache miss for any
    peer, the state optimistically blocks (preserving the "don't
    silent-fail" invariant) and launches a probe. If any peer's relays
    turn up, unblock immediately without requiring the user to reopen
    the conversation.

    Wired at both ChatNewMessageState construction sites in
    DesktopMessagesScreen to DesktopIAccount.dmInboxResolver (which
    Main.kt injected in the earlier P4 wire-up commit). Android's
    ChatNewMessageViewModel is a separate class and keeps
    cache-only behaviour — Android UI parity is deferred.

Surfaced during manual testing 2026-07-06 with two accounts where one
had a kind:10050 and one didn't: the UI correctly blocked send, but
the block persisted even after publishing kind:10050 for the missing
account until the conversation was reopened.
2026-07-09 07:33:43 +03:00
nrobi144
007217f407 fix(desktop): pad AuthApprovalBanner around macOS traffic lights
Amethyst Desktop uses apple.awt.fullWindowContent = true (see
applyNativeWindowChrome), which draws content edge-to-edge under
the title bar so the macOS traffic-light buttons overlap whatever
sits in the top-left of the App content column.

The AuthApprovalBanner mounts at (0, 0) of the content column,
which put its lock icon + "pyramid.fiatjaf.com" text directly
under the red/yellow/green window buttons. Screenshotted in
manual testing 2026-07-06.

Two-part fix:

1. Bump the row's own padding from horizontal 12dp / vertical 8dp
   to horizontal 16dp / vertical 10dp for better breathing room
   in general.

2. At the mount site in Main.kt, wrap the banner in a
   platform-aware Modifier: on macOS pad 80dp from start (clears
   3 traffic lights at 14pt each + spacing) plus 8dp top / bottom
   4dp; on other platforms just an 8dp horizontal / 4dp vertical
   margin. Non-mac users see the banner flush-ish since their
   window chrome doesn't overlap.

Padding lives at the mount site so the banner composable itself
remains reusable inside chat panes or other contexts where
traffic-light clearance isn't needed.
2026-07-09 07:33:42 +03:00
nrobi144
68d1276b1a test: testing sheet for feat/desktop-dm-reliability
Captures every verification step for the 16-commit branch:
  §A  automated — compile + unit tests + spotless + package + branch
      integrity. All passing as of 2026-06-12.
  §B  manual desktop — AUTH end-to-end (tier-1 + tier-2), banner UX,
      NIP-17 send security, indexer fan-out, group DM rumor coherence,
      relay-hint placement, outbox AUTH carve-out, bunker concurrency
      cap, SigningOpState.Progress.
  §C  Android sanity — commons inheritance check.
  §D  security audit — code/git inspection (D1 all passing), threat
      walkthrough (D2).
  §E  sign-off matrix.

Includes a known-gap callout for D2.2 (logout does NOT clear AUTH
approvals — by design; account-delete is the trigger, follow-up
verification needed) and an out-of-scope list mirroring the
deepening synthesis (retry queue, per-message bubble UI, NIP-46
batch RPC, Android UI parity).
2026-07-09 07:33:42 +03:00
nrobi144
49d31ccb44 feat(commons): SigningOpState.Progress for per-step in-flight UI
Adds a Progress(current, total, label?) variant to SigningOpState so
multi-step signing operations (NIP-17 group sends via remote signer,
batched zaps) can show "Encrypting via remote signer (3 of 5)" rather
than an opaque indeterminate spinner.

Backwards compatible:
- Pending stays a data object — existing callers' `is Pending` checks
  unaffected.
- New helper `isPending()` returns true for both Pending and Progress;
  SigningState.execute uses it so a second execute() during Progress
  returns null (matching the old single-flight semantics).
- SigningAwareButton renders both Pending and Progress as a spinner;
  callers wanting the counter must read the state directly.
- SigningStatusBar adds a Progress branch that shows "<label>
  (<current> of <total>)" — uses "Signing" as default label.

New `SigningState.updateProgress(current, total, label?)` lets the
in-flight block emit progress updates between Pending start and
finish. No-op when state is Idle/Error so callers don't have to gate.

Wire-up for NIP-17 bunker sends (publishing per-recipient progress
during NIP17Factory.createWraps' mapNotNullAsync) is a follow-up
that depends on threading the SigningState reference into the
factory's signing lambda; the substrate is here.
2026-07-09 07:33:42 +03:00
nrobi144
4ce21e7034 test(commons): AUTH end-to-end exercising policy → signer round-trip
Four tests covering the lambda shape that DesktopAuthCoordinator's
signWithAllLoggedInUsers calls into for every NIP-42 challenge:

  build RelayAuthEvent template → classify via policy → sign or
  block → return List<RelayAuthEvent> for RelayAuthenticator

- tier-1 own-inbox auto-signs a valid kind:22242 event with the
  right challenge + relay tags
- tier-2 unknown surfaces a PendingAuthApproval; ONCE resolution
  produces a signed event (no persistence)
- tier-2 BLOCKED returns null AND persists the rejection
- tier-2 ALWAYS persists and skips the prompt on subsequent calls

Concurrency: the policy.classify call inside the lambda suspends on
the CompletableDeferred when prompting; tests use coroutineScope +
async + yieldUntilNotNull to model the banner-resolving-from-outside
pattern, mirroring how DesktopAuthCoordinator.resolve() drives the
deferred from a UI click.

Together with the existing PoolEventOutboxStateTest (auth-required
carve-out), AuthApprovalPolicyTest (classifier), and
GiftWrapRelayHintTest (NIP-17 hint placement), this completes
unit-level coverage of the AUTH pipeline. The websocket-level
round-trip stays covered by geode/.../KtorRelayTest.kt against a
real Ktor mock relay; that infra is reusable for a future
desktopApp integration test that combines mock relay + this stack.
2026-07-09 07:33:42 +03:00
nrobi144
3bbeda4cef feat(desktop): wire DmInboxRelayResolver into NIP-17 send path
Completes Phase 4 end-to-end. DesktopIAccount.resolveDmInboxRelaysStrict
now uses the resolver injected from Main.kt instead of the
LocalCache-only fast path. Three-layer lookup at every call:

  1. LocalCache hit (kind:10050 already observed via feed pipeline)
  2. Resolver's 1h LRU cache
  3. Indexer fan-out via the dedicated unauthenticated NostrClient

The unauthenticated NostrClient is constructed in App() alongside
relayManager and connects on creation; DisposableEffect disconnects
on the App-level dispose. Critically NO RelayAuthenticator is
attached to this client — only the primary relayManager.client has
one (via DesktopAuthCoordinator). This closes security review F-01:
indexer queries no longer extract identity-key signatures during
kind:10050 probes against curated indexers.

resolveDmInboxRelaysStrict is converted from sync to suspend; the
three send paths (sendNip17PrivateMessage, sendNip17EncryptedFile,
sendGiftWraps) already run in suspend context inside DmSendTracker
batches, so the conversion is local. Resolver is plumbed through
MainContent as a new parameter rather than a CompositionLocal —
explicit threading matches the existing pattern for accountRelays
and relayManager.

The legacy LocalCache-only fallback inside resolveDmInboxRelaysStrict
is preserved for the constructor-default case (tests, CLI). When
dmInboxResolver is null, behaviour matches the pre-this-commit
strict-fix from 5293dae65.
2026-07-09 07:33:42 +03:00
nrobi144
2240d64ae8 test(commons): cover DmInboxRelayResolver three-layer lookup + cache
Eight tests covering the resolver contract:

- localLookup hit short-circuits indexer fan-out
- empty indexer set returns empty
- empty local + empty indexer (no events arrive) yields empty
- cache hit within TTL skips indexer
- cache expiry triggers fresh indexer call
- clear() wipes all entries
- invalidate(pubkey) removes only the named entry
- localLookup returning an EMPTY list falls through to cache/indexer
  (the takeIf { isNotEmpty() } guard — emptyList from localLookup
  means "I don't know", not "I know they have nothing")

Uses EmptyNostrClient so RecipientRelayFetcher.fetchRelayLists returns
no events — covers the canonical "indexer found nothing" path without
needing a real mock relay. Tests for the populated-indexer path will
land with the Phase 4 wire-up commit when a Ktor-based mock relay is
plumbed through.
2026-07-09 07:33:22 +03:00
nrobi144
e091f6d3d3 feat(commons): DmInboxRelayResolver with strict kind:10050-only fan-out
Three-layer resolver for "where do I publish this NIP-17 gift wrap":

1. LocalCache hit — if the caller already saw the user's kind:10050
   via the regular feed pipeline, skip I/O entirely.
2. In-memory LRU cache — TTL 1h, 100 entries; avoids re-querying
   indexers when opening several conversations in sequence.
3. Indexer fan-out — RecipientRelayFetcher against a curated set
   (DefaultDmIndexerRelays: relay.nos.social, relay.damus.io,
   nos.lol, relay.nostr.band, purplerelay.com — purplepag.es
   deliberately excluded for poor kind:10050 coverage).

Strictness vs. the existing User.dmInboxRelays():
  - filters to kind:10050 ONLY; NEVER falls back to NIP-65 read
    marker (kind:10002). User.dmInboxRelays() silently substitutes
    that, which is the same metadata-leak class fixed by 5293dae65.
  - empty list = canonical "unreachable" signal; caller refuses to
    publish (DesktopIAccount.resolveDmInboxRelaysStrict already
    does this).

Security: the NostrClient passed in MUST be a dedicated
unauthenticated instance — no RelayAuthenticator attached. An
authenticated indexer fan-out (the current state with the primary
client) would extract identity-key signatures during the kind:10050
probe, escalating "indexer learns we want to DM pubkey X" into
"indexer learns user U wants to DM pubkey X". KDoc warning is
explicit; Phase 4 follow-up creates the unauth client in Main.kt
and injects it.

LocalLookup callback is plugged via lambda so CLI / headless
callers (amy) can use this without a Compose LocalCache.

Not yet wired into DesktopIAccount.resolveDmInboxRelaysStrict —
that wire-up is the next commit and converts the sync helper to
suspend, threading through sendNip17* batch construction.
2026-07-09 07:33:22 +03:00
nrobi144
2f3805bbfa feat(commons,desktop): inline AUTH approval banner with [Once] [Always] [Never]
Adds AuthApprovalBanner in commons.relayClient.auth — a Compose-
Multiplatform composable that renders one row per pending tier-2
NIP-42 AUTH challenge with three actions matching the AuthApprovalScope:

  [Once]    — sign this challenge, don't persist
  [Always]  — sign + persist ALWAYS via the store
  [Never]   — drop + persist BLOCKED via the store

Wired into desktop Main.kt as a global top-of-content banner reading
authCoordinator.pendingApprovals and calling authCoordinator.resolve.
Now tier-2 challenges actually have a UI to resolve — desktop AUTH is
end-to-end usable.

Up to 3 rows stack inline; the rest collapse into a "+N more pending"
row (click-to-expand can come later). Each row shows the relay's
display URL plus message-count when multiple challenges from the same
relay have coalesced.

The composable itself is in commons so Android picks it up free when
its AccountAuthApprovals VM wire-up lands — only the Main.kt-level
wiring (where to mount the banner in the layout) is platform-specific.

Lifecycle:
- Banner subscribes to pendingApprovals via collectAsState; recomposes
  only when the PersistentMap identity changes (per the substrate
  built in earlier commits).
- onResolve calls authCoordinator.resolve(url, scope), which completes
  the underlying CompletableDeferred + removes the entry from the
  pending map; the suspended signer wakes up and signs (or doesn't).
2026-07-09 07:33:21 +03:00
nrobi144
3ab3642757 feat(desktop): wire NIP-42 AUTH on desktop via DesktopAuthCoordinator
Until now desktop had no NIP-42 AUTH wiring at all — relays demanding
AUTH from desktop users got silently ignored. This commit closes the
gap, but does it the security-conscious way using the
AuthApprovalPolicy substrate from earlier commits.

DesktopAuthCoordinator binds to AccountState transitions in Main.kt
and per logged-in account:

- constructs a PreferencesAuthApprovalStore scoped by pubkey
- constructs an AuthApprovalPolicy with self-approved relays sourced
  from the active account's NIP-17 DM-inbox (kind:10050) cache
- constructs a RelayAuthenticator whose signWithAllLoggedInUsers
  lambda routes every AUTH challenge through the policy

Tier 1 (own DM-inbox + persisted ALWAYS) signs automatically. Tier 2
challenges hand back a CompletableDeferred surfaced on
authCoordinator.pendingApprovals. Until the inline banner UI lands
(P2.5 follow-up), tier-2 pending stays unresolved — which means
tier-2 relays don't get an AUTH response, same outcome as the
pre-this-commit world. The improvement here is tier-1: own DM
inbox relays now AUTH automatically without any prompt.

Lifecycle: onLogin attaches the authenticator; onLogout and account-
switch tear it down and complete any pending deferreds with BLOCKED
so suspended signers don't dangle.

Self-approved relays are deliberately scoped to kind:10050 (DM
inbox) only, NOT NIP-65 write/read relays. A user may follow read-
only relays they don't want to AUTH-identify themselves on — and the
common case where AUTH matters most is the user's own DM inbox.
2026-07-09 07:03:56 +03:00
nrobi144
a854b38cd8 perf(quartz): cap NIP-17 wrap building at 4 concurrent bunker RPCs
NIP17Factory.createWraps launches all per-recipient seal builds via
mapNotNullAsync, which today runs them fully parallel. Each seal
needs nip44_encrypt + sign — for a NIP-46 (bunker) signer that means
two round-trips per recipient. A 5-recipient group send launches 10
concurrent in-flight requests against the bunker socket, and nsec.app
/ Amber / Keychat typically serialize past ~10 in-flight, so some
requests queue past the 65s timeout and silently fail.

Cap at 4 concurrent when signer is NostrSignerRemote. Local signers
(NostrSignerInternal, NostrSignerSync) bypass the semaphore and stay
fully parallel — no overhead, no behaviour change for nsec users.

The real fix is the batched nip44_get_conversation_keys NIP-46 RPC
(separate spec PR + plan) which collapses N×2 round-trips into ~2.
This commit is the interim throttle until that lands.
2026-07-09 07:03:56 +03:00
nrobi144
d6c1b13136 fix(desktop): stop falling back to user's connected relays for NIP-17 DMs (P0 security)
Per NIP-17 §Publishing, gift wraps MUST only be published to the relays
advertised in the recipient's kind:10050. Today three send paths in
DesktopIAccount fall through to relayManager.connectedRelays.value
when the recipient has no kind:10050 cached:

  sendNip17PrivateMessage      (line 200)
  sendNip17EncryptedFile       (line 231)
  sendGiftWraps                (line 253)

This is the security-review F-04 metadata leak: at best the wrap never
reaches the recipient (their other clients don't read those relays);
at worst the recipient pubkey + send timestamp leak to general/feed
relays outside their chosen inbox. Same class of bug as the relay-
power-tools work explicitly closed for the relay picker on
2026-04-20 ("block DM fallback to all relays — metadata leak").

Replace the fallback with strict resolution: if the recipient has no
kind:10050 in the cache, return an empty target set. DmSendTracker
already handles total relay count == 0 with a "No relays available"
failure state, so the user gets a visible error instead of a silent
leak.

Indexer fan-out + a UI dialog for the missing-10050 case is the
permanent fix, scoped to Phase 4 (DmInboxRelayResolver). This commit
is the conservative pre-Phase-4 plug — better to fail visibly than
leak silently.

NIP-04 send is unchanged: that path is pre-NIP-17, the encrypted
content sits next to other public events on the sender's outbox by
design.
2026-07-09 07:03:55 +03:00
nrobi144
ac26a3624f test(quartz): pin relay-hint placement on gift wrap p tag
Three regression tests covering the NIP-17 relay-hint contract just
introduced on GiftWrapEvent.create:

- default (no hint) emits the historical two-element ["p", pubkey]
  shape — guards every existing caller against a wire-format
  regression.
- with-hint emits ["p", pubkey, relay-url] — the canonical NIP-17
  shape with the hint on the public wrap (NOT inside the seal, which
  is the encrypted envelope and would hide routing info).
- null-hint must NOT produce ["p", pubkey, ""] — that would broadcast
  "this user has no canonical inbox" as a metadata leak.
2026-07-09 07:03:55 +03:00
nrobi144
07d4a6d8c4 feat(quartz): plumb optional per-recipient relay hint into NIP-17 gift wraps
Per NIP-17 §Publishing, a gift wrap (kind 1059) MAY carry the
recipient's primary DM inbox relay as a third element of the p tag.
Other clients the recipient runs (or relays acting as inbox routers)
can then locate the wrap without performing their own kind:10050
lookup — handy when the recipient is multi-device and the second
device's 10050 cache is cold.

GiftWrapEvent.create gains an optional `recipientRelayHint:
NormalizedRelayUrl?` parameter that flows into PTag.assemble (which
already accepts a relay hint). NIP17Factory.createWraps and the four
public createMessageNIP17 / createEncryptedFileNIP17 /
createReactionWithinGroup entry points gain a matching
`recipientRelayHints: (HexKey) -> NormalizedRelayUrl?` lambda so
multi-recipient sends can pass per-recipient hints in one shot.

All new parameters default to null / { null }, so every existing
caller compiles unchanged and still emits the historical
two-element ["p", recipientPubKey] shape. Callers that resolve
kind:10050 via the (forthcoming) DmInboxRelayResolver can wire the
result through to populate the hint.

While here, document the existing — but undocumented — invariant
that shared rumor created_at falls out naturally because the
rumor is signed once before the per-recipient mapNotNullAsync loop.
This is what anchors cross-recipient reaction/receipt dedupe.
2026-07-09 07:03:55 +03:00
nrobi144
6abf0784da feat(desktop): add PreferencesAuthApprovalStore for persisted AUTH grants
Desktop persistence for the AuthApprovalPolicy in commons. Backs the
`auth_approvals` use case from the plan using java.util.prefs.Preferences
instead of the originally proposed sibling outbox.db SQLite table.

Trade-off rationale: the AUTH approval set per account is small
(typically < 50 relays for any user) and the read pattern is bounded
(one lookup per relay per session, easily cached in memory by the
policy layer). java.util.prefs is already in use elsewhere on desktop
(SearchHistoryStore, DesktopPreferences) and adds zero new
dependencies or schema migrations.

The retry_queue table from the same outbox.db proposal needs the
higher-throughput characteristics SQLite gives us; it remains scoped
to P3 (send visibility), which can introduce a proper sibling DB at
that point.

Per-account scoping by Preferences node — logout/account-delete calls
clear() which removeNode()s the subtree. ONCE scope is never written
to disk, enforced explicitly here in addition to the interface
contract.

Not yet wired into a DesktopAuthCoordinator (today desktop has NO
AUTH wiring at all). That wiring lands in P2.5 alongside the banner
UI.
2026-07-09 07:03:55 +03:00
nrobi144
2ba051a952 feat(commons): add AuthApprovalPolicy classifier for tiered NIP-42 AUTH
The current Android-only AuthCoordinator signs every NIP-42 AUTH
challenge from every relay unconditionally (and across every logged-in
account). For desktop there is no AUTH wiring at all — challenges are
ignored, so AUTH-walled relays silently drop DMs.

Both behaviours fail the security review: unconditional signing lets
any relay the user reads (or any malicious relay they touch) extract an
identity-key signature with timestamp, and signing across all accounts
links them under one relay observer.

This commit adds the substrate for a tiered classifier — wire-up will
follow with the desktop AuthCoordinator (P2.5) and SQLite-backed
persistence (P2.4). The policy itself is platform-agnostic and lives in
commons so Android can adopt the same design later.

Two tiers, no third silent-drop path:
- auto-allow when the relay is in the user's own outbox/DM-inbox set,
  or has a persisted ALWAYS grant (subject to BLOCKED override)
- prompt-and-suspend via CompletableDeferred for everything else, with
  the user's `[Once] [Always] [Never]` choice driving the deferred

Includes InMemoryAuthApprovalStore for tests + the ONCE session cache;
SqliteAuthApprovalStore lands in P2.4 with the sibling outbox.db.

Eight unit tests cover tier-1, persisted ALWAYS, persisted BLOCKED
(including BLOCKED overriding tier-1), unknown-prompt-then-cache,
re-eval of selfApprovedRelays on Account changes, and store.clear().
2026-07-09 07:03:55 +03:00
nrobi144
af76c3a3f3 feat(quartz): expose per-relay AUTH state as a Compose-stable StateFlow
RelayAuthStatus has to stay mutable — it holds LruCaches addressable from
the per-relay OkHttp dispatcher thread, and replacing the whole holder
on every mutation would be wasteful. But its mutability also makes it
useless as a StateFlow value: mutating an entry doesn't change map
identity, so distinct-until-changed downstream swallows the update and
Compose never recomposes.

Add an immutable view alongside: RelayAuthSnapshot (phase +
lastAuthSuccessAt). RelayAuthStatus.snapshot() derives it from the LRU.
RelayAuthenticator publishes a PersistentMap<NormalizedRelayUrl,
RelayAuthSnapshot> via authStateFlow on every mutation (connect,
disconnect, AUTH-submitted, AUTH-OK, AUTH-fail). PersistentMap gives
O(log32 n) updates and a fresh identity per put, so both StateFlow
equality and Compose strong-skipping work.

This is the substrate for downstream consumers — the AUTH approval
banner, the retry-queue wake on authCompleted, the indexer-fan-out gate
— none of which are wired yet. They will read authStateFlow rather than
querying RelayAuthStatus directly.
2026-07-09 07:03:55 +03:00
nrobi144
2229986c5c fix(desktop): drop since on kind:1059 sub to honor NIP-17 randomized timestamps
Per NIP-17, seal (kind 13) and gift wrap (kind 1059) created_at are
randomized up to 2 days in the past for privacy. A subscription that
applies a `since` window — even with a 2-day adjustment — silently drops
wraps whose randomized timestamp predates the window, losing real DMs
and suppressing the unread badge.

Today only one caller (the desktop subscription coordinator) reaches
FilterDMs.giftWrapsToMe and it already passes no `since`, but the
parameter remained on the function signature as a footgun. Drop it so
the invariant is enforceable by the type, and document why in KDoc.
2026-07-09 07:03:54 +03:00
nrobi144
9d539b22f6 fix(quartz): don't count auth-required: against the publish try cap
NIP-42 AUTH challenges arrive as `auth-required:` OK responses. Today
they accumulate via PoolEventOutboxState.newResponse → Tries.addResponse,
and after three of them the relay is silently dropped from the outbox on
the next newTry — even though RelayAuthenticator is concurrently signing
the AUTH event and the relay would have accepted the original publish
once authenticated.

Carve `auth-required:` out of the failure path: it's a "wait, AUTH in
flight" signal, not a rejection. The existing
RelayAuthenticator.checkAuthResults → client.syncFilters hook re-pumps
the outbox after AUTH-OK, so the original event is retried naturally.

Adds PoolEventOutboxStateTest covering the carve-out plus regressions
for regular rejections, terminal rejections, and success.
2026-07-09 07:03:54 +03:00
Vitor Pamplona
f11a723518 Merge pull request #3507 from vitorpamplona/claude/eventstore-pubkey-10002-query-hotv4z
feat(quartz): add IEventStore.authorsMissingOutbox() anti-join query
2026-07-08 23:25:58 -04:00
Claude
fbaf15b893 fix(pictures): show title/caption before reactions row
Reorder the picture feed card so the title and content caption render
above the reactions row instead of below it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmDR7mKnt126g2Tpc4VgLy
2026-07-09 03:12:08 +00:00
Claude
4f75b9d092 fix(quartz): audit fixes for authorsMissingOutbox — giftwrap carve-out + EXCEPT
Audit of the authorsMissingOutbox anti-join surfaced one correctness bug and
one performance win:

- Bug (semantic): kind-1059 giftwraps store a random one-time key in
  event_headers.pubkey (the real recipient is only a hash), so the query
  returned an unbounded set of ephemeral keys that can never own a 10002 —
  junk for the outbox model this feeds. Both the SQLite path and the generic
  default now exclude kind 1059 from the "authors" set.

- Performance: replaced the DISTINCT + correlated NOT EXISTS scan with an
  index-only EXCEPT (all authors minus 10002 owners). Both sides ride the
  unconditional query_by_kind_pubkey_created covering index — so it does NOT
  depend on the optional pubkey-alone index — and measured ~3x faster
  (44ms vs 137ms at 152k events / 20k authors); the gap widens with author
  count, since the old form paid one seek per distinct author. A loose-index
  skip-scan was rejected: it needs the pubkey-alone index and degrades to a
  full scan per author without it.

Also: corrected the KDocs (the old text implied an efficient index-only
distinct that wasn't guaranteed), added a giftwrap-exclusion test, and added
FsAuthorsMissingOutboxTest — the only coverage of the IEventStore DEFAULT
implementation, which EventStore always overrides.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuLzfXyVZ16ozG8oJ7hBBc
2026-07-09 02:50:39 +00:00
Claude
405f5fd70b refactor(cli): rename graperank sync to graperank crawl
The network-only WoT data traversal is a crawl, not a sync — and main now
ships negentropy sync (`amy sync`, `graperank update`), so the old verb name
was ambiguous. Rename the subcommand and its handler to `crawl`, keeping
`sync` as a back-compat alias so existing scripts keep working.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-09 02:20:28 +00:00
Claude
c70b5c5453 Merge remote-tracking branch 'origin/main' into claude/graperank-sync-crawl-1n05im 2026-07-09 02:16:15 +00:00
Vitor Pamplona
4ed1bee012 Merge pull request #3506 from vitorpamplona/claude/amy-graperank-negentropy-sync-rc1xql
Add NIP-77 outbox-model refresh for GrapeRank via NegentropyStoreSync
2026-07-08 22:04:42 -04:00
Claude
7bd957c3c4 perf(quartz): snapshot ids for reconcile + harden NegentropyStoreSync
Audit follow-ups on the sync engine:

- Perf: syncGroup reconciled against a full store.query<Event>(filter),
  decoding the entire local matched set (~1 KB/event) just to read ids +
  created_at and to index events for a small residual upload. Reconcile now
  uses store.snapshotIdsForNegentropy (id + created_at only, ~40 B/entry) and
  the uploader fetches only the residual haves by id. Peak memory drops from
  O(all local matches) to O(residual) — matters when a relay hosts a large set.

- Bug: sync() promised best-effort ("one bad relay can't abort the set") but
  syncGroup only caught NegentropySyncException, so any other failure (store
  I/O, an unexpected throw) escaped async and cancelled every other relay via
  awaitAll. Each group now runs under a guard that records the failure instead.

- Bug: the page-fallback catch (Exception) swallowed CancellationException,
  breaking cooperative cancellation. Both new catch sites rethrow it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdEvjsZ81XuUtdJsVzmHxt
2026-07-09 01:52:02 +00:00
Claude
cf4eddeaad test(quartz): benchmark authorsMissingOutbox generic vs sqlite at 1M events
Adds AuthorsMissingOutboxBenchmark (gated behind -PprodRelayBench=1, like the
other prod benches). It syncs a real sample from relay.damus.io (kind 1 notes +
kind 10002 relay lists), replicates it to 1,000,000 stored rows while preserving
the real author set and outbox-owner set, then times the two shipping
implementations of authorsMissingOutbox() on the same store:

  - generic: the IEventStore interface default (decodes every event via
    query(Filter()))
  - sqlite:  EventStore's SELECT DISTINCT pubkey ... NOT EXISTS

Both are asserted to return the same set, matching the seeded ground truth.

Measured on a 4-core container, 1,000,000 events (best of 3):
  generic  138,880 ms
  sqlite     2,623 ms   → ~53x faster

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuLzfXyVZ16ozG8oJ7hBBc
2026-07-09 01:22:14 +00:00
Claude
2f11c134ab refactor(quartz): generalize the updater engine into NegentropyStoreSync
Extracts GrapeRankUpdater's per-relay sync engine into a standalone
NegentropyStoreSync in the relay-client accessories, so any caller can
two-pass sync an arbitrary `relay -> filters` set against a local store.

Given an INostrClient + IEventStore it syncs each (relay, filter) group:
a bidirectional NIP-77 reconcile into/from the store (down/up), a deletion
settle over the residual (applyDown downloads the relay's kind:5 when an
uploaded record was rejected), and a paged-download fallback when a relay
can't reconcile. sync() runs many groups with relays concurrent and each
relay's own filters sequential (so one relay never exceeds its subscription
budget). Directions and bounds are a Config; every group is best-effort and
its outcome is a GroupResult. This is also the reusable engine `amy sync`
open-codes today.

GrapeRankUpdater now only owns the GrapeRank specifics: it reads kind:10002,
inverts to write-relay -> authors (the outbox model), fans that into one
filter per (relay, author chunk), hands the set to NegentropyStoreSync, and
folds the per-group results back up per relay. Its public Config/Result and
the CLI wrapper are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdEvjsZ81XuUtdJsVzmHxt
2026-07-09 00:37:28 +00:00
Claude
574320cf22 feat(quartz): add IEventStore.authorsMissingOutbox() anti-join query
Adds a whole-store query returning every distinct author with at least
one stored event that has NO NIP-65 relay list (kind 10002 / outbox).

This is a set-difference the positive-only nostr Filter grammar can't
express (there is no "NOT kind 10002"), so it lives as a dedicated
IEventStore method rather than a query(Filter). The interface carries a
correct default (collect authors-with-outbox, then stream events keeping
the rest — O(events)); SQLiteEventStore overrides it with a single
SELECT DISTINCT ... NOT EXISTS that seeks the outbox check on the
(kind, pubkey, created_at) index.

"Missing" is relative to what the store holds: an author whose only
10002 was deleted (NIP-09) or expired (NIP-40) is reported as missing
again, since no row remains.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuLzfXyVZ16ozG8oJ7hBBc
2026-07-09 00:18:24 +00:00
Claude
4a686fc057 refactor(quartz): extract GrapeRankUpdater outbox-model WoT refresh utility
Moves the `amy graperank update` logic out of the CLI and into quartz as
GrapeRankUpdater, alongside GrapeRankDataCrawler in experimental/graperank,
so Android and any other quartz consumer can run the same refresh.

Given an INostrClient + IEventStore it reads every kind:10002 in the store,
inverts them into a write-relay -> authors map (the outbox model), then runs
one NIP-77 negentropy reconcile per write relay scoped to its authors:
bidirectional content sync into/from the store, deletion settle over the
residual (applyDown downloads the relay's kind:5 when an uploaded record was
rejected because the author retracted it), and a full paged-download fallback
when a relay can't reconcile. Bounds and directions are a Config; per-relay
and aggregate outcomes are returned as a Result.

The CLI `graperank update` is now a thin wrapper: it parses flags, builds the
Config, and renders GrapeRankUpdater.Result as text/JSON — no sync logic left
in cli/ (all reconcile/window/back-pressure/deletion logic lives in quartz's
relay-client accessories, which GrapeRankUpdater composes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdEvjsZ81XuUtdJsVzmHxt
2026-07-09 00:12:22 +00:00
Claude
fe85709d02 feat(cli): add amy graperank update outbox-model WoT refresh
Adds a store-driven refresh of the record kinds a GrapeRank score is a
function of (0 profiles / 3 follows / 10002 outbox lists / 1984 reports).

It reads every kind:10002 already in the local store, inverts them into a
write-relay -> authors map (the outbox model), then runs one NIP-77
negentropy reconcile per write relay scoped to exactly the authors who
publish there. Bidirectional by default; each group then settles deletions
over the reconcile residual via quartz's negentropySettleDeletions, whose
applyDown direction downloads the relay's covering kind:5 when an uploaded
record was rejected because the author retracted it.

When negentropy can't reconcile a relay (no NIP-77, an over-cap minimal
window, a mid-sync disconnect), the group falls back to a full paged
download (Context.drainAllPages) of the same authors+kinds so those
records are still refreshed.

Thin assembly only: reconcile, windowing, back-pressure, and deletion
settle all live in the quartz relay-client accessories, mirroring
SyncCommand; this only routes ids to Context.drain / drainAllPages /
publish and inverts the relay list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdEvjsZ81XuUtdJsVzmHxt
2026-07-09 00:01:03 +00:00
Claude
6a663781de fix: address relay-group audit findings (correctness, perf, consistency)
Correctness:
- Discovery sort no longer reads createdAt live in the comparator (TimSort
  "contract violated" crash risk); orders by member count then the shared
  sortedByDefaultFeedOrder snapshot.
- One shared resolver (relayGroupDiscoveryChannelFor) is used by the feed
  match, sort AND the row, so a 39000 seen on >1 relay always binds one
  channel — no more "sorted by relay B, rendered with relay A's empty roster".
- Roster (39001/39002) arrivals now re-inject the group's 39000 into the feed
  and re-invalidate the datasource, so a group where a follow is an admin/
  member surfaces instead of staying hidden / frozen at 0 members.
- Group replies route to the group's host (resolved from the channel), never
  falling through to signAndComputeBroadcast — fixes the outbox leak when the
  parent note had no relay provenance.
- Messages list (inline mode) updates group rows incrementally: the additive
  path now handles group-scoped messages, not just public/ephemeral/DM.
- Optimistic null-relay group sends attach only to an unambiguous single
  channel, so a message to the "_" group on relay A no longer bleeds into "_"
  on relay B.

Consistency:
- AllFollows discovery also REQs #t/#g (not just authors), matching the local
  AnyOf constraint; muted-authors maps to ByPeople; community stays AllGroups
  — fetch and display now agree.
- Global drops the 1-week `since` floor so long-lived group metadata is fetched.

Performance:
- #d metadata backfill scans the group cache once per filter assembly (grouped
  by relay), not once per relay.
- Discovery rows warm content only; the directory subscription already streams
  metadata/rosters for the relay.
- memberCount is memoized (members ∪ admins recomputed on roster change, not per
  read); thread re-sort extracted.
- Discovery list gets rememberFeedContentPadding, contentType and animateItem.

CLI:
- `relaygroup edit` preserves current name/about/tags when only a flag changes.

Cleanup: dropped dead relayKeys() branches and the redundant AnyOf single-lens
collapse in the discovery constraint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 23:48:49 +00:00
Claude
d6db83b43d fix(graperank): run reachability probe on an isolated thread pool
The probe does blocking DNS + TCP connect, and dead-domain DNS lookups hang well
past the connect timeout. On the shared Dispatchers.IO those hanging lookups
starved the crawl's own IO: an A/B at hop-3 showed probe-on 981s vs probe-off
517s, the entire +464s landing on the finishing drain (rounds were identical).
Coverage was unchanged (91.84% vs 91.74%), so the probe classification is correct
— it was purely IO contention.

Give the probe its own fixed daemon pool (128 threads) so its blocking work can
never touch the crawl's IO, and align the culler's concurrency to it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-08 23:11:48 +00:00
Claude
5c39b56ce6 feat: warm visible discovery groups + elevate the discovery card
Two gaps in the discovery feed's rendering:

- Preload: each on-screen group row now mounts RelayGroupPreviewSubscription,
  so the newest ~15 chat messages / threads are prefetched to the group's host
  relay while the card is visible — tapping a group opens an already-populated
  screen instead of loading from scratch. Lifecycle-aware and bounded to the
  rows the LazyColumn actually composes; tears down as they scroll off. Same
  warm-up the inline group-link card already used.

- Visuals: the flat list row is now an ElevatedCard matching the inline card —
  primary-ring avatar, member-count icon, status pill, description — while
  keeping the discovery-only actions (favorite-relay star + Join). Rows stay
  reactive: they observe the channel metadata flow, so name/picture/members/
  membership fill in and update live without moving the layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 23:10:35 +00:00
Claude
b02461f000 fix(graperank): reachability culler skips already-live relays
The culler filtered candidates by !isDead and not-yet-probed, but not by
liveRelays — so it probed relays the WS path had already proven live, wasting a
probe and opening a needless TCP connection to the hot relays the crawl depends
on. Skip any authority already in liveRelays up front. liveRelays becomes a
ConcurrentSet so the background culler can read it while the crawl writes it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-08 22:54:02 +00:00
Claude
7f10ff6fae fix: narrow relay-group follows/authors REQ instead of broad directory pull
A follows/authors filter no longer pulls every group on the relay and filters
client-side. It now emits, per relay, the three narrowed REQs a relay-signed
group is discoverable by:
  1. {kinds:[39000], authors:<follows>}          — the relay signing-key is a follow
  2. {kinds:[39001,39002], #p:<follows>}          — a follow is an admin/member
  3. {kinds:[39000], #d:<roster group-ids>}       — metadata backfill for (2)

(3) reads the group-ids of cached 39001/39002 rosters that already mention a
follow (empty on the first pass, filled once (2)'s events land and the
sub-assembler re-invalidates), since a #p roster hit doesn't carry the 39000.

ByFollows routes through the shared per-relay author builder (mirrors Git);
muted-authors likewise. Global / communities keep the broad directory pull,
since they carry no author dimension to narrow on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 22:41:26 +00:00
Claude
54ad837559 feat(graperank): TCP reachability pre-probe + .onion skip to cull the dead graveyard
At hop-8 the crawl dials into thousands of dead relay hints from old accounts.
Most fail slowly: a silently-dropping host has no RST to receive, so the WS
connect just hangs to the 7s connectTimeout. First-strike eviction pays that once
per host, but with ~3,000 dead hosts that's ~80s of connect-setup serialized
through the dispatcher.

Add a background reachability culler: a cheap raw TCP connect (one round trip, 2s
timeout) over the learned relays COLD-TAIL FIRST, dropping the unreachable ones
into deadHosts before the WS path pays its 7s. The key property is that a tight
TCP timeout is safe where a tight WS timeout is not — a busy-but-alive relay
accepts the SYN instantly at the kernel level and only stalls at the app layer, so
the probe separates "unreachable" from "slow" and never false-kills the busy. It
only ever marks dead and probes each authority once; a host the WS path already
resolved (isDead) is skipped, and a live host passes the probe, so the WS verdict
always wins. Injected as an optional Config.reachabilityProbe (JVM: java.net.Socket
in the CLI; --no-probe disables); writeRelayFreq becomes concurrent so the culler
can read it while routeByOutbox writes.

Also: when there's no Tor transport (Config.torEnabled=false), isDead skips every
.onion relay on sight — no socket, no wasted connect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-08 22:38:46 +00:00
davotoula
b5313e28ca refactor: replace duplicated string literals with constants
docs: explain the intentionally empty default of RelayUnderTest.prepare
fix: surface failed checkpoint deletion in CorpusDownloader
2026-07-08 23:34:18 +01:00
Claude
dc5bf6499a refactor: rebuild relay-group discovery on the canonical feed stack
Restructures the discovery feed to match the gitRepositories package exactly —
the shape every feed screen in the app uses — replacing the bespoke ViewModel +
per-relay directory fan-out.

New package layout (mirrors gitRepositories/):
- dal/RelayGroupDiscoveryFeedFilter — AdditiveFeedFilter<Note> over the 39000
  addressables; GroupDiscoveryConstraint (moved to its own file) supplies the
  relay-signed match (relay-key / admin / member, or #t/#g tag).
- datasource/RelayGroupsDiscoveryFilter — makeRelayGroupsDiscoveryFilter per-type
  dispatch over IFeedTopNavPerRelayFilterSet.
- datasource/subassemblies/ — FilterRelayGroupsGlobal / ByFollows / ByAuthors
  (+ muted) / ByHashtag / ByGeohashes / ByCommunity, plus the shared directory
  builder. Authors can't be a REQ constraint (a 39000 is relay-signed), so the
  people filters pull the directory per relay and the dal narrows locally; only
  topic/geo carry a relay-side #t/#g constraint.
- datasource/RelayGroupsDiscoveryFilterAssembler + SubAssembler
  (PerUserAndFollowListEoseManager) + FilterAssemblerSubscription.

Wiring, parallel to gitRepositories:
- AccountFeedContentStates.relayGroupsDiscoveryFeed (+ update/delete/trim fan-out)
- TopNavFilterState.relayGroupsDiscoveryRoutes
- RelaySubscriptionsCoordinator.relayGroupsDiscovery
- ScrollStateKeys.RELAY_GROUPS_DISCOVERY_SCREEN

Screen now runs on FeedContentState + RefresheableBox + RenderFeedContentState
(custom onLoaded rendering the joinable group cards). Old ViewModel deleted; the
constraint additions to the directory assembler reverted (browse-a-relay path
keeps the plain broad directory).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 22:17:50 +00:00
Vitor Pamplona
2db071b2d0 Merge pull request #3504 from davotoula/feat/local-sonar-check
Opt-in local SonarQube analysis via local.properties
2026-07-08 18:03:48 -04:00
Claude
0140b837b1 feat: per-type relay group discovery (follows/admins/members + topics/geo)
Relay-signed kind-39000 has no author-of-a-follow, but the people dimension
still exists: a follow may be the relay signing key, a group admin (39001), or
a member (39002). Discovery now resolves each top-nav filter into a per-relay
GroupDiscoveryConstraint instead of collapsing every filter to the same REQ.

quartz:
- GroupMetadataEvent / EditMetadataEvent: build + read #t (topics) and #g
  (geohash, mip-mapped so a coarser followed geohash still matches). Interop
  tests for parse/build round-trips.

amethyst:
- dal/RelayGroupDiscoveryFeedFilter: sealed GroupDiscoveryConstraint
  (AllGroups / ByPeople / ByHashtags / ByGeohashes / AnyOf) + toGroupConstraints()
  mapping each IFeedTopNavPerRelayFilterSet to per-relay constraints, with
  matches() covering the relay-key/admin/member people paths and topic/geo tags.
  Unit tests.
- Directory REQ narrows to 39000 #t/#g for topic/geo filters, broad directory
  otherwise (people match needs the rosters).
- ViewModel keys the feed on the constraint map and re-scans on any directory
  event (metadata OR roster) so late-arriving admins/members surface groups.
- Create/edit form gains a Discovery section (topics + geohash) threaded through
  Account.createRelayGroup/editRelayGroupMetadata and EditMetadata.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 21:42:38 +00:00
David Kaspar
4d1f6bc6e8 Merge pull request #3500 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-08 22:37:15 +01:00
davotoula
cf43e6e435 build: opt-in local SonarQube analysis via local.properties
Adds a `sonar` Gradle target that activates only when `sonar.host.url`
is present in local.properties (gitignored). Developers who don't opt
in are unaffected: the scanner plugin is neither resolved nor applied,
so no dependency downloads, no extra tasks, no config-time cost
2026-07-08 22:33:06 +01:00
vitorpamplona
61507acdd7 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-08 21:32:24 +00:00
Vitor Pamplona
f01afbb1ad Merge pull request #3505 from vitorpamplona/claude/amy-brew-macos-install-tqrjyg
chore: automate Homebrew formula sync for amy CLI + add cask reference
2026-07-08 17:30:23 -04:00
Vitor Pamplona
aaf97dd06c Merge pull request #3503 from vitorpamplona/claude/appmodules-memory-trim-deprecations-7301bk
Adapt memory trimming to Android 14+ trim level changes
2026-07-08 17:29:43 -04:00
Claude
a6a401fcd4 Revert "perf(graperank): drop the per-batch awaitAll barrier in Phase B"
This reverts commit a824f6e09b.
2026-07-08 21:18:19 +00:00
Claude
f19f965435 Revert "perf(graperank): raise drain concurrency to 4096 to match the old fan-out"
This reverts commit 56724454b7.
2026-07-08 21:18:19 +00:00
Claude
56724454b7 perf(graperank): raise drain concurrency to 4096 to match the old fan-out
Dropping the per-batch awaitAll (previous commit) made the rounds faster but a
hop-3 A/B regressed total wall (727s vs 532s): the Semaphore(1024) throttled
concurrent relay drains to ~249 parked at peak vs the old batch model's ~4,438,
so slow-relay park windows that the old model absorbed during the rounds spilled
into a long serial finishing drain. Raise the default so the parked work drains
inside the rounds again. Value under validation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-08 21:03:03 +00:00
Claude
3312065ad8 refactor: modernize onTrimMemory to the two levels the OS still delivers
Since API 34, ComponentCallbacks2 no longer notifies apps of the foreground
RUNNING_* levels or the deeper MODERATE/COMPLETE background tiers — those
constants are deprecated and the OS only ever delivers UI_HIDDEN (20) and
BACKGROUND (40). The tiered trim logic keyed on the deprecated levels was
therefore dead on any Android 14+ device.

Rebuild the whole trim chain around the two levels still delivered:
- UI_HIDDEN (every app switch): light trim — release image bitmaps, keep the
  CPU-heavy rich-text/Robohash caches warm so resuming is instant.
- BACKGROUND (process on the LRU list, real reclaim pressure): aggressive —
  free every rebuildable cache, run the heavy LocalCache prune, release the
  ExoPlayer warm pool, trim feeds, and evict warm embedded tabs.

Folds the old COMPLETE/MODERATE "free everything" behavior into BACKGROUND and
removes all deprecated TRIM_MEMORY_* references across AppModules,
MemoryTrimmingService, Amethyst, PlaybackService and AccountFeedContentStates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016FnhMdPu7XaCu8DwW8FmLf
2026-07-08 20:59:21 +00:00
Claude
1b1413f861 refactor(nip29): drive group discovery through the standard feed-filter pattern
Follow the Pictures/Git pattern exactly instead of the custom lean variant:

- Persist the selection in a new `defaultRelayGroupsDiscoveryFollowList` account
  setting (AccountSettings + LocalPreferences save/parse/wire + change mutator +
  Account `liveRelayGroupsDiscoveryFollowLists(PerRelay)` flow) — like every other
  feed. A missing pref key just defaults to Global, so it's additive, not a
  migration.
- Top bar uses the shared `FeedFilterSpinner` bound to that setting, so all the
  standard options apply — Global, Follows, followed hashtags/geohashes, and
  per-relay chips (a starred favorite relay shows up as a chip). No bespoke
  filter enum, no separate favorites toggle.
- Remove the 40-relay cap: fan the directory query out to every relay in the
  set, matching the other global feeds.
- ViewModel now reads `liveRelayGroupsDiscoveryFollowListsPerRelay` → relayKeys()
  (extended for the Relay chip) and lists the groups those relays host.

The ★ still stars a group's host relay (kind-10012 relay-feeds list), which is
what surfaces it as a relay chip in the filter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 20:50:01 +00:00
Claude
a824f6e09b perf(graperank): drop the per-batch awaitAll barrier in Phase B
Phase B drained users in 256-user batches: a worker called drainGated for the
whole batch and awaitAll'd every relay in it, so one slow relay held the worker
(and the batch's already-finished fast relays' contact lists) for the full 10s
fast window before anything was ingested. With 24 workers all waiting out their
batches' slowest relay at once, progress dropped to 0 lists/sec in waves.

Restructure to drain each relay independently and stream its result the instant
it resolves — no per-batch join. A per-user counter (relaysLeft) tracks how many
of a user's relays are still outstanding; the single-writer consumer finalizes a
user (ingest, or count a failed outbox attempt) only when the last of its relays
resolves, so correctness is unchanged. Concurrency is now a semaphore over
relay-units rather than an implicit batches×fan-out product; drainConcurrency
becomes "concurrent relay drains" (default 1024, ~the old 24-batch fan-out).
A straggler the outbox model routes nowhere is finalized directly as a miss.

Fast relays' lists are now ingested immediately instead of behind a batch's
slowest relay, removing the 0/s stalls on slow-relay-heavy rounds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-08 20:42:09 +00:00
Claude
a18cad6121 feat(nip29): relay-group discovery feed with top-bar filter + favorites
A discovery feed to find and join groups, modeled on the Git/Pictures top-nav
filter. Because a group's kind-39000 is relay-signed (not author/tag scoped),
every filter option reduces to a RELAY SET: the feed queries kind 39000 on the
relays the selected filter resolves to.

- Global / Follows / Around me — resolved via the account's shared
  topNavFilterFlow + outbox loader (relayKeys() takes the per-relay set's keys).
  Driven by a ViewModel-local TopFilter, so no account-settings/LocalPreferences
  plumbing and no persistence (resets to Global per visit).
- Favorite relays (B) — a toggle backed by the kind-10012 relay-feeds list
  (account.relayFeedsList); a star on each card adds/removes the group's host
  relay via followRelayFeed/unfollowRelayFeed.
- Fans the existing per-relay RelayGroupDirectorySubscription out across the set
  (capped at 40 relays); the feed reads LocalCache.relayGroupChannels for those
  relays, re-scanning as kind-39000 events arrive.
- Joinable cards: avatar, name, member count, private/invite-only pill, Join
  (open groups join directly; closed ones open the group for the code), and the
  favorite-relay star. "Find groups" now opens discovery; the paste-a-relay
  browse is a top-bar action.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 20:21:41 +00:00
Claude
f3088d00c5 feat(nip29): convert group create/edit/new-thread dialogs to full screens
Content-authoring flows are screens in Amethyst, not dialogs. Promote the three
NIP-29 group dialogs to routed screens:

- RelayGroupCreateScreen / RelayGroupEditScreen — a shared metadata form backed
  by RelayGroupMetadataViewModel with a tap-to-upload avatar hero (gallery pick →
  compress → NIP-96/Blossom upload, mirroring the emoji-pack editor). Create now
  exposes EVERY group parameter: name, description, picture, and all four status
  flags (private, invite-only, members-only posting, unlisted), each with a
  one-line explanation. Edit prefills reactively from the metadata flow and won't
  clobber in-progress edits.
- RelayGroupNewThreadScreen — full-screen title+body composer with rememberSaveable
  state so a half-written thread survives rotation / process death.

Plumbing: Account + AccountViewModel createRelayGroup/editRelayGroupMetadata gain
picture + isHidden + isRestricted (the quartz 9002 builder already supported them).
Routes RelayGroupNewThread/Create/Edit registered; the Threads FAB, channel-list
FAB, and topbar Edit menu now navigate to them; the three dialog files are removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 19:40:17 +00:00
Claude
32c309e86f refactor(relay): collapse TRANSIENT into DEAD — a failed relay is not retried this run
The drain classifier had two "act on it" verdicts, HARD (drop now) and TRANSIENT
(strike a few times, might clear). Re-probing hop-8's failed relays fresh showed
the TRANSIENT bucket almost never clears: 503 Service Unavailable 0/12 reachable,
502 Bad Gateway 3/15, connection-establishment failures 0/30; the codes that were
alive (402/403) are gated and will never serve us, and 200 isn't a relay. So the
extra dials TRANSIENT bought were spent on hosts that stay dead for the run.

Collapse to a single DEAD verdict, dropped on the first strike, and carve out the
only two connect failures that genuinely recover so they stay retryable (null):
a READ timeout (relay answered the handshake, slow — 67% reachable fresh, kept on
the clear-on-success authority-strike path) and an HTTP 429 rate-limit (alive,
4/4 reachable — retrying spaced by the limiter is how we get its data). Removes
the now-unused relayStrikes map, MAX_DEAD_STRIKES, and the HARD/TRANSIENT merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-08 19:33:27 +00:00
Vitor Pamplona
ff27720aff Merge pull request #3502 from vitorpamplona/claude/nostr-client-first-event-race-egv20l
Fix race condition in fetchFirst when relay sends buffered events at EOSE
2026-07-08 15:23:48 -04:00
Claude
b8b25060fb fix: drain buffered event on EOSE in fetchFirst to avoid race
A relay sends its matching events before its EOSE, so both an event and
the relay's completion can sit buffered in their channels at the same
time. The select() over the two channels picks a ready clause at random,
so it could process the doneChannel completion first, empty `remaining`,
and exit the loop while the matching event was still unread — returning
null instead of the event.

On a relay completion, drain the event channel first and treat any
already-buffered event as the result before marking the relay done.
2026-07-08 19:15:11 +00:00
Claude
7e680ddea0 feat(nip29): richer group card + threads/browse polish
Card glow-up (from the UI review): ElevatedCard with a subtle avatar ring, the
name promoted to titleMedium, a tonal "Private"/"Invite-only" status pill, and a
member-count chip with a people icon + primary-tinted chevron — so the inline
card reads as a living community, not a link row. Still fixed-layout / no reflow.

Threads: gate the compose FAB on membership (a non-member's kind-11 is rejected
by the relay), make per-thread reply counts reactive via observeNoteReplyCount
(so a new kind-1111 comment bumps the count live), and use leading dividers.

Browse: show an inline error when the pasted relay URL doesn't normalize, and
drop a redundant Row wrapper around the text field.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 19:06:18 +00:00
Claude
5312b61164 fix(relay): evict connection-establishment failures on the first strike
A connect failure and a read timeout were both treated as "busy, retry" and
took three strikes to drop. Re-probing hop-8's failed relays fresh, outside the
crawl, showed the two are not alike: relays that failed to ESTABLISH a
connection (connect timed out, refused, unroutable, or the proxy couldn't tunnel
the CONNECT) were 0/30 reachable — genuinely dead — while relays that hit a READ
timeout were 12/18 (67%) reachable, alive but overloaded by the crawl's fan-out
(user.kindpag.es among them).

So classifyDrainFailure now returns HARD for connection-establishment failures
(one strike drops them instead of burning two more dials on a dead host), while
a read/generic timeout still returns null and stays on the patient,
clear-on-success timeout-strike path so live-but-slow relays we need are not
wrongly evicted. Mid-stream resets stay TRANSIENT. Adds DrainFailureTest, which
the classifier previously had none of.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-08 19:01:09 +00:00
Claude
15c336e623 fix(nip29): live roster/metadata loading + empty states on group screens
Three review-found reactivity/loading defects:

- Members screen mounted no per-group roster subscription — observeChannel is a
  no-op for a RelayGroupChannel, so the roster only appeared if the chat screen
  had cached it. Mount RelayGroupPreviewSubscription so it fetches kinds
  39001/39002 from the host relay while visible; add a loading state and the
  group name in the subtitle.
- Invite dialog read toNAddr()/isClosed() as one-shot snapshots, so opening it
  before the kind-39000 arrived left the Copy button permanently disabled. Collect
  the metadata flow so the naddr/code fill in live; show a "Preparing invite…"
  placeholder meanwhile; move to the non-deprecated LocalClipboard.
- Channel-list polled the cache every 1500ms and rendered a blank screen when
  empty. Drive refresh off LocalCache.observeEvents(kind 39000) instead of a
  timer, sort the initial value (no first-frame reshuffle), and add an empty
  state. Both lists now use leading dividers (no trailing divider under the last
  row).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 18:59:51 +00:00
Vitor Pamplona
c4c2f41d6b Merge pull request #3501 from vitorpamplona/claude/module-warnings-review-0u3t4t
Eliminate null-safety suppressions and unsafe casts
2026-07-08 14:57:52 -04:00
Claude
d9dee8967b fix: resolve compiler warnings across modules
Clears real Kotlin compiler warnings surfaced across quartz, cli,
relayBench, amethyst, and desktopApp:

- quartz Sha256/EventHasher/ScratchLocal: ThreadLocal.get() is nullable
  in Kotlin; assert non-null (withInitial never yields null).
- quartz GitHttpClient: PriorityQueue.poll() under isNotEmpty() is
  non-null; assert it.
- relayBench CorpusDownloader: drop redundant !! on smart-cast Long;
  Jackson fields() -> properties().
- cli GrapeRankCommand: drop redundant ?. where latest is smart-cast.
- PodcastRemoteContent: OkHttp body is non-null; drop dead elvis.
- Dead/redundant expressions: remove no-op when-branch values and a
  redundant trailing Unit (HomeScreen, LocalCache, EmbeddedTabLayer,
  ParticipantHostActionsSheet, NestActionBar, ControlWhenPlayerIsActive,
  ShareNoteAsImageScreen exhaustive-when else).
- CalendarEventDetailScreen / SetPasswordDialog / ProfileClinkOfferResolver:
  drop always-true conditions (reorder to keep smart-casts).
- WalletColumnScreen: OkHttp body non-null; drop unreachable null-guards.
- PcmTapRegistry: the @OptIn used androidx.annotation.OptIn, which does
  not opt into Kotlin's ExperimentalCoroutinesApi; use kotlin.OptIn.
- GitRepositoryScreen: suppress the standard ViewModel-factory cast.
- PushNotificationReceiverService: suppress override-of-deprecated.
- Desktop GlobalScope call sites: @OptIn(DelicateCoroutinesApi::class).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GMqkg1ndvFihEwZcENiRs
2026-07-08 18:31:10 +00:00
Claude
34dfd691d3 refactor(nip29): render group-card description only when present
Drop the reserved two-line description block: description-less groups now stay
compact instead of showing two blank lines. The about still fills in when the
metadata loads (a small one-time grow), which is the better trade for the common
case where a group has no description.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 18:18:17 +00:00
Claude
7ea266f811 feat(nip29): show group description on the inline group card
The group-link card now renders the group's about/description below the header
(avatar + name + relay · members). The description occupies a reserved two-line
block so it fills in when the relay-signed metadata arrives without changing the
card's height — preserving the no-layout-shift behavior while making the card
substantially richer than the bare URL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 18:14:46 +00:00
Claude
51299400ef feat(nip29): one-tap join from invite links carrying a code
An invite link's `?code=` now flows all the way to the join: tapping a
`wss://relay'id?code=…` card/link (or opening it as a deep link) opens the group
and, because the tap is itself the opt-in, fires the kind-9021 join with that
code once — no more re-typing it into the join dialog. Plain (code-less) group
links still just open the group for viewing.

- Route.RelayGroup gains inviteCode; threaded through AppNavigation →
  RelayGroupChatScreen → RelayGroupTopBar
- RelayGroupTopBar auto-joins once when a code is present and we're not already
  a member (reuses the optimistic "requested" state)
- RelayGroupCard / ClickableRelayGroupLink / uriToRoute carry the parsed code

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 18:05:00 +00:00
Claude
00246c6ae2 fix: resolve compiler and Gradle deprecation warnings
- GrapeRankPublisher: dTag() is non-null (""), so the Elvis on the
  grouped target was dead code; skip blank targets via ifBlank instead.
- amethyst: migrate deprecated resourceConfigurations to
  androidResources.localeFilters (same locale qualifiers).
- desktopApp: replace deprecated compose.desktop.uiTestJUnit4 accessor
  with the direct org.jetbrains.compose.ui:ui-test-junit4 dependency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GMqkg1ndvFihEwZcENiRs
2026-07-08 18:01:55 +00:00
Vitor Pamplona
de5b1da720 Merge pull request #3487 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-08 13:55:43 -04:00
Claude
f6fa262017 fix(graperank): paginate capped relay pages across the whole crawl
A single REQ can match up to authors×kinds events; a relay that caps its
response below that silently drops the tail. Measured: user.kindpag.es returns
at most ~100 events per REQ and ignores our limit, so a dense chunk -- 300
authors × the 4 FETCH_KINDS, or a popular-author kind:3 sweep -- loses
everything past the newest 100 on the first (and only) page drainGated fetched.
On a dense set kindpag returned 100 events single-shot vs 238 paginated; nos.lol
and damus (higher caps) matched at 246 and 127.

drainGated never paginated -- it took one page and moved on -- so this bit every
sweep and outbox query, not just the aggregator recovery. Truncated users became
stragglers that the multi-round retry mostly (not always) recovered elsewhere,
which is why it stayed hidden.

Now any page that comes back at FULL_PAGE_THRESHOLD (100, the smallest cap
observed) is treated as possibly-capped and its remainder is drained in the
background with fetchAllPages `until` cursors, streamed to lateHarvest exactly
like a parked slow relay (tracked by parkedInFlight so the round waits for it,
gated by the limiter). The boundary second is re-fetched and de-duplicated by
persist's crawl-wide seen-set, so nothing double-counts. Only dense pages pay
the extra REQs; the common under-cap page is untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-08 17:48:04 +00:00
Claude
22b8089a96 Revert "fix(graperank): paginate aggregator recovery so the page cap can't truncate it"
This reverts commit f19b8052b0.
2026-07-08 17:35:52 +00:00
Claude
f19b8052b0 fix(graperank): paginate aggregator recovery so the page cap can't truncate it
The recovery pass drained each aggregator with the crawl's single-shot path
(drainGated: one REQ, collect until EOSE). Against an indexer that caps a page
at ~100 events and ignores our limit, every straggler beyond the newest 100 was
silently dropped -- and drainGated additionally merged all chunks into one giant
REQ, which the big indexers answer with nothing at all.

Query each aggregator with fetchAllPages instead, walking `until` cursors to
exhaustion, one AUTHORS_PER_FILTER chunk per request so no request carries the
whole straggler set. Relays paginate concurrently; each relay's chunks run
sequentially to keep one subscription live per connection, gated by the same
limiter. Delivered events land on a channel off the reader threads, then are
verified/persisted and folded once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-08 17:22:11 +00:00
Claude
2b11dbd7d9 test(nip29): cover weird apostrophe placements + guard relay-URL possessives
Reject a single-character group id (except the default `_`) so a possessive
glued to a bare relay URL — `wss://relay.damus.io's uptime` — no longer
linkifies group "s". Real ids (relay29/Wisp/0xchat) are all longer.

Adds coverage proving only genuine ws/wss relay URLs are peeked: apostrophes
after http, nostr:, blossom:, email and bech32 tokens never become group links;
plus ws:// (insecure), second-apostrophe boundary, and multi-link cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 17:21:15 +00:00
Claude
041e6c83b8 docs(graperank): correct why aggregator recovery is kind:3-only
The comments said a multi-kind filter makes the big indexers "time out returning
nothing." Reproduced against user.kindpag.es, the real mechanism is a per-REQ
result cap: it returns ~100 events regardless of the requested limit, and a
kinds=[3,10000,1984,10002] query fills that cap entirely with the far more
abundant kind:10002, returning 0 kind:3. Asked kind:3-only it returns the
contact lists in a few seconds. Same conclusion (query kind:3 alone), accurate
reason.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-08 17:12:33 +00:00
Claude
9b7d2f7e45 feat(nip29): render inline group links as self-loading group cards
In the preview render path, a `<relay>'<groupId>` link now draws as a
full-width group card instead of a bare link. The card renders immediately
with a stable layout — robohash avatar seeded from the group id, the id as a
placeholder name, and the host relay — then fills in the real name, picture and
member count in place as the relay-signed metadata arrives, without changing
the card's structure or height.

While a card is on screen it warms the group via a lightweight, host-pinned
compose subscription: keeps kind 39000/39001/39002 + roles live so the card
stays current, and prefetches the newest ~15 chat messages / threads so tapping
the card opens an already-populated screen. Non-preview contexts (e.g. DMs)
keep the plain clickable link and issue no outbound subscription.

- RelayGroupPreviewFilterAssembler / RelayGroupPreviewSubscription (registered
  in RelaySubscriptionsCoordinator)
- RelayGroupCard + RichTextViewer preview-path branch
- relay_group_open string

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 17:10:26 +00:00
Claude
eef2832bf4 feat(graperank): add nostr.oxtr.dev and nos.lol to the content-aggregator set
Per-relay attribution on observer 460c25e6 showed two big general relays hold
kind:3 for a chunk of the missing authors that no profile indexer has:
nostr.oxtr.dev (76 distinct) and nos.lol (72). Add both to the aggregator set
so the patient kind:3-only recovery pass sweeps them alongside the indexers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-08 17:02:18 +00:00
Claude
7598a157dd fix(graperank): recover aggregator kind:3 for evicted hosts in the patient pass
The dedicated straggler-recovery pass was skipping any content aggregator the
main crawl had timeout-evicted, so it recovered ~1 contact list instead of the
hundreds those indexers actually hold.

Root cause: during the competitive crawl an indexer like user.kindpag.es is
only ever asked for kind:10002 in bulk and kind:[3,10000,1984,10002] one author
at a time. The latter parks and times out (60-80s each), striking the host until
its authority is timeout-evicted. It is never asked for a clean bulk kind:3 --
the one thing it serves fast (~19 lists per 300 authors in seconds; ~369 of the
run's missing authors live there). So by the time recovery runs, kindpag.es is
dead and dropped from the aggregator set (8 configured -> 6 used), and the
biggest single source of missing lists is never queried.

Fix: the recovery pass now queries every configured aggregator regardless of
eviction (drainGated doesn't re-check isDead, and a genuinely dead endpoint only
costs one shared park window since units run concurrently), and clears any
timeout strikes first so a partially-struck host starts clean. Also stops
folding aggregators into routeByOutbox's multi-kind fan-out (they time out
there) and asks them kind:3-only, matching what they serve.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-08 16:59:14 +00:00
vitorpamplona
eddd3ea4e1 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-08 16:55:15 +00:00
Vitor Pamplona
0ff48cfbe0 Merge pull request #3483 from nrobi144/feat/wot-shared-index-relays
feat(desktop): Web-of-Trust score badges + shared index relays + amy wot verbs
2026-07-08 12:53:08 -04:00
Vitor Pamplona
50ce22e49e Merge pull request #3495 from nrobi144/feat/desktop-wallet-privacy-lock
feat(desktop): apply the privacy lock to the Wallet column
2026-07-08 12:52:54 -04:00
Vitor Pamplona
de43c0bc7f Merge pull request #3499 from vitorpamplona/claude/mls-secrettree-preservation-8passk
Persist SecretTree ratchet positions across MLS group restores
2026-07-08 12:52:36 -04:00
Claude
f9b24156e8 feat(nip29): linkify group invite links inline and via deep links
Recognise the de-facto `<relay>'<groupId>[?code=<code>]` NIP-29 group invite
link format used by Wisp and 0xchat, both inside rendered note content and as
an external deep link, so tapping one opens the group.

The URL detector correctly stops a host at the apostrophe (host names can't
contain `'`), so the group id is torn off before classification. Rather than
loosen the shared URL grammar — which would swallow prose possessives like
`example.com's` — group links are recovered by peeking just past each relay
URL the detector already found. This is cache-miss-only and costs nothing on
notes without a `wss://` link.

- quartz: GroupInviteLink.parse / suffixLength (+ tests)
- commons: Urls.groupLinks, UrlParser peek, RichTextParser plumbing
  (atomic span through fixMissingSpaces, new RelayGroupLinkSegment) (+ tests)
- amethyst: ClickableRelayGroupLink renderer + uriToRoute deep-link branch

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 16:15:31 +00:00
Claude
610f0c7355 feat(graperank): recover straggler kind:3 from aggregator indexers
The outbox model fetches a user's kind:3 only from their own kind:10002 write
relays (and the write-frequency backbone). But a large tail of reachable users
have no kind:3 on their own advertised outbox at all — it's dead, or they never
published one there — while a network-wide aggregator (user.kindpag.es, …) that
scrapes the whole network holds it. Those aggregators were queried only for
kind:10002 relay lists in ensureRelayLists, never for kind:3 content, so the
crawl structurally could not find these lists no matter how many rounds it ran.

Add Config.contentAggregatorRelays and fold it into routeByOutbox for stragglers
— users whose own outbox already failed (attempts > 0) or is unknown. The CLI
wires the profile indexers (kindpag/purplepag/coracle/yabu/nostr1) plus the
ActivityPub bridges (ditto/momostr/mostr, which host bridged users' lists);
--no-aggregators disables it.

Measured offline on observer 460c25e6 (max-hops 3): of ~2.2k users the crawl
left without a contact list, querying the aggregators for kind:3 recovers ~500
(user.kindpag.es alone ~180) — lifting coverage from ~89% toward ~92%. The
remainder have no kind:3 retrievable on any relay we know (bridged / inactive /
never-published) — a data-absence floor, not a crawl deficiency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-08 16:10:15 +00:00
Vitor Pamplona
e3e9e2fa20 Merge pull request #3498 from vitorpamplona/claude/negentropy-sync-deletions-t2a7sf
NIP-77 deletion sync: two-pass settle over the reconcile residual
2026-07-08 12:05:07 -04:00
Claude
01ab0cf0bf test(geode): keep deletion-settle benchmark as robust shape guard
Drop the flaky publish-into-large-relay warmup from DeletionSettleBenchmark
(it timed out the measured reconcile at N=100k — the container noise the
docstring already warns against) and remove the throwaway ScratchSettleTiming
investigation tool. Record in the docstring what the phase breakdown proved:
the settle's extra time over a bare reconcile is O(K) relay-ingest of the K
residual deletions, dominated by one-time JVM/JIT warmup of the publish path
(consecutive K-note batches fell ~3100->570ms), not the deletion algorithm and
not O(N).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgL1WTV4Hkp2uuXcUHCHGt
2026-07-08 15:48:27 +00:00
Claude
4ffc56829a test(geode): benchmark deletion-settle cost is O(residual), not O(database)
relayBench measures relay-to-relay reconcile, not the amy/quartz client feature,
so the deletion-settle perf claim belongs in an in-process benchmark of
negentropySettleDeletions itself.

Models the post-content-settle state: a relay with N notes, a local store with
the same N except K it deleted (keeping the K kind-5s). The reconcile residual is
exactly those K, so a sendUp settle fetches K — not N. Asserts residual==K,
sentUp==K, and relay convergence (correctness guard at the small default N),
and prints one-reconcile vs full-settle so the deletion overhead reads as
"a few reconciles + K", never "+ a content re-download". Measured:

  N=2000   K=20:  settle ~2x   one reconcile, fetched K=20 not N
  N=100000 K=20:  settle ~5x   one reconcile, fetched K=20 not N=100000

The growth is the relay rebuilding its negentropy index after the deletions
(O(N) once) — inherent to applying deletions, and still far cheaper than
re-fetching the need set, which the old per-need-fetch approach did.

Scale with -DdelBenchN / -DdelBenchK (forwarded by the geode test task).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgL1WTV4Hkp2uuXcUHCHGt
2026-07-08 15:19:30 +00:00
Claude
c011dfca6e test(cli): headless end-to-end for deletion sync (real amy vs amy serve)
Drives the built `amy` binary against a real `amy serve` relay to prove NIP-77
deletion propagation end-to-end — the running answer to "does it actually work",
on top of the in-process geode tests:

  T1 (up)   we deleted a note the relay still has → `amy sync` sends our kind-5
            up and the relay drops it (checked by an isolated third account that
            reads the relay only, so no local tombstone masks the result).
  T2 (off)  `--no-sync-deletions` sends nothing and the relay keeps the note.
  T3 (down) the relay deleted a note we still hold → `amy sync --up` pulls the
            relay's kind-5 down and applies it locally; a second sync converges.

Each amy account gets its own $HOME (accounts under one $HOME share the file
store). Follows the cli/tests/*-headless.sh pattern; state dir gitignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgL1WTV4Hkp2uuXcUHCHGt
2026-07-08 15:09:57 +00:00
Claude
0145f8bdcb refactor: extract deletion-settle loop into a quartz INostrClient accessory
The two-pass deletion convergence is protocol logic, not CLI assembly, and the
geode mirror is a near-term second consumer — so move it out of SyncCommand into
a reusable accessory alongside the rest of the negentropy family.

quartz: negentropySettleDeletions(relay, filter, store, sendUp, applyDown, …) —
re-reconciles after a content settle and resolves only the residual: publishes
our covering deletions up (sendUp) and/or ingests the relay's kind-5 down
(applyDown, vanish never auto-applied), looping until a round resolves nothing.
Returns DeletionSettleResult(sentUp, appliedDown, rounds). Everything it needs is
already quartz (negentropyReconcileIds, fetchAll, deletionsCovering,
publishAndConfirm, Event.verify, IEventStore), so it carries no CLI dependency.

SyncCommand's pass 2 collapses to a single call; pass 1 (content) is unchanged.
Catalogued in the accessories README.

Tests: DeletionSyncTest drives the accessory end-to-end both ways (sendUp → relay
converges to gone; applyDown → local converges to gone), on top of the existing
deletionsCovering unit + manual-wiring cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgL1WTV4Hkp2uuXcUHCHGt
2026-07-08 14:42:20 +00:00
Claude
77e8108c6c Merge remote-tracking branch 'origin/main' into claude/graperank-sync-crawl-1n05im
# Conflicts:
#	cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt
#	quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankDataCrawler.kt
2026-07-08 14:35:52 +00:00
Claude
ff79bc89de perf(graperank): evict connect-silent hosts by authority after repeated timeouts
classifyDrainFailure deliberately treats every timeout — connect timeout or
park idle-cut — as "busy, retry" and never dead, so a relay that connects but
never answers a REQ gets re-routed through every straggler's outbox, every
round, each visit burning the full timeout + park window for zero data. The
outbox model makes this worse: one dead server (e.g. filter.nostr.wine) is
advertised as hundreds of distinct per-user path URLs, so a per-URL counter
never reaches a threshold on any single one.

Count unproductive-timeout strikes per relay AUTHORITY (host[:port]) and evict
the whole host after Config.timeoutEvictStrikes (default 3; CLI --timeout-evict,
0 disables). Any clean EOSE or delivered event clears the authority, so only
never-productive hosts are evicted; a multi-path relay where some paths are
slow but others deliver stays live. Authority is host-only and never folds a
filter. subdomain into its parent, so an open bare host is untouched when its
sibling filter host is shed. Purely behavior-driven — no NIP-11.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSW59hJtP4Yn8fnRUxc7F5
2026-07-08 14:27:02 +00:00
Claude
677c0ee207 refactor: deletion sync as a post-settle residual pass (both directions, O(residual))
Replace the per-need-event fetch (which pulled the whole need set just to read
metadata — an O(db) regression on large syncs) with a second reconcile pass over
the residual, per the "settle, then diff, then explain what didn't converge" idea.

Pass 1 is the plain content sync again (drain needs, publish haves) — zero
deletion overhead. Pass 2+ re-reconciles; the leftover diff is exactly the
deletion mismatches, and only that (tiny) set is fetched:
- residual need (relay has it, we still lack it after --down) = we deleted it →
  publish our covering deletion up so the relay drops it;
- residual have (we have it, relay still lacks it after --up) = the relay deleted
  it → pull the relay's covering kind-5 down and apply locally (vanish is NOT
  auto-applied on pull — account-wide blast radius).
Loops until a round resolves nothing (converges + self-verifies).

So `amy sync` makes the relay honor our deletions; `--up` makes us honor the
relay's; `--up --down` converges both ways. Cost is one cheap reconcile + the
residual regardless of database size — the large-DB bottleneck is gone by
construction, not by heuristics.

quartz: deletionsCovering is now source-agnostic (takes a query lambda) so the
same coverage rule runs against the local store (up) or the relay (down); the
IEventStore overload is the local convenience.

Tests: DeletionSyncTest gains the down-direction end-to-end (relay deleted →
local removes) alongside the up-direction and the per-form unit cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgL1WTV4Hkp2uuXcUHCHGt
2026-07-08 14:25:37 +00:00
Vitor Pamplona
997083e258 Merge pull request #3497 from vitorpamplona/claude/graperank-crawl-speedup
perf(graperank): faster crawl (cap 100→16, dead-discovery shedding, fewer sweep barriers) + relay diagnostics
2026-07-08 10:25:21 -04:00
Claude
e4a6e13036 fix: nostrord interop — thread titles and public group list
Two compatibility gaps found analyzing nostrord (a NIP-29 client):

- Thread titles: NIP-7D (and Amethyst) use a `title` tag, but nostrord
  writes/reads `subject`, so neither showed the other's thread titles.
  ThreadEvent.title() now reads `title` OR `subject`; we still emit only the
  spec-correct `title`.
- Joined-groups list (kind 10009): Amethyst wrote memberships as NIP-44
  private items, but both reference clients (Flotilla, nostrord) store — and
  nostrord only READS — public `["group", id, relay]` tags, so an Amethyst
  user's groups were invisible to them. follow() (and the amy CLI) now write
  public tags. NIP-29 membership is already public via the relay's kind-39002
  list, so this loses no real privacy; reads still merge any legacy private
  items so existing lists keep working.

Tests: read title from title/subject (title wins; we emit title only); public
group is a plain tag and still read through the cache; a mixed public+private
list reads as both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 14:21:42 +00:00
Claude
07d982b5b5 fix(marmot): preserve SecretTree ratchet across restore to stop generation reuse
MlsGroupState reconstructed the SecretTree from encryption_secret alone, so
every restore rewound each sender's generation counter to 0. The restored
local member then re-emitted generation 0 within the same epoch — reusing the
AEAD key+nonce (a confidentiality break) and getting rejected by strict
receivers (openmls / MDK / Whitenoise) that forbid generation reuse, per
RFC 9420 §9.

Two parts:
- Persist per-sender ratchet positions. SecretTree gains export/importSenderStates;
  MlsGroupState carries them as an optional field (STATE_VERSION 2, v1 blobs still
  decode as empty = legacy behavior); saveState/restore wire them through.
- Persist after every send. MlsGroupManager.encrypt now saves group state, not
  just commits — application sends advance the ratchet but previously never hit
  the store, so a restart between two commits still reset it.

Regression tests: a peer that consumed generation 0 accepts the restored
sender's next message (single + multi-send), encrypt persists the ratchet
between commits, and a v1 blob still decodes/restores.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G6uT4xzjty1xosZBkb3sHA
2026-07-08 14:16:03 +00:00
Vitor Pamplona
3cba102a09 perf(graperank): faster crawl (cap 100→16, dead-discovery shedding, fewer sweep barriers) + relay diagnostics
Speeds up a from-scratch GrapeRank crawl ~25-30% at equal completeness on a
drift-controlled A/B, by:
- lowering the per-relay concurrent-sub cap 100→16 — the old 100 drowned popular
  relays (damus/nos.lol) in concurrent giant REQs, driving them to time out; 16
  restores their responsiveness (damus yield 0%→14%) and is still generous for
  the single-user fetches other amy commands do,
- shedding proven-dead relays from the kind:10002 discovery sweep instead of
  re-hammering refusing indexers every round,
- trimming the sharded backbone sweep 6→2 rotations (Phase A was ~36% of the
  crawl at half Phase B's per-list efficiency; 2 clears the bulk with no
  completeness loss).

Also adds relay observability under --diagnose to document how relays reply to
our queries: per-relay telemetry (outcome mix, yield, latency, worst time-sinks),
a LIVE / THROTTLED / UNREACHABLE classification table with the limits we settled
on per relay, and per-round Phase-A/Phase-B timing; plus contact_lists_by_hop in
the sync result for per-hop completeness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 10:02:24 -04:00
Claude
e43c752a07 fix: keep group thread replies scoped to the group
Replying to a NIP-29 group thread went through the shared NIP-22 comment
composer, which built a plain kind-1111 comment with no group `h` tag and
broadcast it to the author's outbox. Such a reply is not group content: the
host relay rejects it and no other member — or other NIP-29 client like
Flotilla — ever sees it, and for a private/closed group it leaks to
unrelated relays.

Both fixes are tightly guarded on the replied-to event being group-scoped,
so ordinary comments are untouched:
- CommentPostViewModel inherits the group's `h` tag from the event being
  replied to (covers replies to the kind-11 root and to nested 1111
  comments — both route here).
- The reply is published only to the group's host relay (the relay the
  thread was seen on) via signAndSendPrivatelyOrBroadcast, instead of the
  outbox-computing broadcast — so it reaches the group and never leaks.

Verified: quartz test builds the reply as the composer does
(CommentEvent.replyBuilder { hTag } over a kind-11 root) and asserts it is a
1111 carrying the group `h` tag and referencing the thread.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 13:36:10 +00:00
Claude
c57681b3e9 docs: catalog INostrClient relay-client extensions so they're discoverable
The one-shot/high-level relay ops (fetchAll, fetchFirst, fetchAllPages,
publishAndConfirm, count, negentropy sync/reconcile, …) are INostrClient
extension functions spread across ~8 files with no index, so they don't surface
under "usages of NostrClient" or in completion — easy to miss and re-implement
(as just happened with a bespoke fetchRaw duplicating fetchAll).

- Add accessories/README.md cataloging each public extension with a one-line
  "use when".
- CLAUDE.md (Feature Workflow): point at that package/README before hand-rolling
  a subscribe/REQ/publish loop.
- relay-client skill: add a Related note steering headless/one-shot callers to
  the accessories instead of Subscribable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgL1WTV4Hkp2uuXcUHCHGt
2026-07-08 13:25:13 +00:00
Claude
2fb6e3cb2f feat: relay-group threads (kind 11) as a secondary view
Closes the Flotilla interop gap where NIP-29 groups also carry kind-11
"threads" (forum-style posts) that Amethyst's chat-only room view dropped.
Threads are a secondary surface, kept out of the kind-9 chat feed — a
Threads button in the group top bar, mirroring Discord/Slack.

- RelayGroupChannel: a separate `threads` collection (kind-11 notes) with a
  reactive StateFlow, distinct from the chat timeline.
- LocalCache: attach kind-11 to channel.threads (same host-pinned + own-send
  null-relay routing as chat messages).
- Host-pinned RelayGroupThreadsFilterAssembler (kinds 11 + 1111 scoped by
  `#h`), active only while a group's Threads screen is open; fetching the
  1111 comments too means opening a thread has its replies already cached.
- RelayGroupThreadsScreen lists a group's threads (title, author, preview,
  reply count) and opens each in the existing thread view (Route.Note) for
  the full comment tree — no bespoke detail screen needed. Members start a
  thread via NewRelayGroupThreadDialog → Account.postRelayGroupThread
  (ThreadEvent.build(body, title){ hTag }).
- Route.RelayGroupThreads + a Forum icon in the group top bar.

Verified: kind-11 with h + title round-trips and is queryable by #h against
an embedded relay (the exact filter Flotilla uses); commons threads-collection
test (dedup/flow/remove) passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 13:15:23 +00:00
Claude
0af65b4296 refactor: use quartz INostrClient.fetchAll instead of a bespoke Context.fetchRaw
fetchAll already does exactly what the need-metadata fetch needs — subscribe,
collect (deduped by id), return on EOSE/timeout, no verify, no store — so drop
the duplicated Context.fetchRaw and call the existing extension.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgL1WTV4Hkp2uuXcUHCHGt
2026-07-08 12:59:47 +00:00
nrobi144
e45d8b18e6 fix(commons): import kotlin.concurrent.Volatile for iOS/Native compat
`@Volatile` without `import kotlin.concurrent.Volatile` resolves to the
JVM-only `kotlin.jvm.Volatile`, which breaks `commonMain` on iOS/Native
targets. CI catches this on `:commons:compileKotlinIosSimulatorArm64`.

Two call sites needed the import:
- OutboxDispatcher.kt:468 (`@Volatile private var lastCount`)
- FeedMetadataCoordinator.kt:433 (`@Volatile private var lastCount`)

Verified: `./gradlew :commons:compileKotlinIosSimulatorArm64` now green.

Closes CI break on PR #3483.
2026-07-08 11:24:15 +03:00
Claude
af7c6c11e1 feat: send exactly the deletions that cover a relay's need events (id/addr/vanish)
Refine the sync deletion rule to what was asked: for the events the relay HAS
that we LACK (the reconcile need set), publish only the local deletions that
would actually make the relay remove them — and nothing else, not other
deletions by the same author.

Determining coverage needs the need event's author/address/created_at, which we
don't have for an id we lack, so we fetch the need events (raw — no verify, no
store) purely for metadata. quartz gains IEventStore.deletionsCovering(events,
relay), which maps server-held events to the covering local deletions across all
three forms:
- NIP-09 id-based: a kind-5 with an `e` tag naming the event id;
- NIP-09 address-based: a kind-5 with an `a` tag naming the event's
  addressable/replaceable coordinate, at/after it (created_at <= deletion);
- NIP-62 vanish: a kind-62 by the event's author, targeting this relay, issued
  after it (created_at < vanish).

SyncCommand's need workers now fetch each need batch once (Context.fetchRaw),
publish its covering deletions (deduped across workers), and — when --down —
store the rest; anything we deleted is rejected by the store's own tombstone.
Nothing is pulled down or applied locally, so it cannot over-delete the store.

DeletionSyncTest covers each form (with cutoff and wrong-relay negatives) plus an
end-to-end reconcile → cover → publish that removes the note on the relay.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgL1WTV4Hkp2uuXcUHCHGt
2026-07-08 02:45:47 +00:00
Claude
e09a939b08 refactor: reduce deletion sync to "send deletions for need ids", nothing else
Per the actual requirement, deletion propagation is exactly: for the ids the
relay HAS that we LACK (the negentropy need set), if we hold a kind-5 deletion
targeting one of them, publish that deletion up — so a note we deleted is
deleted on the relay too instead of being re-downloaded. Only the need ids,
only kind-5, up only.

This removes all the machinery the earlier approach accreted and that the audit
flagged as over-broad / data-loss-prone:
- deleted NostrClientDeletionSyncExt (the bidirectional side-channel, author
  scoping, vanish gating, kind selection);
- reverted geode MirrorWorker to base (no deletion side-channel, live-sub
  changes, catch-up ordering, or convergence changes);
- dropped the 3-phase SyncCommand flow (deletions-first pull, author-scope
  derivation, reject-reaction backstop, --sync-vanish, deletions_* output).

The new path pulls nothing down and applies nothing locally, so it cannot
over-delete the store, and it needs no author scoping — the need set already
bounds it. Kind-62 is intentionally excluded: a vanish is not "of an id".

Emits deletions_sent. DeletionSyncTest now exercises the exact wiring
(reconcile → look up local kind-5 by its e tag for the need ids → publish),
including the negative case (a need id we never had sends nothing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgL1WTV4Hkp2uuXcUHCHGt
2026-07-08 02:29:52 +00:00
Claude
633903b5c1 fix: relay-group audit — timeline, roster, membership, list-safety
Fixes found in a full audit of the NIP-29 relay-groups feature across
quartz/commons/amethyst/cli.

Correctness (app):
- Own group messages never appeared in the timeline until an app restart:
  the optimistic send is consumed with a null relay, so attachToRelayGroup
  bailed on the relay==null guard, and the host relay's echo (new==false)
  was skipped by the "only attach when newly consumed" gate. Attach now runs
  on every arrival, gated on the note being loaded, and the null-relay case
  attaches to the already-open channel(s) for that group id. Also avoids the
  wrong-relay phantom by only fabricating a channel from real provenance.
- Roster subscription was frozen after an in-place join/leave (state keyed
  on the stable account, never re-derived); it now invalidates on every
  liveRelayGroupList change, so a fresh join's 39002 admission is fetched.
- membershipOf demoted a 39001 admin with an empty/unknown role to MEMBER,
  hiding moderation; presence in the admins list now means at least MODERATOR.
- Members roster showed permanent truncated-hex names (one-shot
  getUserIfExists cached null); uses checkGetOrCreateUser so UsernameDisplay
  fills in when kind:0 arrives.

Protocol / data:
- GroupTag had no value equality → joined-group Sets never deduped and the
  StateFlow re-emitted on every identical re-arrival. Equality is now the
  (id, relay) pair, excluding the cosmetic name.
- create/edit emitted non-canonical ["public"]/["open"] status tags; NIP-29
  flags are presence-only, so only private/closed are emitted when set.
- Metadata/member/admin supersede guards use <= so an equal-createdAt
  duplicate isn't reprocessed (first-arrival wins); updatedMetadataAt is now
  private-set. Relay-group channels are now included in the prune loops.

CLI:
- join/leave/create updated the kind:10009 list from a network-only drain;
  a slow/empty fetch could publish a fresh list containing ONLY the new
  group, wiping the rest. Now reads the local store (source of truth) too.
- edit re-asserted both visibility axes from flag presence, so --closed on a
  private group leaked it public. It now reads current 39000 and merges,
  with --public/--open counter-flags; only the specified axis changes.
- create now tracks the new group in kind:10009 (parity with join/Android).

UI polish:
- Invite dialog no longer mints a 9009 for open groups and won't copy a code
  it never displayed. Browse "popular" list normalizes URLs before filtering.

Tests: GroupTag identity, unknown-role-admin-moderates, equal-createdAt
no-resupersede added; all quartz+commons NIP-29 suites and the amy
relaygroup harness pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 02:27:18 +00:00
Claude
17687e43fc fix: bound deletion sync scope; stop mass over-deletion (audit fixes)
An audit (adversarial-verified) found the deletion side-channel over-deletes
and over-propagates. Root cause: deletionSideChannelFilter fell open to
authors=null for any non-author-scoped content sync, so `amy sync --kind 1`
reconciled the RELAY'S ENTIRE kind-5/62 history and applied it to the personal
FsEventStore — every kind-5 deleting its targets + installing an id-tombstone
for every target id, every ALL_RELAYS kind-62 wiping all of a pubkey's events
(all kinds), and pushing our whole local deletion history up. Data loss plus a
full-history reconcile on every scoped sync.

Fixes:
- Bound the side-channel to the authors we actually hold content for (filter
  authors ∪ local matched-set authors), never the relay's population. Skip when
  that scope is empty; Phase 3's reject-reaction covers the author-less case.
- Kind-5 (precise, owner-scoped) propagates by default; kind-62 vanish is opt-in
  via --sync-vanish (its blast radius always exceeds a content sync's scope).
- excludesDeletionKinds() now checks each deletion kind independently
  (`--kind 1,5` no longer silently drops kind-62); the side-channel reconciles
  only the missing kinds.
- amy Phase 1 is best-effort: a deletion-reconcile failure records deletions_error
  and falls through to content, never aborting the primary sync (matches geode).
- Mirror up-catch-up converges on whether a PUBLISHABLE event was pushed, not raw
  haveCount — a vanish targeting another relay no longer burns all 8 rounds every
  startup. Mirror keeps its (correct) global scope for relay-to-relay replication.

Helper API: negentropyPropagateDeletions gains scopeAuthors + deletionKinds;
deletionSideChannelFilter takes authors + deletionKinds and returns only the
missing kinds. Tests updated for the new semantics.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgL1WTV4Hkp2uuXcUHCHGt
2026-07-08 02:14:08 +00:00
Claude
d85bac1f55 feat(cli): amy relaygroup — NIP-29 relay groups in the CLI
Adds a first-class `amy relaygroup` verb group so the CLI can drive the
same NIP-29 relay groups as the app. Thin assembly over quartz builders +
Context.publish/drain — no protocol logic in cli/.

Verbs:
- list / browse RELAY / info RELAY GID — reads (joined kind:10009 list with
  private-item decryption; a relay's 39000-39003 directory; one group's
  metadata + roster).
- create / join / leave / message — lifecycle. join/leave also maintain the
  caller's kind:10009 list (add/remove private item) so `list` reflects them,
  mirroring the app's follow/unfollow.
- edit / invite / put-user / remove-user — moderation (9002/9009/9000/9001).

All writes pin to the group's single host relay. Output follows amy's
text/--json contract with snake_case keys.

Verified end-to-end against an embedded relay (amy serve) with a new
self-contained harness, cli/tests/relaygroup/relaygroup-headless.sh:
create/message/join/list/browse all pass (5/5). browse/info return empty
against geode since it doesn't sign 39000-39003 — a relay capability, not a
client issue. README command table + ROADMAP updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 01:41:15 +00:00
Claude
de0f84fbad chore: drop 'command-line' from amy formula desc for brew audit
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TGdEUziwC3Uc3XhB7DFQYt
2026-07-08 01:35:01 +00:00
Claude
7ffc38f55d feat: promote Relay Groups to a top-level destination + live rosters
Renames the feature to "Relay Groups" and makes it a first-class
navigation destination rather than a Messages-tab sub-view, and keeps
joined-group membership accurate app-wide.

- Top-level Route.RelayGroups home (RelayGroupsHomeScreen): a root
  destination with the drawer top bar + bottom bar, listing the host
  relays of joined groups (each drilling into its channels) plus the
  discovery entry. Registered as NavBarItem.RELAY_GROUPS (Forum icon) in
  the drawer's Feeds section and pinnable to the bottom bar, with a
  roster preloader entry.
- Naming: user-facing strings now say "group(s)" instead of "channel(s)";
  added relay_groups_title = "Relay Groups".
- Live rosters: RelayGroupRosterFilterAssembler keeps every joined
  group's 39000/39001/39002 fresh (one #d-scoped filter per host relay,
  re-derived from the join list) while a groups-bearing screen is on top.
  Mounted on both Messages panes and the new home, so membership,
  pending→member transitions and member counts stay accurate without
  opening each chat — the gap that most affected closed/private groups.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 01:25:27 +00:00
Claude
f8b2f17979 feat: deletions-first ordering + reject-reaction backstop in sync
Reorder amy sync so both sides fully reflect each other, including deletions:

1. Deletion side-channel now runs FIRST, before content, in both directions.
   The content snapshot is taken AFTER it, closing a resurrection bug: a
   deletion pulled down mid-sync removes a local event, but the old top-of-run
   snapshot still listed it and would re-offer it up — resurrecting it on a
   relay that also lacked the deletion.
2. Content reconcile, over the post-deletion snapshot.
3. Reject-reaction backstop: when the relay blocks a content push (usually it
   holds a deletion we lack), pull that author's kind-5/62 and ingest locally
   so we stop re-offering the dead event. Verify-by-fetch — only a real
   deletion the store accepts has any effect; fires only on an actual reject.

The up-push of deletions is already verified per-event: ctx.publish awaits the
relay's OK, and ingesting the kind-5 runs the delete synchronously, so OK=true
confirms the remote applied it.

Mirror catch-up gets the same deletions-first ordering (down and up), so a
deletion lands, or the reject-trigger is armed, before its target — no
add-then-delete churn.

Tests: MirrorDeletionSyncTest gains scopedUpMirrorPushesDeletion (authoritative
push — local holds the deletion, remote drops the note).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgL1WTV4Hkp2uuXcUHCHGt
2026-07-08 01:25:18 +00:00
Claude
f7be77c6e7 test: cover NIP-29 client-side membership and private-list round-trip
Adds runnable jvmTest coverage for the commons logic behind relay groups,
which until now was only compile-verified:

- RelayGroupChannelTest: the roster fold in RelayGroupChannel — role
  derivation (admin/moderator/plain member), an admin present only in the
  39001 list still resolving as a member, member-count dedup across
  admins+members, and the createdAt supersede guards dropping stale
  out-of-order 39000/39002 events.
- RelayGroupListDecryptionTest: drives the exact create/add/remove calls
  join/leave delegate to, then reads them back through the decryption
  cache, proving a followed group survives the NIP-44 encrypt→sign→decrypt
  round-trip and that unfollow removes only the intended group.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-08 01:04:00 +00:00
Vitor Pamplona
2fc5d52627 Merge pull request #3494 from vitorpamplona/claude/graperank-wot-cli-qreg2a
GrapeRank web-of-trust for amy: sync → score → publish (NIP-85)
2026-07-07 21:02:28 -04:00
Claude
26dc8dee6f chore: add reference Homebrew Cask for the Amethyst desktop app
amethyst-nostr does not exist in homebrew-cask yet, so bump-homebrew.yml has
had no cask to bump. Add a submission-ready reference cask (mirrors the amy.rb
convention) pinned to the v1.12.6 arm64 DMG with its verified sha256
(69882e83…). arm64-only because the release matrix builds no Intel DMG; zap
targets ~/.amethyst where the desktop app stores its data.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TGdEUziwC3Uc3XhB7DFQYt
2026-07-08 00:07:27 +00:00
Claude
683f993c7b feat: propagate deletions (NIP-09/62) across negentropy sync
NIP-77 reconciles by event id over the content filter, so a scoped sync
(`--kind 1`) never carries the kind-5/62 that deletes one of those notes:
the deletion stays stuck on whichever side issued it while the target
lives on forever on the other. Add a deletion side-channel that reconciles
kinds 5 & 62 on their own, independent of the content filter.

quartz: NostrClientDeletionSyncExt — DELETION_PROPAGATION_KINDS,
Filter.excludesDeletionKinds()/deletionSideChannelFilter() (kinds 5/62 scoped
to the same authors, no time window since a deletion's created_at is not its
target's), shouldPropagateDeletionUp() (kind-5 always; kind-62 only to a relay
it targets, honoring the vanish's declared relays), and
negentropyPropagateDeletions() — one bidirectional reconcile that streams
have→upload and need→download.

amy sync: run the side-channel bidirectionally regardless of --up/--down
whenever the filter excludes 5/62; emits deletions_{need,have,downloaded,
uploaded}; --no-sync-deletions opts out.

geode MirrorWorker: thread a per-upstream deletionScope through both catch-up
phases and both live subs (down + up), in the mirror's configured direction;
relax down containment to accept in-scope deletions, gate kind-62 pushes by
target relay, and carry the deletion filter on re-subscribe so a reconnect
never drops it.

Tests: DeletionSyncTest (up/down propagation + filter/vanish-gate units) and
MirrorDeletionSyncTest (a kind-scoped down mirror still removes the note).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgL1WTV4Hkp2uuXcUHCHGt
2026-07-08 00:00:24 +00:00
Claude
6167bb9d16 chore: finalize amy Homebrew formula for v1.12.6
Pin the reference formula to the published v1.12.6 release asset with its
verified sha256 (209316d7…) so it is submission-ready for homebrew-core or a
personal tap. Note in the header that bump-homebrew-formula.yml now keeps the
url + sha256 synced automatically on each stable release.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TGdEUziwC3Uc3XhB7DFQYt
2026-07-07 23:23:35 +00:00
Claude
d6b76c400f feat: make relay groups discoverable, shareable and deep-linkable
Turns NIP-29 groups from a joined-only feature into something users can
find, share and open from a link:

- RelayGroupBrowseScreen (Route.RelayGroupBrowse): paste any relay URL to
  browse the channels it hosts, pick a relay you're already on, or try a
  popular public relay. Reached from a new "Find channels" FAB entry and
  from the grouped server list.
- RelayGroupServerList always shows a "Find channels" row (even with no
  joined groups), so a new user has a starting point instead of an empty
  view; extracted RelayGroupServerRow for reuse by the browse screen.
- Share action in the group top bar: fires the system share sheet with a
  njump link to the group's naddr, available to members and non-members.
- Cold-start deep links: a kind-39000 group naddr now routes straight to
  the group chat using the naddr's relay hint, instead of falling through
  to the generic note-redirect screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 23:22:20 +00:00
Claude
24c1ef3100 Merge remote-tracking branch 'origin/main' into claude/graperank-wot-cli-qreg2a
# Conflicts:
#	cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StoreCommands.kt
2026-07-07 23:18:50 +00:00
Claude
4a53dccaf9 ci: auto-sync amy Homebrew formula on release; fix bump failure-reporter perms
Add bump-homebrew-formula.yml: on a stable release it downloads the published
amy-<version>-jvm.tar.gz bundle, computes its sha256, and opens a PR syncing
cli/packaging/homebrew/amy.rb's url + sha256 — automating the manual step the
formula header calls out and keeping the reference formula ready for the
homebrew-core submission. The homebrew-core auto-bump is deferred (documented
TODO) because brew bump-formula-pr can only bump a formula already in the tap,
and amy has not been submitted to homebrew-core yet.

Also fix the "Report failure" step in bump-homebrew.yml and bump-winget.yml:
both declared `permissions: contents: read`, but github.rest.issues.create
needs issues:write, so the failure reporter itself 403'd and never filed an
alert. Add issues:write to both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TGdEUziwC3Uc3XhB7DFQYt
2026-07-07 23:18:25 +00:00
Claude
c6f90b20a0 feat(cli): split graperank into sync (load) and score (compute) sub-verbs
The crawl persists everything to the store and the score is a pure function
over it, so separate them: `amy graperank sync` crawls the reachable graph into
the store (idempotent + cumulative — run it a few times to be sure it's loaded)
and reports what it loaded without scoring; `amy graperank score` builds from the
store and scores instantly, repeatable with different params and no re-crawl
(same as bare `--offline`). Bare `amy graperank` stays the sync+score combo.

Extract the shared crawler wiring into newCrawler(); score() just forces the
offline path, and sync() runs a persist-only crawl (null builder).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-07 23:09:09 +00:00
Claude
db39536bde feat(graperank): live progress heartbeat + optional builder for persist-only sync
The crawler only logged once per round, so a deep hop (140k users, minutes of
work) went silent between lines. Add a heartbeat ticker on the background scope
that emits every few seconds with the current round's completion (a real X/Y %
against the round's known pending target), a rolling fetch rate + rough ETA for
it, and live counts (events stored, relays parked/dead) — and a "finishing"
line while draining the parked tail. Scoring stays sub-second, so it keeps its
per-sweep lines and needs no ticker.

Also make crawl()'s builder nullable: null runs a persist-only pass (every event
still lands in the store, the frontier still expands off each contact list) with
no in-memory graph — the basis for a `sync` that loads data without scoring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-07 23:09:09 +00:00
Vitor Pamplona
325eb3ccfe Merge pull request #3492 from vitorpamplona/claude/amethyst-status-command-yf5be6
Add `amy status` command for cross-account disk overview
2026-07-07 19:05:26 -04:00
Vitor Pamplona
e68bbb437c Merge pull request #3493 from vitorpamplona/claude/cli-account-requirement-31pdbn
CLI: enable read-only verbs to run without an account
2026-07-07 19:02:15 -04:00
Claude
439b85a8e1 feat(cli): let read-only verbs run without an account
amy gated every non-primitive verb behind a chosen account: `DataDir.resolve`
threw when `~/.amy/` had no unambiguous account, and every networked command
called `Context.open`, which requires an identity — even though queries only
read relays and the shared store and never sign. `store` maintenance and the
local `offer`/`debit info` decoders were caught by the same gate despite
touching no account state.

Reads now work anonymously; only signing needs an account:

- `DataDir.resolveOptional` hands back an accountless dir (`hasAccount = false`)
  pointing only at the shared event store when there is no unambiguous account,
  instead of throwing.
- `Context.openOrAnonymous` uses the resolved account when present, else an
  ephemeral key-less `Identity.anonymous()` — can read, can't sign. Marmot
  stores are now lazy and run-state isn't persisted for anonymous runs, so an
  accountless read leaves `~/.amy/shared/` clean.
- `Context.open` (signing path) re-asserts the requirement with the "which
  account?" hint, so ambiguous/no-account signing verbs still exit 2.
- Main resolves optionally for every verb except the identity-lifecycle ones
  (`init`/`create`/`login`/`logoff`/`whoami`), which still need a concrete
  account. `offer info` / `debit info` join the stateless primitive block.
- Read subverbs (fetch, subscribe, count, publish, outbox, search, sync,
  store, profile/git/podcast/podcast20 reads, nsite/napplet fetch·serve·list,
  blossom download·check) switch to `openOrAnonymous`.

No `--json` shapes change. Docs updated (help text, README, DEVELOPMENT).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRaoqGod5LUeSwq4GHRSNF
2026-07-07 22:58:22 +00:00
Claude
21387569e9 feat: add NIP-29 group roster and admin moderation UI
Adds a members roster screen and admin/moderator actions for relay-based
groups:

- Account: removeRelayGroupUser (kind 9001), putRelayGroupUser (kind 9000
  promote/demote), editRelayGroupMetadata (kind 9002), all pinned to the
  group's host relay; plus AccountViewModel wrappers.
- RelayGroupMembersScreen: relay-signed roster (39001 admins / 39002
  members) with avatars, names and role badges. Moderators get a per-user
  menu to promote to admin/moderator, remove a role, or kick (with a
  confirm dialog). Menu items are gated so a moderator can't act on an
  admin and nobody acts on themselves.
- EditRelayGroupDialog: admin-only edit of name/topic/visibility.
- Wire Members, Edit channel (admin) and existing Invite/Leave into the
  chat top bar overflow; new Route.RelayGroupMembers registration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 22:56:21 +00:00
Claude
7eefdd2fb5 feat(nip29): visual polish pass on the relay-group UI
Replace the plain, glyph-based rows with real iconography and avatars (all icons
reuse the existing MaterialSymbols subset — no font regen):

- Relay rows (grouped view): the relay's NIP-11 avatar + name, host subtitle, and
  a ChevronRight icon instead of a "›" character.
- Channel-browse rows: a channel avatar, a private-lock glyph, member count, and a
  "joined" check; the FAB uses the Add icon instead of a "+" character.
- Chat top bar: a real MoreVert overflow icon; the header now shows a colored role
  pill (Admin/Moderator/Requested), a members count with a Group icon, and a lock
  for private groups — replacing the flat "host · N members · role" text.
- Inline row's relay chip gains a small Dns icon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 22:45:50 +00:00
Claude
2dca306914 feat(nip29): shareable group naddr + invite-code join flow
Nostr-native invites/deep-links for relay groups (no proprietary URL scheme):

- RelayGroupChannel.toNAddr(): a NIP-19 `naddr` for the group's kind-39000
  metadata (authored by the relay key, host relay as hint) — a cross-client
  coordinate that opens the group.
- ClickableRoute: a clicked/rendered `naddr` for kind 39000 routes straight into
  the group chat (Route.RelayGroup with the relay hint) instead of the generic
  addressable-note view.
- InviteRelayGroupDialog now shares the group `nostr:naddr…` (for discovery) plus
  the one-time code for closed groups; copy grabs both.
- JoinRelayGroupDialog: closed groups prompt for the invite code, which the join
  request (9021) carries; open groups still join in one tap.

Follow-up: cold-start `nostr:naddr` deep links (from outside the app) still route
to the generic note view; only the in-app clicked/rendered path is wired here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 22:36:07 +00:00
Claude
85d6f5a162 feat(cli): add amy status overview command
A cross-account, read-only snapshot of everything amy holds under
`~/.amy/`, built for the returning user: which accounts exist, which
one is pinned as current, each signer type (local keychain/ncryptsec/
plaintext, NIP-46 bunker, or read-only) and whether it can still sign,
the per-account local footprint (aliases, Marmot groups, published
KeyPackage bundle, Cashu wallet, sync cursors), and the shared event
store's size.

Like `use`, it dispatches before account resolution so it works with
zero, one, or many accounts. Strictly metadata-only: it never unlocks a
private key (no keychain prompt / NIP-49 passphrase) and never touches
the network.

Factors the on-disk event-store walk into a shared `StoreStats` helper
reused by both `status` and `store stat`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3GJb11JkvopP61ETWAVyy
2026-07-07 22:32:06 +00:00
Claude
7c0f24ef57 feat(nip29): accurate membership state from the relay roster
Membership is now derived from the relay's own signed lists (kind 39001 admins /
39002 members) instead of the client's kind-10009 intent:

- RelayGroupChannel gains members/admins (from 39002/39001), a RelayGroupMembership
  derivation (ADMIN/MODERATOR/MEMBER/NONE, + a client-side PENDING), and a member
  count. LocalCache consumes 39001/39002 into the channel.
- RelayGroupTopBar shows the real state: member count and your role in the
  subtitle; a Join button when you're not a member, an optimistic "Requested"
  after you tap Join (until the relay's roster confirms), and Invite (mods only) +
  Leave once you're in. Invite is gated on moderate rights.

Caveat: private groups may hide 39002 from non-members, so state resolves after
the roster is visible; a targeted 9000/9001 subscription could tighten that later.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 22:31:57 +00:00
Claude
a013bcd9d9 feat(cli): quiet quartz DEBUG logging by default, add --verbose/-v
The CLI ran at the library's default Log.minLevel = DEBUG, so quartz internal
chatter (relay-auth init, MLS restore, URL-rejection, throttle notices) leaked
onto stderr around every command's real output. Set Log.minLevel = WARN at
startup, before dispatch, so a normal run shows only warnings/errors plus the
command's own progress. A new global --verbose / -v flag restores full DEBUG;
it's parsed with the other global flags so subcommands never see it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-07 22:27:19 +00:00
Claude
f95df91d6d chore(quartz): drop AdaptiveRelayLimiter + relay-URL rejection logs to debug
The per-relay concurrency/rate throttle notices and the "Rejected <url>"
normalizer messages fire constantly during a large crawl (thousands of
rejected/throttled relays) and are operational detail, not warnings. Move
them from Log.w to Log.d so they stay available under debug logging without
flooding a normal run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-07 22:15:29 +00:00
Vitor Pamplona
16357b0a9f Merge pull request #3491 from vitorpamplona/claude/account-logout-uc4ych
Add logoff command to CLI for account deletion
2026-07-07 18:14:14 -04:00
Claude
e8a2f0cdac feat(nip29): two-pane Messages parity for relay-group views
Bring the tablet/wide two-pane Messages layout to parity with single-pane: the
view-mode toggle and the GROUPED relay rows now render above the chatroom list in
the first pane (inline group rows already flowed through the shared feed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 22:13:54 +00:00
Claude
5f677280d8 feat(nip29): join / leave / create / invite actions
Wire the NIP-29 membership + admin flows into the UI, all published only to the
group's host relay:

- Account: joinRelayGroup (9021 + follow), leaveRelayGroup (9022 + unfollow),
  createRelayGroup (9007 + 9002, returns the new GroupId), createRelayGroupInvite
  (9009); AccountViewModel wrappers.
- RelayGroupTopBar: a Join button when not a member; once joined, an overflow
  with Invite + Leave. Membership is read from the kind-10009 list.
- CreateRelayGroupDialog: name/topic/private/invite-only; mints a random group id,
  publishes, and navigates into the new channel. Reachable from a FAB on the
  relay channel-list screen.
- InviteRelayGroupDialog: mints a kind-9009 invite code on open and offers copy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 22:12:11 +00:00
Claude
c8c4111e0c feat(cli): add amy logoff to clear an account's local data
Adds `amy logoff [--yes] [--keep-events]`, the CLI counterpart to logging
out: it removes everything an account left on the machine.

  - the identity file and any backend-held secret (keychain / ncryptsec /
    plaintext), via DataDir.deleteIdentity
  - the rest of the per-account directory ~/.amy/<account>/ (run-state
    cursors, aliases, cashu counters, all Marmot/MLS state)
  - the ~/.amy/current pin, when it points at this account
  - the account's events in the SHARED ~/.amy/shared/events-store/

The event store is shared across accounts, so logoff does not wipe it
wholesale — it deletes only the events that involve this account: those it
authored plus those addressed to it via a #p tag (gift wraps, nutzaps,
reactions, mentions). Other accounts' cached events are left untouched.
`--keep-events` skips the shared-cache purge entirely.

The public key is read straight from identity.json (never unlocking the
private key), so logoff needs no passphrase and pops no keychain prompt.
Destructive and irreversible, so it follows the `marmot reset` precedent:
`--yes` is required to execute; without it the command prints a dry run of
what would be deleted and exits 2.

Thin-assembly only — event deletion is quartz's FsEventStore.delete; this
just resolves the account, counts, and wires the filesystem teardown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PH3rqz5KaA7CYFPAtxgoz1
2026-07-07 22:11:52 +00:00
Claude
660bd86dc7 perf(graperank): idle-based park timeout so streaming relays aren't cut mid-flight
The park window's timeout was absolute from subscription open, so a relay
still actively streaming a large result set once it passed parkTimeoutMs was
unsubscribed and its untransmitted tail lost. Reset the window on every
incoming event (a conflated activity signal drives a select against the
terminal deferred), so a parked subscription is closed only after
parkTimeoutMs of actual silence — never while events are still arriving. The
fast window stays absolute: it only decides when to hand a slow relay to the
background park lane, which loses nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-07 22:11:19 +00:00
Claude
d5d4125978 feat(nip29): grouped view mode + view-mode toggle & persistence (C, part 4)
Completes the Messages-tab integration and makes the view mode user-switchable.

- RelayGroupServerList: the GROUPED mode's relay rows (one per host relay of the
  user's joined groups), tap opens the relay's channel list; rendered above the
  DM feed in MessagesSinglePane only in GROUPED mode (MessagesPager gains a
  modifier param so it weights correctly under the section).
- RelayGroupViewModeToggle: a segmented Inline/By-relay control, shown once the
  user has joined a group; flips AccountSettings.relayGroupViewMode.
- Persistence: the view mode is saved/restored via LocalPreferences (mirrors
  defaultRelayAuthPolicy), with an updateRelayGroupViewMode setter that saves.

The full NIP-29 relay-groups feature is now navigable end-to-end: browse a
relay's channels, chat in a group, and see joined groups in the Messages tab in
either inline (default) or grouped view.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 21:59:08 +00:00
Claude
34c1e4b6bb feat(nip29): inline group rows in Messages list + view-mode setting (C, part 3)
The default "inline" Messages view: joined NIP-29 channels appear as rows in the
Messages list, each with a tappable chip naming its host relay.

- RelayGroupViewMode setting (INLINE default / GROUPED) on AccountSettings.
- ChatroomListKnownFeedFilter includes joined relay-group channels' latest
  messages in the flat feed when the mode is INLINE (excluded in GROUPED, where
  they'll be reached via relay rows).
- ChatroomHeaderCompose resolves a relay-group row via the note's channel
  gatherer (like Marmot groups) and renders RelayGroupRoomCompose: name + a
  relay chip. Row tap opens the chat (Route.RelayGroup); chip tap opens that
  relay's channel list (Route.RelayGroupServer).

Remaining: the Settings toggle + persistence for the view mode, and the GROUPED
mode's relay rows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 21:51:52 +00:00
Vitor Pamplona
607665e846 Merge pull request #3490 from vitorpamplona/claude/kotlin-compilation-warnings-bcxeyp
Clean up empty when branches and remove unnecessary null checks
2026-07-07 17:46:22 -04:00
Claude
b03b335466 feat(nip29): relay channel-directory screen + subscription (C, part 2)
The shared backbone both Messages-tab view modes need: browse every channel a
relay hosts.

- RelayGroupDirectoryFilterAssembler + subscription: streams a relay's kind
  39000-39003 directory (registered in RelaySubscriptionsCoordinator), consumed
  into per-group RelayGroupChannels.
- LocalCache.getRelayGroupChannelsOnRelay + AccountViewModel accessor enumerate
  a relay's channels.
- RelayGroupChannelListScreen lists them (name + topic), tap opens the chat;
  Route.RelayGroupServer + AppNavigation registration.

Next: the view-mode setting (default inline) and the Messages-tab entries
(grouped relay rows vs inline channel rows with a relay chip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 21:43:12 +00:00
Claude
01d61b0851 fix: resolve remaining Kotlin compiler warnings in commons and cli
- Suppress DEPRECATION on REASONABLE_SIGN_KINDS, which intentionally lists the
  deprecated TorrentCommentEvent kind.
- Replace deprecated readLine() with readlnOrNull() in SecureKeyStorage.
- Drop unnecessary !! non-null assertions in KeyCommands and NostrConnect where
  the receiver is already smart-cast to non-null.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018nqdy4VTLKidUWzGTJPja9
2026-07-07 21:39:04 +00:00
Claude
560b95c2ec fix: resolve Kotlin compiler warnings in quartz and quic
- Replace unused Unit/null expressions in statement-position when branches
  with empty blocks (CommandSerializer, QuicConnection, QuicConnectionParser,
  Http3FrameReader, WtPeerStreamDemux).
- Suppress DEPRECATION on KindNames.names, which intentionally registers the
  deprecated GitReplyEvent and TorrentCommentEvent kinds for display.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018nqdy4VTLKidUWzGTJPja9
2026-07-07 21:32:41 +00:00
Claude
154264f149 feat(nip29): relay-group chat screen + navigation (C, part 1)
An end-to-end, reachable NIP-29 group chat screen, reusing the shared channel
feed/composer/subscription stack:

- RelayGroupChatScreen + RelayGroupChannelView + LoadRelayGroupChannel +
  RelayGroupTopBar (mirrors the ephemeral-chat screen), driving the generic
  ChannelFeedViewModel / ChannelNewMessageViewModel / ChannelFilterAssembler-
  Subscription against a RelayGroupChannel.
- ChannelNewMessageViewModel: RelayGroupChannel branch builds a kind-9 ChatEvent
  scoped with the `h` tag, published only to the group's host relay.
- AccountViewModel: get/checkGetOrCreate RelayGroupChannel by GroupId.
- Navigation: Route.RelayGroup, routeFor(RelayGroupChannel)/routeFor(GroupId),
  and the AppNavigation registration.

Opening a group (relay + id) now streams its live timeline and sends messages.
Remaining C: the Messages-tab entries listing chat relays and their channels.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 20:53:32 +00:00
Claude
8d5f48fbc0 feat(nip29): account-level relay-group list state (B)
The user's joined NIP-29 groups (kind 10009, NIP-51 simple-groups), mirroring
the ephemeral-chat list infrastructure:

- commons: RelayGroupListState over SimpleGroupListEvent — liveRelayGroupList
  (Set<GroupTag>), liveRelayGroupServers (distinct host relays for the Messages-
  tab rail), follow()/unfollow() a RelayGroupChannel; joined groups stored as
  NIP-44 private items. RelayGroupListDecryptionCache decrypts once.
- AccountSettings implements RelayGroupRepository (backupRelayGroupList + get/
  update); guards on null only, since private items live in encrypted content.
- Account instantiates the state and exposes follow/unfollow(RelayGroupChannel).
- LocalPreferences persists/restores the kind 10009 backup across restarts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 20:45:35 +00:00
Claude
72755fd520 feat(nip29): subscribe relay-group channels (A)
Wire RelayGroupChannel into the shared channel subscription orchestration so an
open group streams live:

- filterMetadataToRelayGroup: relay-signed directory (39000-39003) by #d, pinned
  to the host relay.
- ChannelPublicFilterSubAssembler: RelayGroupChannel branch = messages + metadata.
- ChannelFromUserFilterSubAssembler: RelayGroupChannel branch = my messages
  (optimistic-send reconciliation).

The EOSE/subscription lifecycle (PerUniqueIdEoseManager, ChannelQueryState) is
already generic over Channel, so registering a ChannelQueryState for a group is
all a screen needs to go live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 20:37:45 +00:00
Claude
32e8a8a7ed feat(quartz): add group()/groupSet() tag helpers for kind 10009
TagArray.groups()/groupSet() parse the NIP-51 simple-groups list `["group", id,
relay, name?]` items, mirroring ephemChat's rooms()/roomSet(). Needed for the
upcoming relay-group list state (the user's joined groups + servers).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 20:29:30 +00:00
Claude
5ce9eeb77d feat(nip29): wire relay groups into LocalCache
Populate RelayGroupChannel from incoming events, mirroring the ephemeral-chat
consume path:

- relayGroupChannels cache keyed by GroupId + getOrCreate/getIfExists.
- consume(GroupMetadataEvent): store the kind 39000 addressable note and update
  the channel's name/picture/about/flags. Keyed by (serving relay + group id):
  NIP-29 events don't carry their host relay, so the channel is associated only
  when the serving relay (provenance) is known.
- attachToRelayGroupIfScoped(): route kind-9 chat + kind-1068 poll events that
  carry an `h` tag into their group's channel timeline, on top of normal consume.
- Dispatch GroupMetadataEvent and the group-aware ChatEvent/PollEvent branches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 20:26:51 +00:00
Claude
6b957e1950 perf(graperank): park slow relays in the background instead of blocking rounds
The crawl was round-synchronised: each hop drained all its relays and only
started the next hop after the slowest one reached EOSE or the timeout. That
made waiting for slow-but-alive relays expensive — every hop paid its slow
tail before the next hop's fast relays could begin — so a long timeout for
completeness cost ~2x wall-clock (measured), and a short one dropped the slow
relays' data.

Diagnostics on a ~190k-user crawl showed the genuinely-slow set is a stable
~30 relays that DO reach EOSE, just in 5-25s. So decouple the two concerns:

- drainGated now drains on the FAST `timeoutMs` that sets the round cadence. A
  relay still streaming when it elapses is not cut but PARKED: it hands its
  open subscription to a background scope (releasing its AdaptiveRelayLimiter
  permit so the round moves on), keeps receiving for up to the new
  `parkTimeoutMs`, and its late events are persisted + its late contact lists
  pushed to a crawl-wide lateHarvest channel.
- The round loop folds late harvest into the graph between rounds and won't
  converge until the frontier is empty AND no relay is still parked — so the
  crawl waits for slow relays for completeness without paying that wait in each
  round's wall-clock.

Graph state stays single-writer: parked coroutines only touch the store,
seenIds, and the channel — never hopOf/done/builder. Persistence moved from a
single per-drain consumer to a shared `persist()` that fast and parked units
both call; crawl-wide dedup is now race-safe via ConcurrentSet.add's atomic
test-and-set (an id is added only after a good signature, so no duplicate
reaches the store's UNIQUE constraint and a forged copy can't suppress the
genuine one).

Also carries the --diagnose slow-relay logging (relay + filter + elapsed for
every slow/parked REQ, so a human can replay it) and keeps --drain-concurrency
at the validated default of 24 (an A/B at 64 was ~2x slower with more dead
relays). New --park-timeout flag (default 40s; set <= --timeout to disable).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-07 20:13:21 +00:00
Claude
dc4fe7500f feat(nip29): add RelayGroupChannel model + group message datasource
First feature-layer slice for NIP-29 relay groups, cloning the ephemeral-chat
(NIP-C7) pattern and reusing the existing relay-pinned send/subscribe
primitives (RelayBasedFilter + signAndSendPrivatelyOrBroadcast) — no new
transport.

- quartz: GroupId(id, relayUrl) identifier for a relay group (host relay +
  group id), mirroring ephemChat's RoomId.
- commons: RelayGroupChannel — a metadata-backed Channel (like NIP-28's
  PublicChatChannel) keyed by GroupId, deriving name/picture/about/flags from
  the relay-signed kind 39000 event and pinning relays() to the single host.
- amethyst: filterMessagesToRelayGroup / filterMyMessagesToRelayGroup
  datasource sub-assemblers — kind 9 + poll timeline scoped by the `h` tag and
  pinned to the host relay via RelayBasedFilter, mirroring the ephemeral-chat
  sub-assemblers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 20:05:35 +00:00
Claude
2620ce9a50 feat(quartz): make NIP-29 event layer a verified superset of Armada
Audited Quartz's nip29RelayGroups package against every event and tag
Armada (gitlab.com/soapbox-pub/armada) creates and reads, and closed the
gaps so Quartz can round-trip all of them.

- GroupMetadataEvent (39000): fix flag parsing. `private`/`restricted`/
  `hidden`/`closed`/`livekit` are single-element presence tags, but the old
  accessors used hasTagWithContent (requires a value) AND conflated
  restricted<->closed and hidden<->private with wrong defaults (an open
  public group parsed as private+restricted). Now each flag is a plain
  presence check. Add hasLivekit() and supportedKinds(), extend GroupStatus
  with RESTRICTED/HIDDEN/LIVEKIT, and support supported_kinds in build().
- Add GroupParticipantsEvent (kind 39004, LiveKit AV presence) and register
  it in EventFactory + KindNames.
- Add TagArray.hasTagName() presence helper in nip01Core.
- Add GroupScope.kt: hTag() builder + Event.groupId()/isGroupScoped()
  readers so kind 9 (chat), 11 (thread), 7 (reaction), 1068, 1111 etc. can
  be group-scoped via the `h` tag and read back — NIP-29 reuses these
  carriers rather than defining new content kinds.
- Add Nip29ArmadaInteropTest covering parse + build for every event with
  Armada's exact tag shapes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 19:45:26 +00:00
Claude
da2f2ad5ab docs: deep study of Armada (NIP-29) + Amethyst integration plan
Study of Soapbox's Armada NIP-29 client — event creation, chat
management, display, invites/join/leave, relay-scoping discipline —
and a concrete plan for bringing NIP-29 relay-based groups into
Amethyst, mapping onto the existing nip29RelayGroups/nip43RelayMembers
Quartz packages and the ephemChat (relay-scoped chat) UX analog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-07 19:23:40 +00:00
Claude
c9ee79beba feat(graperank): log slow/timed-out relays with their query under --diagnose
Add a --diagnose slow-relay log: every content drain that reaches its terminal
(EOSE or timeout) slower than SLOW_DRAIN_LOG_MS, or times out entirely, is
recorded with the offending relay URL, the failure/EOSE reason, elapsed ms, and
the exact filter shape (kinds + author count + first authors). This lets a human
replay that precise REQ later to understand why the relay lags. Gated on
--diagnose so there is no per-group timing/collection overhead otherwise.

Make the content-drain fan-out configurable via a new --drain-concurrency flag
(Config.drainConcurrency), replacing the DRAIN_CONCURRENCY constant. Default
stays at the validated 24: an A/B at 64 ran ~2x slower with more dead relays
(a higher global fan-out re-floods busy hubs faster than the per-relay demotion
catches up), so the flag is a probe knob, not a speedup. Client WebSocket pings
were also tried and reverted — busy-but-alive relays don't reliably pong while
their query handler runs, so pinging just cut them as dead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-07 19:10:33 +00:00
Claude
9eec6323c8 perf(quartz): don't re-query a relay that EOSE'd without a user's list
The per-user retry counter was blunt: it bumped attempts whether an outbox was
dead, timed out, or cleanly EOSE'd with no event — so a straggler kept being
re-queried against a live relay that had already definitively answered it lacks
their kind:3. Distinguish the cases: drainGated now reports the relays that fully
EOSE'd (answeredOut); the consumer records, per user, the relays that answered but
did not return their contact list (askedEmpty); routeByOutbox excludes those from
the user's candidate relays. A timed-out relay is never added (it might just be
slow — still worth a retry), only a clean-EOSE-empty one; dead relays stay pruned
as before.

Measured on --max-hops 3: redundant fetching dropped ~8% (74k -> 68k events
stored). It does NOT move the wall-clock tail, though — that tail is dominated by
timeout/dead outboxes (the retryable case), not EOSE-empty relays. The wall-clock
lever remains the timeout retry budget (MAX_OUTBOX_ATTEMPTS / drain timeout).
2026-07-07 18:21:52 +00:00
Claude
9da382b698 refactor(quartz): extract GrapeRankPublisher from the CLI command
Mirror the crawler extraction on the emit side: the NIP-85 kind:30382 card
reconcile + publish logic (existingCards read-back, rank-diff upsert, stale-card
kind:5 retraction batched under the 64KB event cap) moves out of GrapeRankCommand
into a reusable GrapeRankPublisher in quartz experimental/graperank. It takes an
IEventStore for the prior-card read-back and an injected publish function
(event + relays -> per-relay ack), so the store/relay wiring stays in the app
while the reconcile logic is reusable (e.g. by the Android app).

GrapeRankCommand is now a thin orchestrator: crawl (GrapeRankDataCrawler) ->
score (GrapeRank) -> publish (GrapeRankPublisher). The account-specific bits stay
in the CLI: operator-key derivation, the observer's kind:10040 discovery pointer,
and the operator/register/providers sub-verbs.
2026-07-07 17:56:06 +00:00
Claude
f7c3aef6fc perf(quartz): batch inserts + crawl-wide dedup in GrapeRankDataCrawler
The crawl re-verified and re-inserted the same event many times: the outbox
model mirrors each event (especially kind:10002 relay lists) across relays,
indexers, and rounds, but dedup lived in a per-drain SeenIds, so only the copies
within one drain were caught. Add a crawl-wide seen-set (thread-safe ConcurrentSet
of event ids, shared across all 24 concurrent drains and every round), checked
before verify and added only after verify so a forged copy can't suppress the
genuine one. Group-commit the store writes via IEventStore.batchInsert instead of
one transaction per event.

Measured on a from-scratch --max-hops 3 crawl: events actually verified+stored
dropped ~34% (112k -> 74k) and verify time fell in lockstep. The write path now
also reports verify/insert timing + events_stored in Stats, exposed as verify_ms/
insert_ms/events_stored on the CLI, and takes an --insert-batch knob.

Finding: with the work reduced, inserts serialize on SQLite's single writer
mutex rather than transaction count, and the crawl's wall-clock ceiling is the
drain-timeout retry tail on dead outboxes, not the disk.
2026-07-07 17:51:27 +00:00
Claude
41e88695e0 refactor(quartz): simplify GrapeRankDataCrawler after extraction review
Cleanups from a reuse/simplification/efficiency/altitude review of the crawler
extraction:

- Collapse the redundant `discovered` set into `hopOf` — a user is discovered
  iff it has a hop stamp, so the two always held the same key set. The frontier
  is now `hopOf.keys`; one fewer collection to keep in sync.
- Drop the unused `Stats.discovered` / `Stats.deadRelays` fields (no reader —
  the CLI reports rounds / relaysContacted / hopHistogram / downloadMs).
- Extract a single shared verify-then-store sink, `IEventStore.verifyAndInsert`,
  and route both the crawler and `Context.verifyAndStore` through it instead of
  each carrying its own verify + insert + UNIQUE-swallow copy.
- Fast-path the present-key hit in `ConcurrentMap.getOrPut` (jvmAndroid) so the
  crawl's hot relay-hint accumulation stops allocating a mapping-function closure
  on every call.
- Hoist the repeated `crawlStats?.hopHistogram` null-plumbing in GrapeRankCommand.
2026-07-07 16:00:39 +00:00
Claude
667358a06a refactor(quartz): extract GrapeRankDataCrawler to commonMain
The web-of-trust crawl (~400 lines: outbox routing, sharded backbone sweep,
Phase-B worker pool, relay-list discovery, report-deletion fetch, warm pool)
was making the CLI's GrapeRankCommand unmaintainably large. Move it into a
reusable, KMP-portable GrapeRankDataCrawler in quartz commonMain.

The crawler takes a NostrClient + IEventStore + AdaptiveRelayLimiter, injected
relay policy (discovery + content-fallback sets, since those defaults live in
app code, not the protocol library), and a log callback; it streams contact
lists into a TrustGraphBuilder and returns crawl Stats. GrapeRankCommand shrinks
to arg-parsing + offline load + scoring + publish + sub-verbs, delegating the
online path to the crawler.

To reach commonMain (portable to every target, incl. iOS):

- Add ConcurrentMap / ConcurrentSet expect classes under utils/concurrent, with
  jvmAndroid actuals (java.util.concurrent) and native actuals (copy-on-write
  over kotlin.concurrent.atomics.AtomicReference, mirroring ConcurrentHashCache).
  commonMain has no ConcurrentHashMap, and the crawl's producer/consumer/drain-
  worker state needs atomic getOrPut/merge plus a concurrent set.
- Move AdaptiveRelayLimiter and DrainFailure/classifyDrainFailure from cli to
  quartz commonMain (java atomics -> kotlin.concurrent.atomics, ConcurrentHashMap
  -> ConcurrentMap, System.currentTimeMillis -> TimeUtils.nowMillis, stderr -> Log).
- The gated drain (REQ-size splitting, per-relay permits, verify+store) moves into
  the crawler; Context.drain loses its now-unused gatePerRelay path.

Net: cli -1077 lines; the crawler + relay machinery are now reusable by the
Android app. Adds ConcurrentCollectionsTest; verified via JVM + commonMain
metadata compile, the wot/graperank suites, and a bounded live crawl.
2026-07-07 15:41:31 +00:00
Claude
6bbc1b7d22 fix(cli): cap kind:5 retractions at 400 a-tags to stay under 64KB events
Each addressable coordinate is ~130 bytes, so 500 pushed the deletion event to
~65KB — over the 64KB event-size cap many relays enforce (stricter than the
256KB message cap). Drop DELETE_PER_EVENT to 400 (~52KB).
2026-07-07 14:42:35 +00:00
Claude
be6ff5456d docs(cli): document graperank operator keys + publish reconciliation
README + amy usage: add the `graperank operator [status|relay|providers]`
sub-verb, update the `graperank --publish` description to the per-observer
service-key model (sign with a derived key, publish to the operator relay,
reconcile new/changed/skip/retract, cutoff rank>=2, NIP-09-drop retracted
reports), and add a 'Publishing GrapeRank scores' section explaining the
operator master, deterministic per-observer key derivation, and the kind:10040
discovery wiring.
2026-07-07 14:39:32 +00:00
Claude
840f8f3153 feat(cli): publish 30382 cards under per-observer service keys, reconciled
Rewire `graperank --publish` onto the operator-key model:

- Sign each observer's kind:30382 cards with the dedicated service key derived
  for that observer (OperatorKeys), not the account key — a stable per-observer
  identity so re-signing replaces the addressable prior card.
- Publish to the operator's configured relay(s) (new `graperank operator relay
  <url>` sub-verb; --publish-relay still overrides). Errors clearly if unset.
- Three-way reconciliation against what the provider key already published:
  upsert cards whose rank tag string changed (or are new), skip unchanged, and
  RETRACT (kind:5, same service key, addressable `a`-tag, chunked under the
  message cap) any existing card whose target is no longer publishable — dropped
  from the graph, or below the cutoff.
- Raise the default publish cutoff to rank >= 2 (drops the barely-trusted tail);
  the retract rule removes any now-sub-cutoff cards.
- When we hold the observer's key (observer == active account), publish/refresh
  their kind:10040 pointing 30382:rank -> providerPubkey at the operator relay,
  to their outbox — the pointer clients follow to find the cards.

Adds `operator [status|relay|providers]` for managing the machine's operator.
2026-07-07 14:23:26 +00:00
Claude
0deae996bb feat(cli): operator-key module for GrapeRank provider signing
A machine holds one operator master seed, independent of any amy account, stored
under ~/.amy/operator/ via the same SecretStore backend the accounts use. From it
OperatorKeys deterministically derives one service key per observer —
serviceKey(observer) = sha256(masterPriv || "graperank-provider:" || observerHex)
— which will sign that observer's kind:30382 rank cards and their retractions.

Deterministic derivation gives a stable per-observer identity (so re-signing a
card replaces the addressable prior one instead of orphaning it) and one-secret
backup (every service key re-derives from the master alone). The manifest
(operator.json) records the master pubkey, operator relay(s), and observer ->
provider-pubkey mapping — public data; only the master rides the SecretStore.
Exposed via DataDir.operatorKeys(). Wiring into publish comes next.
2026-07-07 14:18:32 +00:00
Claude
c637d1984d fix(cli): cap REQ frame size so relays don't reject oversized subscriptions
A REQ carries all of a subscription's filters in one frame, so a popular relay
routed thousands of authors produced a multi-MB frame that most relays reject
outright ("message too large (2MB > 256KB)"), silently dropping every author
in it. The gated drain now splits each relay's filters into REQ-sized groups by
total entry count (authors + ids + tag values), MAX_REQ_ENTRIES=2500 (~167KB,
under the common 256KB cap), and opens one gated subscription per group. A relay
with more authors simply gets several smaller REQs instead of one rejected huge
one. Each group carries its own subId/listener/terminal signal; per-relay
failure classification takes HARD over TRANSIENT across a relay's groups.
2026-07-07 14:11:14 +00:00
nrobi144
8bcebd886e perf(wot): parallelize Phase 2 outbox REQs + partial-result telemetry
Manual test showed the previous sequential loop over recommendations
timed out the overall budget when a well-connected account produced 23
outbox-relay recommendations (23 x 4s per-relay = 92s worst case).

Refactor: build one filterMap keyed by recommendation.relay and issue a
single client.subscribe. All relays fan out in parallel; the per-relay
EOSE gate bounds the wait regardless of set size. Phase 3 fallback
gets the same shape for consistency.

Also:
- Bump overallTimeoutMs default from 8s -> 20s (belt only; parallel
  Phase 2 makes it unlikely to trip).
- Log OVERALL TIMEOUT when withTimeoutOrNull returns null so
  reviewers can distinguish 'ran fine, no data' from 'timed out'.
- Add per-phase debug logs (start / phase1 done / phase2 recommendations
  and done / phase3 fallback and done) so the outbox pipeline is
  auditable without a debugger.

Existing 7 OutboxDispatcher tests still pass.
2026-07-07 16:56:08 +03:00
nrobi144
a7e91ac5f6 chore(desktop): log OutboxDispatcher summary per follow-set change
One-line summary log after loadKind3ViaOutbox so reviewers and manual
testers can confirm the outbox pipeline actually fired without wiring
in a full metrics collector. Shape:

  DEBUG: [WotOutbox] fetchKind3Only authors=N covered=M fallback=K
                     kind10002=X kind3=Y

Zero overhead when the log level is above DEBUG.
2026-07-07 16:47:44 +03:00
Claude
8d4dfedd68 perf(cli): skip duplicate events before verify in the gated drain
The outbox model — and especially the wide relay-list broadcast — delivers the
same event from many relays at once, and the gated drain ran a Schnorr verify
(and a store insert) on every copy before the store's UNIQUE constraint dropped
it. On a fan-out that asks hundreds of relays for the same kind:10002s, that is
hundreds of redundant verifications per event and pegged a core.

Add a per-drain SeenIds skip-before-verify to the consumer, mirroring
drainAllPages: an id is marked seen only after it verifies, so a forged copy
(valid id, bad signature) delivered first can't suppress the genuine one. Cuts
the redundant verification across the whole crawl, not just the wide sweep.
2026-07-07 13:44:23 +00:00
Claude
98709ef933 refactor(cli): use quartz DeletionIndex for retracted-report detection
Replace the hand-rolled report-id/author matching with quartz's DeletionIndex —
the same NIP-09 indexer the Android app's LocalCache uses. It keys each deletion
under the deleter's pubkey, so hasBeenDeleted(report) is authoritative only when
the report's own author deleted it, and it also handles created_at ordering (and
addressable events, for free). Deletions come from the store, which already
verified them, so they're added as pre-verified.
2026-07-07 13:31:04 +00:00
Claude
8ee8ebdb00 feat(cli): drop retracted reports via NIP-09 deletions in the graph
A report the author has since deleted should not count as a negative trust
edge. After the crawl, ask each reporter's outbox for kind:5 deletion requests
that cite the reports we gathered — #e-filtered to those report ids, so we pull
only the deletions that affect our reports, not every deletion the user ever
made. When building the graph, a report is dropped iff a kind:5 in the store
cites its id AND is signed by the report's own author (NIP-09: a deletion is
authoritative only from the event's author). Reports the reporter never
retracted are unaffected. The run reports reports_deleted.
2026-07-07 13:26:53 +00:00
Claude
dd6384b19e feat(cli): only publish a 30382 card when its rank tag string changed
Gate publishing on the exact rank TAG VALUE STRING, not a re-parsed Int. A
card carries only a `rank` tag (plus the d-tag target), and RankTag.assemble
writes `rank.toString()`, so we diff that string against the one on the newest
kind:30382 card the signing key already published (read back from the store).
An unchanged score is skipped — no new signature, no new event id — so a client
that syncs the provider's cards by id only ever downloads the ranks that
actually moved. Replaces the prior Int comparison with a faithful
what-would-be-written string diff.
2026-07-07 13:18:47 +00:00
Claude
dad703baed perf(cli): fresher relay lists, wider discovery, and busy-vs-dead pruning
Three crawl fixes:

1. Fetch kind:10002 alongside content. The content query asked only for
   3/10000/1984, so a user's freshest relay list — which lives on their own
   outbox — was never pulled from there; we trusted a possibly-stale indexer
   copy. Fold 10002 into the same fetch. The store keeps newest-by-created_at,
   so pulling it from popular relays too can't stale it.

2. Widen ensureRelayLists Tier 2. It only asked the top-30 backbone for a
   still-missing 10002. A stray relay list can sit on any one relay, so Tier 2
   now asks EVERY relay we've seen work — fired fire-and-forget on a background
   scope so the large fan-out never blocks the round; results enrich routing
   for later rounds.

3. Classify dead relays instead of striking everything the same. A connect
   TIMEOUT is a busy relay — retried, never marked dead. A HARD failure (bad
   domain, TLS misconfig, dead HTTP code — see DrainFailure/classifyDrainFailure,
   keyed on the exception type now in the failure message) is dropped on the
   first strike. Transient failures (refused/reset/unreachable, 429/5xx) keep
   the multi-strike leniency. Connect timeout raised 5s -> 7s so slow-but-alive
   relays finish the handshake.
2026-07-07 13:11:12 +00:00
Claude
59d5fc752c perf(cli): split rate-limit from subscription-count limit in the crawl
A relay pushes back for two different reasons that need two different fixes,
and treating them the same mishandles the relay:
  - a subscription-COUNT cap ("too many subscriptions", "maximum concurrent
    subscription count") is fixed by fewer CONCURRENT subs — demote the
    per-relay concurrency cap (100 -> 20 -> 10), as before;
  - a RATE limit ("rate-limited: too many messages", "burst exhausted") is
    too many subscription CHANGES per second — fewer concurrent subs don't
    help; the fix is to SPACE the REQs out in time.

AdaptiveRelayLimiter now routes each complaint to its own actuator by matching
the notice text, and adds a per-relay rate gate: a growing minimum interval
between subscription opens (250ms -> 500ms -> 1s -> 2s), enforced in withPermit
before the concurrency permit. A relay can be under both controls at once. The
snapshot reports each dimension separately.
2026-07-07 13:10:58 +00:00
Claude
6d64b7b41c feat(quartz): include exception type in relay connect-failure message
BasicRelayClient collapsed a connection failure into a message string built
from the throwable's text alone. Message text is localized and inconsistent
across platforms, so a listener can't reliably tell a busy relay (a connect
timeout) from a dead one (bad domain / TLS misconfig) from it. Always append
the exception class name (SocketTimeoutException / UnknownHostException /
SSLHandshakeException / ConnectException …), which is stable, so listeners
can classify the failure by type. Message text is preserved; the type is
added in parentheses. Updated the one test that pinned the old format.
2026-07-07 13:10:58 +00:00
Claude
9611be4135 perf(cli): stream the crawl through a worker pool, no batch barriers
Phase B drained DRAIN_CONCURRENCY batches, waited for the SLOWEST (a dead
relay's full timeout), ingested, then started the next group — so every
batch's long tail idled the whole pool, and connections were torn down and
rebuilt between groups. Replace the chunked awaitAll barriers with a
continuous producer -> workers -> consumer pipeline:

- Producer (1 coroutine) routes each author-batch by outbox and feeds a
  bounded queue (keeps writeRelayFreq single-writer, backpressured so we
  don't precompute every filter map at once).
- DRAIN_CONCURRENCY workers pull a batch, drain it, and grab the next the
  instant the drain returns — no worker waits on a slow sibling, and hot
  relays stay connected because some worker is always subscribed to them.
- Consumer (1 coroutine) ingests serially (discovered/done/builder/hopOf
  stay single-writer), now overlapped with draining instead of blocked
  behind each batch.

The four structures now crossed between producer/worker/consumer
(relayHints, attempts, deadRelays, relayStrikes) become concurrent; all
graph mutation stays single-writer on the consumer. Removes the dead-relay
timeout stalls that were serializing the crawl and cuts reconnect churn.
2026-07-07 12:25:48 +00:00
Claude
df0cf49876 perf(cli): widen OkHttp dispatcher + tighten connect timeout for the crawl
The crawl's client ran on OkHttp defaults: Dispatcher.maxRequests=64 and a
10s connectTimeout. Every relay WS-upgrade handshake is an async call through
that shared dispatcher, so 64 caps the connection-ramp width — and a dead
relay squats on a slot for the full connectTimeout, starving live relays
queued behind it (observed: only ~150 sockets open at once during an active
wave touching hundreds of relays). Raise maxRequests to 256 /
maxRequestsPerHost to 16 and drop connectTimeout to 5s so unreachable relays
release their slot fast.

This is orthogonal to REQ concurrency (bounded per-relay by
AdaptiveRelayLimiter on already-open sockets), so it can't trip a relay's
REQ rate-limit — it only speeds connection setup. The dispatcher's executor
pool grows threads on demand, and FD headroom is ample (4096 limit vs ~150
in use), so the wider cap just lets more short-lived handshakes run at once.
2026-07-07 12:13:14 +00:00
Claude
0c0a8caaff perf(cli): dial crawl fan-out back to 24 under the adaptive cap
Measured 48 against the per-relay adaptive limiter (hop-4 A/B): it demoted
the right hubs and cut rate-limited CLOSEDs further (1433 -> 501), but the
higher fan-out re-floods busy relays faster than demotion catches up — a
new dominant complaint ("max concurrent subscription count reached")
appeared and download_ms regressed ~11% vs the 24 baseline. Keep the
adaptive per-relay cap (it targets the misbehaving relays precisely) but
return the global fan-out to 24, where the 20/10 ladder still bites below
the global bound and wall-time stays at its best-observed value.
2026-07-07 12:05:46 +00:00
Claude
143a63c520 refactor(quartz): move GrapeRank web-of-trust algorithm into quartz
The GrapeRank engine, TrustGraph (compact int-CSR) and TrustGraphBuilder
are pure Nostr-social-graph computation over HexKeys — no UI, no Compose,
and no commons-only dependency. They're a utility for implementing the
NIP-85 rank assertions quartz already models, so they belong in quartz
rather than commons. Move commons/wot -> quartz experimental/graperank
(package com.vitorpamplona.quartz.experimental.graperank), including both
commonTest suites, and repoint the CLI import. TrustGraphBuilder was already
protocol-agnostic (takes HexKey lists; the caller does the event->edge
extraction), so nothing had to change but the package. Makes the algorithm
reusable by the Android app for spam/trust filtering without pulling in
commons.
2026-07-07 12:03:59 +00:00
Claude
de8648f3f1 perf(cli): adaptive per-relay subscription cap for the crawl
Replace the blunt global concurrency number with per-relay back-pressure.
Every relay starts generous (100 concurrent subscriptions) and is demoted
down a ladder (100 -> 20 -> 10) only when it complains about concurrency —
a CLOSED rate-limited, or a NOTICE like "too many concurrent REQs" /
"too many subscriptions" / "burst exhausted". Well-behaved relays keep
the full cap; only the busy hubs that push back get throttled, and only as
far as they keep pushing.

AdaptiveRelayLimiter registers as a RelayConnectionListener so demotions
are driven straight off the same NOTICE/CLOSED frames RelayDiagnostics
already observes, keyed by relay.url. Context.drain gains a gatePerRelay
path that opens one subscription per relay, each held behind that relay's
gate, so our concurrent subs on it never exceed its current cap. The gate
is a fair FIFO bounded semaphore whose limit can only be lowered; shrinking
below the in-use count admits no new subs until enough finish, so
concurrency converges down to the new cap.

Because a hot relay can no longer be flooded, the global content-drain
fan-out is raised (18 -> 48) to crawl the many well-behaved relays faster.
The crawl emits a relay_throttling summary (which relays were capped, and
to what) alongside relay_feedback.
2026-07-07 11:44:54 +00:00
Claude
fb0011129d perf(cli): cap crawl at ~20 subscriptions per relay
Each concurrent content drain opens exactly one subscription per relay it
touches, so DRAIN_CONCURRENCY is effectively the per-relay concurrent-sub
cap. RelayDiagnostics showed the previous value (24) blew past typical
relay limits — rate-limited=1433, "too many concurrent REQs"=1286,
"too many subscriptions"=710 — causing dropped fetches and retry churn.
Lower it to 18 so the peak (18 drain subs + 1 persistent warm-pool sub)
stays ~19, just under the common ~20 cap.
2026-07-07 11:11:32 +00:00
nrobi144
1e076c5cc2 feat(desktop): apply privacy lock to the Wallet column
Extends the messaging privacy lock to the Wallet deck column via the
same master `lockEnabled` flag (single toggle, single password) with
per-scope lock state so each route re-locks independently.

commons/ui/privacylock/
  LockScreen.kt        Shared internal composable (scope + copy)
  WalletLockGate.kt    Mirrors MessagesLockGate for scope=Wallet
  MessagesLockGate.kt  Shrunk to a 20-LOC wrapper delegating to LockScreen

desktopApp/security/
  DesktopLockScreen.kt         Shared password-input surface with optional
                               "No password set" deep-link (plan Q5).
  DesktopMessagesLockGate.kt   Now delegates to DesktopLockScreen
  DesktopWalletLockGate.kt     New; deep-links to Settings via
                               onNavigateToRelays when no password is set
  WalletFirstRunBanner.kt      Mirrors MessagesFirstRunBanner; both read
                               the single firstRunCardSeen flag (dismiss
                               once = dismissed everywhere)
  MessagesFirstRunBanner.kt    Copy updated: "Lock Messages and Wallet?"
  PrivacyLockBlurModifier.kt   Modifier.privacyLockBlurWhenUnfocused()
                               reads LocalWindowInfo.isWindowFocused;
                               applied to text nodes only (balance,
                               generated-invoice amount, QR code) — cards
                               and layout stay crisp (plan Q4).

desktopApp/ui/
  wallet/WalletColumnScreen.kt Inserts WalletFirstRunBanner at top;
                               wraps sensitive text with blur modifier.
  deck/DeckColumnContainer.kt  Wraps Wallet branch with
                               DesktopWalletLockGate; passes
                               onNavigateToRelays so the "No password"
                               branch deep-links to Settings.
  settings/PrivacyLockSettingsScreen.kt
                               Master-lock copy: "Enable privacy lock"
                               header; body mentions Messages AND Wallet
                               columns; auto-lock + caveat cards updated
                               to reference both routes.

Testing sheet: docs/plans/2026-07-07-wallet-lock-manual-testing.md
  12 manual scenarios covering cross-scope lockout, blur-on-unfocus,
  password-clear cascade, deep-link to Settings, and first-run banner
  parity across the two routes.

All existing PrivacyLockStateTest cases green + the 3 Wallet-reuse
tests from the previous commit. amethyst + desktopApp compile clean.
2026-07-07 13:33:32 +03:00
nrobi144
0a631d0730 docs(plans): mark PR #3483 fix-outbox plan completed 2026-07-07 13:32:32 +03:00
nrobi144
bb2a83c1fe feat(desktop,cli): route WoT kind-3 fetch through OutboxDispatcher (NIP-65)
Phase 3 of the outbox refactor (PR #3483, per Vitor's directive). The
WoT service's kind-3 seeding on Desktop and the `amy wot sync` verb now
go through OutboxDispatcher — index relays discover each author's
kind-10002 write relays, then per-outbox-relay REQs fetch kind-3.

Changes:

  Desktop:
    - DesktopRelaySubscriptionsCoordinator gains an inner
      OutboxCacheGateway that bridges DesktopLocalCache
      (cachedAdvertisedRelayList / consume) to OutboxDispatcher.
    - New suspend loadKind3ViaOutbox(pubkeys) method returns the
      dispatcher's Result for observability.
    - Main.kt WoT-seed effect now:
        1. gates on wotService.isDisabled to preserve MAX_FOLLOWS
           guardrail (fix 2 from Phase 1)
        2. calls loadKind3ViaOutbox instead of the direct
           loadKind3Batched on index relays
        3. keeps the 2s markReady safety net for cold-start UX
    - clear() now also clears outboxDispatcher's dedup markers.

  amy:
    - WotCommand.sync rewritten to construct an OutboxDispatcher, buffer
      events in the gateway, and persist to ctx.store after fetch
      returns (store.insert is suspending; can't call from non-suspend
      gateway callbacks).
    - --json output additively gains kind10002_received,
      outbox_covered_authors, fallback_authors, persisted keys.
    - --timeout N still supported; now maps to overallTimeoutMs.

Not in this commit (deferred to a follow-up on same PR if reviewers
want it):
  - Routing stranger-avatar kind-0 fetch through the outbox path
    (MetadataPreloader wiring is more invasive; keeps this diff focused
    on the primary WoT concern).

Plan: commons/plans/2026-07-06-fix-wot-outbox-model-and-review-fixes-plan.md
2026-07-07 13:31:41 +03:00
nrobi144
dddeae74b6 feat(wot): OutboxDispatcher — fetch kind 0/3 via each author's outbox relays
Reviewer Vitor (PR #3483): stop blasting kind 0/3 REQs at a static index
relay list. Use NIP-65: index relays discover each author's kind-10002,
then per-author kind 0/3 REQs go to that author's declared write relays.

New in commons/commonMain:
  - OutboxCacheGateway — platform-agnostic bridge to the local event
    cache. Three ops: cachedOutbox(pubkey), onOutboxDiscovered(event,
    relay), onDiscoveredEvent(event, relay).
  - OutboxDispatcher — three-phase pipeline reusing Quartz's existing
    RelayListRecommendationProcessor.reliableRelaySetFor(...) for the
    author→relay inversion + minimal-cover algorithm.
      Phase 1: REQ kind-10002 for authors not already cached, from
               index relays. Per-relay 4s timeout.
      Phase 2: reliable-relay-set → per-outbox-relay REQ for kind 0
               and/or kind 3 filtered to that relay's authors.
      Phase 3: index-relay fallback for authors that never returned
               a 10002. Preserves current behaviour on cold accounts.
    Retries the "not in kind*Succeeded and not in kind*InFlight" set so
    a zero-EOSE run is retryable on the next call.

New in DesktopLocalCache:
  - route() branch for AdvertisedRelayListEvent (kind 10002) storing in
    addressableNotes so cachedAdvertisedRelayList(pubkey) can serve
    future lookups without a REQ.
  - cachedAdvertisedRelayList(pubkey): AdvertisedRelayListEvent? — the
    gateway's peek into the cache for Phase-1 skipping.

Tests (7): cached-outbox-skips-Phase-1, Phase-1-discovers-then-Phase-2,
Phase-3-fallback-for-no-10002, cached-author-covered-when-Phase-1-hangs,
clear-releases-dedup, concurrent-EOSE-safety.

Plan: commons/plans/2026-07-06-fix-wot-outbox-model-and-review-fixes-plan.md
2026-07-07 13:26:38 +03:00
nrobi144
d0daf786b1 feat(commons): scope-parameterise PrivacyLockState for multi-route lock reuse
Genericises the messaging privacy-lock state holder so a single master
`lockEnabled` flag can drive multiple gated routes independently:

- `LockScope { Messages, Wallet }` enum added.
- `MessagesLockState` → `PrivacyLockState(scope, settings, coroutineScope)`.
  Each scope keeps its own StateFlow<LockState> + idle-timer Job; both
  scopes share the same `PrivacyLockSettings` so failed-attempt counters
  and lockout schedule stay device-global (brute-force protection).
- `LocalMessagesLockState` (single instance) → `LocalPrivacyLockState`
  (Map<LockScope, PrivacyLockState>) + `lockStateFor(scope)` accessor.
- `redactionLevel` → `dmRedactionLevel` (Kotlin-side rename; persisted
  prefs key `redaction_level_ordinal` unchanged).
- `setPasswordHashed(null)` cascades to `setLockEnabled(false)` so a
  master lock cannot stay armed without a credential to verify against.

MessagesLockGate, DesktopMessagesLockGate, MessagesFirstRunBanner,
SetPasswordDialog, and RedactionCard now read `lockStateFor(Messages)`
— behaviour-preserving. Ships 3 new PrivacyLockStateTest cases:
independent per-scope state, shared failed-attempt counter, and the
password-clear cascade.

Plan: docs/plans/2026-07-07-feat-wallet-privacy-lock-reuse-plan.md
2026-07-07 13:18:56 +03:00
nrobi144
5217035f94 fix(coordinator): retryable batched-REQ dedup and race-free EOSE aggregator
Reviewer davotoula (PR #3483) flagged two commons/relayClient issues on
FeedMetadataCoordinator that both bite the Android app once WoT is wired
there:

  5. loadKind3Batched / loadMetadataBatched marked pubkeys as sent BEFORE
     any relay EOSE'd. On flaky-network cold-starts where every index
     relay timed out, the pubkeys stayed permanently marked and WoT was
     silently empty for the whole session — the next call short-circuited.
     Fix: pubkeys enter `queuedKind3Pubkeys` / `queuedPubkeys` only after
     ≥1 EOSE; on zero-EOSE timeout they roll out of the new
     `inFlightBatched*` sets so a subsequent call retries.

  6. `val eoseReceived = mutableSetOf<NormalizedRelayUrl>()` was mutated
     from per-relay `onEose` callbacks the client dispatches on
     `Dispatchers.IO`. Concurrent `add()`/`size` on an unsynchronised
     HashSet could drop entries or throw CME, forcing the batch to wait
     the full timeout instead of firing early. Fix: `BatchEoseGate`
     funnels EOSE notifications through a `Channel` so a single consumer
     coroutine is the sole reader/writer of the `seen` set — KMP-safe,
     no `synchronized {}` or JVM-only atomics.

Tests exercise:
  - zero-EOSE timeout → retry re-fires
  - ≥1 EOSE → next call short-circuits
  - full-EOSE from 20 relays hammered from Dispatchers.IO in parallel
  - clear() releases in-flight dedup
  - same semantics on loadMetadataBatched

Plan: commons/plans/2026-07-06-fix-wot-outbox-model-and-review-fixes-plan.md
2026-07-07 13:18:38 +03:00
nrobi144
5166216e2e fix(wot): hold MAX_FOLLOWS guardrail, add close(), correct SnapshotStateMap docs
Reviewer davotoula (PR #3483) flagged three commons/wot issues that would
bite the Android app on adoption:

  2. Guardrail bypass. handleFollowSet assigned myFollows before the
     MAX_FOLLOWS check, so subsequent applyKind3 calls whose follower
     landed in the huge set fully repopulated reverseIndex/_scores —
     defeating the "skip WoT for mega-follow accounts" promise. Fix:
     check size FIRST, clear myFollows, expose a disabled StateFlow, and
     early-return handleKind3 while disabled. Guardrail also releases
     itself when the follow set later shrinks back under the cap.

  3. No teardown API. WoTService owned a writer coroutine + ops Channel
     but had no close(). On account switch a new instance was created
     while the old one leaked its writer. Fix: implement AutoCloseable;
     close() shuts the channel so writerLoop exits and post-close
     trySend calls are dropped silently. Main.kt wires it via
     DisposableEffect(iAccount) so account switch is a clean teardown.

  4. Misleading docs. KDoc claimed Snapshot.withMutableSnapshot conferred
     per-key isolation. That's a SnapshotStateMap property, not a
     withMutableSnapshot property; the wrap only coalesces an op's
     writes into a single Compose commit. Rewritten to be accurate so
     future integrators don't trust the wrong invariant.

Tests: existing guardrail test extended with isDisabled assertion, plus
new tests for guardrail-holds-under-applyKind3, guardrail-releases-when-
follow-set-shrinks, close-stops-accepting-ops, and close-is-idempotent.

Plan: commons/plans/2026-07-06-fix-wot-outbox-model-and-review-fixes-plan.md
2026-07-07 13:13:20 +03:00
nrobi144
d8961c0d75 fix(desktop-cache): eliminate accountPubkey race that could wipe follow list
Reviewer davotoula (PR #3483) flagged a P0 race in
DesktopLocalCache.consumeContactList: lastContactListByAuthor was stamped
before the self-check. During login, hydration launched on Dispatchers.IO
before Main.kt's LaunchedEffect bound accountPubkey. If the user's own
cached kind-3 hydrated first, the map got poisoned; the same event later
arriving from a relay was rejected by the createdAt gate, _followedUsers
stayed empty, and FollowAction.follow would call createFromScratch and
wipe the real follow list.

Two-part fix:

1. Reorder Main.kt so localCache.accountPubkey is set before hydration
   launches. Also clear the pubkey on logout and on account switch.
2. Belt-and-braces: consumeContactList now only stamps
   lastContactListByAuthor inside branches where we know self identity.
   When accountPubkey is null (login/hydration window), skip the stamp so
   the relay retry that arrives after bind can populate _followedUsers.

Regression tests reproduce the "hydrate before bind, replay after bind"
scenario and confirm the follow set populates on retry.

Plan: commons/plans/2026-07-06-fix-wot-outbox-model-and-review-fixes-plan.md
2026-07-07 13:09:49 +03:00
Claude
6060c50f79 perf(cli): keep a warm connection pool to the top relays during the crawl
The client's relay pool reconciles open sockets to the relays that active
subscriptions currently need, so between-round gaps (routing + contactsOf scans
> ~300ms) and niche-relay churn dropped connections we reuse every round, then
reconnected them — a TCP+TLS+WS handshake each time.

Hold a persistent do-nothing subscription (WARM_SUB_ID) open to the busiest
WARM_POOL_SIZE(20) live relays, refreshed to the current top set at each round
start (same subId → just updates the desired-relay set) and closed when the
crawl finishes. Its filter matches an impossible event id, so the relay EOSEs
immediately and streams nothing — it only keeps the socket warm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-07 04:53:38 +00:00
Claude
82f0c3cc6c feat(cli): NIP-42 auth + relay-feedback diagnostics in the crawl
Two blind spots in the crawl's relay I/O:

- No NIP-42 auth. Auth-gated relays sent AUTH, the client never answered, and
  their sub CLOSed 'auth-required' — so those outboxes served us nothing. Wire a
  RelayAuthenticator into Context that signs the AUTH challenge with the account
  key (local signer only; a remote bunker is skipped to avoid a per-relay
  round-trip storm mid-crawl). Signing with any key still unlocks relays that
  just want some auth.
- No visibility into REQ failures. Add RelayDiagnostics, a connection listener
  that tallies NOTICE frames, CLOSED reasons by NIP-01 prefix (auth-required /
  rate-limited / restricted / …), and AUTH challenges. Surfaced in the crawl's
  stderr summary and as relay_feedback in the JSON, so a failed fetch can be
  explained instead of guessed at.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-07 04:40:35 +00:00
Claude
3af168eff9 perf(cli): preserve crawl recall — wider broadcast pool, softer dead-strike
Review of the sharded-sweep restructure flagged two recall regressions vs the
removed last-mile:
- the sweep + broadcast only ever hit the top SHARD_RELAYS (10), while the old
  last-mile reached busy relays ranked 11-80 where a user's kind:3 is often
  mirrored. Broadcast the small remainder to BROADCAST_RELAYS (60) top live
  relays instead of just the rotation's 10, restoring that reach (indexers are
  intentionally excluded — they don't serve kind:3).
- MAX_DEAD_STRIKES was 2 with no recovery, so two transient connect blips
  evicted a relay for the whole run. Raise to 3 for a safety margin; drain still
  only counts hard connect failures, not slow relays.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-07 04:00:50 +00:00
Claude
6e51f9c262 perf(cli): faster graperank crawl — dead-relay pruning, higher concurrency, sharded backbone sweep
The download phase was ~90% idle, blocked on the per-wave drain timeout waiting
on dead/stalled outbox relays. Three changes:

1. Dead-relay pruning. Context.drain now reports relays that failed to CONNECT
   via a deadOut set; the crawl strikes them (MAX_DEAD_STRIKES=2) into a
   deadRelays set and excludes them from routeByOutbox and the sweep, so a wave
   stops re-paying the timeout on the same dead outboxes.
2. DRAIN_CONCURRENCY 8 -> 24, safe now that dead relays are pruned rather than
   piling up as stalled connections.
3. Sharded backbone sweep (Phase A each round): split the pending authors across
   the top-SHARD_RELAYS(10) live relays — one shard per relay, no relay gets the
   same list twice — drain all concurrently, rotate the still-missing onto
   different relays for up to SHARD_ROTATIONS(6) passes, then broadcast the
   remainder to all top relays only once it drops below SHARD_BROADCAST_THRESHOLD
   (2000). Phase B then outbox-routes only whoever the popular relays lacked. The
   old broadcast-everyone last-mile is removed (the sweep subsumes it).

Each concurrent drain uses its own dead-set (no shared-HashSet race); harvest
feeds from the drain's returned events instead of re-scanning contactsOf over
the whole missing set. Adds download_ms so the crawl phase is timed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-07 03:58:01 +00:00
Claude
35078c460d feat(cli): measure crawl/download time in graperank (download_ms)
The online crawl phase — the network-bound fetch of the whole graph off the
relays (rounds + last-mile sweep) — was untimed; only the offline store-load
had a phase timer. Add download_ms around the crawl block and fold it into the
"crawl complete" log and the JSON, so a from-scratch run reports every phase:
download -> graph build -> scoring (and, with --bench-sign, card signing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-07 02:48:49 +00:00
Claude
6a1988976e feat(cli): --bench-sign to time kind:30382 card generation (no publish)
Adds a benchmark path to `amy graperank`: after scoring, build + sign one
NIP-85 kind:30382 ContactCardEvent per scored user (rank >= --min-rank) with a
throwaway keypair, fanned out across CPU cores, and report bench_signed +
bench_sign_ms. The signed events are discarded — this measures the id-hash +
Schnorr-sign cost of emitting the full card set without touching any relay or
real identity. Complements the existing store_load_ms / graph_build_ms /
scoring_ms phase timings so the whole pipeline (load → build → score → sign) is
measured end to end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-07 02:20:42 +00:00
Claude
fd63d3843b perf(wot): score with Gauss-Seidel sweeps instead of a change-propagating worklist
The worklist re-enqueued a node's dependents on every >convergence nudge, so on
a dense graph its total work scaled with the in-degree of the churning core —
an offline score over the full ~419k-node / 19.3M-edge Vitor graph ran 640M+
node-visits and still had not converged after 32 minutes.

Replace it with synchronous Gauss-Seidel sweeps over all nodes (in-place
updates, so trust flows outward within a sweep), ending when no node moves more
than the convergence delta. Same per-node formula, same 0.0001 threshold, same
unique fixed point as NosFabrica's Brainstorm reference (which iterates the same
way) — verified byte-identical by the existing GrapeRankTest suite — but each
node is touched once per sweep instead of once per churning rater, cutting the
work by roughly the average in-degree. Progress now reports per-sweep
(node-updates + nodes-still-moving) and the CLI surfaces the sweep count.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-07 02:11:46 +00:00
Claude
d20f3d71e3 feat(cli): time the offline store-load separately from graph build
The graph_build_ms timer covered only builder.build() (the in-memory int-CSR
pack, a few hundred ms). It excluded the real pre-scoring cost: reading and
deserializing every kind:3 contact list out of the store. Add store_load_ms
(offline path) so the three phases — store load, CSR build, scoring — are each
measured and reported (stderr + JSON), instead of the load hiding behind a
misleadingly small build number.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-07 01:50:07 +00:00
Claude
f24e0f92d4 feat(cli): second-tier kind:10002 discovery over the learned relay pool
Outbox resolution used only the fixed indexer/aggregator set to find a user's
kind:10002. Users the aggregators don't carry got no relay list, so their
content couldn't be routed to their own outbox (falling back to hints / the
broad last-mile content sweep).

Add a tier-2 pass in ensureRelayLists: any pubkey still without a relay list
after the indexer sweep is retried for kind:10002 against the known-good
backbone — the busiest live relays learned from the `r` tags in everyone
else's 10002s. A user publishes their own 10002 to their own write relays,
which overlap heavily with that pool, so this recovers relay lists the
aggregators miss. Bounded to the backbone (not an unbounded fan-out to every
working relay) to avoid connection saturation; early rounds no-op until the
backbone is learned.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-07 01:23:31 +00:00
Claude
3c45a3187d feat(cli): report graperank graph-build and scoring time
Measure and surface the two compute phases of `amy graperank`: building the
int-CSR trust graph and running GrapeRank to convergence. Both are logged to
stderr ("graph built … in N ms", "scored N users in M ms") and added to the
--json result as graph_build_ms / scoring_ms, so the pure scoring cost over a
given dataset is measurable without eyeballing logs — e.g. an `--offline` pass
over a fully-crawled store times score generation with no network in the loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-07 01:03:12 +00:00
Claude
9582647238 Merge remote-tracking branch 'origin/main' into claude/graperank-wot-cli-qreg2a
# Conflicts:
#	cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt
2026-07-07 00:23:20 +00:00
Vitor Pamplona
b5d5fc2d2a Merge pull request #3489 from vitorpamplona/claude/amy-geode-fetch-sync-compare-0xlsl9
fix(quartz): fetchAllPages paging correctness + amy drainAllPages + SeenIds
2026-07-06 20:16:32 -04:00
Claude
c75e0ff1ab fix(cli): don't log benign UNIQUE-constraint dups as store failures
The SQLite backend raises a catchable UNIQUE-constraint exception when an
event is a duplicate id or an older/duplicate replaceable (kind 0/3/10000-
19999) — which the outbox model produces constantly, since each user's
replaceable is fetched from several of their write relays. verifyAndStore
was logging every one as `[cli] store insert failed`, so a full-network
GrapeRank crawl emitted ~294k spurious error lines. The store is behaving
correctly (its partial unique index + trigger keep the newest version and
reject stale copies); the FS backend simply no-ops on the same duplicates.

Suppress UNIQUE-constraint rejections (normal dedup) while still surfacing
genuine persistence failures (I/O, full disk, corruption).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-07 00:09:44 +00:00
Claude
e8f4c5f806 refactor(cli): align fetch default limit across paths; harden paging
Audit follow-ups before merge:

- amy fetch default limit is now the same on both paths: absent --limit → 100
  for plain AND --paginate (previously --paginate silently meant "unbounded").
  `--limit 0` is the explicit opt-in to drain everything (unbounded); negative
  is rejected. The effective limit is carried on the filter so both paths agree.

- drainAllPages sizes its SeenIds for CLI-scale fetches (initialSlotsPow2 = 12,
  ~64 KB) instead of the large-walk default (~16 MB eagerly allocated per fetch);
  it grows if an unbounded drain needs it.

- fetchAllPages clamps the inclusive advance to `min(pageMinTs, boundary)` so a
  misbehaving relay that answers with an event past the requested `until` can't
  push the cursor upward — the boundary dedup and termination rely on `until`
  never increasing. No-op for honest relays (they only return events ≤ until).

Verified live: default and --paginate both cap at 100; --limit 50 → 50; --limit 0
--paginate drains the full window (>100); paging tests + SeenIds tests still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YEbdqCRPkszkGCoi89RMt
2026-07-06 23:57:40 +00:00
Claude
e0ebc8fad6 feat(cli): dedup drainAllPages via SeenIds; unbounded amy fetch --paginate
Two changes to the paginated fetch path:

- Cross-relay dedup before verify. drainAllPages' single consumer now runs a
  SeenIds filter: the same widely-mirrored event arrives once per relay, and the
  repeats are dropped BEFORE the expensive Schnorr verify + store instead of
  after (they were only trimmed by FetchCommand's distinctBy). An id is marked
  seen only once it verifies, so a forged copy (valid id, bad sig) delivered
  first can't suppress the genuine one from another relay. Adds SeenIds.contains
  (peek without recording) for that check-then-add.

- `amy fetch --paginate` no longer forces a --limit. With --limit N it still
  pages up to N per relay; WITHOUT --limit it drains the whole filter unbounded
  (the filter's null limit flows straight through). Plain (non-paginate) fetch
  still trims to the default 100.

Verified live: unbounded --paginate over a ~20-min nos.lol firehose window
returns 406 (all unique, 3s) vs the old 100 cap; --limit 50 caps at 50; default
caps at 100; cross-relay fetch stays count==uniq.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YEbdqCRPkszkGCoi89RMt
2026-07-06 23:31:09 +00:00
Claude
0d6f7fd186 feat(cli): SQLite event-store backend for amy (default), FS opt-in
The FS event store writes one pretty-printed JSON file per event plus one
file per index posting (kind, author, every p-tag value). At crawl scale
this explodes: a 96k-event GrapeRank crawl produced 5.6M tiny index files
rounding up to 2.8GB on disk — only 457MB of which was actual event data.
An 8-hop crawl would blow past available disk.

Wire amy's shared store through a new StoreFactory that selects the backend
from AMY_STORE (default `sqlite`, opt into the legacy tree with `fs`). Both
implement IEventStore, so every command works unchanged. SQLite packs the
same postings into shared B-tree pages — several times smaller on disk and
the natural fit for large crawls. The two stores live side by side under
`<data-dir>/shared/` (events.db vs events-store/) so switching never
clobbers the other's data.

`amy store` maintenance verbs are now backend-aware: stat reports the total
+ disk bytes for both (kind histogram/mtime stay fs-only); scrub is a no-op
on sqlite (indexes are transactional); compact runs VACUUM on sqlite.

Verified end-to-end via the built amy image on both backends: init, notes
post round-trip (event persisted + read back), and every store verb.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-06 23:24:10 +00:00
Claude
338c9a41af refactor(quartz): make SeenIds single-writer, move to commonMain
Drop @Synchronized from add/reset/size: SeenIds is now documented as
single-writer (not thread-safe). Callers dedup across concurrent relay
producers by funneling events into one consumer that owns the instance — the
one-consumer ingest pattern used elsewhere — which keeps a single global set,
stays lock-free, and lets resize run without coordination.

With the JVM-only @Synchronized gone the class is pure common Kotlin
(LongArray + Hex.readLong), so it moves from the jvmAndroid source set to
commonMain and is now available on every target.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YEbdqCRPkszkGCoi89RMt
2026-07-06 22:52:46 +00:00
Claude
cb493a2291 feat(quartz): add SeenIds — a memory-lean event-id dedup filter
A run-scoped "already seen this id" filter for large, mostly-duplicate id
streams (a broad relay walk re-receiving the same event from many relays).
Keys on the first 128 bits of the id, sliced straight out of the hex with
Hex.readLong (table lookups, no parse, no allocation), in one open-addressed
LongArray — ~16 bytes/entry and the 64-char String is never retained, so tens
of millions of ids cost ~1 GB instead of a HashSet<String>'s ~6 GB. add() is
O(1) and synchronized.

Lives in the jvmAndroid source set (uses @Synchronized; a 40M-id walk is a
server-side concern). Ports the caller's implementation with the
parseUnsignedLong hot path swapped for Hex.readLong (~45-70 ns/op cheaper).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YEbdqCRPkszkGCoi89RMt
2026-07-06 22:38:37 +00:00
Claude
8531c6475d feat(cli): last-mile relay sweep for graperank crawl
After the outbox crawl gives up on users whose own kind:10002 relays never
answered (dead/misconfigured outboxes), take one more pass at every still-missing
user within the hop budget against the WHOLE known-good relay pool — the busiest
live relays learned from the crawl (which include the big aggregators) plus the
discovery set — instead of re-asking each straggler's broken outbox. Recovered
contact lists feed the graph and can reveal a few more reachable users, so the
sweep repeats up to LAST_MILE_PASSES times until it stops recovering.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-06 22:27:23 +00:00
Claude
ccb5912e33 feat(cli): learn a known-good relay backbone; retry unreachable users on it
Answers "do we try relays we know work from other people's lists when a user
isn't in the first set?" — now yes.

- Drop dead relays I'd added unchecked (relay.nostr.band, relayable.org,
  relay.nostr.bg); keep only ones that reply to a limit:1 query. NIP-11 presence
  is not used for liveness (it's optional).
- Learn a backbone dynamically from the crawl: tally how often each relay appears
  as someone's kind:10002 write relay, and mark relays that actually delivered
  events as live. The most-used live relays form the backbone.
- Route retried users (whose own outbox already failed) and outbox-less users to
  outbox + backbone, since popular relays usually hold a copy of their kind:3.

Effect for Vitor (--max-hops 3): hop-3 coverage rose from ~112,600 to 146,413
(95% of Brainstorm's 153,409), as round-3 contact-list fetches more than doubled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-06 22:13:22 +00:00
Claude
07a9b2d116 feat(cli): widen graperank 10002 discovery + log slow relays on timeout
Fixes the hop-2 (and general) completeness undercount, and makes the cause
observable.

- Add a broad set of aggregator + big general relays (relay.nostr.band,
  relay.damus.io, snort, offchain.pub, relayable.org, …) to the kind:10002
  discovery set. Effect: for Vitor, hop-2 discovery went from ~16,980 to 19,886
  (~83% -> ~98% of Brainstorm's 20,332).
- `Context.drain` gains a `diagnoseSlow` flag: on a timeout it logs which relays
  stalled and why — slow (no EOSE, with the event count they did send) vs
  cannot-connect (with the failure reason) vs closed. `amy graperank --diagnose`
  turns it on. This showed the remaining misses are relay-side: users advertise
  dead/misconfigured write relays (HTTP 404/530/503, "Unexpected response",
  read/connect timeouts), not anything blocking on our end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-06 21:56:25 +00:00
Claude
5c016fc2d4 fix(quartz): fetchAllPages must not drop events at page boundaries
fetchAllPages advanced with `until = oldest - 1` (exclusive) and no dedup. That
skips any event sharing the boundary second that didn't fit in the page — which
happens at *every* page boundary landing inside a second, not just pathological
dense ones — silently dropping events. An in-process probe with no second denser
than the relay's page cap still lost one event straddling the boundary.

Page inclusively now: `until = oldest created_at of the previous page`, and drop
the re-fetched boundary events by id. The dedup set is bounded to just the current
boundary second (`until` only decreases, so duplicates can only recur there), so
memory stays O(one second), never O(total).

A single second denser than the relay's page cap can't be drained (its tail is
unreachable — no client-side fix; raising the request limit is futile since we
already send one above the relay's cap). Once a page yields nothing new we step
strictly past that second so paging keeps progressing to older events instead of
stalling forever.

Tests: boundary-straddle retrieves all 6 (was 5); dense-second-beyond-cap steps
past without stalling and still delivers the neighbours. Verified on live relays
(strfry / nostr-rs-relay / khatru): ground-truthing each dense internal second
against the paginated set shows no gaps, incl. a 36-event second fully retrieved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YEbdqCRPkszkGCoi89RMt
2026-07-06 21:53:48 +00:00
Claude
a2ab9876f4 feat(cli): track and report graperank crawl hop distance + --max-hops
Stamp each discovered user with its follow-graph hop distance from the observer
(observer=0, a user's fresh follows = its hop+1). Report the per-hop histogram
on the crawl-complete line and as `max_hop_reached` / `users_by_hop` in --json,
and add `--max-hops N` to bound how deep the crawl fetches (deeper users still
appear as follow targets). Brainstorm's graph for an observer saturates within
~8 hops, so `--max-hops 8` matches its scope.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-06 21:40:17 +00:00
Claude
d91673bb34 perf(wot): compact int-CSR trust graph + freshness pass; stream into it
Point #1 (freshness): each run now fetches every discovered user's LATEST
kind:3/10000/1984 once from their outbox (grouped by write relay in
routeByOutbox; empty-tagged relays already count as write), instead of skipping
users whose list is already cached. `done` still guarantees once-per-run and
that a fresh download isn't repeated.

Point #2 (memory): replace the HexKey-keyed, TrustEdge-object graph with a
compact representation that scales to the whole network:
- commons/wot: pubkeys interned to dense Int ids; edges stored in two CSR
  IntArray layouts (by target for scoring, by source for the worklist), each
  incoming entry packing source id + relation into one int. GrapeRank.compute
  returns a DoubleArray by node id (no boxed map at millions of nodes).
  TrustGraphBuilder is now stateful/streaming (addFollows/addMutes/addReports).
  Tests rewritten; the full-sweep cross-check still passes.
- cli: contact lists stream straight into the builder as they arrive and the
  Event is discarded — the crawl never holds millions of kind:3 objects. Mutes
  and reports (far fewer) are fed from the store. Output de-interns the top-N.

Validated: a fresh online run built a 108,961-user / 1.67M-edge graph and
scored 83,613 users in a 4 GB heap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-06 21:21:48 +00:00
Claude
aa8412630e refactor(quartz): collapse fetchAllPages to a single active-filter list
The search-single-page logic left two lists with different roles: the listener
counted matches over the full `pagedFilters` (including a search filter already
dropped from paging) while the subscription only sent `remainingFilters`. That
worked — the dropped filter's count was unused and `advancesCursor` kept its
hits off the cursor — but it read as if a non-subscribed filter still mattered.

Collapse to one `activeFilters` list (index + filter) that is both what we
subscribe and what the listener iterates, so counting can't drift from what was
asked. Behavior is identical; the multi-filter and search tests still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YEbdqCRPkszkGCoi89RMt
2026-07-06 20:42:34 +00:00
Claude
e63ca5d637 fix(cli): graperank skips contact lists already in the store
Two related fixes so a warm/shared store isn't re-downloaded every run:

- Only DOWNLOAD contact lists we don't already have. Each round now splits
  pending users into "already in the store" (expanded from disk with zero
  network) vs "need to fetch" (routed to their outbox). Verified on a warm
  store: round 2 pending=250 -> cached=236, downloaded=7; round 3 pending=19491
  -> cached=15827, downloaded=87.
- Build the trust graph from the store (kind:3/10000/1984 query) instead of the
  in-run `collected` list, so cached-and-skipped lists still contribute their
  edges. Online and offline paths now share the same graph source.

No behavioural change on a cold store (nothing cached -> download everything
once), and the crawl still runs to full graph depth with no user cap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-06 20:35:51 +00:00
Claude
590731f356 feat(cli): add Context.drainAllPages + shared fetchAllPagesFromPool accessory
Amy's one-shot queries all go through Context.drain, a single REQ drained to
EOSE — so a relay that caps its REQ response (strfry's per-REQ limit, ~500)
silently truncates the result with no way to page past it.

Extract the per-relay fetchAllPages fan-out that already lived privately in
EventSync into a reusable quartz accessory, fetchAllPagesFromPool: a
sliding-window pool (maxConcurrentRelays) that paginates each relay on its own
`until` cursor, tags every event with its source relay, and does not dedup
across relays. EventSync now delegates to it (its private downloadPool/
downloadFromRelay are deleted — no behavior change: perRelayFilters is already
ordered by and complete over the relay list).

Add Context.drainAllPages, the paged sibling of drain: same verify+store and
per-relay tagging, but fully draining sets larger than one REQ. Wire it into
`amy fetch` behind --paginate/--all (filter mode only), pushing the limit into
the filter so paging stays bounded. sync (NIP-77) and fetch stay separate
interfaces.

Tests: fetchAllPagesFromPool fan-out/tagging/no-cross-relay-dedup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YEbdqCRPkszkGCoi89RMt
2026-07-06 20:20:57 +00:00
Claude
54c8cefe69 fix(quartz): don't time-walk a search filter in fetchAllPages
NIP-50 search results are ranked by relevance, not created_at, so paging a
search filter by an `until` cursor silently degrades a top-N search into a
full time-walk of the corpus — and never terminates against a relay that
runs FTS over its whole corpus regardless of `until`.

fetchAllPages now queries a `search` filter on its first page only: it is
dropped from every later page and its hits neither advance nor drag back the
`until` cursor that co-resident non-search filters page with. onNewPage also
moves below the empty-page break so it never announces a page that isn't
fetched. Adds a test proving a search filter returns a single relay page
while a plain filter over the same capped relay still pages through the set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YEbdqCRPkszkGCoi89RMt
2026-07-06 19:58:11 +00:00
Claude
8f7b9cf591 fix(cli): batch graperank outbox fetches so contact lists actually download
A live run against Vitor's ~110k-user WoT exposed the crawl's real bottleneck:
draining every pending user's outbox in one subscription saturates connections
and times out. Round-by-round evidence — 250 users queried downloaded 205
contact lists (82%), but 17,055 queried downloaded only 137 (0.8%). Net: 93k
outboxes found but only ~14k contact lists pulled, so most users had no
outgoing edges and scores came out far below Brainstorm's.

Fix: fetch content in bounded batches (USER_BATCH=256) drained a few at a time
(DRAIN_CONCURRENCY=8). Routing (store reads) runs serially; only the drains run
concurrently — inserts serialize on the store write lock, so that is safe.
kind:10002 discovery stays a bulk indexer query (they aggregate 10002 and
handle bulk author filters fine); only the per-user outbox fan-out is batched.

Effect on the same graph: round 3 went from 137 → 4,157 contact lists
downloaded (+36k events), and users scored after 3 rounds jumped 34,976 →
109,760. Full-run parity verification against the live Brainstorm set is in
progress.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-06 19:40:54 +00:00
Claude
3f503f0d04 refactor(cli): drop graperank --max-attempts, hardcode 3 retries
The per-user outbox retry bound doesn't need to be tunable — replace the
--max-attempts flag with a MAX_OUTBOX_ATTEMPTS = 3 constant. Same behaviour,
one fewer knob. Updates usage text, README, and the parity doc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-06 18:51:01 +00:00
Vitor Pamplona
4828ba70f4 Merge pull request #3488 from vitorpamplona/claude/hex-bit-slice-methods-ra35l0
Add efficient hex-to-Long conversion utilities for Nostr IDs
2026-07-06 14:30:17 -04:00
Claude
4ec3da1e86 feat(cli): exhaustive graperank crawl — no user cap, check every outbox
Replace the depth-limited, user-capped BFS with a completeness loop that runs
until every discovered user's kind:10002 outbox has been checked and their
latest kind:3/10000/1984 pulled from it:

- Delete the --max-users cap entirely.
- Crawl round by round until the pending set (discovered minus done) is empty.
  A user is "done" once we download its contact list, or after --max-attempts
  (default 3) failed tries of its outbox — so an unreachable outbox can't stall
  the crawl, and it still terminates on a finite graph.
- --max-rounds replaces --max-depth as an (unbounded by default) safety backstop.
- Track and report the pool of relays actually contacted (relays_contacted),
  the "running relays" we connect to as more outboxes are discovered.

JSON: `depth_reached` -> `crawl_rounds`, add `relays_contacted`. Per-round and
final crawl-summary progress on stderr. Local regression: scores unchanged
(rank 26); the crawl retries contact-list-less users then terminates cleanly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-06 18:23:10 +00:00
Claude
e40d8df4d0 feat: add fast hex-to-Long slicing to Hex
Add Hex.toLong64/toLong128/toLong256 (plus the shared readLong helper) to
pack the first 64, 128 or 256 bits of a hex string into a single Long, two
Longs or four Longs. Big-endian, allocation-light, branch-free — 16 table
lookups and shifts per word. Useful as cheap map/set keys or bucket hashes
for 32-byte event ids and pubkeys without decoding to a ByteArray.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019CU1wR6NvQmdmNNsPe9GuN
2026-07-06 18:21:21 +00:00
Claude
dd86b617c1 fix(cli): route graperank content to outboxes, indexers only for 10002
Correct the injector's relay model: indexer relays (purplepag.es, coracle, …)
aggregate kind:10002 (and kind:0) for the whole network but do NOT serve
kind:3/10000/1984. Those live only on each user's own outbox.

- Split the relay sets: `relayListDiscoveryRelays` (bootstrap + event-finder +
  indexers) is used only to locate kind:10002; `contentFallbackRelays`
  (bootstrap + event-finder, no indexers) is the best-effort fallback for
  content when a user's outbox is unknown/down.
- Content is fetched from each user's outbox write relays, with harvested
  relay hints and general relays as fallback — never indexers.

Also add progress status (all on stderr, stdout stays the JSON contract):
- loading already logs per-hop frontier/recovered/new/total counts;
- "graph built: N users, E edges; scoring…" and "scored N users" bracket the
  calculation;
- GrapeRank.compute gains an optional (visited, scored, queued) progress
  callback, wired to emit a scoring line every 5000 worklist visits so a large
  graph shows movement instead of hanging silently.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-06 18:05:17 +00:00
Claude
135390ff66 feat(cli): broaden graperank injector for full graph discovery
To match Brainstorm's full-graph ingest, the crawl now discovers data through
three tiers (mirroring the app's pickRelaysToLoadUsers) instead of just the
outbox + a small fallback:

- Indexer relays (purplepag.es, coracle, …) join the discovery set. They serve
  kind:0/3/10002 for the whole network and are where a stranger's relay list
  and contact list are actually found — the biggest completeness lever.
- Per-follow relay hints are harvested from the `p`-tag hints in every contact
  list we crawl and used as a discovery tier below each user's kind:10002.
- A per-hop retry pass re-queries any frontier member whose contact list still
  didn't arrive (no kind:10002, or its outbox was unreachable) against the
  indexer + hint set, recovering users the outbox model alone would miss.

This tightens the only real source of score divergence from Brainstorm — data
completeness — since a signal's weight scales by the rater's influence, so the
users that matter are exactly the in-graph ones this crawl now reaches more
reliably. Local regression check: scores unchanged (rank 26 for direct follows).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-06 17:33:28 +00:00
Claude
1f6a11c59c docs(cli): analyse Brainstorm GrapeRank service for score parity
Analysis of NosFabrica/brainstorm_graperank_algorithm (Java scoring worker)
and NosFabrica/brainstorm_server (Python orchestration) to confirm amy's
scores match the reference GrapeRank service.

Finding: our commons/wot formula and every scoring parameter are already
identical to Brainstorm's DEFAULT preset (attenuation 0.85, rigor 0.5,
follow 1.0/0.03, from-observer 0.5, mute/report -0.1/0.5, delta 0.0001). Our
`score` is exactly their ScoreCard `influence`. Remaining divergence is data
completeness, not math — and because a signal's weight scales by the rater's
influence, only in-graph raters move a score, which our outbox crawl already
captures. Documents the pipeline, side-by-side params, divergence sources,
and follow-ups (presets, influence/verified fields).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-06 17:14:24 +00:00
Claude
1b78dfec57 feat(cli): add NIP-85 provider discovery to graperank (kind:10040)
Publishing kind:30382 rank cards is only half of NIP-85 — clients also need
the kind:10040 TrustProviderListEvent to discover which key provides which
assertion, and where. Add the discovery layer as two sub-verbs:

- `amy graperank register [PROVIDER]` — append a ServiceProviderTag
  (default `30382:rank`, self, first outbox relay) to the account's kind:10040,
  fetching the freshest list first so existing providers are preserved.
  Idempotent, supports `--service KIND:TAG`, `--relay`, and `--private`.
- `amy graperank providers [USER]` — list a user's declared providers
  (cache-first; own private entries are decrypted and included).

Bare `amy graperank [OBSERVER]` still computes scores; the dispatcher only
peels off the `register` / `providers` words. All built on quartz's existing
`TrustProviderListEvent` / `ServiceProviderTag` / `ProviderTypes`.

Verified against a local geode relay: register creates the 10040 and is
idempotent on re-run; providers lists both a public 30382:rank entry and a
private 30382:followers entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-06 16:14:48 +00:00
Vitor Pamplona
c509014cf6 Merge pull request #3486 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-06 12:09:36 -04:00
vitorpamplona
4eba1b3e32 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-06 15:13:00 +00:00
Claude
9045235802 feat(cli): skip republishing unchanged graperank cards
Previously `graperank --publish` rebuilt and rebroadcast a NIP-85 kind:30382
ContactCard for every scored user on every run, minting a new event id and
created_at even when the rank was identical — pure churn for a parameterized-
replaceable event.

Read back the ranks we last published from the account's own kind:30382 cards
in the local store (ctx.publish already persists them) and publish only the
targets whose rank is new or changed. Report the count left alone as
`skipped_unchanged`.

Verified against a local geode relay: first run publishes N cards
(skipped_unchanged=0); an immediate re-run with identical ranks publishes 0
(skipped_unchanged=N).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-06 15:11:28 +00:00
Claude
7b7c553331 refactor(cli): drop graperank --target and the signal-toggle flags
- Remove `--target USER`: the command already emits the full ranking, and a
  single-user lookup is a trivial slice of it.
- Remove `--no-mutes` / `--no-reports` and the include* parameters on
  TrustGraphBuilder.build. GrapeRank is defined over follows, mutes and
  reports together; scoring with a signal disabled isn't a meaningful WoT.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-06 15:11:27 +00:00
Claude
e6291fa912 feat(cli): add GrapeRank web-of-trust calculator (amy graperank)
Bring the GrapeRank algorithm into Amethyst as a WoT service calculator
on the CLI.

commons/wot (protocol-agnostic, CLI-safe, reusable by the apps):
- TrustGraph / TrustEdge / TrustRelation — pubkey-keyed graph model.
- GrapeRank — single-observer scoring engine, a faithful port of the
  reference v3 TargetedBFS variant using a worklist that reaches the same
  fixed point a full sweep would while only touching reachable users.
- TrustGraphBuilder — pure kind:3 / kind:10000 / kind:1984 events -> graph
  (latest-replaceable-per-author, dedup, self-edge drop).
- Unit tests: hand-computed values plus an adversarial full-sweep
  cross-check over 50 random graphs.

cli: `amy graperank [OBSERVER]` crawls the follow/mute/report graph via the
outbox model (locate each user's kind:10002 write relays, then fetch their
lists from their own relays, with a broad event-finder fallback) until no
new users appear, scores it, and prints a ranked list (text / --json).
--target queries one user, --offline scores from the local store, and
--publish writes NIP-85 kind:30382 ContactCard assertions
(rank = round(score*100)) per user at or above --min-rank.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWk2ZMrGBSr4WenKgwqmbB
2026-07-06 15:11:27 +00:00
Vitor Pamplona
ec4e5324ba Merge pull request #3485 from vitorpamplona/claude/relay-settings-parity-d5ocwr
cli: full relay-settings parity for `amy relay` (noun-first, all list kinds)
2026-07-06 11:10:30 -04:00
Claude
5aae4368da refactor(cli): noun-first amy relay, outbox/inbox NIP-65 facets
Restructure `amy relay` from verb-first `relay add URL --type T` to noun-first
`relay <noun> <verb>`, matching amy's `marmot group …` / `cashu mint …`
convention. The relay-list type is now a required path segment (no implicit
default), and a bare noun lists that bucket.

NIP-65 (kind:10002) is fronted by two facet-nouns, `outbox` (write) and `inbox`
(read), replacing the `--marker` flag. They edit the single 10002 event and
apply the spec's merge rules:
- outbox add R on a read-only R  → both
- inbox  add R on a write-only R → both
- outbox remove R on a both-R    → read  (stays in inbox)
- inbox  remove R on a both-R    → write (stays in outbox)
- dropping the last facet removes R entirely
`relay nip65` shows the combined view; `nip65 remove`/`clear` edit the whole
event.

Other buckets are noun+verb: `relay dm|key-package|search|private|blocked|
trusted|proxy|indexer|broadcast|feeds <add|remove|set|clear|list>`. `set` needs
≥1 URL; `clear` empties. `relay add|remove URL` (no noun) stays as the
transport fan-out (nip65 both + dm + key-package).

BREAKING (cli --json/args): removes `relay add/remove/set --type T` and
`--marker`; `relay list` overview now keys nip65 as `outbox`/`inbox`/`nip65`
and the DM bucket as `dm` (was `inbox`). In-repo harnesses updated
(cache/dm/marmot setup drop `--type all`; cache T5 asserts `.dm`).

Verified end-to-end: merge semantics, encrypted NIP-51 round-trips, facet
set/clear, fan-out, aliases, and error paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EjHzNewJ2sfBGCcSwe35Mc
2026-07-06 15:02:28 +00:00
Vitor Pamplona
b4c18d5efb Merge pull request #3482 from davotoula/feat/ps1-save-kind-38192
render PS1 memory-card saves over nostr (kind 38192)
2026-07-06 10:43:46 -04:00
Claude
c7bde868e1 feat(cli): require --clear to empty a relay bucket
`relay set --type T` with no URLs is now rejected (bad_args, exit 2) instead
of silently wiping the list — a bare empty `set` is almost always a shell
variable that expanded to nothing. Emptying a bucket is explicit: pass
`--clear` (mutually exclusive with URLs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EjHzNewJ2sfBGCcSwe35Mc
2026-07-06 14:30:08 +00:00
Vitor Pamplona
436d0d93e8 Merge pull request #3484 from nrobi144/fix/desktop-sidebar-nav-overlay
fix(desktop): sidebar nav replaces detail overlay + guard RelayLatencyTracker.sweep against CME
2026-07-06 10:21:36 -04:00
Claude
0319809b8c feat(cli): full relay-settings parity for amy relay
Expand `amy relay` from the 3 transport lists (nip65/inbox/key_package) to
every relay-list bucket Amethyst's relay-settings screen manages, and add
remove/set verbs alongside add/list.

Buckets (kind): nip65 (10002, read/write markers), inbox/dm (10050),
key_package (10051), search (10007), private (10013), blocked (10006),
trusted (10089), proxy (10087), indexer (10086), broadcast (10088),
feeds/favorites (10012). The private NIP-51 lists are signed NIP-44-encrypted
via the quartz event factories, exactly like the app. Local relays (device
pref, no event) and named relay sets (30002) are intentionally out of scope.

New/changed commands:
- `relay add URL --type T [--marker read|write|both]` — `--marker` sets the
  nip65 role; `all` still means nip65+inbox+key_package.
- `relay remove URL --type T` — new.
- `relay set --type T [URL…] [--marker …]` — new; replace a whole bucket
  (no URLs clears it).
- `relay list [--type T]` — lists every bucket, or one.
- `relay publish-lists` — now broadcasts every configured list.

Thin-assembly only: buckets are a small registry over the existing quartz
`create`/`relays` factories; adds one generic `Context.latestReplaceable`
helper. `--json` is additive — legacy keys (`nip65`/`inbox`/`key_package`,
`nip65_event_id`/…) are unchanged, so the existing test harnesses keep passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EjHzNewJ2sfBGCcSwe35Mc
2026-07-06 13:59:47 +00:00
nrobi144
57a64d258d fix(commons): guard RelayLatencyTracker.sweep against concurrent writes
`putPending` stores each relay's pending map as
`Collections.synchronizedMap(LinkedHashMap(...))` and wraps every writer
path (`putPending`, `recordSent`, `recordIncoming`, `recordDisconnect`)
in `synchronized(perRelay)`. `sweep` iterated `pending.entries.iterator()`
without taking the same lock, violating the wrapper's Javadoc contract.

Any concurrent websocket-thread write during `RelayHealthStore.reclassify`'s
sweep threw `ConcurrentModificationException` on the underlying
`LinkedHashMap$LinkedHashIterator`. Because `reclassify` schedules sweep
on the AWT dispatcher, the CME killed Amethyst Desktop's Compose render
thread and froze the UI.

Wrap both inner iterator loops in `synchronized(pending) { ... }` — the
exact synchronization the wrapper's Javadoc prescribes for manual
iteration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-06 16:08:18 +03:00
nrobi144
6361c54a4e fix(desktop): sidebar nav replaces detail overlay instead of hiding behind it
On Desktop, tapping a sidebar nav item while a detail screen (profile,
thread, article, editor) was open only mutated the sidebar destination.
The opaque `AnimatedContent` overlay driven by `ColumnNavigationState`
kept covering the (already-swapped) root content until the user hit
Back, creating the impression that the click did nothing.

Fix: emit a `clearOverlaySignal` from `SinglePaneState.navigate` and
`DeckState.focusExistingColumn`. Each layout collects the signal in a
`LaunchedEffect` and calls `navState.clear()`, draining any pending
detail stack so the tapped destination is what the user actually sees.

- SINGLE_PANE: one signal (Unit), one layout-local `navState`.
- DECK: signal payload is the column id; each `DeckColumnContainer`
  filters on `column.id`, so only the focused column's detail clears —
  other columns' navigation stacks are preserved.
- Same-item taps also clear (signal fires unconditionally, unlike a
  StateFlow value comparison).
- `onOpenSettings` uses the same navigate / focusExistingColumn paths
  and inherits the fix automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-06 16:08:06 +03:00
nrobi144
6ff2e54212 refactor(desktop): move Index Relays UI to Relays dashboard, match sibling-editor UX
Relocates the shared index-relays editor off the Configure/Settings
screen and into the Relays column's Configure tab as a 6th collapsible
section next to Connected / NIP-65 / DM / Search / Blocked relays —
where users already look for relay-list editing.

Rewrites the section to match the SearchRelayEditor pattern: local
SnapshotStateList buffer seeded from the persisted set, OutlinedTextField
with a compact IconButton(Add), per-row Close remove, Enter-key add,
plus a Save button that commits the buffer to PreferencesIndexRelays and
a Reset-to-defaults button that reseeds the buffer with the 4 built-in
defaults. Adds a savedMessage toast noting the 'restart to apply' caveat.

File moved: desktop/ui/settings/IndexRelaysSection.kt →
desktop/ui/relay/IndexRelaysEditor.kt (matches the *Editor.kt sibling
naming convention).
2026-07-06 09:53:28 +03:00
nrobi144
fe22de0817 feat: shared index relays across Desktop and amy + settings UI
Unifies the "index relays" set (used for kind 0 profile metadata and
kind 3 follow list REQs) across the Desktop app and the `amy` CLI so
they always compute WoT scores against the same data source, and adds
a user-configurable settings section for the list.

Before this change:
- Desktop hard-coded `DefaultRelays.RELAYS` at coordinator
  construction; users could not override.
- `amy wot sync` used `outboxRelays().ifEmpty { inboxRelays() }` —
  NIP-65 write / DM inbox relays, which are semantically different
  from index relays. `amy wot get` after `amy wot sync` could return a
  different score than the Desktop UI would compute.

New `PreferencesIndexRelays` (commons/jvmMain) is a tiny class backed
by `java.util.prefs.Preferences.userRoot().node("com/vitorpamplona/amethyst/relays/index")` —
the same JVM-user-scoped shared-node trick `PreferencesHashtagSpamSettings`
already relies on. Both Desktop and amy running as the same OS user
observe the same value with zero extra plumbing. App-global (not
per-account); users typically have one preferred index-relay set
regardless of which account is logged in.

Behaviour changes for users who never open the settings UI: none.
`DEFAULT_INDEX_RELAYS` is byte-for-byte identical to the four URLs in
`DefaultRelays.RELAYS`.

Wiring:
- `DesktopRelayCategories` gains a straight-through `indexRelays`
  StateFlow (no combine — index relays are a curated user choice, not
  a NIP-65-derived set) plus `setIndexRelays(new)`.
- `Main.kt` instantiates `PreferencesIndexRelays` at App() root and
  passes it into both the subscriptions-coordinator constructor and
  `DesktopRelayCategories`. Coordinator snapshots the effective set
  at construction — changes take effect on next relaunch (documented
  in the settings section explainer).
- `Context.indexRelays()` reads the same preferences node so
  `WotCommand.sync` produces identical relay batches to Desktop.
- New `IndexRelaysSection` composable in
  `desktopApp/.../ui/settings/` — list + per-row remove + add-row
  with URL normalisation. Deletion of all entries falls back to
  defaults (delete-all is the reset — no separate "Reset" button).
  Placed between the Local Relay and Content Filters sections of the
  Relays settings screen.

Tests:
- `PreferencesIndexRelaysTest` — defaults fallback, round-trip
  persistence, blank-token skipping, non-empty defaults guardrail.
- Full existing test suites remain green.

Companion PR (search-result badges) landed on `feat/wot-search-badges`
and is this branch's parent. Both remain stacked on the WoT feature
branch pending upstream review.

Plan: docs/plans/2026-07-01-feat-wot-followups-search-badges-and-index-relays-plan.md
2026-07-06 09:31:02 +03:00
nrobi144
afa1a3b652 feat(desktop): WoT badges on search-result person cards
Extends the WoT trust indicator to the Search screen's person-picker
results, matching the badges already shown on note-card avatars.

- `UserSearchCard` (commons) gains an optional
  `badge: @Composable (BoxScope.() -> Unit)? = null` param, forwarded
  to its embedded `UserAvatar` (which has the slot from the WoT PR).
  Default null → no visual change for callers that don't opt in;
  Android search screens continue to render as before.
- `SearchResultsList` (desktopApp) inlines the score-lookup gates in
  a small `wotBadgeFor(pubkey)` helper and passes the badge lambda at
  both person-result call sites (main list + expandable overflow).

Same visibility rules as the note-card avatar badges:
score > 0, past the 2 s startup readiness gate, and pubkey not in
`LocalSpamExemptKeys` (self / already-followed).
2026-07-06 09:22:51 +03:00
David Kaspar
b4f9da567f Merge pull request #3479 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-06 01:04:32 +02:00
davotoula
9af611faf1 fix: fetch uncached addressable thread roots; render blank PS1 blocks as empty slots 2026-07-06 00:35:58 +02:00
davotoula
836caa5cd6 Code review:
- Use the app's cached stringRes helper instead of raw stringResource,
  matching the dominant convention in ui/note/types
- Drive the icon animation from the Compose frame clock (withFrameMillis)
  instead of a delay loop, so the ticker suspends whenever the composition
  stops drawing rather than waking the main dispatcher 4x/sec from the
  back stack
- Drop the unconsumed memoryCardId/blockState/blockHash accessors; the
  tag schema stays documented in the class KDoc
- Document the frames arrays as frozen: mutating them in place would
    silently break the @Immutable skip contract; build a new instance
    to change pixels
- Replace the API-29-deprecated Bitmap.createBitmap(IntArray, ...)
    overload with createBitmap(w, h, config) + setPixels
2026-07-06 00:35:58 +02:00
davotoula
c0cc2b0245 feat: render PS1 memory-card saves over nostr (kind 38192)
feat: animate the PS1 BIOS save icon on kind-38192 cards
2026-07-06 00:35:58 +02:00
vitorpamplona
74b8a3ad95 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-05 21:31:53 +00:00
Vitor Pamplona
567d98cbcb Merge pull request #3481 from vitorpamplona/claude/onchain-wallet-visibility-6mf589
feat: add setting to hide the on-chain (Bitcoin) wallet
2026-07-05 17:29:24 -04:00
Claude
e3fd1c53e4 feat: hide on-chain wallet from zap buttons too
Extend the showOnchainWallet preference to the zap flows, which also surface an
on-chain rail:

- the on-chain rail on each zap-amount chip in ZapAmountChoicePopup (folded into
  the shared railCapability so every caller — reaction row, ReusableZapButton —
  is covered)
- the "Send on-chain instead" hand-off button in the custom zap dialog

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UM57Rq5iUPL1SGhzhzTusa
2026-07-05 20:25:51 +00:00
Claude
d69d99a8e1 feat: add setting to hide the on-chain (Bitcoin) wallet
Some users don't want the on-chain wallet surfaced. Add a `showOnchainWallet`
UI preference (default true, so behavior is unchanged) that hides it across the
app when turned off:

- the "Bitcoin" card on the Wallet screen (OnchainSection)
- the on-chain chip on profiles (DisplayPaymentRailChips)
- the on-chain rail in the Send Payment screen

The flag lives in the existing UiSettings/UiSettingsFlow display-preferences
system (persisted to the shared-settings DataStore, alongside the other
show/hide profile toggles) and is exposed as a switch on the Profile UI
settings screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UM57Rq5iUPL1SGhzhzTusa
2026-07-05 19:24:48 +00:00
Vitor Pamplona
c27e977b7a Merge pull request #3480 from vitorpamplona/claude/negentropysynch-nip42-hang-1m4hxb
Fix negentropy refusal handling to prevent window-split storm
2026-07-05 12:09:55 -04:00
Claude
5927c1837e fix(nip77): don't treat a "blocked:" NEG-ERR refusal as an over-cap overflow
A relay that refuses negentropy with a NEG-ERR whose reason merely starts
with "blocked" (e.g. "blocked: Negentropy sync is disabled" from a relay
that has NIP-77 turned off, or an auth/ban refusal) was misclassified as a
strfry `max_sync_events` overflow by `isOverflow`. Overflow triggers
created_at window-splitting, so every split re-opened, was refused again,
and the splitter fanned out breadth-first across the whole created_at range
(~2^31 windows). The call therefore never threw NegentropySyncException (so
`negentropySyncOrFetch` never took its paging fallback) and never tripped
the idle watchdog (the relay answered every NEG-OPEN promptly), so it hung
indefinitely. A second relay whose refusal string did not start with
"blocked" fell through to `Failed` -> paging and completed, which is why the
two behaved differently despite advertising the same NIPs.

Narrow `isOverflow` to genuine "result set too large" signals only; a bare
`blocked:` refusal now maps to a hard failure and fails over to paging.

Adds a regression test driving an in-process relay that refuses every
NEG-OPEN with "blocked: Negentropy sync is disabled" while still serving
plain REQ: negentropySyncOrFetch now pages and delivers every event instead
of hanging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015UF3eh76rRiwAuPg32rwiz
2026-07-05 16:08:25 +00:00
Vitor Pamplona
4a13a15f6c Merge pull request #3478 from vitorpamplona/claude/benchrelay-1m-events-test-v6vtp0
perf(relay): geode↔strfry 1M sync — negentropy + store speedups, strfry-parity mirror, benchmark harness
2026-07-05 10:14:19 -04:00
Claude
8efe8af2dc refactor(quartz): move NDJSON import/export into Quartz as store logic
The `import`/`export` engine is pure protocol/store logic — it operates only on
the `IEventStore` interface and Quartz event types (Event, OptimizedJsonMapper,
verify, Filter), with zero geode dependency — so per the sharing philosophy
("quartz = Nostr business logic, protocol, data") it belongs in Quartz, not in
the geode app. Any Quartz consumer (a relay, the `amy` CLI, a desktop
backup/restore) can now reuse it.

- move `com.vitorpamplona.geode.ImportExport` →
  `com.vitorpamplona.quartz.nip01Core.store.NdjsonImportExport` (commonMain,
  next to IEventStore); rename for a clear library-level name.
- geode keeps only the CLI glue (verb dispatch, arg parsing, file/stdin/stdout,
  the stderr summary) in Main.kt, delegating to the Quartz engine.
- move the test into quartz jvmTest, rebuilt on Quartz's own EventFactory +
  NostrSignerSync (real Schnorr signing) instead of geode fixtures.

No behavior change — `geode import`/`export` work exactly as before (verified
end-to-end previously); this is purely where the code lives.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-05 14:10:16 +00:00
Claude
5db2543cfc feat(geode): add import / export NDJSON verbs; drop the benchmark-only server
Bulk NDJSON import/export as first-class geode subcommands, mirroring
`strfry import` / `strfry export` (one JSON event per line — the interchange
format for seeding a relay, migrating between relays, or taking a backup):

  geode import [--db …] [--no-verify] [FILE…]   # files, or stdin when none
  geode export [--db …]                          # NDJSON to stdout

Both stream — memory is bounded to one batch (import) / one event (export), so
a multi-million-event corpus round-trips in roughly constant memory. `import`
verifies signatures by default (same `Event.verify()` the relay's VerifyPolicy
uses), upholding the relay's verify-by-default stance rather than trusting the
file; `--no-verify` is the trusted-input escape hatch. Verb dispatch is
backward-compatible: a bare `geode --port …` (no verb) still serves.

This makes the benchmark-only `CorpusServerMain` redundant — a corpus source is
now just `geode import` into a DB, then a normal `geode` serve — so it's
deleted, removing benchmark-only code from the production geode artifact (the
question that started this). The 1M sync-throughput plan is updated to describe
sources via `geode import` + serve.

Also fixes a native-target CI break: MergeQueryCorrectnessTest used the
deprecated `String(CharArray)` (error-level on Kotlin/Native) — switched to
`CharArray.concatToString()`.

Verified end-to-end through the packaged `geode` binary: import (file + stdin,
--no-verify), export round-trip, and verify-on rejecting bad signatures.
ImportExportTest covers the counts, duplicate handling, malformed-line
skipping, and verify accepting a freshly-signed event while rejecting bad sigs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-05 13:56:11 +00:00
Claude
cece5b6e04 docs: sync comments/plans with the audit fixes
Follow-up to the audit fixes so nothing describes the pre-fix behavior:

- CorpusServerMain: drop the leftover "reuses an already loaded DB … skips
  the reload" comment above `val dbFile` — the sentinel-gated reuse it
  described is now spelled out in the block just below it.
- sync-throughput-1m plan: the up-catch-up now streams `negentropyReconcile`
  (publishing each onHaveIds batch) instead of materializing the full diff
  via negentropyReconcileIds; note the O(batch) memory win at 1M.
- follow-feed plan: the k-way merge dedups repeated authors/kinds, and its
  id-ASC tie-break is byte-exact vs the single-SQL path only when the store
  indexes id (useAndIndexIdOnOrderBy) — otherwise ties fall in rowid order.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-05 12:20:11 +00:00
Claude
2ce3e2bf5c Merge remote-tracking branch 'origin/main' into claude/benchrelay-1m-events-test-v6vtp0
# Conflicts:
#	gradle/libs.versions.toml
2026-07-05 12:13:37 +00:00
Claude
c863812b1e fix(relayBench): stop the corpus tools silently serving/keeping wrong events
Two benchmark-integrity bugs that could invalidate sync numbers while a run
reports success:

- CorpusServerMain keyed its serve-existing decision only on port + row
  count, so re-running a port with a different corpus/maxCount, or after a
  load crashed mid-way, silently served a stale/partial DB. Gate reuse on a
  completion sentinel keyed on corpus identity (path + byte length) and
  maxCount, written only after a full load; on any mismatch the prior DB is
  dropped and reloaded.
- CorpusDownloader treated CLOSED identically to EOSE, so a relay ending a
  sub early (rate-limit/policy) after sending a partial page advanced the
  cursor past the unsent tail — silent corpus loss. Treat CLOSED as a soft
  failure (null → reconnect and retry the same cursor; the id dedup set
  absorbs the re-fetch), distinct from EOSE which means the page is
  complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-05 04:55:37 +00:00
Claude
d567aec6de perf(geode): stream haveIds in the mirror up-catch-up instead of materializing
runCatchUpUp used negentropyReconcileIds, which builds the FULL need+have
id lists in memory even though the up direction only needs haveIds — on a
large window (e.g. 1M local events against an empty upstream) that is a
~100 MB+ heap spike per convergence round, plus a needIds list built and
immediately discarded.

Switch to the streaming negentropyReconcile: publish each haveIds batch as
it arrives (bounded to one batch of ids) and drop the need direction via a
no-op onNeedIds. The publish still suspends the reconcile round, so the
back-pressure and the reconcile-as-delivery-check convergence loop are
unchanged — only the peak memory drops from O(window) to O(batch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-05 04:55:36 +00:00
Claude
7aa6144dd1 fix(store): make k-way merge honor id tie-break and dedup repeated authors
MergeQueryExecutor's winner-picker tie-breaks equal created_at by id ASC,
but each per-stream cursor sorted by created_at DESC only, and repeated
authors/kinds opened duplicate cursors:

- id tie-break: thread the IndexingStrategy through run()/prepareStreams
  and append ", id ASC" to the per-stream ORDER BY when
  useAndIndexIdOnOrderBy is set — matching every sibling query in
  QueryBuilder. The id-indexed order comes straight off the index (no
  extra sort, lazy cursor preserved), so the merge now matches the
  single-SQL path byte-for-byte on same-second same-author events. Without
  the id index the tie stays in rowid order (a valid NIP-01 newest-N);
  documented on the class.
- dedup: streamCount/prepareStreams now operate on distinct authors and
  kinds, so a filter with a repeated pubkey can no longer open two
  identical cursors and emit each matching event twice (the single-SQL
  IN(…) path already dedups).

Adds two MergeQueryCorrectnessTest cases the suite was missing: a
within-stream same-second tie sliced by the limit, and duplicate authors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-05 04:55:19 +00:00
Claude
5dd9fc5266 docs(relayBench): add REQ-mode 1M pull results alongside negentropy
geode paged-REQ sink (verify on, FTS off):
  geode→geode  4,963 ev/s (completes)   strfry→geode  5,801 ev/s (completes)

Findings: strfry has no REQ bulk sync (stream is live-only limit:0), so the
strfry-sink REQ pairs are n/a. strfry→geode over REQ now completes (earlier
FTS-on stall was strfry killing the slow client at its 32MB pending cap; FTS-off
keeps pace). REQ is ~15-30% slower than negentropy for the geode sink, and
geode-as-a-REQ-source is slower than strfry (SQLite range scans vs LMDB).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-05 03:43:43 +00:00
Claude
59641ba1ef docs(relayBench): record the 4-pair 1M negentropy sync comparison
All four source→sink pairings via NIP-77, geode sink configured like strfry
(verify on, FTS off), run sequentially:

  geode→geode   6,971 ev/s   strfry→geode  6,690 ev/s
  strfry→strfry 2,682 ev/s   geode→strfry  2,127 ev/s

Key findings: the sink sets the rate (geode ~6.7-7.0k, strfry ~2.1-2.7k from
either source — geode ingests ~2.6-3.3x faster); full geode↔strfry negentropy
interop both directions; strfry-source count is lower because its negentropy
snapshot drops NIP-40-expired events (geode, no cutoff, offers its full set).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-05 02:57:37 +00:00
Claude
6058c9943c test(geode): CorpusServerMain serves an already-loaded DB (skip reload on re-run)
Reuse the file-backed source DB when it already holds events instead of
deleting + reloading the corpus every boot, so re-running a single sync pair
skips the multi-minute load.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-05 02:50:25 +00:00
Claude
36571cfb94 test(geode): sync-benchmark knobs + corpus-server tool for the negentropy comparison
- MirrorSyncThroughputTest: add -DsyncVerify (default true — `strfry sync` always
  verifies received events, so the negentropy sink verifies too for an
  apples-to-apples comparison) and -DsyncFts (default true; pass false to match
  strfry, which has no NIP-50). Both forwarded through the geode test task.
- CorpusServerMain: a benchmark-only tool that boots a real geode relay
  (geode's default indexing) preloaded with an NDJSON corpus over a file-backed
  store and serves forever, so `strfry sync` / another geode / the negentropy
  sink can reconcile against a geode source holding the same 1M corpus a strfry
  source does.

Used to run the 4-pair 1M negentropy sync comparison (geode↔geode, strfry→geode,
strfry↔strfry, geode→strfry).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-05 02:29:58 +00:00
Claude
4949d58d17 feat(geode): add up-direction negentropy catch-up (strfry sync --dir up parity)
strfry's `sync --dir` is bidirectional (its source: doUp = both||up,
doDown = both||down), but geode's catch-up was down-only. Add the up half so
`dir=up`/`dir=both` reconcile-and-push matches `strfry sync --dir both`.

runCatchUpUp reconciles the local set against the upstream (negentropyReconcileIds)
and publishes the events we hold that the upstream lacks (the reconcile's `have`
ids). Symmetric to the down catch-up: same one `dir`, live up-session starts at
`now` when the up catch-up covers history.

Reliability: client.publish's outbox is best-effort under a bulk burst (each
publish also churns a reconnect — measured ~1-2% dropped per pass), so the push
runs as a reconcile→push convergence loop. Each round re-reconciles — the
reconcile IS the delivery check against the upstream — and re-pushes only the
stragglers until the have-diff is empty. Test observed 3000 → 69 → 2 → 0 across
3 rounds, lossless.

Test: MirrorNegentropyCatchUpTest.negentropyCatchUpPushesUp pushes 3000 local
events to an empty (no-verify) sink and asserts all 3000 land. The four existing
mirror tests still pass (up catch-up needs a store + negentropyBackfill, both off
by default).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-05 02:01:05 +00:00
Claude
96674e7ce4 feat(geode): mirror strfry's two-phase model — NIP-77 sync catch-up + live REQ tail
geode's MirrorWorker mirrored `strfry router` (live REQ streaming) but had no
`strfry sync` equivalent, so backfilling a large foreign relay from empty could
not complete: a plain REQ dump of the history overruns the sink and strfry kills
the slow client at its maxPendingOutboundBytes cap (see
relayBench/plans/2026-07-04-sync-throughput-1m.md).

MirrorWorker now runs a one-shot NIP-77 "sync" catch-up per down/both upstream
before the live tail, using strfry's own vocabulary — one `[[mirror]]` entry,
one `dir` driving both phases:

- Catch-up reconciles the local set against the upstream over the
  [now - backfill_seconds, now] window and downloads only the diff via the
  existing INostrClient.negentropySyncOrFetch — client-paced (strfry can't
  overrun us) and it completes the pull. Reconcile-against-local means a warm
  restart re-fetches nothing it already holds, like `strfry sync`.
- Either mode, transparently: negentropySyncOrFetch auto-falls back to paged
  REQ for an upstream without NIP-77 — no config toggle.
- Live REQ tail unchanged; it starts at `now` when catch-up is on (history is
  the sync's job). The windows overlap at `now`; the store's unique-id
  constraint dedups the seam.

Changes:
- quartz: add a backward-compatible `localEntries` param to the public
  negentropySync / negentropySyncOrFetch (default empty = prior behavior) so the
  reconcile diffs against a caller-supplied local set.
- geode MirrorWorker: `runCatchUp()` (bounded, backpressured ingest; same
  trusted-scope re-check as the live path; failure is non-fatal). New `store` +
  `negentropyBackfill` ctor params; default off so existing live-REQ tests are
  unchanged. Main opts production in.
- Test: MirrorNegentropyCatchUpTest isolates catch-up from the live tail by
  preloading historical events a live-only sub cannot deliver, then proves the
  post-boot event still arrives (3000 catch-up + 1 live = 3001).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-05 01:33:47 +00:00
Claude
4c8066c54c test(geode): add negentropy strfry→geode sync — completes where REQ stalls
Adds a NIP-77 negentropy client path to MirrorSyncThroughputTest (default for
external sources; `-DsyncMode=req` keeps the paged-REQ drain). geode reconciles
its empty set against strfry, then client-paced fetch-by-ids + ingest.

Result on the 1M damus.io corpus (strfry source):
- reconcile (empty local → 995,024 need-ids): 11.4 s, 64 rounds.
- fetch + ingest: ~7,000 ev/s steady-state.
- overall: 994,936 / 997,980 in 171.0 s => 5,818 ev/s — COMPLETES.

This is the apples-to-apples counterpart to strfry→strfry's `strfry sync`
(both negentropy, same corpus, empty→full): strfry→geode 5,818 ev/s vs
strfry→strfry ~2,550 ev/s — geode ingests real content ~2.3x faster and
finishes the pull. The paged-REQ path, by contrast, stalls at ~310k every time
(strfry kills a slow REQ client at its 32 MB maxPendingOutboundBytes cap),
confirming that cross-relay bulk sync from strfry requires negentropy, not REQ.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-05 00:40:33 +00:00
Claude
8fbca75850 test(geode): measure 1M sync throughput strfry↔strfry, geode↔geode, strfry→geode
Adds a strfry→geode drain mode to MirrorSyncThroughputTest and documents the
three-way 1M-corpus sync throughput comparison.

Findings (relayBench/plans/2026-07-04-sync-throughput-1m.md):
- strfry→strfry (strfry sync / negentropy): ~2,550 ev/s, completes.
- geode→geode (MirrorWorker REQ): 13,161 ev/s, completes — but inflated by
  synthetic 4-byte content; real-content ingest is ~7,000 ev/s.
- strfry→geode (paged REQ drain, real corpus): sustains ~7,000 ev/s but does
  NOT finish — strfry hard-kills a slower REQ client at its 32 MB
  maxPendingOutboundBytes cap (~310k events in; confirmed by the source log's
  "Pending: 32.01M" disconnects). Independent of heap (2G/10G) and the live
  negentropy index (on/off).

Conclusion: strfry's REQ serving is structurally hostile to any client slower
than its scan (buffers outbound, then kills or OOMs). Only NIP-77 negentropy —
pull/reconcile-based and client-paced — completes a cross-relay bulk pull, which
is why strfry's own sync uses it. A production geode backfill from a foreign
relay should use negentropy, not the live-tail REQ path.

Also documents MirrorWorker's unbounded intake channel (a deliberate live-tail
trade) OOMing under a 1M bulk backfill, and forwards the sync* system
properties through the geode test task.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 23:55:58 +00:00
Claude
bbd8b3a596 perf(nip77): stop the live index turning bulk backfill into O(n^2)
LiveNegentropyIndex kept a sorted ArrayList and paid an O(n) element shift per
incremental insert. That's cheap for near-tail live traffic (created_at ≈ now),
but a mirror/import backfill delivers historical, out-of-order events, so every
insert memmoves ~n/2 entries and the whole sync goes O(n^2) — a geode→geode 1M
mirror crawled to <300 ev/s once the index passed ~130k, versus a sustained
~20k ev/s with the index off.

When an insert lands more than REBUILD_THRESHOLD (4096) from the tail, drop the
index instead of shifting: it rebuilds in one O(n log n) scan on the next
NEG-OPEN (liveNegentropySnapshot already does this when unpopulated), and while
unpopulated newDeltaOrNull skips delta tracking, so backfill costs O(1) per
event. Near-tail live inserts keep the cheap incremental path. NIP-77
convergence and byte-exact tests still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 22:45:43 +00:00
Claude
91a7261555 test(sync): toggle live negentropy index to isolate its O(n)-insert backfill cost
Adds -DsyncLiveIndex and a fast (no-live-index) source preload. Rate curves show
geode's mirror sustains ~20k ev/s with the live index off, but collapses to
O(n^2) with it on: the LiveNegentropyIndex is a sorted ArrayList whose per-event
insert is O(n), and a backfill delivers historical (non-near-tail) events, so
every insert memmoves ~n/2 entries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 22:41:13 +00:00
Claude
847d5f47e6 test(sync): use geode's real RelayIndexingStrategy + live rate logging in throughput test
Default EventStore(null) tokenizes FTS synchronously on every insert, which
dominates ingest and misrepresents mirror sync throughput. Use geode's actual
RelayIndexingStrategy (deferred FTS, live negentropy index) for both source and
sink, and log instantaneous events/s every 3s so the rate is visible during the
run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 22:34:23 +00:00
Claude
334c4b622c test(sync): use an OS-assigned port for the throughput source to avoid bind clashes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 22:03:40 +00:00
Claude
6095e5768e test(sync): 1M sync-throughput harness for strfry↔strfry, geode↔geode, strfry→geode
Two pieces measuring how fast each sink pulls a large corpus from a source using
its native sync client:

- relayBench/sync-throughput-strfry.sh: `strfry import` N events into a source
  strfry, boot it, `strfry sync --dir down` an empty sink, report events/s.
- MirrorSyncThroughputTest: geode downstream pulls via the real MirrorWorker
  (WebSocket). Default in-process geode source (geode→geode); with
  -DsyncSourceUrl it mirrors an external relay (e.g. strfry) for strfry→geode.
  Sized by -DsyncN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 21:57:49 +00:00
Claude
998e5fc422 docs(relayBench): record that the sync shortfall is a harness artifact, not geode
The ~19/40k event shortfall in the geode↔geode sync is not a geode event-loss
bug: geode is proven lossless across the store, the concurrent IngestQueue
pipeline, RelaySession (OK-true only post-commit), and the real MirrorWorker
WebSocket path (50k/50k). The shortfall is in the benchmark's hand-rolled
fetchByIds+publish delta transfer. Points the fix at the harness (or at driving
convergence through geode's real mirror) rather than geode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 21:32:26 +00:00
Claude
062e725bb5 test(geode): prove real mirror sync is lossless over the WebSocket transport
Streams 50k events from an upstream KtorRelay to a downstream via the production
MirrorWorker (real OkHttp WebSocket + trusted skipVerify ingest) and asserts the
downstream receives every one — 50000/50000, 0 missing.

This closes the last untested layer: the in-process guards (BatchInsertLossTest,
ConcurrentIngestLossTest) call IngestQueue.submit directly, bypassing the wire.
With this, geode is proven lossless end-to-end — store, concurrent pipeline,
background pool contention, RelaySession, and the real WS mirror path. The
~0.05% shortfall seen in the geode↔geode relayBench sync is therefore an
artifact of the harness's hand-rolled fetchByIds+publish delta transfer, not a
geode event-loss bug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 21:30:34 +00:00
Claude
a7d549b579 test(store): guard that concurrent ingest (queue + pipeline + bg writers) is lossless
Drives the full IngestQueue pipeline — parallel verify, greedy-drain group
commit, a concurrent deferred-FTS catch-up worker taking the pool writer, and
windowed concurrent submits via the trusted (skipVerify) mirror path — over the
clean 200k corpus, asserting every Accepted regular event is queryable after.
Passes (199,612 in/accepted/stored, 0 lost), together with BatchInsertLossTest
proving geode's ingest is lossless at every in-process layer. The geode↔geode
sync event-loss therefore lives above the store+queue — in the real Ktor
WebSocket path or the benchmark harness's hand-rolled delta transfer, which the
in-process paths bypass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 21:26:33 +00:00
Vitor Pamplona
adda77e586 Merge pull request #3477 from davotoula/fix/ots-equals-hashcode
Repair equals/hashCode contracts in OpenTimestamps ops and VerifyResult
2026-07-04 17:03:35 -04:00
Claude
12e4e725c7 test(store): guard that sequential batchInsert never loses accepted events
Drives the exact store path — batchInsertEvents with geode's indexing strategy
in 64-event batches — over the clean 200k corpus and asserts every Accepted
kind-1 (regular, never replaced/deleted here) is queryable afterward. Passes
(199,612 in, 199,612 stored), which is the point: it proves the sequential
store path is lossless and narrows the geode↔geode sync event-loss to the
concurrent IngestQueue pipeline (async verify + greedy-drain batching +
concurrent WS submits), not the store itself.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 20:31:05 +00:00
Claude
8ae8ad7529 test(relayBench): instrument the delta-transfer path to localize event loss
Report, per sync pair: fetch coverage (did the peer REQ return every needed id,
without duplicates?), publish acks (accepted/rejected/unacked per target), and —
for any event a relay ends up missing — whether it was in the delta batch
delivered to that relay. In-batch-but-absent isolates ingest loss
(ack-without-persist) from a fetch/read gap, turning a vague "missing N" into a
pinned layer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 20:12:26 +00:00
Claude
7f51a43dad docs(relayBench): note the NIP-62/09/40 sync-fairness gap in effectiveEvents
The harness's reconcile reference only collapses replaceable kinds; it ignores
NIP-62 Request to Vanish, NIP-09 deletions, and NIP-40 expiration. So a
compliant relay (geode, which honors NIP-62) is falsely flagged "did not
converge" and the phantom events inflate its reconcile round count, masking
negentropy speedups at the wire. Documents the symptom (traced to one
ALL_RELAYS vanish pubkey in the damus corpus), the census (7 vanish pubkeys,
381 expiry events, 0 deletions in the first 200k), and the fix design for
whoever implements it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 20:00:16 +00:00
Claude
937ddd7fe2 test(relayBench): name the events a relay is missing when a sync doesn't converge
When the identical-set reconcile shows a side missing events the reference set
has, log those events grouped by kind (plus a few samples with their tag keys)
instead of leaving a bare "did not converge". The harness already holds every
event by id, so it can resolve exactly what a relay dropped — turning a vague
verdict into an actionable ingest-semantics diagnosis (NIP-09 deletion / NIP-40
expiration / validation).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 19:45:01 +00:00
davotoula
489e8ae50b Code review:
- enforce op equality by class and pin the tag-uniqueness invariant
- hoist crypto op equals/hashCode onto OpCrypto
2026-07-04 21:38:08 +02:00
davotoula
772b4ea8ed fix(quartz): repair equals/hashCode contracts in OTS ops and VerifyResult
Op instances key Timestamp.ops (MutableMap<Op, Timestamp>), so contract
violations corrupt hash-map behavior:

- OpKECCAK256 defined equals without hashCode, so equal instances hashed
  by identity — two equal keys could land in different buckets, producing
  duplicate branches or failed lookups in keccak256 timestamp trees. Add
  hashCode = TAG, mirroring OpSHA1/OpSHA256/OpRIPEMD160.
- OpBinary defined hashCode without equals — and its TAG referenced
  Op.TAG (0x00), a no-op XOR. Define the equals/hashCode pair once on
  OpBinary using tag() and drop the duplicated overrides from
  OpAppend/OpPrepend (behavior unchanged: same tag + same arg content).
- VerifyResult.equals cast without a type test (ClassCastException on
  foreign types instead of false) and hashCode force-cast the nullable
  timestamp (NPE for null-timestamp results). Convert to a data class;
  the custom toString and compareTo stay.
2026-07-04 21:37:24 +02:00
Claude
469220de77 perf(nip77): bump kmp-negentropy to v1.2.0 (faster reconcile/fingerprint internals)
v1.2.0 (on Maven Central) speeds up the library's own reconcile and fingerprint
walk on top of the v1.1.1 PrefixSumStorageVector wiring. At the 1M relayBench
slice shape (NegentropyReconcileBenchmark, converges exactly, need/have=200k):
client reconcile 264 → 178 ms, seal 424 → 320 ms, and the library's O(range)
fingerprint walk 447 → 252 ms (~1.8×). Our prefix-sum path still answers each
range fingerprint in 0.7 ms (356× the now-faster walk). All NIP-77 tests pass.

Update the reconcile-profiling plan: the fix shipped via the upstream
IStorage.fingerprint seam (v1.1.1) rather than a quartz-side fast server; record
the v1.0.2 → v1.1.1 → v1.2.0 progression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 19:33:54 +00:00
Claude
24dc3133ec fix(relayBench): raise harness heap to 8g so the 1M sync phase doesn't OOM
`SyncBenchmark.effectiveEvents` materializes the whole corpus as Event objects
plus a dedup LinkedHashMap to derive the 80% slices, so a million-event run
blew past the 2g default with an OutOfMemoryError before the negentropy sync
could start. -Xmx is a ceiling, not a reservation, so smaller runs don't pay
for the higher limit; JAVA_OPTS still overrides it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 18:51:21 +00:00
Vitor Pamplona
db14f7f921 Merge pull request #3476 from vitorpamplona/claude/ci-localrelaystorehydration-test-oh1ljr
fix: stabilize flaky LocalRelayStoreHydrationTest against GC eviction
2026-07-04 13:49:19 -04:00
Claude
9bb1d3aaf2 fix: stabilize flaky LocalRelayStoreHydrationTest against GC eviction
DesktopLocalCache stores Users in a WeakReference-backed LargeSoftCache. The
followee User in kind3IsHydratedBeforeKind0SoMetadataLoadsForFollowedAuthors is
created only during hydrate's kind:0 phase and has no Note referencing it, so it
is only weakly reachable once hydrate returns. A GC landing between hydrate()
and the assertions evicted it, flaking the test (reproduced deterministically by
forcing System.gc()).

Pin a strong reference to the followee's User for the duration of the test so
the cache cannot evict it, mirroring how followed users stay reachable via live
account/UI state in the running app. The ordering invariant the test asserts is
unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHQ3g7wD9WbDvj7NspiAWW
2026-07-04 17:48:18 +00:00
Claude
042b6a0c76 perf(nip77): use kmp-negentropy v1.1.1 PrefixSumStorageVector for O(1) range fingerprints
kmp-negentropy v1.1.1 adds the `IStorage.fingerprint(begin, end)` seam we needed
and ships `PrefixSumStorageVector` — a drop-in IStorage that builds an additive
prefix-sum table on seal() and answers any range fingerprint in O(1) instead of
re-walking the range. Range fingerprints are the CPU-bound core of a NEG-MSG on
a large snapshot; profiling pinned them as the steady-state reconcile cost geode
lost multiples on (not serialization).

Seal a `PrefixSumStorageVector` in both `NegentropyServerSession.sealVector`
(server / relay-relay responder, also backs the `LiveNegentropyIndex` snapshot
cache) and `NegentropySession` (initiator). Byte-identical to the plain vector —
only the fingerprint path is accelerated. `NegentropyPrefixFingerprintTest` now
also asserts the library's `PrefixSumStorageVector.fingerprint` matches the plain
walk over 2000 random ranges + boundaries at 50k; the reconcile-shaped mix
measures 601× (447 ms → 0.7 ms).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 17:19:46 +00:00
Claude
9ac5106bc0 fix(store): compile the merge raw-path correctness check; validate follow-feed at 1M
The `rawQueryPathMatchesDecodedQuery` case called `store.rawQuery(filter)`, but
`EventStore` only exposes the streaming `rawQuery(filters, onEach)` — the
list-returning overload lives on the inner `SQLiteEventStore`. Point the check
at `store.store.rawQuery(filter)` so the zero-decode path is actually exercised.

Record the shipped k-way merge result in the plan doc: a fresh 1M relayBench run
has geode `follow-feed` at 18.8 ms vs strfry 17.7 ms (down from 97.7 ms, now at
parity) and 46,258 ev/s vs strfry 15,365 @8conn, both returning the same 500.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 17:19:32 +00:00
Vitor Pamplona
b7912244fb Merge pull request #3475 from vitorpamplona/claude/negentropy-kmp-update-s5m8cw
Upgrade negentropyKmp to v1.1.1
2026-07-04 12:49:20 -04:00
Claude
8d09671218 perf(store): k-way merge for the home-feed REQ shape
The home-feed REQ (`authors=[…] (+ kinds=[…]) [+ since/until] limit=N`,
newest-first) is one of the most common relay queries. SQLite serves it by
seeking every `(kind, pubkey)` combo and feeding *all* matching rows through a
LIMIT-bounded sorter, so it reads O(the followed set's whole matching history)
— on a cold on-disk 1M corpus that was the `follow-feed` regression (relayBench:
97.7 ms vs strfry 17.6 ms).

Add `MergeQueryExecutor`, an app-level k-way merge that opens one lazy
newest-first cursor per stream off the existing composite indexes
(`query_by_kind_pubkey_created`, or `query_by_pubkey_created` for authors-only),
merges their heads `(created_at DESC, id ASC)` and stops at the limit — reading
only O(limit + streams) rows regardless of how much history the authors have.
It reuses indexes that already exist, so write throughput and on-disk size are
untouched. Eligibility is narrow (2..2048 streams, simple filter, explicit
limit, no ids/d-tags); everything else falls through to the single-SQL plan.

Wired into both `query` and the zero-decode `rawQuery` paths (the relay REQ hot
path) and the single-element filter-list variants, so `LiveEventStore` REQs go
through it.

`MergeQueryCorrectnessTest` proves the merge returns exactly the single-SQL
top-N — vs an independent Kotlin reference and vs the SQL path — across
distinct/tied created_at, since/until windows, authors-only, fewer-than-limit,
streaming onEach, and the raw path. `FollowFeedReadBenchmark` gains a `merge`
variant: at 1.05M events it's flat ~10-12 ms across both prolific-recent and
sparse-old, where `scan` is catastrophic on sparse follows (1995 ms) and
`current` is disk-bound on prolific ones.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 16:49:13 +00:00
Claude
bb6bf99586 chore: update negentropy-kmp to v1.1.1
Bumps the negentropy-kmp dependency from v1.0.2 to v1.1.1. The release is
backward compatible for consumers (StorageVector still provided; the codebase
only consumes IStorage and never implements it). Quartz compiles and all
NIP-77 negentropy tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3659Rs6ayVm4XmxvbtpWE
2026-07-04 16:21:55 +00:00
Claude
f29196f0fc test(store): measure follow-feed read/write/size tradeoff — keep current plan
follow-feed (kinds=[1,6] × 150 authors, ORDER BY created_at DESC LIMIT 500)
was geode's 5.5× loss (97.7ms vs strfry 17.6ms). Investigated whether any
change is worth it, across read + write + size.

Read (FollowFeedReadBenchmark, in-memory, scale 5 ≈ 1.05M events):
                  prolific-recent   sparse-old
  current            5.7 ms          1.9 ms
  scan (strfry)      1.0 ms       1601.9 ms
  union            316.9 ms         20.0 ms

- scan (created_at index + early LIMIT) wins for active follows but is
  catastrophic for sparse/inactive follows AND grows with corpus size
  (234ms→1601ms from scale 1→5) — following rarely-posting accounts is
  common, so it'd be a severe regression.
- union (300 per-branch subqueries) is dominated by branch overhead.
- current is the only robust option — flat across scale, bounded by the
  followed set, never catastrophic. The 97.7ms is a worst case (the 150
  MOST prolific authors, disk-bound reading all their matching rows).

No safe SQL-level swap exists; each alternative trades geode's worst case
for a worse one on a common workload. The only universal improvement is
strfry's app-level k-way merge (O(LIMIT+streams)) — a real new executor,
not a SQL tweak.

Write & size: neutral for every candidate — all reuse existing indexes
(query_by_kind_pubkey_created / query_by_created_at_id), none adds a
CREATE INDEX, so ingest throughput and storage are untouched regardless of
choice. A new index was considered and rejected (taxes every write, helps
one shape, reverts under ANALYZE).

Decision: keep the current composite plan. Full write-up in
quartz/plans/2026-07-04-follow-feed-read-tradeoff.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 16:04:20 +00:00
Vitor Pamplona
df01354fa5 Merge pull request #3474 from davotoula/fix/unchecked-file-delete-results
Handle unchecked File.delete() return values across all modules
2026-07-04 11:59:21 -04:00
davotoula
afe8c783c0 test(commons): cover deleteOrWarn and restrictToOwner helpers 2026-07-04 17:48:34 +02:00
Claude
3a68927829 test(store): measure why the profiles fix pins the index vs adding one
Answers 'could a new index beat the INDEXED BY pin without the pin?' for
the profiles shape (kind=0 AND pubkey IN(...) ORDER BY created_at DESC).
Measured:

- stock unhinted: scans query_by_kind_created (1.40ms) — the bug.
- pinned composite: seek + tiny sort (0.23ms) — the shipped fix.
- new (pubkey,kind,created_at) index, unhinted: STILL scans — no help.
- new (kind,pubkey,created_at ASC) index, unhinted: picks the seek (0.23ms)
  BUT that's a no-stats cost-model artifact — ANALYZE reverts it to the
  scan (1.47ms). Fragile, and a full duplicate of the DESC composite.

Root reason: ORDER BY created_at over a multi-value pubkey IN(...) needs a
sort no matter the index (no B-tree gives global created_at order across
pubkeys), and SQLite prefers the one sort-free plan — the full-kind scan.
Only the explicit pin reliably overrides that. A new index would add
write+storage cost on every event for the whole relay, help only this one
shape, and break under ANALYZE — so the free, deterministic, scoped pin is
strictly better. Diagnostic evidence for the design choice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 15:47:37 +00:00
Claude
b9d7ea2574 perf(nip77): direct-build NEG-MSG wire frames (~2.5–2.8× serialization)
The server's per-round reconcile spends a large slice turning the ~1MB hex
reconcile frame into wire JSON: the generic (Jackson) serializer wraps the
hex string in a value node and scans every char for JSON escapes a
[0-9a-f] payload can never contain, then re-copies.

NegMsgMessage.toJson() now builds ["NEG-MSG","<sub>","<hex>"] directly —
no node tree, no escape scan of the hex. Fast path fires only for
escape-free printable-ASCII subIds (what the JSON encoder emits verbatim);
exotic subIds fall back to the generic serializer, so output is
byte-identical. RelaySession.send routes through message.toJson() (default
unchanged for every other message type).

Measured (toJson + UTF-8, per frame): 64KiB 2.5×, 250KiB 2.6×, 500KiB
(strfry cap) 2.8× — ~2.5ms saved per NEG-MSG, ~35ms over a 14-round
reconcile. Correctness: a subId battery asserts byte-identity with the
generic path, and GeodeVsStrfryNegentropySyncTest (real strfry) reconciles
against the fast-built frames.

Also records the ingest-latency candidate as measured-not-worth-it: the
IngestQueue pipeline overhead is only ~0.17ms p50, <10% of the ~2.4ms
receipt→queryable gap — that gap lives in the REQ-visibility path, not the
writer. Full write-up in
quartz/plans/2026-07-04-sync-serialization-and-ingest-latency.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 15:40:26 +00:00
Claude
b97c74152a test(perf): add ingest-latency and NEG-MSG serialization benchmarks
Measurement harnesses for the two remaining relayBench gaps, isolating each
cost so a fix can be judged on the delta:

- IngestLatencyBenchmark: times single-event submit→onComplete through the
  group-commit IngestQueue vs a direct batchInsert. The delta is the
  pipeline's coroutine-handoff overhead — the receipt→queryable latency the
  1M run measured geode losing (4.68ms vs strfry 2.32ms).
- NegMsgSerializationBenchmark: times the NEG-MSG wire path (Hex.encode →
  MessageKSerializer JsonElement tree → UTF-8) against a direct StringBuilder
  build, asserting byte-identical output. Isolates the ~40% serialization
  slice of the server reconcile the JFR flagged.

Both compile and are self-contained; results + fixes to follow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 15:23:35 +00:00
Claude
ab32b21ec7 test(store): audit query plans for all relayBench scenarios
After the profiles fix, sweep every relayBench query shape with EXPLAIN
QUERY PLAN against a realistic multi-kind, tagged store to check for other
scan-that-should-seek mis-costs. Result: clean — every scenario seeks an
index (or does an index scan + LIMIT, like firehose's created_at walk).
The remaining TEMP B-TREE sorts are all over bounded result sets
(author/id count or LIMIT), not the profiles-scale pathology. The tag
queries (thread/notifications/hashtag) do an inherent subquery + sort;
hashtag is the slowest scenario but strfry is equally slow there, so it's
absolute cost, not a competitive gap.

Kept as a regression guard: asserts no scenario full-table-scans a base
table (bare SCAN without USING INDEX).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 15:10:42 +00:00
davotoula
206c0979b1 Code review:
- align voice-file debug log with deleteOrWarn's is-gone contract
- Convert the delete-then-warn sites the sweep left hand-rolled in already
  touched files: ThumbnailDiskCache corrupt-file and temp-thumbnail cleanup,
  NappletBlobCache.put leftover temp, and SecureKeyStorage's bare delete of
  the fallback key file (the highest-stakes delete in that file).
- Drop the exists() guards left layered over deleteOrWarn — the helper
  already treats an absent file as silent success.
- Collapse AccountManager's legacy-file triple into a loop and drop the
  stale "silent" from its comment.
- Snapshot lastModified alongside length in NappletBlobCache.trimToSize so
  sortedBy compares in-memory values instead of stat-ing per comparison.
- Promote DesktopTorManager's private restrictToOwner into a shared
  File.restrictToOwner(tag) in commons (600 files / 700 dirs) — the repo's
  sixth private copy of this pattern was one too many; the remaining copies
  can migrate incrementally
2026-07-04 15:53:02 +02:00
davotoula
27650f2f77 fix: handle remaining unchecked File.delete() and Tor dir permission results 2026-07-04 15:52:33 +02:00
davotoula
2b9ffb4849 Code review:
- extract shared File.deleteOrWarn helper for cache eviction
2026-07-04 15:51:41 +02:00
davotoula
c3d6c05482 fix: handle File.delete() return values in cache eviction and voice cleanup 2026-07-04 15:51:40 +02:00
Claude
87192e7793 fix(store): pin (kind,pubkey) index for multi-author no-limit REQs
The profiles scenario — Filter(kinds=[0], authors=[50]), no limit — was
geode's ~100x loss to strfry on the 1M corpus (99.5ms vs 0.94ms).

Root cause: the REQ path always appends ORDER BY created_at DESC. For a
multi-author pubkey IN(...) filter, query_by_kind_created (kind,
created_at) satisfies that order for free by scanning an ENTIRE kind, so
SQLite prefers it over the selective query_by_kind_pubkey_created (which
would need a sort). The scan is O(all kind-0 profiles) — cheap at 2k, the
99.5ms at 1M. ANALYZE does not fix it (verified: even a reopened store
reading fresh sqlite_stat1 keeps the scan, since the ORDER BY genuinely
lets the scan skip a sort). A single author is costed right and already
seeks; only the IN-list of >1 is mis-costed.

Fix: pin INDEXED BY query_by_kind_pubkey_created for exactly that shape —
multi-author + kinds, no ids, no d-tags, no limit — keeping the ORDER BY.
SQLite seeks the authors and sorts the small result: identical rows,
identical newest-first order (zero behavior change), ~8x at 2k profiles,
growing to ~100x at 1M. Limited feeds (home/global) keep the created_at
scan + early LIMIT; single-author and d-tag queries are untouched. The
index is created unconditionally so the hint never dangles.

(Considered dropping the ORDER BY for no-limit author queries — faster and
hint-free, but it changes on-the-wire result ordering, which broke
FsParityTest's ordered-parity assertions, so it's client-visible. Rejected
in favor of the order-preserving pin.)

Adds ProfilesQueryPlanBenchmark (regression guard: asserts the live REQ
plan seeks the composite index, not the kind scan) and
plans/2026-07-04-profiles-query-plan.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 11:38:31 +00:00
Vitor Pamplona
526ffde3f5 Merge pull request #3473 from davotoula/feat/birdstar-detection-kind-2473
support Birdstar bird detection events (kind 2473)
2026-07-04 07:30:25 -04:00
David Kaspar
a68572316b Merge pull request #3472 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-04 12:51:34 +02:00
davotoula
370a56ab69 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-04 10:46:51 +00:00
David Kaspar
c535c64e67 Merge pull request #3471 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-04 12:44:47 +02:00
davotoula
b8d3791ea3 Code review:
- only parse commonName when the alt tag has the Birdstar prefix
- summary() on both Birdstar events now uses the canonical NIP-31
  tags.alt() helper instead of a raw firstTagValue("alt") lookup
- speciesReference() only returns http(s) URLs since UIs render it as
  a clickable link (rejects e.g. javascript: schemes), with a test
- Detection card: parse tags once into a single remember slot, drop
  the near-dead '?: summary' title fallback, stop rebuilding the
  italic TextStyle every recomposition
- Hoist the duplicated bird-emoji literal into a shared BIRD_PREFIX
- Trim the redundant factory test to the assertIs idiom
2026-07-04 12:42:18 +02:00
davotoula
a83b8e4064 feat: support Birdstar bird detection events (kind 2473)
- fetch Birdex life lists in home and profile relay REQs
- richer Birdstar cards — common name title, Wikidata link, bird emoji
- surface Birdstar bird detections in home and profile feeds
2026-07-04 10:45:20 +02:00
Claude
9d76a6a8fa perf(store): diagnose the slow profiles query (kind-0 + authors REQ)
profiles (Filter(kinds=[0], authors=[50]), no limit) was the one query
geode lost to strfry on the 1M corpus — 99.5ms vs 0.94ms (~100x).

Root cause: the REQ path appends ORDER BY created_at DESC even with no
limit. query_by_kind_created (kind, created_at) satisfies that order for
free while scanning EVERY kind-0 profile, so SQLite prefers it over the
ideal query_by_kind_pubkey_created (which would need a sort). The scan is
O(all profiles) — cheap at 2k, the 99.5ms at 1M.

ANALYZE does not help: verified that even a reopened store reading fresh
sqlite_stat1 keeps the scan, because the ORDER BY genuinely lets the scan
avoid a sort.

Two fixes measured (both return identical rows), scoped to no-limit
kinds+authors filters:
- Fix A: drop ORDER BY when limit==null -> planner picks the composite
  index itself (~7x at 2k profiles, ~100x at 1M). Changes result order
  across authors (a NIP-01 SHOULD; clients re-sort).
- Fix B: force INDEXED BY query_by_kind_pubkey_created + keep ORDER BY
  (~same speed, newest-first preserved, at the cost of a scoped hint).

Adds ProfilesQueryPlanBenchmark (prints plans+timings, asserts row-count
equivalence) and plans/2026-07-04-profiles-query-plan.md. No production
change yet — the fix is a core QueryBuilder behavior/ordering decision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 07:51:01 +00:00
vitorpamplona
3d4c2f1963 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-04 07:31:05 +00:00
Vitor Pamplona
9f981117bd Merge pull request #3470 from vitorpamplona/claude/ci-subscribe-before-connect-test-2c5wow
fix(quartz): record REQ state before send in PoolRequests.syncState
2026-07-04 03:28:59 -04:00
Claude
f64b2e6f1c fix(quartz): record REQ state before send in PoolRequests.syncState
On connect, syncFilters re-sends every desired REQ through
PoolRequests.syncState. It previously sent the frame and only recorded
the subscription as SENT afterward, in the post-send onSent callback. A
relay that answers faster than that callback runs — the in-process
transport used by the desktop launch-optimization tests, or any relay on
a fast path — can deliver the EOSE while the per-sub state still reads
"nothing in flight" (onConnecting cleared it, onSent hasn't recorded it).
The EOSE handler then sees empty filters, concludes it never sent a REQ,
and fires a duplicate, replaying the whole page a second time.

Pre-mark the sub as SENT under its lock before the frame leaves, mirroring
the decideCommandLocked pre-mark already used by sendToRelayIfChanged, so
a response can never race ahead of the record. The send stays
unconditional: this is a fresh-connection sync (onConnecting always
cleared the per-relay state first), so there is no in-flight REQ on the
new socket to dedupe against.

Fixes the flaky SubscribeBeforeConnectTest, which asserted a pre-connect
subscription delivers exactly its events once and intermittently saw them
doubled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4kmtZoNUSXxwG23wD2JwP
2026-07-04 05:42:46 +00:00
Claude
8a607b08b9 perf(negentropy): profile NIP-77 reconcile, verify prefix-sum fingerprint fix
The 1M relayBench run had geode losing the negentropy phase to strfry
(initial reconcile 6066ms/27r vs 1270ms/14r; identical-set 1947ms vs
557ms). Three layered benchmarks pin where the time actually goes:

- NegentropyReconcileBenchmark (quartz): the kmp-negentropy server loop
  in isolation is ~200ms for the full 14-round exchange — the reconcile
  ALGORITHM is not the bottleneck. (An early version showed 22s/139r;
  that was a benchmark bug — index slices over randomly-sorted ids
  scatter the diff. Real relayBench slices are contiguous time ranges;
  monotonic created_at fixes it and matches strfry's round count.)
- NegentropyServerReconcileBenchmark (geode): the real in-process geode
  server over loopback is 3214ms — 15x the library loop. JFR of the
  server call-trees: ~40% hex/UTF-8/JSON serialization of the payloads,
  ~26% actual reconcile, rest allocation. The gap is the JVM
  constant-factor tax on hex-in-JSON, which strfry pays in C++, not a
  single hotspot.
- NegentropyPrefixFingerprintTest (quartz): the one algorithmic lever.
  Negentropy's fingerprint is an additive sum mod 2^256, so a prefix-sum
  table answers any range in O(1). Proven bit-for-bit identical to the
  library over 2000 random ranges, and 460x faster per call — the fix
  for the ~26% reconcile slice (dominant in the identical-set case).

Not yet wired: the library instantiates FingerprintCalculator
internally, so shipping prefix-sum needs a kmp-negentropy change (or a
quartz-side fast server). Full write-up + artifacts in
quartz/plans/2026-07-04-negentropy-reconcile-profiling.md.

Benchmarks are CI-safe (small defaults / opt-in gates); JFR via
-PnegProfile, scale via -DnegBenchN, geode server bench via
-DnegServerBench=1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 05:22:20 +00:00
Claude
8b29c06c13 feat(relayBench): deep resumable --download for million-event corpora
The corpus downloader previously sampled ~100k events max (40-page cap
per kind bucket) and held everything in memory with no failure recovery.
Rework it for full-depth timeline pulls:

- page the latest events newest-first with an inclusive until cursor
  (id-dedup absorbs the same-second overlap) instead of kind buckets
- no page cap: keep paging until the --limit target is met
- stream every unique event to an on-disk NDJSON spill instead of RAM
- checkpoint the pagination cursor per relay; interrupted downloads
  resume where they left off
- reconnect with exponential backoff on socket drops/timeouts
- filter deterministically droppable events (kind-5 deletions are ~40%
  of a live firehose, ephemerals, oversize) at page time so they never
  count toward the download goal

Verified with a 1M-event pull from relay.damus.io (~2.1 GB raw,
4100 pages, ~22 min through a proxy) feeding a full geode vs strfry
run; corpus prepared to exactly 1,000,000 events, fingerprint
141e746599d901f5.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 03:52:54 +00:00
Vitor Pamplona
ba9db9e2cb Merge pull request #3469 from vitorpamplona/claude/relay-performance-geode-quartz-v6lbys
feat(quartz): NostrServer.ingest — local write path with per-submission verify skip
2026-07-03 22:43:49 -04:00
Claude
bae2031cf3 fix(geode): validate config knobs and mirror filter at boot
Fail-loud on config that would silently degrade the running relay:

- readers = 0 makes every query hang forever on an empty reader pool;
  readers < 0 crashes with an unrelated message. optimize_interval_seconds
  <= 0 busy-loops PRAGMA optimize under the writer mutex. StaticConfig
  .validate() (called at boot) rejects both.

- A typo in [[mirror]].filter (e.g. `kindss`) parsed to an empty
  match-everything filter through the tolerant deserializer — silently
  widening a trusted upstream's skip-verify scope to the whole firehose.
  MirrorFilterValidator strict-checks the filter JSON at boot: unknown
  keys and non-array list fields fail startup.

- The self-mirror guard now compares scheme-insensitively (ws:// vs
  wss:// for the same host is still us) via displayUrl().

- The maintenance loop rethrows CancellationException and no longer
  swallows Errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-04 02:32:35 +00:00
Claude
401f36ee82 fix(geode): mirror trust bound to origin relay; reconnect advances since watermark
Two mirror hardening fixes from the audit:

- Trust leak: the skip-verify decision was keyed on the subscription id,
  but the client pool dispatches EVENTs by subscription id alone and
  every [[mirror]] upstream shares one client. A hostile untrusted
  upstream could answer with the trusted upstream's subscription id and
  ride its skip-verify into the store. startDown now drops any event
  whose delivering relay isn't the one that subscription dialed
  (relay != up.url). New MirrorWorkerTrustOriginTest injects a foreign
  subId frame from a hostile relay and asserts it never lands.

- Reconnect replay: `since` was frozen at boot, so a long-lived daemon
  re-streamed the whole backfill window on every upstream flap. The down
  path now tracks the newest ingested created_at and, on the
  connected->disconnected edge, advances the REQ's since to
  watermark - overlap before the reconnect re-sends it. Advancing only on
  disconnect means a stable link never re-queries. The reconnect test now
  asserts the watermark advanced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-04 02:32:21 +00:00
Claude
9bb75d9bbd fix(quartz/desktop): close socket on connect failure; NODELAY for desktop pre-init client
TcpNoDelaySocketFactory's connecting overloads used
`socket().apply { connect(...) }`, which leaks the file descriptor if
bind/connect throws (the JDK's connecting Socket constructors close on
failure; ours didn't). Wrapped in a helper that closes on throw. OkHttp
only calls the no-arg overload, so this guards any other direct caller.

DesktopHttpClient's pre-init `simpleClient` (direct relay sockets opened
before setInstance) now gets the same TcpNoDelaySocketFactory as
directClient. failClosedClient is left as-is: it's a SOCKS client and
OkHttp bypasses the socket factory for SOCKS proxies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-04 02:32:08 +00:00
Claude
2be0c9f880 fix(quartz): NostrServer.ingest verifies inline when the queue hook is off
ingest() bypasses the per-connection policy chain, which is where
VerifyPolicy lives. The IngestQueue verify hook only exists when
parallelVerify is true, so with parallelVerify = false and
skipVerify = false, ingest() previously verified nothing — an untrusted
mirror upstream on a relay running the legacy in-policy verify path could
inject forgeries. ingest() now verifies inline in that configuration
(same rejection reason as the queue), so the documented "default keeps
verify-everything semantics" holds regardless of parallelVerify. KDoc
also spells out that ingest() skips the entire policy chain (blacklists,
size limits), which callers must screen for themselves.

Test covers the parallelVerify = false server: forged rejected, trusted
skip and valid still land.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-04 02:31:54 +00:00
Claude
6ac2903a3a fix(quartz): live negentropy index — same-batch displacement, no-op kind-5, rebuild cap
Audit follow-ups on the live NIP-77 index, all with the store/scan
equivalence test extended to cover them:

- Same-batch replaceable displacement left a dead id in the index.
  applyAfterCommit applied all removes before all adds, so when a later
  row in one transaction displaced an earlier row of the same batch (two
  versions of one replaceable — the mirror-backfill hot path), the
  displaced row's remove no-op'd against an index that hadn't taken its
  add yet, then the add re-inserted it: the index advertised an id the
  trigger had already deleted. recordAccepted now cancels the pending add
  instead of queueing a remove (added is a LinkedHashSet for O(1)
  cancel).

- A kind-5 that deleted nothing (a delete broadcast for events this relay
  never stored — the common case) still invalidated the whole index,
  forcing a full-scan rebuild under the writer mutex on the next
  NEG-OPEN. DeletionRequestModule.insert now returns the rows it deleted;
  recordAccepted only invalidates when that count is > 0, else records the
  kind-5 as a plain row.

- The first NEG-OPEN over a corpus larger than the serve cap scanned the
  whole table uncapped, built a full index that could never produce a
  snapshot, and then maintained it forever for zero benefit.
  liveNegentropySnapshot now caps the rebuild scan at maxEntries + 1 and
  leaves the index unpopulated when the corpus is over-cap (the scan path
  answers NEG-ERR, as before).

- delete/deleteExpired/clearDB invalidate() moved inside the writer mutex
  so no NEG-OPEN can seal a snapshot of just-deleted rows, and no
  concurrent rebuild can be discarded by a late invalidate.

Also corrects the ~40 B/event heap figure to ~140 B (IdAndTime keeps the
id as a 64-char hex string, not 32 bytes) in the strategy/plan docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-04 02:31:40 +00:00
Claude
3d23f563b1 feat(geode): mirror directions — dir = "down" | "up" | "both" (strfry-router parity)
Each [[mirror]] entry now takes strfry-router's dir:

 - down (default): pull — subscribe to the upstream and ingest, exactly
   as before.
 - up: push — an in-process session on the LOCAL relay subscribes with
   the same scoped filter (so backfill_seconds and filter behave
   identically in both directions, and the relay's own policy chain
   gates what leaves), and every matching event is handed to the
   client's outbox for the upstream, which owns delivery and re-sends
   across reconnects.
 - both: pull and push, with echo suppression: a per-upstream LRU of
   recently exchanged ids keeps an event pulled down from being pushed
   straight back (and vice versa when the upstream fans our own publish
   back). Eviction only costs a duplicate round trip — the stores'
   unique-id constraints stay the correctness backstop.

trusted (verify skip) remains a down-only concept; the up direction
never verifies since the upstream does its own gatekeeping.

Tests: up pushes both the backfill window and the live tail; both
converges two stores with disjoint content and holds exact counts after
the echo settles (no ping-pong). Live-published test events are
genuinely signed — the local publish path verifies, which is also what
the debugging showed: the mechanism was fine, the first version of the
tests was pushing forged events into a verifying relay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-04 01:32:18 +00:00
Claude
cc73639ae5 docs(geode): SQLite knobs A/B verdict — no winner, knobs stay off by default
Backlog items 4-5 close. Both-variants-in-one-run A/B at 50k, repeated
with relay order reversed: every delta flipped with the order (the
second-running relay won queries and ingest latency in BOTH runs), so
readers=8 / mmap_size=256MiB / temp_store=MEMORY / periodic PRAGMA
optimize are all noise-level on container-class hardware. The config
plumbing stays (hardware-dependent, operators should measure their own
box); the example config now says so explicitly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-04 00:50:37 +00:00
Claude
79373672df feat(geode/quartz): [database] tuning knobs + periodic PRAGMA optimize
Backlog items 4-5 plumbing, config-gated and off by default so quartz
library defaults stay untouched for the app-side stores:

 - quartz: SQLiteEventStore/EventStore accept extraPragmas (applied on
   every pooled connection AFTER the built-in configuration, so they
   can override it) and expose optimize() — an analysis_limit-bounded
   PRAGMA optimize for incremental planner-stats refresh.
 - geode: [database] readers / mmap_size / temp_store_memory map onto
   the store; optimize_interval_seconds drives a maintenance coroutine
   (cancelled first in the shutdown hook, before the store closes).

The A/B verdict on whether the example config should RECOMMEND any of
these on container-class hardware follows in the next commit — the
knobs themselves are operator tools worth having either way, since
mmap/temp-store value is hardware-dependent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-04 00:48:22 +00:00
Claude
fd6662ca30 perf(quartz): TCP_NODELAY for every relay websocket client — kills CLOSE→REQ Nagle stalls
Found while attributing the small-REQ wire floor (backlog item 6,
latency half): geode's new WireReqFloorBenchmark measured a flat
43.7 ms per REQ round trip that survived every server-side change —
store configs, dispatchers, the pump — and then vanished when the
round's preceding CLOSE was dropped. Root cause is client-side: OkHttp
does not set TCP_NODELAY, relays never answer a CLOSE (NIP-01), so its
bytes sit unACKed for the peer's ~40 ms delayed-ACK window and Nagle
holds the next REQ behind them. CLOSE-then-REQ is a Nostr client's
hottest pattern — every feed/filter switch.

relayBench's harness client already shipped a no-delay socket factory
(which is why benchmark numbers never showed the stall) but the
production clients did not. New TcpNoDelaySocketFactory (quartz
jvmAndroid, next to BasicOkHttpWebSocket) is now used by the Android
relay pool factory, the Desktop relay client, amy's relay connections,
and geode's mirror worker. Direct connections only — SOCKS/Tor paths
are untouched.

With the factory, the benchmark puts geode's ~21-row REQ at ~1.25 ms
on the wire (matching relayBench): ~0.6 ms Ktor CIO+OkHttp loopback
floor, ~0.5 ms per-REQ server work (already investigated). Per-frame
burst cost measured negligible and the pump adds ~nothing, so the
send-path latency angle of backlog item 6 is closed as not-a-problem;
its ingest-CPU share remains a separate throughput question. Findings
recorded in quartz/plans/2026-07-04-small-req-floor.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-04 00:38:09 +00:00
Claude
f22657c870 revert(quartz): inline small-REQ fast path — no wire-level win, floor is transport-side
Reverts the queryRawInline fast path (fb29d655, 1b786f31) per the
keep-only-winners rule. Three relayBench runs at 50k (baseline, cap-256
where the path never engaged, cap-512 where author-archive/by-ids/
500-limit feeds genuinely took it) showed no movement outside the
container drift band — strfry's own numbers drifted ±30% between runs
and inline-eligible scenarios moved the same as ineligible ones.

The in-process win was real but small (~17%, 0.60 -> 0.50 ms per
~21-row REQ); the wire-level p50 is 1.2-1.7 ms, so the missing ~1 ms
per REQ sits in the transport (Ktor frame send path + client round
trip) — backlog item 6 territory, not dispatch. Findings, numbers, and
the do-not-retry note live in quartz/plans/2026-07-04-small-req-floor.md;
SmallReqFloorBenchmark stays as the measurement tool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-04 00:03:34 +00:00
Claude
1b786f31b4 perf(quartz): broaden inline REQ eligibility — ids-bounded filters, cap 512
The first cut's rule (every filter needs limit, sum <= 256) missed the
shapes relays actually receive: relayBench's author-archive carries
limit=500 and by-ids carries only an ids list, so the fast path never
engaged in the acceptance run. Bounds now come from limit OR the ids
count (ids are unique keys, the result cannot exceed the list), summed
against a 512 cap — a 500-row inline replay measures single-digit ms,
nothing a CLOSE could meaningfully preempt. Provably unbounded filters
(no limit, no ids — e.g. a bare tag filter) keep the launched path.

Pending: relayBench verdict decides whether the fast path stays at all,
per the keep-only-winners rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-03 23:58:48 +00:00
Claude
fb29d655fb perf(quartz): inline fast path for bounded small REQs
Backlog item 2 (small-REQ dispatch floor), first cut. relayBench at 50k
shows geode ~2.5x slower than strfry when a REQ returns ~20 events
(author-archive 1.18 vs 0.48 ms EOSE p50) while WINNING the 500-event
scenarios — with tiny results the fixed per-REQ cost dominates. A new
stage benchmark (SmallReqFloorBenchmark) decomposes the floor: at ~21
rows, raw SQL is 0.18 ms and the remaining ~0.42 ms is live-subscription
machinery plus per-REQ coroutine dispatch.

A REQ whose filters all carry a limit summing to <= 256 now runs its
stored replay inline on the receive coroutine and only retains a
live-tail handle (SessionBackend.queryRawInline; LiveEventStore's
queryRaw is re-expressed on the same core) — no per-REQ launch, no Job,
no dispatcher handoffs, no awaitCancellation scaffolding. Unbounded
REQs keep the launched path so CLOSE can always interrupt a giant
replay. In-process time-to-EOSE for ~21-row REQs drops 0.60 -> 0.50 ms;
the bigger effect expected under concurrency (no per-REQ Job+dispatch
churn) is relayBench's to judge.

InlineReqFastPathTest pins wire-behavior parity: stored-then-EOSE
ordering, live tail delivery and CLOSE detachment, same-subId
replacement, launched fallback for unbounded and over-cap REQs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-03 23:54:19 +00:00
Claude
0d6a9b1e4d docs(quartz): record strfry head-to-head for the live negentropy index
Same container, 50k corpus, geode --no-search, strfry built from
source: identical-set reconcile 41.4 ms (geode) vs 30.1 ms (strfry) —
down to ~1.4x from the campaign-opening full-scan-per-open; cold
reconcile 177 vs 112 ms (geode's first open pays the lazy rebuild);
ingest at parity in this container; storage 72.8 vs 106 MiB. Notes the
zero-copy IStorage follow-up that would close the remaining ~11 ms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-03 23:15:09 +00:00
Claude
de388907c2 docs(quartz): record live-negentropy-index A/B results — shipped
Two relayBench runs (50k corpus, both geode variants side by side, then
order-reversed): identical-set reconcile 56/57 ms with the index vs
110/130 ms without in both orders (~2.2x, the post-write NEG-OPEN the
old cache always missed); ingest and first-ever reconcile deltas flip
with relay order, i.e. run-order noise — no regression. Also records
the run-order-bias protocol note for future A/Bs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-03 23:09:56 +00:00
Claude
73ee5cce99 perf(quartz/geode): serve full-set NEG-OPENs from the live negentropy index
Milestones 2-3 of quartz/plans/2026-07-03-incremental-negentropy-storage.md.

SQLiteEventStore now maintains the LiveNegentropyIndex when the strategy
opts in (geode's does by default; [negentropy].live_index = false turns
it off; app-side stores are untouched):

 - Write paths collect a LiveIndexDelta and apply it after COMMIT while
   still holding the writer mutex — rolled-back savepoint rows never
   reach the index and updates land in exact commit order (also vs the
   rebuild, which runs under the same mutex).
 - Replaceable/addressable overwrites report the row their BEFORE-INSERT
   trigger displaces via one indexed pre-SELECT that mirrors the trigger
   predicate (including the NIP-01 lowest-id tie-break and the
   d_tag-NULL case).
 - Paths that can't itemize (kind-5, vanish, delete-by-filter/id,
   expiration sweeps, clearDB) invalidate; the next NEG-OPEN rebuilds
   from one scan on the writer connection.
 - Until that first NEG-OPEN populates the index, ingest pays zero
   bookkeeping — the populated check happens under the writer mutex so
   it can't race the rebuild.

LiveEventStore serves a single unconstrained filter (the relay-relay
sync default; relayBench sends exactly this) from the index; everything
else keeps the scan+seal path and its single-slot cache.

Micro-benchmark at 50k events (LiveNegentropyBenchmark, in-container):
scan+seal cold path 80-100 ms; index post-write open 9-16 ms (~5-10x).
The relayBench A/B is the acceptance gate and comes next.

Correctness: LiveNegentropyIndexStoreTest asserts index content ==
snapshotIdsForNegentropy scan after every mutation pattern (overwrites,
losers, kind-5 rebuilds, filter deletes, mixed-outcome batches,
transactions); the full geode suite (NIP-77 + interop sync tests) runs
with the index on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-03 23:05:23 +00:00
Claude
7ad7dee5fa feat(quartz): LiveNegentropyIndex — always-current (created_at, id) set for NIP-77
Milestone 1 of quartz/plans/2026-07-03-incremental-negentropy-storage.md.

Sorted array with binary-search insert (near-tail in the common case),
itemized remove for displaced rows, wholesale invalidate for delete
paths that can't itemize, and sealed snapshots memoized per mutation
generation — reconcile only reads, so one snapshot backs any number of
concurrent sessions and stays immutable under later writes. Over-cap
answers null so the caller keeps the strfry-parity NEG-ERR.

Not wired into any store yet; next milestones plumb displaced-row
deltas from the SQLite modules and serve index-total filters from it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-03 22:41:35 +00:00
Claude
175cac3e46 docs(quartz): plan — incremental live negentropy storage
Design for backlog item 3 of the relay performance campaign: an
always-current (created_at, id) index maintained from the store's write
path so cold NEG-OPENs stop paying the full scan + O(n log n) seal
(~340 ms at 50k events vs strfry's ~21 ms off its live tree). Covers
the snapshot/COW model, the removal-correctness split (RETURNING deltas
for replaceable overwrites, wholesale invalidation for rare delete
paths), IndexingStrategy gating so app-side stores are untouched, and
the micro + relayBench A/B measurement plan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-03 22:36:06 +00:00
Claude
c2cabf3c47 feat(geode): mirror survives upstream restarts — retry pump, ping keepalive, e2e proof
Measured over the real OkHttp transport (upstream killed mid-mirror and
restarted on the same port): NostrClient re-dials once on disconnect and
then falls back to its 60s keep-alive, which showed up as a 61s mirror
blackout when the immediate re-dial raced the port rebind.

 - Retry pump: the worker nudges the pool every 5s. Cheap and safe —
   reconnectIfNeedsTo skips connected relays and each relay's
   exponential backoff (1s doubling, 5min cap) still gates real dial
   attempts, so dead upstreams aren't hammered. Restart recovery drops
   from 61s to ~6s in the new reconnect test.
 - WebSocket pings (120s, same as the Android relay pool): without
   them a half-open connection (network drop, no FIN) never fires
   onDisconnected and the mirror would stall silently forever.
 - MirrorWorkerReconnectTest: end-to-end over a real Ktor port — ride
   through the drop, re-subscribe, drop the duplicate replay, pull the
   event that only exists on the new instance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-03 22:26:15 +00:00
Claude
a2b38cd1a3 feat(geode): per-upstream [[mirror]] filters, strfry-router parity
Each [[mirror]] entry takes an optional NIP-01 filter as a JSON object
string, like strfry-router's per-stream filter. It is applied twice,
matching strfry's design (cmd_router.cpp):

 - it shapes the REQ sent upstream (the mirror still owns since via
   backfill_seconds and strips limit — the subscription is unbounded);
 - every delivered event is re-checked against it before ingest, so an
   upstream answering outside its REQ — including a trusted one whose
   events skip signature verification — can only inject events inside
   the operator-declared scope. Out-of-scope deliveries surface on a
   'filtered' counter.

Malformed filter JSON fails the boot, not the first delivery. Several
disjoint scopes for one upstream = repeat [[mirror]] with the same url.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-03 21:54:49 +00:00
Claude
50e259b495 feat(geode): [[mirror]] upstream streaming with relay-to-relay trust
Backlog item 1 of the relay performance campaign: skip signature
verification for events ingested from explicitly configured trusted
upstream relays, strfry-router style.

geode now dials each [[mirror]] url from the config, subscribes to
everything newer than now - backfill_seconds, and feeds the stream
through NostrServer.ingest (same group-commit writer + live fanout as
client publishes). The NostrClient underneath owns reconnects, backoff
and REQ re-sync; duplicate replays after a reconnect are rejected by
the store's unique-id constraint and only surface as counters.

trusted = true is the per-upstream trust switch: events from that
connection skip Schnorr verify. The trusted identity is the URL this
relay dialed (TLS-authenticated for wss://), never anything an inbound
peer claims. Default is false — mirror-but-verify — and a relay with no
[[mirror]] entries behaves exactly as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-03 21:22:46 +00:00
Claude
32b15d55d0 feat(quartz): NostrServer.ingest — local write path with per-submission verify skip
Adds a public local-ingest entry to NostrServer for events that don't
arrive over a client connection (mirror/sync workers, import jobs). It
routes through the same group-commit IngestQueue and live fanout as a
client publish, and each Submission can opt out of the parallel
Schnorr-verify hook — the relay-to-relay trust model, for events
streamed from an upstream that already verified them (verify profiles
at ~8% of busy ingest CPU).

Library defaults are unchanged: skipVerify defaults to false everywhere
and nothing in the client-publish path can set it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-03 21:22:31 +00:00
Vitor Pamplona
4e895c20dd Merge pull request #3468 from vitorpamplona/claude/dedup-decode-benchmark-ci-rl7v8u
Make dedup decode benchmark resilient to CI load variance
2026-07-03 17:21:52 -04:00
Claude
130135250b fix: deflake DedupDecodeBenchmark timing assertion on loaded CI runners
A single-sample wall-clock speedup assertion (>1.5x) flakes when the
shared CI machine is loaded. Apply the same pattern already used by
ParallelVerifyBenchmark: retry up to 3 measurement attempts taking the
best, keep a hard 1.05x floor that a real regression (~1.0x when dedup
saves no work) can never pass, and warn instead of fail in the noise
band. The deterministic parsed/reused-count correctness gate still
fails hard on every attempt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SzMix4ZivxoYrcn88TMqtF
2026-07-03 21:18:32 +00:00
David Kaspar
ae5e0ff8ed Merge pull request #3467 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-03 22:27:50 +02:00
vitorpamplona
e2ab7ffd6a chore: sync Crowdin translations and seed translator npub placeholders 2026-07-03 19:59:42 +00:00
Vitor Pamplona
55698947e4 Merge pull request #3466 from vitorpamplona/claude/relay-benchmark-strfry-geode-4v2wxq
Add relayBench: comprehensive Nostr relay benchmark suite
2026-07-03 15:57:40 -04:00
Claude
6760ba450c fix: FTS catch-up worker must not leak an uncaught exception at shutdown
The worker's writer-mutex fast path can slip past its own scope
cancellation and run one batch against a store that close() already
freed. The resulting closed-connection exception escaped scope.launch
under a SupervisorJob with no handler — harmless in production, but the
kotlinx-coroutines-test global handler attributes it to whatever runTest
starts next (seen on CI as EventSourceServerTest.countUsesSource failing
with UncaughtExceptionsBeforeTest). Catch, log, and stop the pass:
nothing depends on it — the pre-search drain covers search correctness.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeoCvXnTxsKzqurkmjdC46
2026-07-03 19:22:43 +00:00
David Kaspar
c81fc8cb02 Merge pull request #3465 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-03 21:00:22 +02:00
Claude
35449c7437 fix: reclaim pool connections before close; strip NIP-50 extensions in queryRaw
Two issues surfaced by rebasing onto main:

- SQLiteConnectionPool.close() freed the writer's native handle without
  taking the writer mutex, so a block still running on another thread
  (the deferred-FTS catch-up worker's current batch outlives its scope
  cancellation) could call sqlite3_prepare on a freed sqlite3* — native
  heap corruption, SIGSEGV in sqlite3DbMallocRawNN (reproduced twice in
  :geode:test). close() now reclaims every reader from the channel and
  acquires the writer mutex before closing handles, then releases so
  stragglers get the managed closed-connection exception. Idempotent
  via a closed flag.

- main's NIP-50 fix (7deda28d) strips search-extension tokens before
  every store query, but the zero-decode queryRaw replay path added on
  this branch predates it and passed raw filters through — an
  extensions-only search would hit SQLite FTS as column syntax and
  error. queryRaw now strips like query/count/snapshot do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeoCvXnTxsKzqurkmjdC46
2026-07-03 18:51:38 +00:00
vitorpamplona
58a72c02fa chore: sync Crowdin translations and seed translator npub placeholders 2026-07-03 18:39:52 +00:00
Claude
115963eb1c perf: defer NIP-50 tokenization off the insert path
FTS indexing ran inside every insert's transaction — a measurable slice
of write cost (relayBench: ~18% of ingest throughput) paid at publish
time for a feature only search queries read. It now runs as a watermark
catch-up:

- IndexingStrategy.deferFullTextSearchIndexing (default false; geode's
  relay strategy enables it with search). Deferred inserts skip
  tokenization entirely.
- FullTextSearchModule keeps a fts_catchup_state watermark (everything
  <= last_row_id is indexed) and gains catchUpBatch(): scan past the
  watermark, tokenize, advance — one write transaction per batch, so
  publishes interleave. DATABASE_VERSION 3->4 seeds the watermark at
  MAX(row_id) for existing (synchronously indexed) databases.
- NostrServer runs the catch-up worker, poked by IngestQueue's new
  onBatchCommitted hook, and *yields to publish traffic*: it only
  drains while the queue has no backlog (IngestQueue.hasBacklog()), so
  bursts ingest at no-FTS speed and tokenization fills the gaps.
- LiveEventStore drains the backlog synchronously before serving any
  filter with a search term (query, queryRaw, count) — NIP-50 results
  stay exactly as fresh as the synchronous path; the deferral is
  invisible to correctness. Geode's existing search tests pass
  unchanged through this path.

The first implementation reused reindexBatch and collapsed ingest 8x —
its per-row 'DELETE FROM event_fts WHERE event_header_row_id = ?'
matches on a plain FTS5 column, i.e. a full FTS-table scan per row
(O(n²) overall), and the worker competed with the replay for the writer
mutex. catchUpBatch therefore inserts without the delete (rows past the
watermark are never indexed; switching a DB between deferred and
synchronous strategies requires reindexAll, same rule as a
searchable-kinds change), and the worker backs off whenever publishes
are pending.

Alternating A/B, 50k corpus, search-enabled default: 4,902/5,090/5,136
events/s synchronous vs 5,425/5,611 deferred (+8-12%), approaching the
--no-search ceiling while keeping NIP-50 advertised and fresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeoCvXnTxsKzqurkmjdC46
2026-07-03 18:39:13 +00:00
Claude
f2174bdab3 perf: cache the sealed negentropy snapshot across NEG-OPENs
A NIP-77 server session rebuilt its reconciliation structure from
scratch on every NEG-OPEN: full id+created_at scan, per-entry hex
decode into a fresh StorageVector, O(n log n) seal. That cost grows
with the corpus and is paid even when nothing changed — the exact
shape of a periodic mirror's heartbeat, where N peers reconcile the
same broad filter over and over. relayBench measured 342 ms per
identical-set reconcile at 50k events vs strfry's 26 ms off its
always-current LMDB tree.

Reconciliation only *reads* the sealed storage, so one instance can
back any number of concurrent sessions:

- NegentropyServerSession now accepts a pre-sealed IStorage (the
  List<IdAndTime> constructor remains and delegates).
- SessionBackend.sealedNegentropyStorage() builds + seals (null when
  the set exceeds maxSyncEvents); LiveEventStore overrides it with a
  single-slot cache keyed by (filter set, write generation) plus a 30s
  TTL. The generation bumps on every accepted ingest; the TTL bounds
  staleness from delete paths the counter can't see (expiration
  sweeps, admin purges) — negentropy snapshots are point-in-time sets,
  so seconds of staleness only means a peer briefly re-offers ids.
- NegSessionRegistry.open consumes the shared sealed storage;
  over-cap NEG-ERR behavior unchanged (strfry parity).

relayBench gains a 'heartbeat' measurement — the identical-set
reconcile repeated immediately with no writes in between. At 50k
events: geode 342 ms -> 27.8 ms vs strfry 21.1 ms (near parity; was
13x). Cold reconciles (first open after a write) are unchanged.

Also fixes the GeodeVsStrfryNegentropySyncTest fixture to write
'nofiles = 0' so the opt-in interop test can boot strfry inside
containers with a low RLIMIT_NOFILE hard cap; the interop suite passes
against strfry v1-b80cda3 with the cache in place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeoCvXnTxsKzqurkmjdC46
2026-07-03 18:37:53 +00:00
Vitor Pamplona
51d5d382c5 Merge pull request #3462 from davotoula/l10n/prune-dead-locales
Prune empty locale files and dead language-picker entries
2026-07-03 14:37:41 -04:00
Claude
3c6d36cde1 feat: authors-only query index — close the one gap vs strfry's index set
Reviewed strfry's LMDB indices (golpe.yaml) against geode's SQLite set:
the two are nearly isomorphic — time, id, kind+time, author+kind+time,
tag+time, plus conditional deletion/expiration/replaceable entries
(geode's are partial indexes, so ordinary events don't pay for them).
Nothing to drop. One real hole: strfry maintains a plain
pubkey(+created_at) index and geode had none, so an authors-only filter
(no kinds) — archive pulls, account-migration tools, 'everything by
these pubkeys' — degraded to a full walk of the time index. EXPLAIN
confirmed: SCAN query_by_created_at_id.

- quartz: IndexingStrategy.indexEventsByPubkeyAlone (default false —
  clients query their supported kinds and can skip it) gates a new
  query_by_pubkey_created index; DATABASE_VERSION 2→3 with an
  idempotent migration that backfills it for opted-in strategies.
- geode: relayIndexingStrategy turns it on.
- relayBench: new 'author-archive' scenario — every kind by 3 *quiet*
  pubkeys. Quiet is the point: prolific authors are dense in the time
  index and a scan finds them quickly, which is why the suite never
  caught this; sparse authors force the full walk.

Measured (50k corpus): author-archive EOSE p50 42.8 ms -> 3.6 ms (12x,
and the old path grows linearly with table size); ingest 5,337 -> 5,156
events/s (~3%, the one extra B-tree per event). strfry reference on the
same scenario: 0.52 ms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeoCvXnTxsKzqurkmjdC46
2026-07-03 18:37:40 +00:00
Claude
f672860ad9 perf: pipeline verify and write stages in IngestQueue
The drain loop ran verify(batch N) and insert(batch N) strictly
back-to-back: the SQLite writer idled during every Schnorr verify and
the CPU cores idled during every commit. Split it into two stage
coroutines joined by a capacity-1 channel — a verifier that collects
and checks batch N+1 while the writer commits batch N. Same shape as
strfry's ingester/writer thread split.

Ordering (single verifier, single writer, FIFO handoff), OK-after-commit
semantics, per-row error isolation and submit() backpressure are all
unchanged; total in-flight grows by at most one batch.

Alternating A/B on the 10k corpus, 4-core host with the benchmark client
competing for the same cores: sequential 4,111/3,977/4,443 vs pipelined
4,050/5,020/5,092 events/s (~+13% mean). The overlap should widen on
dedicated relay hosts where verify has its own cores. Feature-parity
run after this change: geode --no-search 5,911 vs strfry 9,374 events/s
(1.59x, down from 2.2x at the start of the perf work).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeoCvXnTxsKzqurkmjdC46
2026-07-03 18:37:40 +00:00
Claude
58c580cd34 feat(geode): --no-search / [options].full_text_search — run without NIP-50
strfry implements no NIP-50 at all, so geode's default setup pays FTS
tokenization on every searchable event for a feature the other side of a
benchmark isn't providing. The new switch removes it cleanly:

- geode: --no-search CLI flag and [options].full_text_search TOML key
  (default true). When off, the store skips the FTS index entirely,
  NIP-11 stops advertising 50 (an explicit [info] nips list stays
  operator-authoritative), and search filters match nothing via the
  existing QueryBuilder guard. RelayIndexingStrategy becomes
  relayIndexingStrategy(fullTextSearch) with the stock val kept.

- relayBench: --geode-no-search runs the geode entry with the flag (relay
  named geode-nosearch in reports); README documents the apples-to-apples
  rationale and a recipe for benchmarking both geode flavors side by side.

Alternating A/B on the 10k corpus: 4,767/4,792 ev/s without search vs
4,111/3,977 with — NIP-50 costs ~18% of ingest throughput at current
write-path speed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeoCvXnTxsKzqurkmjdC46
2026-07-03 18:37:39 +00:00
Claude
001d5f0eb7 fix: port the O(n²) replay-dedup fix into queryRaw; adopt IdAndTime negentropy API
Main's giant-REQ fix (18bd3600) replaced query()'s copy-on-add immutable
dedupe set with a spin-locked HashSet, but the rebase left queryRaw —
now the default REQ path — on the old pattern, which would have
reintroduced the O(n²) crawl for large replays. Both paths now share
the mutable-set-under-spinlock shape.

relayBench's sync driver moves to NegentropySession.fromEvents(),
following the session's new List<IdAndTime> constructor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeoCvXnTxsKzqurkmjdC46
2026-07-03 18:37:39 +00:00
Claude
5f0a629e18 perf: relay read/write path — ordering index, zero-decode REQ replay, prepared-statement cache
Three changes, each validated head-to-head against strfry with relayBench
(same 10k-event corpus a1cd3517a8296911, stock configs, sig verify on):

- geode: RelayIndexingStrategy turns on indexEventsByCreatedAtAlone for
  the relay's stores (quartz's DefaultIndexingStrategy stays off for
  client-side stores). A relay can't predict client filters, and the
  cheapest REQ of all — {"limit":N} — was a full-table scan + top-N
  sort: 40 ms and O(table) growth before; 11 ms and index-streamed
  (first event 30 ms -> 1.9 ms) after.

- quartz: zero-decode REQ replay. Stored events now stream as RawEvent
  (tags kept in serialized form) and are spliced directly into wire
  frames — no tags parse, no EventFactory dispatch, no re-serialize per
  row. Gated on the new IRelayPolicy.filtersOutgoingEvents capability:
  policies that can veto per-event delivery (none today) keep the
  materialized path; everyone else skips it. Live post-EOSE delivery is
  unchanged (live matching needs Event objects).

- quartz: StatementCachingConnection wraps the pool's writer and reader
  connections, replaying prepared statements instead of re-preparing per
  event/REQ (eager reset on return keeps cursors from holding table
  locks; a 256-statement cap bounds client-controlled filter-shape
  variety). Ingest went 3,000 -> 4,700 events/s (+55%) — prepare
  overhead was the single largest non-crypto write cost.

Net effect on the benchmark: ingest gap vs strfry narrowed from 2.2x to
1.8x, the firehose latency gap from 4x to 1.2x, and geode now wins 4 of
9 query-latency scenarios (notifications, hashtag, by-ids,
recent-window) plus most concurrent-throughput scenarios, while keeping
the smaller on-disk footprint. Result sets stayed byte-identical across
relays and NIP-77 sync still converges.

Measured but deliberately NOT taken: per-row SAVEPOINT elision (+4%,
within run noise — not worth weakening batch error isolation), FTS-off
(+25% ingest but drops NIP-50), --no-verify (+45% but unfair/unsafe).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeoCvXnTxsKzqurkmjdC46
2026-07-03 18:37:39 +00:00
Claude
5f3a790d56 feat: add relayBench — head-to-head relay benchmark (geode vs strfry vs any)
New :relayBench module that boots relay implementations as real external
processes under equivalent setups (persistent storage, sig verification on,
no auth) and compares them on the same corpus:

- Ingest: receipt->queryable-by-REQ latency (publish on one connection,
  hammer-poll REQ{ids} on another), OK-ack latency, and pipelined corpus
  replay throughput over N connections.
- Queries: client-realistic filters derived from the corpus (home feed,
  thread, notifications, profiles, hashtag, by-ids, ...), time-to-EOSE
  percentiles plus aggregate events/sec under concurrency; result-set
  counts are cross-checked between relays and mismatches flagged.
- NIP-77 negentropy sync between every relay pair: 80%/80% slices with 60%
  overlap, reconcile timing/rounds/wire-bytes per server side, delta
  transfer, steady-state identical-set reconcile, convergence verified.
- Storage footprint after full ingest.

Corpora (all cached as NDJSON + manifest with a sha256 id fingerprint so
results are comparable across runs and machines):
- synthetic (default): deterministic to the byte — seeded keys, fixed
  timestamps, seed-derived BIP-340 aux nonces — with a realistic social
  shape (zipf authors, threads, reactions, reposts, zap request/receipt
  pairs, hashtags);
- the checked-in real dump (quartz test fixture, ~31k unique 2024 events);
- external dumps (NDJSON or JSON array, gzip sniffed by magic bytes),
  e.g. the 2.1M contact-list archive, with --max-event-bytes/--max-tags
  raising both the corpus filter and the strfry config together;
- live download from public relays.

Every source runs through the same preparation: dedup, drop unsigned/
ephemeral/kind-5, enforce relay ingest caps, parallel Schnorr verify,
chronological sort.

relayBench/run.sh is the one-command entry point: builds geode + harness,
resolves strfry (STRFRY_BIN, PATH, or source build into .cache), runs the
suite and renders an ANSI report with per-metric bars and winners, plus
report.md and results.json under relayBench/results/<timestamp>/.

The harness client disables Nagle (TCP_NODELAY): with the JDK default, a
REQ following the previous round's CLOSE stalls a full delayed-ACK
interval and every latency floors at ~44 ms against both geode and strfry
(verified: ~0.3 ms with it off).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeoCvXnTxsKzqurkmjdC46
2026-07-03 18:37:16 +00:00
Vitor Pamplona
acaf768309 Merge pull request #3464 from vitorpamplona/claude/nip50-eventstore-search-gv9ses
Strip NIP-50 search extensions before querying SQLite FTS
2026-07-03 14:25:27 -04:00
Claude
7deda28d29 fix(quartz): strip NIP-50 extension tokens before FTS MATCH in the relay store path
Raw key:value tokens like include:spam reached SQLite FTS MATCH, where
the colon is column-filter syntax — any REQ carrying an extension token
died with CLOSED "no such column: include" instead of matching.

Adds SearchQuery.stripExtensions() plus Filter/List<Filter>
.strippingSearchExtensions() so EventStore users can drop the tokens
before querying, and applies them in LiveEventStore (query, count,
negentropy snapshots). Per NIP-50, unsupported extensions are ignored:
an extensions-only search becomes unconstrained, not match-nothing.
EventSource-backed search relays still receive the raw string since a
real search backend wants the extensions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tujoyfc2kNLZNVgiLZAR7F
2026-07-03 18:09:37 +00:00
David Kaspar
31e9d0f14c Merge pull request #3463 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-03 20:09:03 +02:00
davotoula
5e6405e3c9 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-03 18:08:00 +00:00
davotoula
d9fda175e7 fix(l10n): repair mojibake in three Tamil strings
The 2026-06-25 translation import left U+FFFD replacement characters
in show_npub_as_a_qr_code, show_nprofile_as_a_qr_code and relay_reorder
in both values-ta and values-ta-rIN. Restore the accusative suffix
as npub-ஐ (independent vowel AI), matching the nsec-ஐ
precedent in the same files. Clears Sonar's file-encoding warning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iWg6SxLXteav6xJJz7gKS
2026-07-03 20:04:54 +02:00
davotoula
b3f98840a6 chore(l10n): prune empty locale files and dead language-picker entries
Delete 19 strings.xml files that contain zero string resources:

- 12 exports of Crowdin targets with 0% translation (ca-rES, cy-rGB, da-rDK,
  gu-rIN, hr-rHR, iw-rIL, kk-rKZ, ks-rIN, lt-rLT, ne-rNP, sa-rIN, pcm-rNG).
  NOTE: the next Crowdin sync recreates these unless the corresponding target
  languages (Catalan, Welsh, Danish, Gujarati, Croatian, Hebrew, Kazakh,
  Kashmiri, Lithuanian, Nepali, Sanskrit, Nigerian Pidgin) are unchecked in
  the Crowdin project settings; they hold zero translations there, so
  unchecking loses nothing and a language can be re-enabled when a translator
  volunteers.
- 6 orphans with no Crowdin target at all (et-rEE, fo-rFO, ku-rTR, so-rSO,
  ss-rZA, ur-rIN).
- values-hu, emptied earlier to fix aapt2 duplicate resources; the real
  Hungarian translation lives in values-hu-rHU (until Hungarian is
  consolidated to base like Czech in #3461).

Also drop the matching locales_config entries so the per-app language picker
stops offering languages that render 100% in English, including the dangling
"ar" entry (values-ar has never existed; ar-SA remains) and the duplicate
"hu" entry (hu-HU remains).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhEN93aKyQCFVpLUgRNXid
2026-07-03 19:03:47 +02:00
davotoula
ab7abd9614 docs(skills): point find-missing-translations at the base Czech locale
Czech was consolidated onto values-cs in PR #3461 (values-cs-rCZ deleted,
cs: cs languages_mapping). Update the reference-locale table, diff commands,
and prose accordingly, and note that the remaining locales keep their
region-qualified dirs until consolidated the same way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhEN93aKyQCFVpLUgRNXid
2026-07-03 18:40:09 +02:00
Vitor Pamplona
72181a1a05 Merge pull request #3461 from davotoula/l10n/czech-base
Consolidate Czech onto the base values-cs qualifier
2026-07-03 12:28:00 -04:00
davotoula
916ac267af fix(l10n): consolidate Czech onto the base values-cs qualifier
Czech has a single Crowdin target (cs), but Crowdin's default android_code
for it is cs-rCZ, so the human/Crowdin translation was written to
values-cs-rCZ. Promote it to base values-cs, delete the regional dir, map
cs -> cs in crowdin.yml so future syncs land at the base (single target =>
no collision), and drop cs-CZ from locales_config so the per-app language
picker shows just "Czech".

Also removes the bulk-filled base values-cs from PR #3371 ("Add and update
translations across 50+ locales"), which had translated 38 strings marked
translatable="false" in the source (the *_search_keywords English index,
github/mastodon proper nouns, notification-channel names). The promoted
cs-rCZ is Crowdin-clean (0 translatable="false" leakage), so this also
fixes that contamination for Czech.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhEN93aKyQCFVpLUgRNXid
2026-07-03 17:01:33 +02:00
David Kaspar
81a0444929 Merge pull request #3460 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-03 16:42:30 +02:00
vitorpamplona
04fae97d10 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-03 14:41:28 +00:00
David Kaspar
51bc2ffcc4 Merge pull request #3456 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-03 16:41:02 +02:00
vitorpamplona
9eb447de38 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-03 14:39:19 +00:00
Vitor Pamplona
1460412989 Merge pull request #3458 from vitorpamplona/claude/nostrclient-receiver-perf-d8u27o
Add production benchmarks and negentropy sync optimizations
2026-07-03 10:39:12 -04:00
Claude
b5fa6b20c1 feat(cli): migrate amy sync onto the windowed negentropyReconcile pipeline
Replaces the hand-rolled raw-WebSocket NIP-77 negotiate loop (single
un-windowed session — a strfry max_sync_events overflow was a hard
error) with quartz's negentropyReconcile: created_at window splitting
on overflow, keep-alive connection pinning, and streaming id batches.
Downloads and uploads now pipeline with the remaining reconcile
rounds: need-id batches feed 4 concurrent by-id drains, have-ids feed
an uploader (peak 7 subscriptions, under the common relay cap of 20).
Every downloaded event still funnels through the verify-and-store
path. Output field 'rounds' (protocol round-trips) is now 'windows'
(created_at splits).

Verified end-to-end against embedded geode relays: down-only 25/25,
up-only 5/5, and bidirectional re-runs converge to a zero diff.

Also records both adoptions in the perf plan doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 14:37:25 +00:00
Claude
f699fac16c perf: adopt CachingEventDecoder in Android, Desktop, and amy clients
Passes decoder = CachingEventDecoder() at all four NostrClient
construction sites: the Android app pool (AppModules), the Android
crawl client (buildCrawlClient — Event Sync / Cashu discovery, the
duplicate-heaviest path), the desktop RelayConnectionManager, and
amy's Context. Duplicate EVENT frames (14-57% of production traffic)
now skip the full JSON re-parse; dispatch semantics unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 14:37:25 +00:00
Vitor Pamplona
c90c1b4768 Merge pull request #3459 from vitorpamplona/claude/dispatchers-thread-caps-s8yp4c
Add lock-free concurrent collections and fix UDP socket threading
2026-07-03 10:37:00 -04:00
Claude
b561d2b7e6 docs: record lock vs lock-free decoder A/B — 3-4.5x under concurrent decode
Same-JVM interleaved comparison against the resurrected spin-lock
implementation: single-threaded parity (the uncontended lock was ~free),
but 8 threads sharing one decoder run 3.0-4.5x faster lock-free — the
spin lock serialized the concurrent hit path just like the old global
PoolRequests lock did.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 14:09:06 +00:00
Claude
aa4f965b28 test: make ParallelVerifyBenchmark tolerant of suite load
The speedup assertion depends on AVAILABLE parallelism, which a
full-suite run (the pre-push hook) can eat — it flaked at 1.14x under
load. Now retries up to 3 measurement passes and enforces a hard 1.05x
floor that a genuine serialization regression (the per-event-async
version measured 0.94x) can never pass, warning instead of failing in
the noise band between floor and the 1.5x target.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 14:00:54 +00:00
Claude
72c09649a6 perf: make CachingEventDecoder lock-free via ConcurrentHashCache expect/actual
Replaces the decoder's spin lock with lock-free concurrent maps so the
hot per-frame duplicate check never serializes across the pool's relay
consumer coroutines. New minimal ConcurrentHashCache expect/actual
(get/put/size/clear) following LargeCache's per-platform choices:
ConcurrentHashMap on JVM/Android, CacheMap on Apple, copy-on-write on
the CI-only Linux target. Counters become atomics; the generational
rotation keeps its deliberately-tolerated benign races, now documented
per failure mode (each is at worst a redundant re-parse, never a wrong
message).

CachingEventDecoderConcurrencyTest hammers one shared decoder from 8
threads with 80k duplicate-heavy frames and capacity 256 (rotations
fire constantly): zero wrong messages, exact parsed+reused accounting.
The 7 scan-safety tests and DedupDecodeBenchmark's enforced speedup
pass unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 13:49:26 +00:00
Claude
27f6bf6970 docs: ParallelEventVerifier is for bulk flows — app verify is already parallel across relays
Code review confirmed each relay connection owns its own consumer
coroutine on Dispatchers.IO with no downstream funnel or shared lock
before justVerify (LargeCache is a ConcurrentSkipListMap; the
PoolRequests lock is per-subscription now), so multi-relay bursts
already verify in parallel across cores. Corrects the earlier follow-up
suggesting a CacheClientConnector integration: the accessory's scope is
single-connection bulk streams, plus a possible future dispatcher-
hygiene fix if on-device profiling shows CPU-bound verifies
oversubscribing the 64-thread IO pool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 13:33:47 +00:00
Claude
d271223521 revert: restore unbounded websocket receive channels
Deliberate design decision reversing the 4096-frame receive bound:

1. The remote infrastructure isn't ours — TCP backpressure parks the
   backlog in the RELAY's outbound buffers. A client should release the
   relay from its duties as fast as it can send and own the buffering
   itself.
2. The app holds 2000+ simultaneous relay connections; a bounded buffer
   under a slow consumer blocks OkHttp reader threads, and at that
   connection count blocked readers are a thread-starvation hazard far
   worse than the heap growth they prevent.

The UNLIMITED channels now carry an explicit do-not-bound comment with
this rationale, and the slow-consumer risk is addressed from the other
side: keep the consumer faster than any relay's send rate
(CachingEventDecoder, ParallelEventVerifier, PoolRequests sharding).
BoundedReceiveBufferTest removed with the bound it tested; plan doc
records the decision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 13:02:10 +00:00
Vitor Pamplona
28412c055c Merge pull request #3457 from nrobi144/feat/desktop-notifications
feat(desktop): notifications redesign — inbox UX, native OS toasts, shared filter
2026-07-03 08:44:23 -04:00
nrobi144
ecedc4affe feat(desktop): notifications redesign — inbox UX, native OS toasts, shared filter
Rework the Amethyst Desktop notification experience end-to-end.

**In-app inbox** (`desktopApp/…/ui/NotificationsScreen.kt`)
- Dedicated Notifications entry in the sidebar and a new
  `DeckColumnType.NotificationSettings` overlay reachable from a ⚙ button
  in the column header — back button renders automatically via
  `navState.hasBackStack` in deck mode and via body Back in single-pane.
- Redesigned column: filter tabs (All / Mentions / Replies / Reactions /
  Zaps / Reposts / DMs) with per-kind counts, grouped cards (reactions
  and reposts collapse to "N reactions on your post" per day), unread
  dots driven by a persisted `lastReadAt` per pubkey, freshest-first
  ordering via `compareByDescending { timestamp }`.
- User metadata: avatars + display names on every row (including reactor
  strip inside grouped cards) resolved from `LocalCache`, with
  `metadataVersion` observation. Zap sender is the actual zapper (via
  `NotificationItem.effectiveAuthorPubKey` unwrapping
  `LnZapEvent.zapRequest.pubKey`), not the LNURL provider.
- Reaction/repost group cards are clickable → thread; DM cards click →
  Messages column; expandable to show note preview + reactor list.
- `NotificationSettingsScreen`: master toggle, 7 per-kind toggles,
  manual-DND dropdown, preview-privacy switch, per-platform status
  card, "Send a test toast". Permission-aware button adapts across
  NotRequested → Granted / Denied / BundleRequired with a macOS System
  Settings deep-link. State syncs with OS-level changes via
  `LocalWindowInfo.isWindowFocused` regain refresh.

**Native OS notifications** (`commons/…/moderation/notifications/`)
- `NotificationDispatcher` interface + `PermissionState` sealed
  hierarchy in `commonMain`. JVM impl `NucleusNotificationDispatcher`
  routes through Nucleus (three per-OS artifacts: macOS
  `UNUserNotificationCenter` via Swift/JNI, Windows WinRT toast via
  JNI, Linux libnotify via D-Bus). Falls back to `AwtTrayNotifier` when
  native lib fails to load. Async `requestPermission` +
  `refreshPermission` bridge Nucleus's callback API to `suspend`.
- `DesktopNotificationAutoDispatcher` subscribes to
  `DesktopLocalCache.eventStream.newEventBundles` and fires OS toasts,
  applying a 9-check suppression pipeline: kind allow-list, master
  toggle, per-kind toggle, DND, window-focused, cold-boot
  (event.createdAt < sessionStart or >30s stale), macOS permission,
  semantic accept, 30s per-(kind,event-id) dedupe. Wired in Main.kt
  with DisposableEffect(loggedIn.pubKeyHex); window focus tracked via
  LocalWindowInfo → StateFlow.
- Adds `windows { menu = true; shortcut = true }` to
  `desktopApp/build.gradle.kts` so AUMID persists and Windows toasts
  survive reboot.

**Shared notification filter** (`commons/…/moderation/notifications/NotificationKinds.kt`)
- Extracted from Android's `NotificationFeedFilter`. Exposes
  `SUBSCRIPTION_KINDS` (13 kinds: text, DMs kind 4 + 14 + 1059
  gift-wrap, encrypted-file-header, comments 1111, reactions, reposts,
  generic reposts, channel messages 42, nutzaps 9321, zap receipts
  9735, onchain zaps 8333), `subscriptionFilter(pubKey, since, limit)`
  builder that `FilterBuilders.notificationsForUser` delegates to, and
  `tagsAnEventForUser(event, myPubKey, isTargetAuthoredByMe)` semantic
  gate. Reactions/reposts require target-author-match; other kinds
  require `p=me`. Fixes a bug where the helper defaulted to accept and
  let cache-seed leak "mentioned you" notifications from unrelated
  text notes.
- Android's `NotificationFeedFilter.NOTIFICATION_KINDS` now spreads
  `SUBSCRIPTION_KINDS` + Android-only extras (badges, git, highlights,
  polls, videos, voice, live-activities), so a change on either side
  propagates. Downstream push consumers (`NotificationDispatcher.kt`,
  `EventNotificationConsumer.kt`) read the resulting set transparently.
- Content sanitizer strips control chars, RTL overrides, zero-width
  chars, and URLs from toast titles. DM cards never render ciphertext
  body (decryption pipeline deferred).

**Tests** — `NotificationKindsTest` covers 17 scenarios: reactions
target-author-mismatch rejection, own-event rejection except zap kinds,
p-tag routing for text/DMs/zaps/nutzaps/gift-wraps/channel messages,
`SUBSCRIPTION_KINDS` sanity + `subscriptionFilter` shape.

**Testing constraint**: macOS OS notifications require a bundled
process. `gradle run` will always show BundleRequired — use
`gradle :desktopApp:runDistributable` and open the resulting
`Amethyst.app`. First permission grant surfaces the app in
System Settings → Notifications.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 15:19:18 +03:00
Claude
18bd360052 perf: fix O(n²) replay dedup + session-pump backpressure — giant REQs 11x faster
Two server bugs masking each other made a single giant REQ crawl and
then wedge:

LiveEventStore's historical-replay dedup used an immutable Set under an
AtomicReference with copy-on-add — set + id copies the whole set per
streamed event, so large replays were accidentally O(n²) (100k-event
REQ: ~700 events/s, degrading as the response grew). Replaced with a
spin-lock-guarded mutable HashSet (same threads, single contains/add
per critical section).

Fixing that unmasked WebSocketSessionPump's slow-client policy: a fast
replay instantly overflowed the 8192-frame cap — which conflated 'slow
client' with 'replay outruns the socket writer', normal for bulk — and
the 'drop' only closed the internal queue, leaving the socket half-dead
(no EOSE, no close frame, tail silently missing: the likely cause of
the benchmark's 99,998/100,000). Producers are now paced against a full
backlog (bounded blocking wait, consistent with the documented ingest
fanout behavior) and only a client still behind after 30s is dropped,
by actually cancelling the socket.

GiantReqStreamTest guards the regression: 20k-event REQ pre-fix 8.4s
(~2.4k events/s), post-fix 0.7s (~27k events/s), all events + EOSE
delivered. 96 geode tests and the quartz relay/server suites pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 05:07:39 +00:00
Claude
b21fc330ac chore: report negotiated websocket compression in the bulk benchmark
The per-connection-wall test prints the negotiated
Sec-WebSocket-Extensions header; against strfry OkHttp negotiates
permessage-deflate (client_no_context_takeover) out of the box, closing
the 'is compression actually on?' question from the optimization list —
it is, no change needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 04:32:05 +00:00
Claude
4bbfd86a56 feat: add negentropySyncFanOut — multi-connection sync from one relay
One reconcile feeds by-id download batches to N clients (one socket
each) x reqsPerClient workers; reconcile windows also round-robin
across the connections, since a single connection produced need-ids at
only ~9k/s on a 2.6M corpus and starved the downloads. Events funnel
through a bounded channel to a single consumer (exact maxEvents,
single-threaded onEvent); all stages backpressure; localEntries diffing
and have-counting match negentropyReconcile. reconcileWindows became
multi-client internally; single-client paths pass listOf(this).

Production shootout (same-run pairs, 100k cap): +18% to +64% over the
tuned single client, capped by the relay's server-side reconcile id
production rather than download parallelism (which the by-id matrix
shows scales 2.7x with connections). 4 in-process multi-client tests;
18 negentropy tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 04:27:46 +00:00
Claude
5a993810bb perf: add ParallelEventVerifier — batched Schnorr verify off the receiver
Client-side mirror of IngestQueue.parallelVerify: submit() is a cheap
bounded-channel send from the relay consumer coroutine; a drain loop
batches greedily (up to 256) and fans each batch across
Dispatchers.Default in core-sized chunks, dispatching callbacks in
submission order. preVerified short-circuits already-trusted ids; the
bounded channel backpressures the socket instead of growing heap.

Batch/chunk sizing is measurement-driven: per-event async cost ~40us of
scheduling each (swallowing the gain), and the per-batch join barrier
at 64 still cost half (64 -> 1.2x, 256 -> 2.2x, 1024 -> 3.2x on 4
cores). ParallelVerifyBenchmark (fresh signed events per pass — Event
caches derived state after first verify, so passes must not share
instances) measures 1.8x vs sequential with an enforced >=1.5x
assertion. 4 correctness tests cover valid/tampered routing, ordering,
preVerified and callback-crash resilience.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 04:15:29 +00:00
Claude
a134ca3ec8 perf: bound the per-connection websocket receive buffer
The reader-thread-to-consumer channel in BasicOkHttpWebSocket and the
app's OkHttpWebSocket was UNLIMITED: a consumer slower than the socket
accumulated frame Strings without bound (gigabytes over a multi-million
event download). Now capped at 4096 frames — when full, OkHttp's reader
thread blocks and TCP flow control pushes back on the relay instead of
the heap. The trade-off (a blocked reader delays PING/PONG handling) is
documented on the constant; the bound is deep enough that only a
pathologically slow consumer hits it.

BoundedReceiveBufferTest forces sustained backpressure (8-frame buffer,
sleeping consumer, real socket to a local geode relay) and asserts all
events plus EOSE arrive in order with no drops or deadlock.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 03:51:23 +00:00
Claude
b6ea565870 perf: add CachingEventDecoder — duplicate frames reuse the parsed Event
BasicRelayClient's decode step becomes a pluggable MessageDecoder
(default unchanged). The opt-in CachingEventDecoder scans EVENT frames
for their id (~0.3us, JSON-escape-safe so embedded event JSON in repost
content cannot confuse it; any irregularity falls back to full parse)
and on a cache hit synthesizes the EventMessage from the already-parsed
Event with the frame's own subId — every subscription still gets its
delivery and per-relay bookkeeping is unchanged; only the redundant
parse is skipped. Production traffic measured 14-57% duplicate frames.

DedupDecodeBenchmark (60k frames, 67% dups): 10.0us/frame full parse vs
1.5us/frame cached — 6.5x, with an in-benchmark assertion so the gain
is enforced. 7 scan-safety unit tests in CachingEventDecoderTest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 03:42:55 +00:00
Claude
73e68813f4 perf: shard PoolRequests lock per subscription
The single global spin lock serialized every EVENT frame from every
relay and measured negative scaling (4 concurrent relay consumers
pushed 3.6M deliveries/s aggregate vs 11.1M for one thread alone). The
lock now lives in RequestSubscriptionState — one per subscription —
since all compound mutations are per-subId and different subs share no
wire state. decideCommandLocked takes the state instance to avoid
re-entering the non-reentrant lock; all-subs iterations lock one sub at
a time; withLock is inline to keep the hot path allocation-free.

DispatchStageBenchmark (PoolRequests-only, 1 -> 4 feeders): scaling
flips from 0.33x to 3.4-7.3x across runs. PoolRequests concurrency,
NostrClient and negentropy suites pass unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 03:35:05 +00:00
Claude
5408df0271 feat: add negentropyReconcile — standalone need/have id diff for callers
Splits the reconcile out of negentropySync so callers decide how to
load: negentropyReconcile streams needIds (relay has, local lacks —
download) and haveIds (local has, relay lacks — publish) in batchSize
chunks with back-pressure, taking local state as List<IdAndTime> and
slicing it per created_at window on overflow splits; the accumulating
negentropyReconcileIds convenience returns both lists. negentropySync
now delegates to the same window engine.

NegentropySession's primary constructor takes List<IdAndTime> (JVM
erasure forbids a List<Event> overload); the event-list form moved to
NegentropySession.fromEvents, mirroring NegentropyServerSession, with
all call sites migrated.

Adds NostrClientNegentropyReconcileTest (empty local set, partial
overlap both directions, identical sets, batch streaming, since/until
window slicing) — 49 negentropy tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 02:46:43 +00:00
Claude
2fb44d8166 feat: pipeline negentropySync — global worker pool, concurrent window reconciles
Restructures the sync around the measured bottlenecks: one download
worker pool now spans the whole sync (windows no longer join before the
next reconcile starts), overflow-split windows are reconciled by a
caller-set number of concurrent NEG sessions (reconcileConcurrency,
default 1) from a shared work queue, and the reconcile-to-download
buffer depth is exposed (idBufferBatches). No NIP-11 auto-detection:
peak subscription usage (maxConcurrentReqs + reconcileConcurrency + 1)
is documented and budgeting it against the relay's max_subscriptions is
the caller's call.

All 44 negentropy tests pass unchanged. Production shootout on a 2.6M
corpus, single connection: 3.6k events/s with old-equivalent params,
4.5k/s tuned (12 reqs + 4 reconcilers) — ~82% of the measured ~5.5k/s
per-connection by-id ceiling, vs 1.6k/s for the old implementation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 02:28:32 +00:00
Claude
c193c1a15d feat: extend by-id matrix to relay caps — saturation found at scale
The relay was backfilled from 33k to 2.6M kind-30382 events between
runs, and the picture changed: on the big corpus one connection
saturates at ~8 in-flight REQs (~5.5k events/s) and the relay tops out
at ~15k events/s around 40 total in-flight — past that, added
concurrency only inflates per-REQ latency. Axis 1 now stops at the
relay's NIP-11 max_subscriptions (20; exceeding it wedged the
connection in the first extended run), cells carry a hard 120s budget
with one retry per batch, and per-REQ latency is computed over
completed batches so partial cells report honestly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 01:56:28 +00:00
Claude
466667add5 feat: add parallel fetch-by-id matrix — in-flight REQs, not connections, set throughput
Downloads the full 33k kind-30382 corpus from nip85.nosfabrica.com per
cell over a (connections x concurrent REQs) matrix with pre-connected
sockets. One connection scales near-linearly to 16 in-flight REQs
(1,970 -> 30,735 events/s) with no wall at 4, and 1x16 matches 4x4 —
total in-flight REQs is the real variable. Every slow path measured so
far (serial page cursors ~2-3.7k/s, negentropySync 1.6k/s) is a
pipelining/serialization problem: negentropySync starves its download
workers on reconcile cadence and recurses overflow windows
sequentially. Findings and implied negentropySync improvements in the
plan doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 00:58:21 +00:00
Claude
bce9dc0e60 feat: add per-connection wall test — relay pacing, not client parse
A raw no-parse socket and the full quartz stack page the same
production query on one connection at the same ~3.4-3.8k events/s,
proving the single-connection ceiling reported by a user is the
relay's per-connection response cadence rather than the client's
serial parse (which is ~1% busy at that rate). Findings and the
assessment of the proposed parallel-parse + ordered-dispatch pipeline
are in the plan doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 00:46:17 +00:00
Claude
5600b36018 feat: add bulk-download benchmark with negentropy vs paging test case
Measures how to download millions of events from a single relay as fast
as possible, download+parse only: local geode ceilings (giant REQ vs
until-cursor paging vs created_at-sharded connections, quartz stack vs
raw frames), offline per-frame strategies (full parse, parallel parse,
id-scan for raw archiving), and a production case syncing kind 30382
from nip85.nosfabrica.com via NIP-77 negentropy against plain paging.

Headline results in the plan doc: paging beats giant REQs 26x (which
also dropped frames), created_at sharding stacks ~2x on top, parse is
2-5% of the budget, and negentropy is 2.4x slower than paging for a
cold download (its win is incremental re-sync).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-02 23:43:30 +00:00
Claude
4aabde2d19 feat: add dispatch-stage benchmark (post-parse, pre-verify path)
Offline microbenchmark of NostrClient's dispatch stage — PoolRequests
state machine, listener fan-out, id dedup and handoff to a verify
stage — under 1 and 4 concurrent relay feeders. Key results: ~100ns per
message uncontended, but negative scaling under concurrency (the
PoolRequests busy-wait spin lock makes 4 feeders slower in aggregate
than 1), per-event channel handoff costs ~180ns vs a free 64-batch,
and early dedup before the locked path gives 2.7-4x aggregate
throughput at production duplicate factors. Findings appended to the
receiver-perf plan doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-02 23:22:12 +00:00
Claude
4b8cca0d59 feat: add production benchmark for the NostrClient receive path
Gated JVM test (-PprodRelayBench=1) that connects to live relays with
realistic filters and measures per-relay queue delay, processing time,
consumer busy fraction, EOSE latency and duplicate rates, comparing the
current inline verification against a parallel verify stage, plus
offline single-thread parse/verify ceilings on captured frames.

Findings from a first run are written up in
quartz/plans/2026-07-02-nostrclient-receiver-perf.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-02 22:49:42 +00:00
Claude
c549bd22df perf: isolate QUIC blocking socket I/O onto dedicated threads
Part A of the dispatchers/thread-caps audit — the one genuine at-scale
starvation the audit found.

UdpSocket.receive() does a blocking DatagramChannel recvfrom that parks its
thread for the ENTIRE life of the connection. It ran via
withContext(Dispatchers.IO) from a read loop already on Dispatchers.IO, so
the blocking call pinned one shared IO-pool thread per connection. Past ~64
concurrent connections that starves ALL other Dispatchers.IO work in the
process — this module's and the host app's alike.

Give each socket two dedicated daemon threads: recvDispatcher for the
perpetually-parked receive and sendDispatcher for the send (they can't share
one thread — the receive would monopolise it). QUIC's blocking socket I/O now
never touches the shared pool. connect() keeps its one-shot DNS/bind on
Dispatchers.IO (setup cost, not a lifetime parker).

close() calls shutdownNow() on both executors: interrupting the recv worker
breaks the parked recvfrom immediately (ClosedByInterruptException, caught as
ClosedChannelException -> receive() returns null), so the threads exit
promptly instead of leaking per closed connection. The closed-check is hoisted
out of withContext so a post-close call fails fast without dispatching onto a
shut-down executor.

Verified: QuicConnectionDriverLifecycleTest (100 session open/close cycles,
asserts thread growth <=16 and no FD leak) passes, confirming the two new
threads per socket are reclaimed on teardown. New UdpSocketTest covers
round-trip, the dedicated-thread isolation, thread shutdown on close, and the
after-close contract. Full :quic:jvmTest suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANuUziXKRafSTBxbh4SMoq
2026-07-02 21:35:57 +00:00
Vitor Pamplona
1c72d0dd73 Merge pull request #3455 from vitorpamplona/claude/reasonable-event-kinds-6k0n76
feat: expand the "Let's be reasonable" napplet auto-approve set
2026-07-02 14:48:26 -04:00
Claude
c85280259c feat: add reports, torrents, and addressable content to the reasonable set
Complete the "Let's be reasonable" content set:
- reports (1984) and torrents (2003/2004) — additive public events whose
  reputational weight is no greater than the arbitrary kind-1 notes an app
  can already publish.
- long-form articles (30023), wiki (30818), and the legacy addressable
  video kinds (34235/34236) — addressable content. Re-signing with the same
  d tag replaces the app's own prior version; accepted as no worse than the
  arbitrary posting a kind-1 grant already permits.

Only replaceable *configuration* (profile 0, contacts 3, 10000-range lists)
stays ASK, since a bad write there can silently wipe account settings —
distinct from addressable content.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMrHZfwN5tM4ecvdz7xigo
2026-07-02 18:13:34 +00:00
Claude
e4c35523a7 feat: expand reasonable set with more public content/engagement kinds
Add the remaining regular, additive, public, plaintext content and
engagement kinds that sit in the same risk class as notes/pictures:
relay chat (9), threads (11), public messages (24), poll votes (1018) and
polls (1068), file metadata (1063), voice messages (1222) and replies
(1244), live-stream chat (1311), and code snippets (1337).

Documents the borderline kinds left at ASK on purpose: reports (1984) and
torrents (2003/2004) carry reputational/legal weight, and
addressable/replaceable content (long-form 30023, wiki 30818) can overwrite
prior versions. Test pins several of these exclusions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMrHZfwN5tM4ecvdz7xigo
2026-07-02 18:08:48 +00:00
Claude
49e0fee162 perf: add expect/actual ConcurrentSet, use for EventDeduplicator
Part B of the dispatchers/thread-caps audit.

EventDeduplicator is fed from relay subscription callbacks
(AdvancedSearchBarState.trackRelayEvent), which arrive on multiple threads
concurrently. It backed a plain mutableSet with a single KmpLock, so every
delivery from every relay thread serialized on one monitor.

Add ConcurrentSet<E> as a KMP expect/actual util:
- jvmAndroid actual: ConcurrentHashMap.newKeySet() — lock-striped writes,
  lock-free reads, no single cross-thread monitor.
- iOS actual: a KmpLock-guarded set (no lock-free set in the K/N stdlib) —
  same behaviour as before, no regression. The win lands on JVM/Android,
  which is where the high-throughput event paths run.

Point EventDeduplicator at it. Covered by ConcurrentSetTest (commonTest,
behaviour) and ConcurrentSetConcurrencyTest (jvmTest, exactly-one-add-per-key
under 8 threads).

Scope note: the other two commonMain sites the audit flagged were left as-is
on purpose. The compose subscription managers' KmpLock is a deliberate,
documented KMP choice on a single (main-thread) writer where the lock is
effectively free; EOSECache is a bounded LRU with compound value mutation on
a per-subscription (not per-event) path. Neither is a clean fit for a
concurrent set, and converting them would fight a documented decision for
negligible gain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANuUziXKRafSTBxbh4SMoq
2026-07-02 16:53:43 +00:00
Claude
ca5ae978fb perf: add lock-free-read ConcurrentLruCache, use on two hot read paths
Part C of the dispatchers/thread-caps audit. Both LnurlEndpointCache and
DesktopCachedRichTextParser were bounded caches backed by a LinkedHashMap
behind a single monitor (@Synchronized / Collections.synchronizedMap with
accessOrder). An access-order map structurally mutates on get, so every
read took the lock — serializing all readers on paths that are hot
(kind-9735 zap-receipt validation; feed rich-text rendering).

Add ConcurrentLruCache<K, V> in quartz utils: storage is a
ConcurrentHashMap so get is lock-free; writes + eviction run under a small
write lock that is off the read path. Eviction is least-recently-put order
(get does not refresh recency) — exactly what LnurlEndpointCache already
did, and fine for the deterministic rich-text parse cache.

Point both caches at the shared helper. Covered by a new
ConcurrentLruCacheTest (round-trip, eviction order, re-put recency
refresh, get-does-not-refresh, clear, and a concurrent size-bound smoke
test); the existing LnurlEndpointCacheTest still passes unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANuUziXKRafSTBxbh4SMoq
2026-07-02 16:21:27 +00:00
Claude
7e61d24962 perf: remove needless blocking on two per-packet/per-event hot paths
Part of the dispatchers/thread-caps audit. Two low-risk fixes that remove
thread-blocking work from paths hit on every event / every packet:

- LargeCache (iOS actual): drop the runBlocking wrapper around
  createIfAbsent. The block contained only synchronous CacheMap ops (the
  same get/put getOrCreate already calls without runBlocking), so it was
  pure dispatcher-blocking overhead on the per-event ingest path. Now
  mirrors the JVM actual's plain-function shape.

- QUIC JCA AEADs (AES-GCM + ChaCha20-Poly1305): split the single
  `synchronized(this)` monitor into disjoint encryptLock / decryptLock.
  seal-family touches only encryptCipher + recentEncryptNonces; open-family
  touches only decryptCipher, so a connection's send loop and read loop no
  longer serialize against each other through crypto on every packet. The
  documented defence-in-depth against cross-coroutine Cipher corruption is
  preserved per direction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANuUziXKRafSTBxbh4SMoq
2026-07-02 16:09:49 +00:00
Claude
a99ee8d09e feat: auto-approve video posts and NIP-42 relay auth under "Let's be reasonable"
Add video posts (kinds 21 normal, 22 short) as direct siblings of picture
posts (20) — additive, public, non-destructive content in the same risk
class as the original note set.

Also auto-approve NIP-42 relay auth (22242): an ephemeral proof-of-key
bound to a single relay + challenge (unreplayable elsewhere) that
Amethyst's own client already auto-signs for every logged-in account, so
treating it as background noise for napplets matches existing behavior.

Deliberately still ASK: NIP-98 HTTP auth (27235). Unlike relay auth it
authorizes an arbitrary HTTP request as the user — including destructive
NIP-96 blob deletes and NIP-86 relay-management admin calls — so its blast
radius is too broad to sign silently. Test pins the 42-vs-98 contrast.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMrHZfwN5tM4ecvdz7xigo
2026-07-02 16:04:12 +00:00
Claude
20a9622f27 feat: auto-approve zap requests (9734) under "Let's be reasonable"
Signing a Lightning zap request moves no money — it only fetches an
invoice. The payment itself is the separately-gated value.payInvoice
capability, which prompts on every use regardless of policy. So adding
9734 to the reasonable set drops a redundant signature prompt while the
meaningful payment prompt stays.

Nutzaps (9321) remain excluded: publishing one *is* the payment, since
the event carries the spendable ecash proofs. Test pins the contrast.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMrHZfwN5tM4ecvdz7xigo
2026-07-02 15:49:07 +00:00
Claude
b02b5439d5 feat: expand "Let's be reasonable" napplet auto-approve set
Add more additive, public, non-destructive event kinds to the REASONABLE
signer policy so common apps stop prompting for every action. New kinds:
16 (generic repost), 20 (picture post), 42 (public chat message),
1111 (NIP-22 comment), 9802 (highlight), and 30315 (user status) — all in
the same risk class as the original 1/6/7 set.

Kinds that can spend money, overwrite account config (profile, contacts,
relay/mute/bookmark lists), delete content, or leak private data still
prompt. Decryption also stays ASK.

Refactors reasonableDecision() to a documented REASONABLE_SIGN_KINDS set
backed by quartz KIND constants, and adds NostrSignerPermissionLedgerTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMrHZfwN5tM4ecvdz7xigo
2026-07-02 15:14:59 +00:00
nrobi144
ce3536ddf7 test(desktop-cache): bind accountPubkey before consuming self kind-3
The consumeContactList scoping fix means kind-3 events only update
`_followedUsers` when `event.pubKey == accountPubkey`. Tests were
constructing a fresh DesktopLocalCache() (accountPubkey = null) and
publishing kind-3s authored by `userPubKey` / `ownerPubKey`, so the
guard silently rejected them and `followedUsers` stayed empty.

Bind `accountPubkey` in the test setup so the guard passes.
2026-07-02 18:11:16 +03:00
nrobi144
9284e12e86 feat(desktop): Web-of-Trust score badges + amy wot verbs
Adds a friends-of-friends trust score on every user avatar in Desktop
feeds, threads, profile headers, and repost overlays. For pubkey X the
score is the count of accounts in the active user's follow set who also
follow X — Gossip / Snort convention. v1 is display-only; no threshold
filtering.

Data flow
- `commons/wot/WoTService` — sparse `SnapshotStateMap<HexKey, Int>` +
  reverse index + per-follower snapshot for diff-based updates.
  Single-writer coroutine (Channel<Op> → `Snapshot.withMutableSnapshot`)
  serializes all mutations. Cap at 5000 follows/event blocks DoS via
  hostile kind-3s. Guardrail at 2000 follows/account skips WoT for
  mega-accounts.
- `DesktopIAccount.wotService` — per-account instance, matches
  `Kind3FollowListState` / `BookmarkListState` conventions.
- `Main.kt` binds `localCache.accountPubkey`, collects
  `localCache.contactListEvents` → `applyKind3`, collects
  `localCache.followedUsers` → `onFollowSetChange` +
  `subscriptionsCoordinator.loadKind3Batched(...)` with
  `onEose = markReadyOnce`. 2 s fallback timeout guarantees badge
  visibility even if index relays never EOSE.
- `FeedMetadataCoordinator.loadKind3Batched(pubkeys, onEose)` — chunks
  authors into ≤100 per Filter within one subscription. Matches
  nostr-rs-relay defaults.

UI
- `commons/ui/components/UserAvatar` gets an optional
  `badge: @Composable BoxScope.() -> Unit`. Android call sites pass
  null (no compile-time coupling to Desktop-only tooltip APIs).
- `desktopApp/.../ui/note/WoTBadge` — Material3 `TooltipBox` +
  `PlainTooltip` (multiplatform-ready, keyboard/screen-reader a11y).
  `rememberTooltipState(isPersistent = true)` fixes the
  vanish-too-fast desktop default.
- `desktopApp/.../ui/note/WoTBadgedAvatar` — drop-in replacement for
  `UserAvatar` that overlays the badge when
  `LocalWoTService != null && LocalWoTReady && pubkey !in LocalSpamExemptKeys`.
  Score read is a plain `service.scores[userHex] ?: 0` — snapshot
  system tracks per-key, so avatars only recompose when their own
  score changes.
- Call-site migration at 4 v1 surfaces: NoteCard header (covers feed /
  thread / bookmarks / search / QuotedNoteEmbed via NoteCard),
  FeedNoteCard repost header (2 avatars), UserProfileScreen header
  (2 sizes).

Amy verbs
- `amy wot get <pubkey|npub> [--json]` — hydrates a WoTService from the
  local FsEventStore, prints score for target pubkey.
- `amy wot list [--threshold N] [--limit K] [--json]` — sorted score
  list.
- `amy wot sync [--timeout N]` — batch-fetches kind-3 for the active
  follow set from outbox/inbox relays, persists to the event store.

Tests + docs
- 14 unit tests: `WoTServiceTest` covers happy path, sparse map,
  self/follower exclusion, kind-3 churn diff, guardrail, event cap,
  ready gate, clear.
- Manual testing sheet with 17 scenarios at
  `desktopApp/plans/2026-07-01-wot-score-manual-testing-sheet.md`.
- Plan at `docs/plans/2026-07-01-feat-desktop-wot-score-plan.md`.

Prerequisite `DesktopLocalCache.consumeContactList` scoping fix landed
as a separate commit.
2026-07-02 18:03:47 +03:00
nrobi144
741c377f80 fix(desktop-cache): scope consumeContactList to active user + fan out to SharedFlow
The old implementation tracked kind-3 replaceability with a single global
`lastContactListCreatedAt` scalar and unconditionally overwrote
`_followedUsers` on every accepted event. Once *any* subsystem starts
fetching other users' kind-3 events (WoT scoring, mutual-follow lookups,
etc.), a newer-createdAt kind-3 from a follower silently hijacks the
active user's follow-set state, cascading into feed filters, mute logic,
and sidebar counts.

Fix by tracking newest-per-author (`ConcurrentHashMap<HexKey, Long>`) and
guarding writes to `_followedUsers` / `lastContactListEvent` on
`event.pubKey == accountPubkey`. `accountPubkey` is bound from `Main.kt`
on login.

Also expose `contactListEvents: SharedFlow<ContactListEvent>` (buffer 64,
DROP_OLDEST) so downstream consumers (the incoming WoT service) can
observe every accepted kind-3 without adding a bespoke listener API.
2026-07-02 18:03:12 +03:00
Vitor Pamplona
4727669fde Merge pull request #3453 from vitorpamplona/claude/quartz-logging-review-o1l51r
Make Log.sink pluggable for custom logging backends
2026-07-02 09:33:52 -04:00
Claude
167fe96345 refactor(quartz): route stray printStackTrace through the Log facade
printStackTrace() dumps straight to stderr, bypassing both Log.minLevel
and the consumer's Log.sink — the very thing the LogSink work exists to
control. Migrate the five production call sites:

- Lud06: drop two printStackTrace() calls that sat directly above an
  existing Log.w(..., t) carrying the same throwable (pure duplication).
- ElectrumXClient: the swallowed-lookup catch said "Log but don't crash"
  yet used printStackTrace(); route it through Log.w with context.
- OpenTimestamps: log the swallowed merge failure via Log.w; drop the
  print-then-rethrow (the rethrown exception already carries the trace).

Socket-protocol writer.println(...) and README/KDoc println examples are
left as-is — they are wire I/O and documentation, not logging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EK3TrDkP1EXj1d62oKJMdc
2026-07-02 13:31:47 +00:00
Claude
292473ee26 feat(quartz): let consumers own logging via a swappable LogSink
Quartz already funnels every diagnostic through the `Log` facade, but the
sink was hardcoded per platform (android.util.Log / System.err / NSLog /
println), so a consuming app couldn't route Quartz logs into its own stack
(Timber, SLF4J, Crashlytics, a file, a test buffer, or /dev/null).

Add a `LogSink` fun interface and a replaceable `Log.sink`, defaulting to
`PlatformLogSink` which reproduces the historical per-platform behavior.
All ~225 call sites and the `Log.*` signatures are unchanged; the lazy
`() -> String` overloads still short-circuit on `minLevel` before the
lambda runs, preserving the allocation-free fast path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EK3TrDkP1EXj1d62oKJMdc
2026-07-02 13:25:59 +00:00
Vitor Pamplona
016a5bd8b1 Merge pull request #3452 from vitorpamplona/claude/quartz-searchable-event-audit-fhvjws
Implement SearchableEvent interface for NIP-50 search support
2026-07-02 09:13:10 -04:00
Claude
35d0aa0ebc feat(quartz): index more event kinds for NIP-50 full-text search
Several event classes carried human-readable text (titles, names,
descriptions, prompts, free-text notes) but did not implement
SearchableEvent, so their content never made it into the SQLite FTS
index. Implement SearchableEvent on:

Tier 1 (titles/names/descriptions):
- NIP-15 marketplace: ProductEvent, StallEvent, AuctionEvent,
  MarketplaceEvent (name/description/about parsed from JSON content)
- Podcasting20TrailerEvent (title + content)
- TextNoteModificationEvent (proposed text + edit summary)
- GitStatusEvent base -> covers kinds 1630-1633 (status message)
- NIP-29 EditMetadataEvent (group name + about; added name()/about())

Tier 2 (free-text prose / labels):
- LiveActivitiesRaidEvent (raid message)
- CalendarRSVPEvent (RSVP note)
- MintRecommendationEvent (mint review)
- LabelEvent (content + label values)
- P2POrderEvent (maker name, currency, payment methods)
- NIP90 request events: text generation (prompt), image generation
  (prompt + negative prompt), text-to-speech (text)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5MN4vF4JJG7xFCofJAHg8
2026-07-02 13:10:20 +00:00
Vitor Pamplona
d9c423eabf Merge pull request #3449 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-02 08:43:29 -04:00
Vitor Pamplona
13f65dec26 Merge pull request #3450 from vitorpamplona/claude/compose-signature-field-u7rbx6
Add compose signature setting to auto-append custom text to posts
2026-07-02 08:42:20 -04:00
vitorpamplona
5c40187ba0 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-02 11:05:32 +00:00
Vitor Pamplona
8a618a92b0 Merge pull request #3432 from nrobi144/feat/desktop-privacy-lock
feat(desktop): Privacy lock for Messages column
2026-07-02 07:02:59 -04:00
nrobi144
cac54001da chore: retrigger CI after flaky macOS AppStateMachineTest
AppStateMachineTest.bootstrapSubscriptionFiresAtMostOncePerAccountLoad
hit a ConcurrentModificationException on macos-latest only. Test passes
locally 5/5 runs and every other CI check on this PR is green
(lint, Linux DEB, Windows MSI, Android, iOS, Compose smoke). Empty
commit to re-run the macOS DMG job.
2026-07-02 10:17:06 +03:00
nrobi144
33bb81dddb Merge remote-tracking branch 'upstream/main' into feat/desktop-privacy-lock
# Conflicts:
#	desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt
2026-07-02 07:20:44 +03:00
Claude
b0834b8d8a feat: add compose signature pre-filled in text-based post screens
Adds a Signature field to Compose Settings (global UI settings, DataStore
persisted). When opening any text-based composer — new note, reply, quote,
poll, NIP-22 comment (reply/hashtag/geohash/url), or a new long-form
article — the signature is appended to the message with a blank line,
keeping the cursor at the start so the user types above it.

Drafts, forks, and version edits are skipped since their content already
carries (or deliberately omits) a signature, and an untouched
signature-only message is treated as blank so closing the composer never
auto-saves a junk draft.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vq4JQPB9m62nJ8Vdp7xVLN
2026-07-02 03:41:00 +00:00
Vitor Pamplona
e6728f8393 Merge pull request #3447 from vitorpamplona/claude/light-theme-pulldown-styling-2avguo
Remove elevation from bottom and top sheet surfaces
2026-07-01 20:24:17 -04:00
Vitor Pamplona
05eb6db9cd Merge pull request #3446 from vitorpamplona/claude/relay-damus-shutdown-hlno35
Centralize default search relays into DefaultSearchRelayList
2026-07-01 20:23:35 -04:00
Claude
690bab0f0f fix: drive search-relay examples text from DefaultSearchRelayList
The "Good options are: …" hint in the search-relay setup dialog listed a
hardcoded, drifting subset of relays (and the zh locales had corrupted
hostnames like "reiny.nostr.band"). Make it dynamic instead:

- search_relays_not_found_examples now ends in a %1$s placeholder across
  all 57 locales.
- AddSearchRelayListDialog fills it from
  DefaultSearchRelayList.joinToString { " - ${it.displayUrl()}" }, so the
  hint always reflects the shared AmethystDefaults search set and stays in
  sync with the "reset to defaults" button right below it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ttcqa3V78bugGraGhtehj
2026-07-02 00:11:38 +00:00
Claude
44edf3314a fix: remove Material 3 elevation tint from browser pull sheets on light theme
The embedded browser's top pull-down (TopControlSheet) and bottom pull-up
(BottomConsoleSheet) both drew with tonalElevation. Material 3 recolors a
Surface only when its color is EXACTLY colorScheme.surface, swapping in
surfaceColorAtElevation() which blends surfaceTint (= primary, the Amethyst
purple) over the surface. On the light theme that near-white + purple mix
reads as a bright pink/lilac cast instead of the plain background.

- TopControlSheet: drop tonalElevation to 0 so it renders the plain
  background color; keep shadowElevation to lift it off the page.
- BottomConsoleSheet: drop both elevations. It docks flush against the
  bottom navigation bar, so shadowElevation cast a shadow onto that bar (a
  seam breaking the flush look); the tonalElevation had no color effect
  anyway since its color isn't exactly colorScheme.surface. The grabber and
  divider still separate the panel from the page above it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TJ2VbDz7C6hvHVsLpdecYf
2026-07-02 00:07:00 +00:00
Claude
461aa57b57 fix: use AmethystDefaults search relays and drop dead relay.nostr.band
relay.nostr.band has been decommissioned. Remove it from every runtime
relay list and route search-relay defaults through the shared
AmethystDefaults.DefaultSearchRelayList in commons:

- amy NipCommand: SEARCH_RELAYS now = DefaultSearchRelayList (drops the
  hardcoded relay.nostr.band/nostr.wine pair; RelayUrlNormalizer import
  no longer needed).
- desktop DesktopRelayCategories: DEFAULT_SEARCH_RELAYS now =
  DefaultSearchRelayList instead of a single relay.nostr.band entry
  (which would otherwise be empty after removal).
- desktop DefaultRelays and FollowPacks DISCOVERY_RELAYS: drop
  relay.nostr.band.
- Update NIP-50 example hostnames in desktop comments, the search-relay
  editor help text, and the localized search_relays_not_found_examples
  string across all locales to nostr.wine.

Preview sample data, captured sample-event JSON, and quartz test
fixtures that mention relay.nostr.band are left untouched (no runtime
effect).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ttcqa3V78bugGraGhtehj
2026-07-01 23:59:15 +00:00
Claude
b1fda59cd6 fix: drop relay.damus.io from default relay lists ahead of shutdown
relay.damus.io is being decommissioned, so remove it from every runtime
default/fallback relay set to stop the app and amy from wasting connection
slots on a dead host:

- commons Constants: remove `damus`; it dropped out of `bootstrapInbox`
  (default NIP-65 inbox) and `eventFinderRelays` (default outbox/fallback),
  both still carrying 6 healthy relays.
- ChessConfig: remove damus from CHESS_RELAYS / CHESS_RELAY_NAMES, leaving
  the 3 relays the FETCH_TIMEOUT comment already assumes.
- desktop DefaultRelays: remove damus and the also-dead relay.snort.social.
- desktop FollowPacks DISCOVERY_RELAYS: remove damus.
- amy NipCommand SEARCH_RELAYS: swap damus for the NIP-50-capable nostr.wine.

Comments, @Preview sample data, and test fixtures that mention damus.io are
left untouched — they have no runtime effect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ttcqa3V78bugGraGhtehj
2026-07-01 23:35:24 +00:00
David Kaspar
572fdb5ff4 Merge pull request #3438 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-01 22:21:00 +01:00
vitorpamplona
73c9e2086a chore: sync Crowdin translations and seed translator npub placeholders 2026-07-01 21:19:15 +00:00
Vitor Pamplona
19d1167ccd Merge pull request #3441 from vitorpamplona/claude/podcast-event-kinds-merge-vv24gd
Add podcast authoring UI and NIP-XX Podcasting 2.0 support
2026-07-01 17:16:46 -04:00
Vitor Pamplona
4a66435263 Merge pull request #3445 from vitorpamplona/claude/sqlite-event-store-no-fts-9k6y8a
Add optional full-text search indexing toggle to SQLiteEventStore
2026-07-01 17:14:22 -04:00
Claude
8b938396a0 refactor(quartz): move FTS toggle into IndexingStrategy
Fold the full-text-search on/off switch into `IndexingStrategy` as
`indexFullTextSearch` (default `true`) instead of a separate top-level
`enableFullTextSearch` constructor param on `EventStore`/`SQLiteEventStore`.

`IndexingStrategy` is already the single place that decides which indexes
the store builds — every field is a per-index toggle with a size/speed
tradeoff, and `QueryBuilder` already receives it — so FTS, being just
another index, belongs there rather than split across two config surfaces.

Behaviour is unchanged: the module's no-op path and the QueryBuilder
"search matches nothing" guards now read the flag via the strategy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjzUpY8H31c7ux669zytWg
2026-07-01 21:11:26 +00:00
Claude
cd4edefce0 feat(quartz): allow SQLite event store without FTS indexing
Add an `enableFullTextSearch` flag (default `true`) to `EventStore` and
`SQLiteEventStore` so deployments that never serve NIP-50 search from
SQLite — e.g. a relay that offloads search to an external engine like
Vespa — can skip the full-text-search write cost.

When disabled:
- `FullTextSearchModule` becomes an inert no-op: the `event_fts` virtual
  table and its `fts_foreign_key` delete trigger are never created,
  inserts skip `indexableContent()` + tokenization, and both reindex
  entry points return immediately.
- `QueryBuilder` short-circuits any query/count/delete filter carrying a
  non-empty `search` term to a "matches nothing" result (an empty-string
  search still imposes no constraint), so no SQL ever references the
  absent `event_fts` table. In a multi-filter union the search branch
  contributes nothing while the other filters resolve normally.

Everything else (replaceable/addressable handling, deletions,
expirations, right-to-vanish, negentropy) is unchanged, and the default
keeps FTS on for existing callers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjzUpY8H31c7ux669zytWg
2026-07-01 21:00:06 +00:00
Vitor Pamplona
daa453c3c3 Merge pull request #3444 from vitorpamplona/claude/bottom-nav-reset-defaults-jxg5z0
Persist bottom bar defaults as blank sentinel for auto-migration
2026-07-01 16:59:01 -04:00
Claude
d723e93e7a fix(quartz): drop commas from podcast test names for Kotlin/Native
Kotlin/Native (the iOS test target) rejects commas in backtick function
names, so test-quartz-ios failed to compile even though jvmTest — which
allows them — passed. Rename the two offending tests. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-07-01 20:55:58 +00:00
Claude
679c337427 feat(amethyst): persist default bottom bar as a sentinel so resets auto-migrate
The bottom-bar settings screen already offers a "Restore defaults" action, but it
saved the concrete default list to disk. That pinned the user to the default of
the version they reset on, so a future release that changes DefaultBottomBarEntries
would never reach them. The same happened to users who never touched the bar: any
unrelated settings save wrote the concrete default list.

Persist "follow the defaults" as a blank sentinel instead. On load a blank value
already resolves to the current DefaultBottomBarEntries, so whenever the default
changes in a later version, every user on the defaults is migrated automatically.
Genuinely customized bars are still stored verbatim as JSON.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011EBk58qUYuyKxz9Ju4VpEq
2026-07-01 20:50:42 +00:00
Vitor Pamplona
9c191137d5 Merge pull request #3443 from vitorpamplona/claude/nip11-document-builder-gq7eeq
Add type-safe DSL builder for NIP-11 relay information documents
2026-07-01 16:45:31 -04:00
Claude
266f59959e Merge remote-tracking branch 'origin/main' into claude/podcast-event-kinds-merge-vv24gd 2026-07-01 20:30:27 +00:00
Claude
8085f82ca4 feat(podcasts): standard ReactionsRow on each episode in the Podcast screen
Replace the removed per-episode comment chip with the full NoteCompose
ReactionsRow (comment / zap / react) on every episode row in a podcast's
screen — same engagement affordance as the show header and every other note.
addPadding = false so it aligns within the row's existing padding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-07-01 20:27:37 +00:00
Vitor Pamplona
03c71d1d4e Merge pull request #3440 from davotoula/fix/sonar-encoding-nul-bytes
Escape raw NUL bytes to fix Sonar encoding warning
2026-07-01 16:27:23 -04:00
Vitor Pamplona
02df0672e7 Merge pull request #3442 from vitorpamplona/claude/nip05-function-docs-rcebl7
docs: Add NIP-05 identifier resolution guide and update skill
2026-07-01 16:24:13 -04:00
Claude
2f66c31f43 docs(quartz-integration): document the NIP-11 relay info builder
Add a "NIP-11 Relay Information Document" section to the quartz-integration
skill (the guide AI consults when using Quartz in external projects),
covering the relayInformation { } DSL, serving it over
application/nostr+json, the nested limitation/fees/retention builders, and
the limitation(RelayLimits) sync overload. Also add Quick Reference rows
and extend the skill trigger description to the run-a-relay case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C64Yy2d3na7Y28u7GRrHMX
2026-07-01 20:23:35 +00:00
Claude
c871597fc9 feat(quartz): add a type-safe NIP-11 relay info builder
Relay operators wiring up a Quartz-based relay had to hand-write the
NIP-11 document as a large JSON string, which is error-prone and drifts
from the model. Add a DSL builder so they can describe the document in
Kotlin instead:

    val info = relayInformation {
        name = "sot"
        description = "NIP-50 profile search ranked by Nostr web-of-trust"
        software = "https://github.com/vitorpamplona/sot"
        version = "0.1"
        supports(1, 11, 42, 50)
    }
    call.respondText(info.toJson(), ContentType.parse(Nip11RelayInformation.CONTENT_TYPE))

The builder covers every field, with nested `limitation { }` / `fees { }`
DSLs, repeatable list helpers, a `retention(...)` entry adder, and a
`limitation(RelayLimits)` overload that advertises exactly the limits the
relay enforces so the two can't drift.

Also fix FlexibleIntListSerializer to emit numeric `supported_nips` as
JSON integers ([1,11,42,50]) instead of quoted strings, matching the
NIP-11 spec; non-numeric ids still fall back to strings. Geode's default
document now builds via the DSL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C64Yy2d3na7Y28u7GRrHMX
2026-07-01 20:19:03 +00:00
Claude
4147298494 docs: explain NIP-05 identifier resolution in nostr-expert skill
AI agents were hand-rolling their own hex/npub/nprofile/NIP-05 resolvers
(e.g. a bespoke `resolveObserver` with its own well-known fetch and JSON
parse) instead of using `resolveUserHexOrNull` in
`quartz/nip05DnsIdentifiers/`, which already handles every identifier form.

Add discoverable explainers so the canonical functions surface before an
agent reaches for a hand-rolled version:

- New reference `references/nip05-identifiers.md`: full API surface
  (`resolveUserHexOrNull`, `Nip05Client`, `Nip05Id`, `Nip05Parser`,
  `KeyInfoSet`, Namecoin `.bit`, `OkHttpNip05Fetcher`) plus the
  hand-rolled anti-pattern to avoid and the  replacement.
- SKILL.md: new "Resolving User Input to a Pubkey" section, a
  When-to-Use bullet, Bundled Resources + Quick Reference rows, and the
  identifier-resolution trigger added to the skill description.
- nip-catalog.md: fix the stale NIP-05 entry (referenced a nonexistent
  `Nip05Verifier.kt`) to point at `UserHexResolver.kt` / `Nip05Client.kt`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bb8txetygxSnLXHJhPZSwH
2026-07-01 20:14:06 +00:00
Vitor Pamplona
33abc8acb9 Merge pull request #3439 from vitorpamplona/claude/quartz-optional-auth-policy-gkf5b9
Add OptionalAuthPolicy for NIP-42 without requiring authentication
2026-07-01 15:42:17 -04:00
Claude
f7afc56752 feat(quartz): add optional NIP-42 AUTH relay policy
Introduce OptionalAuthPolicy, a relay-server policy that runs the full
NIP-42 challenge/verify handshake — emitting the AUTH challenge on connect
and recording verified pubkeys into the connection scope — but never
requires it: EVENT, REQ, and COUNT are always accepted, so clients that
ignore the challenge keep working.

It subclasses FullAuthPolicy and only relaxes the EVENT/REQ/COUNT gates, so
the authorize() hook and per-connection authenticatedUsers set behave
identically; downstream policies can still gate or rewrite on caller
identity. Wire it into geode via an optional_auth config option and a
--optional-auth CLI flag (ignored when require_auth/--auth is set, since
mandatory AUTH already sends the challenge).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJWy4dBBYLbqthhLvbcZh7
2026-07-01 19:30:19 +00:00
davotoula
f7dd2c210b fix: escape raw NUL bytes to fix Sonar encoding warning
Four Kotlin sources embedded literal NUL (0x00) bytes, used as string
separators and as literal characters in KDoc comments. A NUL is valid
UTF-8 (U+0000) so decoders accept it, but SonarScanner flags an embedded
NUL in a text source as a file-encoding problem, and git tracked these
files as binary.
2026-07-01 21:25:27 +02:00
Claude
9ac1ba692f feat(podcasts): Top Supporters leaderboard, in-app chapters, transcript viewer
Brings three of PodStr's engagement/reading widgets to Amethyst's podcast
screens, reusing existing infrastructure:

- Top Supporters — a sats-ranked zap leaderboard on the show header, top 3
  flagged with gold/silver/bronze medals, tap-through to profiles. Aggregates
  the show note's zaps through the same LiveActivityTopZappersAggregator the
  live-stream leaderboard uses. Anonymous zaps collapse into one bucket.
- In-app Chapters — fetches the Podcasting-2.0 chapters.json referenced by the
  episode's `chapters` tag and renders a collapsible, tappable list; tapping a
  chapter seeks the live media controller (same seek path as soundbites).
- Transcript viewer — fetches the `transcript` file and shows it in a
  collapsible scrollable panel, stripping VTT/SRT scaffolding into flowing text.

Adds PodcastRemoteContent (a bounded URL text fetcher) for the two off-event
side files. Also removes the now-redundant per-episode comment chip from the
episode list rows — the show ReactionsRow and the episode thread already cover
commenting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-07-01 19:22:19 +00:00
Vitor Pamplona
d0497eaed7 Merge pull request #3437 from vitorpamplona/claude/ai-submissions-spotless-check-gftv4v
Add pre-push Spotless formatting gate
2026-07-01 14:57:14 -04:00
Claude
be58d98d2b chore: gate remote submissions on spotless formatting for AI sessions
Add a PreToolUse hook (.claude/hooks/pre-push-spotless.sh, wired in
.claude/settings.json) that runs spotlessApply before a git-push subcommand
or the create_pull_request MCP tool. If spotless reformats tracked Kotlin,
or reports a non-autofixable lint, the call is blocked so the fix is
committed first -- turning CI's spotlessCheck failure into an in-session
block. Gradle infra/network failures warn and allow, leaving CI as the
backstop. Boundary detection tokenizes the command so the words appearing
in a commit message do not trip the gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nWg1Wwq5xCE7kPFzWY95o
2026-07-01 18:48:15 +00:00
Claude
dc9579f994 Merge remote-tracking branch 'origin/main' into claude/podcast-event-kinds-merge-vv24gd 2026-07-01 18:39:59 +00:00
Vitor Pamplona
4e90fbcc2c Merge pull request #3436 from vitorpamplona/claude/download-button-blossom-uri-ii41o1
Support Blossom URI scheme for media downloads
2026-07-01 14:39:04 -04:00
Claude
f35d40cc00 style: drop redundant EOL comments flagged by ktlint in Hex.kt
New KDoc blocks on isHex/isHex64 already state the "~47ns" and
"~30% faster" perf notes, so the trailing EOL comments between the
KDoc and the function now trip ktlint's standard:no-consecutive-comments
rule ("an EOL comment may not be preceded by a KDoc"). Remove them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BawgXifvcPidMMqJ719Ka2
2026-07-01 18:33:19 +00:00
Claude
0689a7d244 fix: resolve blossom: URIs when saving media to gallery
The download/save-to-local button handed the raw content URL straight to
OkHttp. For BUD-10 `blossom:` URIs this failed with "expected scheme http
or https but was blossom" because OkHttp only speaks http/https.

Resolve `blossom:` URIs to a concrete server URL via BlossomServerResolver
(the same resolver the Coil/ExoPlayer pipeline uses) before downloading, in
both save entry points (AccountViewModel.saveMediaToGallery and the zoomable
dialog's save action). When no hosting server can be found, the save reports
an error instead of crashing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BawgXifvcPidMMqJ719Ka2
2026-07-01 18:32:34 +00:00
Vitor Pamplona
308058d242 Merge pull request #3435 from vitorpamplona/claude/quartz-hex-utilities-docs-e4eawy
Document Quartz utilities: hex, time, random, hashing, bech32
2026-07-01 14:17:01 -04:00
Claude
a98ec62004 feat(podcasts): render nostr-native podcast:person credits as real profiles
When a Podcasting-2.0 person's href points at an npub/nprofile (bare,
nostr: URI, or an njump-style link), upgrade the free-text credit to a real
Nostr profile: the standard ClickableUserPicture + UsernameDisplay, tappable
through to the profile. Plain web links keep the free-text card with the
default profile-image loader.

- quartz: PodcastPerson.nostrPubKey() resolves href → pubkey via Nip19Parser
  (npub/nprofile only). Covered by PodcastPersonSoundbiteTest.
- UI: PodcastPeople branches per person — LoadUser + standard profile
  components for nostr identities, free-text card otherwise — sharing one
  card scaffold so both look identical in the Hosts & Guests strip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-07-01 18:15:24 +00:00
Claude
2e2dbcb16b docs(quartz): document core utilities (time, random, hashing, bech32, strings)
Same discoverability treatment as the Hex helpers: KDoc on the reusable,
heavily-used primitives external integrators and AIs kept missing, plus
skill coverage.

- TimeUtils: object + key-fn KDoc emphasizing Unix *seconds* (created_at)
  vs the millisecond nowMillis() exception.
- RandomInstance: object + per-fn KDoc (secure random; use over kotlin.random).
- sha256(): KDoc pointing event-id work to EventHasher.
- EventHasher: object + fn KDoc on canonical id serialization / verification.
- StringUtils: KDoc on the allocation-free case-insensitive matchers + DualCase.
- Bech32: "use NIP-19 helpers unless you need a custom prefix" pointer; KDoc
  on bechToBytes.
- quartz-integration skill: new "Everyday utilities" section (time/random/
  hashing/bech32/base64) + quick-reference rows.
- nostr-expert skill: "Core Utilities" section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HM5uueF4a17umGpjC8wcz
2026-07-01 18:00:45 +00:00
Claude
68a28faab2 docs(quartz): document Hex/HexKey utilities for discoverability
AI agents integrating Quartz were re-implementing hex encoding or pulling
in third-party codecs because the built-in helpers weren't discoverable.

- Add KDoc to the `HexKey` typealias, its extension functions
  (`toHexKey`/`hexToByteArray`/`hexToByteArrayOrNull`/`isValid`) and the
  `PUBKEY_LENGTH`/`EVENT_ID_LENGTH` constants.
- Add KDoc to the `Hex` object and its public API
  (`isHex`/`isHex64`/`decode`/`encode`/`isEqual`).
- Add a dedicated "Hex utilities" section + quick-reference rows to the
  quartz-integration skill, and fix the wrong `HexKey.decodeHex(hex)`
  snippet (no such API) to the real `hex.hexToByteArray()`.
- Add a "Hex Encoding" section to the nostr-expert skill.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HM5uueF4a17umGpjC8wcz
2026-07-01 17:17:23 +00:00
Claude
429ec9177e feat(podcasts): Podcasting-2.0 person credits and soundbites
Adds two Podcasting-2.0 features to the podcast stack:

Persons (podcast:person) — hosts/guests as free-text credits with role,
avatar, and link (not necessarily Nostr users):
- quartz: PodcastPerson model; PersonTag ["person", name, role, img, href]
  on kind 30054 episodes; a persons[] array in the kind 30078 show JSON.
  Exposed via PodcastEpisode.episodePersons() / PodcastShow.showPersons().
- UI: PodcastPeople — a "Hosts & Guests" avatar strip (robohash fallback,
  tap opens href), shown on the episode card and the show header.

Soundbites (podcast:soundbite) — highlight clips:
- quartz: PodcastSoundbite model; SoundbiteTag ["soundbite", start, dur,
  title?] on episodes; PodcastEpisode.episodeSoundbites().
- UI: PodcastSoundbites — "jump to the good part" chips under the audio
  player that seek the live media controller to the clip's start.

Both parse leniently, round-trip through build(), and are covered by
PodcastPersonSoundbiteTest. NIP-F4 returns empty for both (no such tags),
so nothing renders there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-07-01 16:31:33 +00:00
Claude
ec1ea54dcc feat(podcasts): standard ReactionsRow for the show between header and episodes
Give the podcast show itself the usual engagement affordance: the standard
NoteCompose ReactionsRow (comment / zap / react, with counts) now sits
between the show header and the episode list on the detail screen, bracketed
by the usual dividers like any other content detail. Acts on the resolved
show note and only renders once that event loads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-07-01 15:46:56 +00:00
Claude
9d4cb99398 feat(podcasts): surface NIP-22 comments on episode list rows
Episode comments already work end to end — replying to an episode routes to
the NIP-22 GenericCommentPost composer (kind 1111 scoped to the episode
address), the thread datasource fetches them via #a/#A, and the thread view
renders them with a full reactions row. The only gap was discoverability on
the podcast-specific surfaces.

Add a comments chip (comment glyph + live reply count via
observeNoteReplyCount) to PodcastEpisodeListItem. It reads "N comments" (or
"Comment" when empty) and opens the episode's thread, where the discussion
lives and new comments are composed. Reuses the existing thread/composer
machinery — no new event plumbing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-07-01 15:19:39 +00:00
Claude
69f47f2351 feat(podcasts): add NoteCompose 3-dot options menu to the detail top bar
The single-podcast screen's top bar now shows the standard MoreOptionsButton
(the NoteCompose 3-dot menu) to the right of the bookmark button, giving the
show/episode the usual note actions (share, report, mute, etc.). Both appear
only once the show metadata has resolved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-07-01 14:57:25 +00:00
Claude
a61ec2911c fix(podcasts): compact bookmark button, drop the confirmation toast
- The bookmark toggle was a Material IconButton (48dp minimum touch
  target), so it stood taller than the titleLarge line it sits beside and
  broke the side-by-side alignment. Render it as a compact clickable glyph
  sized to a single title line (iconSize, default 20dp) so title and button
  align.
- Drop the add/remove toast — the icon flipping filled/outline is enough
  feedback — and remove the now-unused strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-07-01 14:27:40 +00:00
Vitor Pamplona
2cb058b323 Merge pull request #3434 from vitorpamplona/claude/quartz-negentropy-sync-accessory-r92bue
Add NIP-77 negentropy sync with streaming and windowing support
2026-07-01 10:24:12 -04:00
Claude
322e33678a perf(quartz): make the negentropy idle watchdog allocation-free
IdleClock.bump() is called for every message the relay sends — the connection
listener bumps it per event, so a multi-million-event download bumped it millions
of times. It stored a ValueTimeMark into an AtomicReference, and since the value
class boxes when used as a generic type argument, every bump allocated a heap
object. That is needless GC pressure on the hottest path (and battery/jank on
Android).

Replace it with a single monotonic base mark taken once (stored unboxed) plus a
@Volatile Long of nanos-since-start updated on each bump — zero allocation per
bump, and only visibility (not atomicity) is needed since each relay's bumps come
from its single reader thread and the driver only reads. Behavior is unchanged;
negentropy + concurrency suites pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
2026-07-01 14:18:31 +00:00
Claude
55e945a5ed feat(quartz): idle watchdog for negentropySync instead of a fixed per-round timeout
The fixed `timeoutMs` (default 30s) applied to every reconcile round, but the
FIRST round on a large relay is a legitimate long silence while the relay builds
its whole negentropy fingerprint — observed at ~63-68s for a 3.5M-event kind:0
set on a real relay. So the old default spuriously failed big syncs with
UNAVAILABLE ("reconcile round timed out"), even though the relay was working fine
and would have answered seconds later.

Replace it with `idleTimeoutMs` (default 120s): the maximum time the relay may go
COMPLETELY SILENT before giving up. It resets on every message the relay sends —
each NIP-77 round and every download EOSE/event — and on connect, so a genuinely
slow but progressing sync runs for as long as it needs; only true silence trips
it. Because the watchdog is fed by a connection-level listener that sees all of
the relay's traffic, download activity extends the reconcile deadline and vice
versa. `idleTimeoutMs = 0` disables it entirely (run until the socket drops); the
initial connect and each download batch keep their own finite bounds so an
unreachable relay or a single stuck batch still can't hang the pipeline.

Liveness of a dead/half-open socket does not depend on this: the WebSocket
keep-alive (ping/pong) detects it and the disconnect is already turned into a
clean NEG-ERR abort.

Verified against wss://wot.grapevine.network: the first round took 68.4s (the old
30s default would have thrown UNAVAILABLE) and the sync sailed through it under
the 120s idle window. Negentropy unit suite passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
2026-07-01 14:02:57 +00:00
Vitor Pamplona
ccba020012 Merge pull request #3430 from vitorpamplona/dependabot/github_actions/actions-14298bd68c
chore(actions): bump the actions group with 4 updates
2026-07-01 09:29:02 -04:00
Vitor Pamplona
7963153744 Merge pull request #3433 from davotoula/fix/gzip-test-resource-extension
Rename gzip test corpus to .json.gz to clear Sonar file-encoding warning
2026-07-01 09:28:03 -04:00
Claude
056d54434e fix(podcasts): bookmark button now reflects state + confirms with a toast
PodcastBookmarkButton read `note in bookmarks.public` — an identity-based
List<Note> containment that is unreliable for addressable shows/episodes
(the rendered note instance may differ from the one rebuilt from the
a-tag), so the icon never flipped and there was no way to tell a podcast
was already bookmarked.

Switch to the public bookmark id/address sets (the same reactive pattern
the working git-repository bookmark button uses): match note.address for
addressable notes and note.idHex otherwise. The icon now reliably shows
bookmarked vs not (filled/primary vs outline/onSurfaceVariant), and each
tap fires a confirming toast so the action is never silent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-07-01 13:22:31 +00:00
Claude
23722969fd refactor(quartz): reuse one subscription id across fetchAllPages pages
Revert the fresh-subId-per-page workaround (ed5c25e2) now that the underlying
double-REQ race is fixed at the root in PoolRequests. Relays cap the number of
concurrent subscriptions per connection, so a single reused id — opened per page
with the page's `until`, closed before the next page — keeps the whole download
to one subscription slot instead of churning through a distinct id each page.

Safe because the pool now serializes the "send a REQ" decision: after a page's
EOSE, the auto-resend and the loop's unsubscribe+resubscribe can no longer both
fire a REQ for the same id (guarded by PoolRequestsConcurrencyTest). Unit suites
(negentropy paging scenarios, subscriptions) pass; full-scale real-relay
verification to follow before push.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
2026-07-01 13:01:12 +00:00
Vitor Pamplona
3246f13044 Merge pull request #3431 from nrobi144/feat/desktop-hashtag-spam-filter
feat(desktop): hashtag-spam filter with collapse-with-reveal
2026-07-01 07:35:37 -04:00
nrobi144
9d8856efea fix(privacylock): provide CompositionLocals inside App() for tests
CI failure: AppStateMachineTest called App() directly, bypassing
the outer Window { CompositionLocalProvider } shell in Main.kt.
DesktopMessagesLockGate read LocalMessagesLockState and hit the
compositionLocalOf { error(...) } trap.

Fix: construct the state holder + provide both LocalMessagesLockState
and LocalPrivacyLockSettings INSIDE App() itself. Extracted the body
of App() into a private AppInner() so the provider can wrap it
cleanly. The outer providers are removed from Main.kt — no longer
needed.

Trade-off: MessagesLockState is now scoped to App() (via
rememberCoroutineScope) instead of windowScope. That means it
rebuilds on appRestartKey change, which is intentional — an app
restart should reset the coroutines too. The seeded initial value
is still read synchronously from prefs so the first composition
sees the correct LockState (deep-link race fix preserved).

Also fixes an unrelated `!!` warning on existingHash in
SetPasswordDialog by using a safe smart-cast check.
2026-07-01 12:53:32 +03:00
nrobi144
72c3dff870 feat(privacylock): P0 security hardening — 600k iterations + backoff
Addresses the P0 items in the security review at
docs/plans/2026-07-01-privacy-lock-security-review.md.

## PBKDF2 iterations 100k → 600k (M1) via versioned hash format (M2)

- New PasswordHasher storage format: `v1$saltB64$hashB64` (600k
  iterations, matches OWASP 2023 Password Storage Cheat Sheet for
  PBKDF2-HMAC-SHA256).
- Legacy `saltB64$hashB64` (100k iterations) format still verifies
  correctly — no user gets locked out by the bump.
- `hash()` always produces `v1$…`; users migrate to v1 opportunistically
  when they Change or Set a new password.
- New `PasswordHasher.isLegacyFormat()` helper for callers that want
  to force-migrate on next successful unlock.
- Verify cost goes from ~50ms → ~250ms on a modern laptop — well
  within tolerable UX for a lock users open a handful of times per
  session.

## Exponential backoff on failed unlock (M3)

- `PrivacyLockSettings` gains `failedUnlockAttempts: StateFlow<Int>`
  and `lockedUntilEpochMs: StateFlow<Long?>`, both persisted via
  java.util.prefs so a reboot cannot reset the backoff.
- `MessagesLockState.onFailedUnlockAttempt(nowMs)` implements the
  schedule: no lockout for first 4 fails, then 30s / 60s / 120s /
  300s (capped at 5 min).
- `MessagesLockState.onUnlockSuccess()` transparently clears the
  attempt counter and any active lockout (also called from the
  banner-enable path).
- DesktopLockScreen shows a countdown ("Try again in 27s") in the
  supportingText, disables the password field and Unlock button
  during lockout, ticks every 500ms via a LaunchedEffect.
- RemovePasswordDialog inherits the same protection — Settings can't
  bypass the throttle by disabling the lock.
- 4 new unit tests cover threshold behavior, base trip, doubling +
  cap, reset on success. All 13 tests green.

## Not in this commit

- L1/L2 (String/CharArray memory retention) — out-of-tree fix in
  Compose; accepted per threat model.
- L3 (post-uninstall prefs) — release-notes item.
- M4 (Limitations copy update) — deferred; existing "does not
  protect against filesystem access" line already covers.
2026-07-01 12:06:45 +03:00
davotoula
06d1c1c7ee chore: mark *.gz as binary in .gitattributes 2026-07-01 10:54:16 +02:00
davotoula
385f0bd459 fix(quartz): rename gzip test corpus to .json.gz to clear Sonar encoding warning 2026-07-01 10:54:15 +02:00
David Kaspar
9f3cc60a6a Merge pull request #3429 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-01 09:44:43 +01:00
nrobi144
0ff4ef1e10 docs(privacylock): manual testing sheet, banner plan, security review
- docs/plans/2026-06-30-privacy-lock-manual-testing.md — 10-path
  manual QA sheet covering setup, lock behavior, timer, deep-link
  race, change/remove password, banner discovery, cross-run
  persistence.
- docs/plans/2026-07-01-feat-messages-first-run-lock-banner-plan.md —
  design doc for the discovery banner shipped in aadbf3601.
- docs/plans/2026-07-01-privacy-lock-security-review.md — honest
  review of PasswordHasher (PBKDF2 100k / 16B salt) + prefs storage.
  Flags 4 medium-severity items (iterations below OWASP 2023 rec;
  no versioned hash format; no UI-layer rate-limiting; unencrypted
  prefs storage) with concrete fixes. All within accepted threat
  model but P0 items are cheap and high-value follow-ups.
2026-07-01 11:35:52 +03:00
nrobi144
f34c79c848 feat(privacylock): require password to disable + clear on remove
Toggling the Messages lock OFF now prompts for the current password
via a new RemovePasswordDialog. On successful verification, the
password hash is cleared AND the lock is disabled — re-enabling
later requires setting a fresh password.

Rationale: a user should not be able to disable the lock without
proving they know the password. Clearing the hash on remove prevents
a "silent re-enable" attack where someone toggles OFF then ON again
and inherits the old password. Matches Signal PIN, WhatsApp Chat
Lock, macOS FileVault disable posture.

- New RemovePasswordDialog in SetPasswordDialog.kt: single Current
  password field, reveal toggle, red "Remove" button (colorScheme.error),
  Cancel + Escape dismiss. Auto-focus + Enter submits. Uses the same
  Dialog+Surface+DialogHeader shell as SetPasswordDialog.
- DialogHeader refactored to take a title String (was isChange Bool).
- PrivacyLockSettingsScreen toggle-off path routes through the new
  dialog when a hash exists. Corner case (lockEnabled=true but no
  hash — user manually cleared prefs) still disables directly.
- On successful remove: setLockEnabled(false) + setPasswordHashed(null)
  + "Privacy lock removed" snackbar.
2026-07-01 11:35:52 +03:00
nrobi144
292f8a0c78 feat(privacylock): redesign Set/Change password dialog + snackbar
Rewrites SetPasswordDialog.kt with modern 2024-2026 UX. Adds
"Privacy lock enabled" / "Password updated" confirmation snackbars.

Dialog changes:
- Dialog + Surface(shape=shapes.large, tonal=6.dp) shell instead of
  default AlertDialog. Fixed width 440dp. Matches NewDmDialog.kt.
- Header row with Lock icon + title (titleLarge). Softer body copy
  under the header for the first-time-set path.
- Set-a-password flow uses ONE password field with a reveal toggle
  (Visibility / VisibilityOff, per-field independent). The reveal
  toggle IS the confirmation — no more "confirm password" field.
  Matches WhatsApp Chat Lock + macOS Users & Groups.
- Change-password flow uses two fields (current + new), each with
  its own reveal toggle. Current is verification, not redundancy.
- Real-time checklist row under the New field: green CheckCircle +
  "Min 6 characters" when satisfied, outlined Circle + muted text
  otherwise. Copy-pattern from EditProfileScreen NIP-05 status.
- Save button disabled until the checklist passes.
- Bumps PRIVACY_LOCK_MIN_PASSWORD_LENGTH from 4 to 6.
- Auto-focus first field on open (LaunchedEffect + FocusRequester).
- Enter submits (via onPreviewKeyEvent + KeyboardActions.onDone).
- Escape / click-outside-dismiss are disabled to prevent accidental
  loss of typed password (dismissOnClickOutside = false).
- Wrong-current error shown inline under the Current field.
- All reveal toggles reuse the KeyInputField.kt idiom verbatim.

Snackbar plumbing (scope-local — no CompositionLocal):
- PrivacyLockSettingsScreen owns a SnackbarHostState overlaid at
  Alignment.BottomCenter. LockToggleCard receives an onSaved
  callback, fires "Privacy lock enabled" on first-time set or
  "Password updated" on change.
- DesktopMessagesScreen (banner path) owns its own SnackbarHostState
  overlaid at BottomCenter. MessagesFirstRunBanner takes an
  optional onSaved callback (default {}), fires "Privacy lock
  enabled" after the dialog saves.
2026-07-01 11:35:52 +03:00
nrobi144
d216d22c3e feat(privacylock): Messages first-run discovery banner (Desktop)
Adds an inline banner at the top of the Desktop Messages deck column
that nudges users to enable the privacy lock. Fires only when
!lockEnabled && !firstRunCardSeen; dismissal is sticky across
restarts + lock enable/disable cycles.

- MessagesFirstRunBanner: AnimatedVisibility(expandVertically + fadeIn)
  wrapper around a Surface + Row with a padlock icon, title, body,
  and Enable / Not now buttons. Modeled on OfflineBanner.kt.
- SetPasswordDialog extracted from PrivacyLockSettingsScreen.kt into
  a shared desktop/security/ file so the banner and the settings pane
  both point at the same composable.
- MessagesLockState.onUnlockSuccess() relaxed to accept Disabled as a
  valid previous state, so enabling from the banner keeps the user
  Unlocked and doesn't flash the lock screen. New unit test covers
  this path; all 9 tests green.
- DesktopMessagesScreen wraps its two-pane / compact layout in a
  Column with the banner on top and a Box(weight(1f)) around the
  panes so fillMaxSize propagates correctly.
2026-07-01 11:35:52 +03:00
nrobi144
1c0141aba1 feat(privacylock): Desktop wiring — gate, password unlock, settings
Phase 5 (Desktop-only). Wraps the Messages deck column behind a
PBKDF2-hashed password gate; drops the Android-app slice.

- PrivacyLockSettings gains passwordHashed field + setter (salt$hash,
  base64). Backed by java.util.prefs on desktop.
- PasswordHasher: PBKDF2-HmacSHA256, 100k iterations, 16-byte salt,
  256-bit key, constant-time compare. Same primitive family as
  SecureKeyStorage.
- DesktopMessagesLockGate: synchronous branch select in composition
  (no LaunchedEffect guard) — closes the deep-link race per plan
  §Security Hardening H1. Renders content when Disabled/Unlocked;
  renders inline password TextField when Locked. Fires
  MessagesLockState.onLeaveRoute() in DisposableEffect onDispose so
  navigating away from the Messages column re-locks immediately.
- DesktopMessagesLockGate handles the "no password set" edge case
  with a Disable-lock affordance.
- LocalPrivacyLockSettings CompositionLocal + LocalMessagesLockState
  (from commons) both provided once at the App composition root in
  Main.kt. Constructed with the existing windowScope so the state
  holder's idle timer coroutines are lifecycle-scoped to the Window.
- DeckColumnContainer: DesktopMessagesScreen wrapped in
  DesktopMessagesLockGate for the Messages column.
- Desktop PrivacyLockSettingsScreen: Column + Card layout matching
  LocalRelaySettingsScreen (no Scaffold). Toggle, "Change password"
  affordance with a full set/change dialog (old + new + confirm),
  inactivity timer dropdown (1m / 5m / 15m / 1h / Never), redaction
  level dropdown (Hidden / Full), honest limitations copy. Auto-opens
  the set-password dialog if user toggles ON with no password set.
- Slotted into the existing Settings pane in Main.kt right after
  LocalRelaySettings.
2026-07-01 11:35:52 +03:00
nrobi144
c4f647d01a feat(privacylock): foundation — state holder, settings, gate composable
Phase 1 of the messaging privacy lock. Headless cross-platform spine in
commons; no UI wiring yet.

- LockState sealed interface (Disabled / Locked / Unlocked)
- InactivityTimer enum (1m / 5m / 15m / 1h / Never; default 5m)
- DmRedactionLevel enum (Generic / Full)
- PrivacyLockSettings interface (StateFlows + mutators)
- PreferencesPrivacyLockSettings backed by java.util.prefs in jvmAndroid
  (shared by Desktop + Android; node com/vitorpamplona/amethyst/privacylock)
- MessagesLockState — app-global state holder; initial value seeded
  synchronously from prefs to close the deep-link race; LocalMessagesLockState
  CompositionLocal provided at App root
- CredentialPrompter interface + PromptResult enum +
  LocalCredentialPrompter CompositionLocal
- MessagesLockGate composable — synchronous branch select (no
  LaunchedEffect guard); LockScreen with biometric button
- IdleTimerModifier — pointerInput Initial-pass, non-consuming
- 8 unit tests covering cold-start seed, idle expiry, leave-route,
  Never timer, user-interaction reset, settings cascade,
  credential-unavailable disable. All green.
2026-07-01 11:35:51 +03:00
davotoula
dde6616451 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-01 08:27:22 +00:00
davotoula
15338dffcc docs(embed): explain intentionally-empty method bodies for Sonar S1186 2026-07-01 10:11:24 +02:00
davotoula
0adee41711 refactor: extract duplicated string literals into named constants 2026-07-01 09:38:52 +02:00
davotoula
c66d1db0de fix(embed): add type test to MagnifierFrame.equals for Sonar S2097 2026-07-01 09:29:36 +02:00
nrobi144
d0646acf3c Merge upstream/main into feat/desktop-hashtag-spam-filter 2026-07-01 09:53:09 +03:00
nrobi144
7a18e30fc4 feat(desktop): hashtag-spam filter with collapse-with-reveal
Damus-inspired content filter that collapses notes abusing `t` hashtag
tags into a compact reveal-on-click placeholder. Ships default ON with a
threshold of 5 (adjustable 1–20 in Settings → Content Filters, or off).

Scope
- Pure check (`HashtagSpamCheck`) + settings interface
  (`HashtagSpamSettings`) live in `commons/moderation/`, callable by
  Desktop, `amy` CLI, and (future) Android.
- JVM-backed `PreferencesHashtagSpamSettings` writes to the shared
  `java.util.prefs` node `com/vitorpamplona/amethyst/filters`, so `amy`
  and Desktop observe the same value automatically.
- `CollapsedSpamNote` placeholder in `commons/ui/note/` takes only
  primitive scalars so Android can adopt it without touching commons.
- Desktop wraps every `NoteCard` call site (FeedNoteCard, QuotedNoteEmbed,
  BookmarksScreen, 5 SearchResultsList sites) with a shared
  `SpamCheckedNoteRender` helper. Thread root notes auto-expand via
  `forceReveal=true`; replies still respect the filter.

Exemptions
- Long-form articles (kind 30023)
- Authors in the follow list plus self
- Repost wrappers check the inner event's tags via precomputed
  `note.replyTo`, falling back to `containedPost()`

Search UX fixes bundled in
- Removed the `#hashtag` → "Direct lookup" card. `QueryParser` already
  extracts `#xxx` into the query's hashtag filter, so typing `#bitcoin`
  now goes straight to filtered results.
- Search-result rows now trigger metadata loading via
  `subscriptionsCoordinator.loadMetadataBatched(authors)` and observe
  each user's metadata flow via a new `rememberDisplayData` helper, so
  display names + avatars refresh when kind-0 arrives from index
  relays. Same helper reused in Bookmarks.

Tests + docs
- 19 unit tests (check × 10, displayed-event unwrap × 4, prefs × 5),
  all green.
- Manual testing sheet with 16 scenarios at
  `desktopApp/plans/2026-06-29-hashtag-spam-filter-manual-testing-sheet.md`.
- Plan at `docs/plans/2026-06-29-feat-desktop-hashtag-spam-filter-plan.md`.
- Cross-client desktop feature backlog reference at
  `desktopApp/plans/_desktop-feature-backlog.md`.
2026-07-01 09:46:43 +03:00
Claude
f357b445c3 fix(quartz): serialize PoolRequests state machine to kill shared-sub double-REQ race
A subscription id is driven from two threads at once: the app thread (the
subscribe/unsubscribe path) and every relay's socket-reader thread (an EOSE
that triggers an auto-resend). Both read the subscription state and both can
decide "the filters changed, send a REQ", but the decision (read state) and the
send (mark state SENT in onSent) were not atomic. So the reader could observe
the pre-send state — filters still on the previous value — while the app had
already moved the desired filters forward, and both would send a REQ for the
same sub id.

Two REQs on one id race on the wire: the relay answers with duplicate EOSEs and
events, or — if a CLOSE interleaves — an empty result that silently truncates a
paged download. This is what intermittently broke fetchAllPages on large sets
(fixed at the call site in ed5c25e2 by using a fresh sub id per page); this
commit fixes the underlying race in the relay-client layer, which could equally
corrupt any subscription that spans multiple relays (several reader threads
mutate the same RequestSubscriptionState maps concurrently).

The fix:
- Add a tiny non-reentrant spin lock (withStateLock, same AtomicBoolean
  primitive BasicRelayClient uses) guarding every access to the subscription
  state machine. Listener callbacks and socket sends stay OUTSIDE the lock —
  they re-enter this class via onSent, so holding it across them would deadlock.
- Fold the send decision into decideCommandLocked, which runs under the lock and
  pre-marks the state SENT (+ filters) the moment it decides to send a REQ. A
  concurrent decider then sees SENT/updated filters and declines, so exactly one
  REQ is ever produced.

Verified with a deterministic A/B repro that pins the exact interleaving open:
pre-fix 300/300 episodes produced a duplicate REQ; post-fix 0/300 (max one REQ
per episode). Kept as PoolRequestsConcurrencyTest. Full relay test suite passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
2026-07-01 02:30:19 +00:00
dependabot[bot]
ebc4bee4d9 chore(actions): bump the actions group with 4 updates
Bumps the actions group with 4 updates: [actions/checkout](https://github.com/actions/checkout), [softprops/action-gh-release](https://github.com/softprops/action-gh-release), [actions/cache](https://github.com/actions/cache) and [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request).


Updates `actions/checkout` from 6 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

Updates `softprops/action-gh-release` from 3.0.0 to 3.0.1
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](b430933298...718ea10b13)

Updates `actions/cache` from 5 to 6
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v5...v6)

Updates `peter-evans/create-pull-request` from 7 to 8
- [Release notes](https://github.com/peter-evans/create-pull-request/releases)
- [Commits](https://github.com/peter-evans/create-pull-request/compare/v7...v8)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/cache
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: peter-evans/create-pull-request
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-01 00:45:34 +00:00
Claude
ed5c25e2b1 fix(quartz): fresh subId per page in fetchAllPages (was truncating large results)
fetchAllPages reused a single subscription id across all pages
(unsubscribe + immediately re-subscribe the same id). On a real relay that
caps REQ results, the rapid same-id CLOSE→REQ races on the wire: in-flight
events from the previous page's REQ bleed into the next page's listener.
Those stale events carry a created_at above the freshly-lowered `until`, so
`match()` rejects them, the page ends with pageCount == 0, and the whole
loop breaks — silently truncating the download.

Observed against wss://wot.grapevine.network: a full kind:0 download (~3.55M
events, per a concurrent negentropy sync) stopped at 89,500. A controlled
diagnosis paging the same data with a fresh subId per page vs a shared subId
reproduced it exactly: shared stalled at ~95k with in-page duplicates and
events above `until`; fresh advanced cleanly with no duplicates. After the
fix, the real-relay fetchAllPages sails past the old stall (100k+ and
counting).

Fix: allocate the subId inside the paging loop so each page is an
independent subscription.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
2026-07-01 00:42:33 +00:00
Claude
e5f43904f2 perf(quartz): stream negentropy sync in bounded memory for huge windows
Rework the negentropy download path from "reconcile fully → then download"
into a single back-pressured streaming pipeline so peak memory is independent
of the window size — built for multi-million-event syncs.

- reconcileStreaming drives the NIP-77 rounds directly (instead of via
  NegentropyManager) and hands each round's ids to a bounded id-queue *before*
  acking the next round, so the relay's id stream is paced to the downloader.
- Ids flow id-queue → bounded download worker pool → bounded delivery channel;
  a slow consumer back-pressures the whole chain. The full id list is never
  materialised.
- Drop the global event-dedup set (was O(set) ~ hundreds of MB at 4M): NIP-77
  yields a distinct id set, so each event is requested once. Keep only a tiny
  per-batch dedup (bounded by fetchBatch) to absorb a relay replaying a REQ.
- Pin the relay with a never-matching keep-alive subscription for the sync's
  duration: a NEG-OPEN isn't a REQ, so during a reconcile round the pool would
  otherwise see the relay as unwanted and disconnect it mid-sync.
- Document that timeoutMs must accommodate the relay's first-frame snapshot
  latency on huge sets (a real strfry took ~73s for an unbounded kind:0 set).

Validated against wss://wot.grapevine.network: streamed 30k kind:0 events with
heap bounded at ~30-90 MB (not growing with the set) and zero duplicates. New
unit test forces many small reconcile frames to exercise the multi-round /
back-pressure path; existing windowing/cap/fallback tests still green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
2026-06-30 23:12:21 +00:00
Claude
496cda2f22 feat(podcasts): dedicated Podcast Bookmarks screen + detail-screen toggle
Adds a Podcasts row to the Bookmark lists screen (mirroring Git
Repositories), opening a feed of just the bookmarked podcasts:

- quartz: isPodcastEvent() — a reusable predicate matching shows,
  episodes (NIP-F4 + Podcasting-2.0) and trailers, used to pull the
  podcast subset out of the mixed NIP-51 kind:10003 bookmark list.
- BookmarkPodcastsFeedFilter / ...FeedViewModel / BookmarkedPodcastsScreen
  filter the bookmark list (public + private) down to podcast notes,
  newest first, and render them with the standard podcast cards.
- Route.BookmarkedPodcasts + AppNavigation wiring; a "Podcasts" row with a
  live count in ListOfBookmarkGroupsFeedView.
- The single-podcast detail screen (PodcastScreen) gains a bookmark action
  in its top bar that reflects and toggles bookmarked state (reusing the
  stateful PodcastBookmarkButton). TopBarExtensibleWithBackButton now
  accepts an actions slot to host it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-30 23:05:29 +00:00
Claude
ec3b41aa34 feat(podcasts): pay V4V splits through the zap button as real zaps
The standard zap button now detects a Podcasting-2.0 value-for-value block
on a podcast note (episode or show) and pays that split instead of a plain
author zap — so V4V becomes a first-class zap rather than a separate, no-
receipt payment:

- AccountViewModel.zap() intercepts a note carrying a value block and routes
  the chosen amount to the V4V split. Intercepting at zap() covers every
  entry point (one-tap, amount popup, custom dialog, polls) with no UI churn.
- V4VPaymentHandler gains asZap/zapType: lnaddress shares now attach a NIP-57
  zap request, so a Nostr-aware provider mints a zappable invoice and
  publishes a receipt (driving the zap button's icon/counter). Node shares
  stay keysend — no LNURL, so a receipt is impossible there by protocol.
- Per-minute streaming pays with asZap=false so it doesn't publish a receipt
  every minute.
- The dedicated "Send value" button is now redundant and removed;
  PodcastValueSplits becomes a pure recipient/percentage breakdown display
  next to the zap button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-30 22:45:48 +00:00
Claude
3fdf418177 feat(quartz): make negentropy paging fallback the caller's choice
Per review: negentropySync should not silently switch transports. Plain
created_at paging is heavier and non-delta, and a caller who reached for
negentropy may prefer to know it failed (try another relay, narrow the
filter, abort) rather than get a surprise paged download.

So:
- negentropySync is now negentropy-only. created_at windowing on the
  relay's max_sync_events cap stays automatic (it's still negentropy), but
  a window that genuinely can't be reconciled — minimal window still over
  the cap, or a relay with no NIP-77 support / disconnect / timeout — now
  throws the typed NegentropySyncException (reason OVER_MAX_SYNC_EVENTS or
  UNAVAILABLE, carrying the failing window) instead of paging. Dropped
  NegentropySyncResult.fellBackToPaging.
- Added negentropySyncOrFetch (+ negentropySyncOrFetchEvents Flow form) as
  the ergonomic "try negentropy, else page" combinator: runs negentropySync
  and, on NegentropySyncException, falls back to fetchAllPages over the same
  filter, deduping by id across both phases and honoring maxEvents. Returns
  NegentropyOrFetchResult so callers can see which path ran and why.

Callers now choose explicitly: negentropySync to handle failure themselves,
negentropySyncOrFetch for automatic paging fallback.

Tests: over-cap relay with spread timestamps succeeds via windowing alone;
over-cap minimal window throws (and the caller can page); orFetch pages on
failure and uses negentropy when it works.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
2026-06-30 22:21:02 +00:00
Claude
a65e37b8f5 feat(podcasts): render Podcasting-2.0 shows in detail + thread views
Make the single-podcast detail screen spec-neutral so a Podcasting-2.0
show (kind 30078, d=podcast-metadata, e.g. "Soapbox Sessions") renders
its real cover art and title instead of the default Amethyst banner and
"Podcasts" fallback:

- PodcastHeader/PodcastScreen now take a resolved PodcastShow? instead of
  a NIP-F4-only PodcastMetadataEvent?, resolving from either the kind
  10154 (F4) or kind 30078 (P2.0) metadata event.
- FilterOnePodcast adds a #d=podcast-metadata filter on kind 30078 so the
  P2.0 show metadata is actually fetched on deep-links/fresh loads.
- NoteMaster (thread/full view) now dispatches Podcasting20EpisodeEvent,
  Podcasting20TrailerEvent, and the kind 30078 podcast-metadata variant
  to the shared podcast renderers, matching NoteCompose.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-30 22:05:37 +00:00
Claude
21c714177c refactor(quartz): stream events from the negentropy flow variant
Replace the cumulative `negentropySyncAsFlow(): Flow<List<Event>>` with
`negentropySyncEvents(): Flow<Event>`, which emits each event individually
as it arrives. Rebuilding an ever-growing list per event was O(events²) in
both CPU and memory and pointless for a bulk download; the stream stays
O(1) in memory and hands the caller raw events to collect however they
like.

Events are buffered with Channel.UNLIMITED because negentropySync delivers
through a non-suspending callback — a bounded buffer would drop events when
the collector lags. Callers can apply their own buffer/conflate/
collectLatest downstream.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
2026-06-30 22:03:29 +00:00
Claude
abfea4e7b6 feat(quartz): add high-level negentropy sync-and-download accessory
Add `INostrClient.negentropySync` / `negentropySyncAsFlow`, a high-level
NIP-77 accessory that downloads every event a relay holds matching a
`Filter` and delivers each (deduped) through `onEvent` — mirroring the
existing `fetchAllPages` accessory so downstream apps stop hand-rolling
the `NegentropyManager` dance.

It encapsulates the parts that make raw negentropy painful:
- reconciles the relay's matched set (empty local set) via NegentropyManager
- downloads the resulting ids through a bounded pool of concurrent REQs
  (`maxConcurrentReqs` subs of `fetchBatch` ids each, refilled on EOSE)
- handles the relay-side cap (strfry `max_sync_events`,
  `NEG-ERR "blocked: too many query results"`) by splitting the filter
  into adaptive created_at windows; a minimal window that still can't
  reconcile (or a relay that doesn't speak NIP-77) falls back to
  `fetchAllPages` and reports it via `NegentropySyncResult.fellBackToPaging`
- caps delivery at `maxEvents`, dedupes through a single consumer, and
  tears down all subscriptions + the neg session on completion/cancel

Scope is controlled entirely by the `Filter` (per maintainer guidance the
caller-supplied local-id delta interface is dropped in favour of a custom
Filter), so the common call is one line.

To drive NEG-OPEN on a single connection, add
`INostrClient.getOrCreateRelay(url)` (default throws; NostrClient delegates
to the pool). Because NEG-OPEN is a one-shot command that — unlike a REQ —
is never replayed on reconnect, the accessory connects and waits for the
relay to be ready before opening the session.

Tests (quartz jvmAndroidTest, in-process relay): full download, maxEvents
cap, clean teardown / no leaked subs, the Flow variant, and window-split +
paging fallback against a relay that rejects the full reconcile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
2026-06-30 21:48:11 +00:00
Vitor Pamplona
0c85986f5b Merge pull request #3428 from vitorpamplona/claude/topnav-mine-filtering-9gbrp3
Centralize "Mine" feed filter to shared TopFilter.Mine flow
2026-06-30 17:37:20 -04:00
Claude
3a36819539 feat: scope "Mine" relay set to outbox + local + private + proxy
Mine should download the user's own events from their outbox (NIP-65 write),
private-storage, local and proxy relays. The previous filterXMine path used
account.outboxRelays, which also included broadcast relays — write-only blast
targets that don't serve reads — so it queried the wrong set.

Add AccountMineRelayState (a sibling of AccountOutboxRelayState with broadcast
swapped for proxy) and feed it into the shared TopFilter.Mine resolver.
MineFeedFlow now pins authors=[me] to that fixed relay union via
AuthorsByProxyTopNavFilter — no per-author outbox resolution needed, since the
only author is the user and their relays are known from account state. Both the
DAL (liveXFollowLists) and the relay sub-assemblers (liveXFollowListsPerRelay)
consume it, and the per-relay flow re-emits when the mine relay set changes.

Add MineFeedFlowTest covering author scoping, the empty-relay case, and
re-emission on relay-set change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWUwciqZiXTkbYVSyiF2P4
2026-06-30 21:28:11 +00:00
Claude
8032e8fa49 refactor: resolve TopFilter.Mine to a real author filter in the shared flow
The shared FeedTopNavFilterState.loadFlowsFor() mapped TopFilter.Mine to
AllFollowsFeedFlow, so "Mine" silently fell back to all-follows. Every screen
that offered the Mine chip (badges, communities, music tracks/playlists, git
repositories, nApplets, nSites, and the browser app rows) had to special-case
TopFilter.Mine on both the relay sub-assembler and the local DAL / display
filter to scope content to the user.

Introduce MineFeedFlow, which mirrors AllFollowsFeedFlow's outbox/proxy split
but pins the author set to the logged-in user's own pubkey. Because both
liveXFollowLists (DAL) and liveXFollowListsPerRelay (relay) derive from this
flow, the generic author paths now narrow to the user, so the per-screen Mine
branches are pure redundancy and are removed.

This also makes outbox-change invalidation automatic: the Mine relay set now
comes from liveXFollowListsPerRelay, an OutboxLoaderState over the user's own
outbox, so a NIP-65 update re-emits through the existing followsPerRelayFlow
collector without a screen-specific trigger.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWUwciqZiXTkbYVSyiF2P4
2026-06-30 21:01:53 +00:00
Claude
821e9bafdd fix: drop the "Mock Podcast" kind:10154 spam flood before consuming
Someone is flooding thousands of identical mock NIP-F4 show-metadata events.
They share an exact fingerprint — title "Mock Podcast", description and content
both "Headless test feed" — so match that and refuse to cache them.

- PodcastMetadataEvent.isMockSpam() encodes the fingerprint (all three fields
  must match, so a real show sharing one field is never flagged); unit-tested.
- LocalCache's PodcastMetadataEvent consume branch returns without storing when
  it matches, so the spam never reaches the cache, feeds, search, or the merged
  podcast list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-30 20:50:33 +00:00
Claude
1eeda56b51 feat: add "Mine" to the Podcasts and Podcast Episodes feed filters
The two podcast feeds used kind3GlobalPeopleRoutes, which omits the "Mine"
option — so a creator had no way to filter the feed down to their own published
shows/episodes (and the default follow-list view is usually empty for podcasts,
since NIP-F4 shows are their own keypairs you rarely kind:3-follow).

Add a podcastRoutes catalog in TopNavFilterState that mirrors musicRoutes
(content catalog + Mine + interests + mute list) and point both podcast top bars
at it. "Mine" resolves to an AuthorsTopNavPerRelayFilterSet of the user's own
pubkey, which the podcast SubAssemblyHelper already dispatches through
filterPodcastEventsByAuthors — so selecting it actually fetches the creator's
own catalog, not a dead option.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-30 20:07:54 +00:00
Claude
d0f6f03d2c Merge remote-tracking branch 'origin/main' into claude/podcast-event-kinds-merge-vv24gd 2026-06-30 19:32:50 +00:00
Claude
13d654f6be Merge remote-tracking branch 'origin/main' into claude/podcast-event-kinds-merge-vv24gd 2026-06-30 19:23:05 +00:00
Vitor Pamplona
e32b3160ae Updating libraries. 2026-06-30 15:21:43 -04:00
Vitor Pamplona
d1bd8dd3b4 Merge pull request #3427 from vitorpamplona/claude/plans-in-folders-hajl33
docs: add per-module plans indexes and master roll-up
2026-06-30 12:03:06 -04:00
Claude
ff50652484 docs: add root PLANS.md master index across all module plan folders
Adds a repo-root cross-module roll-up that stitches the 10 per-folder
plans/ indexes into one view: totals, a per-module status table, and a
"live work" section listing every in-progress / queued / abandoned plan
with links. Shipped plans stay in each folder's archive/ and are linked
via the per-module README.

142 plans: 122 shipped, 9 in-progress, 8 queued, 3 abandoned.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016hpUivtmq4pgzqRbY6MYrA
2026-06-30 15:52:49 +00:00
Claude
6579b5f656 docs: audit, status-stamp, and index all module plans
Audited all 143 plan files across the 10 plans/ folders. Each plan now
carries a Status header (shipped | in-progress | queued | abandoned)
backed by codebase evidence, and every folder has a README.md index
grouping plans by status.

Shipped plans were moved into a per-folder plans/archive/ (via git mv,
history preserved) so each plans/ folder surfaces only live work:

  shipped (archived): 122   in-progress: 8   queued: 7   abandoned: 4

docs/plans/ is the frozen legacy folder; its plans were stamped and
indexed in place (48 of 52 archived) but it remains closed to new plans.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016hpUivtmq4pgzqRbY6MYrA
2026-06-30 15:35:38 +00:00
Vitor Pamplona
dc47296a5f Merge pull request #3425 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-30 09:13:26 -04:00
vitorpamplona
a054215c4f chore: sync Crowdin translations and seed translator npub placeholders 2026-06-30 12:51:31 +00:00
Vitor Pamplona
26ac7b7ff0 Merge pull request #3426 from vitorpamplona/claude/connected-apps-permissions-bqion9
Add "Manage permissions" UI for Connected Apps detail screen
2026-06-30 08:49:04 -04:00
Vitor Pamplona
609aa1d0fc Merge pull request #3424 from nrobi144/feat/desktop-follow-packs
feat(desktop): Follow Packs (NIP-51 kind 39089) — Discover + follow flow
2026-06-30 08:44:40 -04:00
nrobi144
de2c970e3e feat(desktop): Follow Packs (NIP-51 kind 39089) discovery and follow flow
Adds a Follow Packs experience to Amethyst Desktop:

- New "Discover" sidebar destination with featured-pack hero, hashtag chips
  driven by NIP-12 `t` tags, a 3-up "From the pack" notes feed, and a
  right-rail of mini pack thumbnails.
- New "Follow Packs" launchable column (App Drawer + Discover "Browse all")
  with multi-field search across title, description, creator name/npub,
  and `t` tags.
- Pack detail overlay (read-only) with per-member Follow/Unfollow buttons
  that reflect the live kind-3 state, plus pack-level Follow all /
  Unfollow all with a dedupe-aware confirm dialog ("Follow N new (M
  already followed)").
- Bulk follow / unfollow batched into a single kind-3 publish via new
  `FollowActions.buildUnfollowBatch` and `Kind3FollowListState.follow/
  unfollow(users: List<User>)`. The mutating call sites are Mutex-
  protected against concurrent races.
- naddr → 39089 references in notes render as a rich inline card with
  avatar stack + Follow all CTA. Cache miss triggers a one-shot
  subscription; empty / deleted packs render minimal states.
- Shuffle button rotates both the featured pack and the gallery,
  excluding the last 5 shown.
- Pack image fields render via Coil `AsyncImage` with a deterministic
  gradient fallback.

Protocol additions:
- Quartz: `FollowListEvent.hashtags()` convenience accessor.

Bug fixes wrapped into the feature:
- `DesktopLocalCache.consumeContactList` now also loads the event into
  `addressableNotes` so `Kind3FollowListState.getFollowListEvent()`
  returns the user's actual kind-3. Without this, every bulk follow
  silently replaced (rather than appended to) the contact list.
- Added Material Symbols `Shuffle` codepoint and regenerated the
  bundled subset font (still 432 KB).
2026-06-30 12:05:16 +03:00
Vitor Pamplona
07e0cc2989 Merge pull request #3419 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-29 22:28:20 -04:00
Vitor Pamplona
a0586a8613 Merge pull request #3422 from vitorpamplona/claude/event-tag-parsing-w7x43s
feat(nip89): parse and display app-handler t/i/a/client tags
2026-06-29 22:28:02 -04:00
Claude
58b3e84184 feat(napplet): add "Manage permissions" to app pull-down sheets
Add a direct link to each running app's editable Connected Apps
permission-detail screen from its top pull-down sheet, so users can
change the trust level and per-capability grants as they navigate.

Covers every top pull-down rendering:
- Embedded napplet/nsite and web-app tabs (Compose TopControlSheet),
  keyed by the napplet `pubkey:dtag` coordinate or the web client's
  `browser:<origin>`.
- Full-screen sandbox host and direct-browser activities (native
  NappletControlSheet), via a new MSG_OPEN_PERMISSIONS IPC: the host
  (which can't state its own coordinate) sends its launch token or
  visited origin, and the main-process broker resolves the trusted
  coordinate and opens MainActivity through a `connectedapp?coordinate=`
  deep link added to uriToRoute.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019dXhUL8To3qVXJVBZGCmhU
2026-06-30 01:59:00 +00:00
Claude
3b3d7b8c11 feat(nip89): richer related refs, handles/NIPs bottom sheets
Refine the app-handler card rendering:

- NIP "Implements" bottom sheet now shows a wrapping row of clickable NIP
  chips that open each spec in the browser, instead of a list of raw URLs.
- "Handles" gets the same "+N" overflow -> bottom sheet treatment as
  "Implements", listing every handled kind.
- Related (`a`-tag) references now render as the referenced event's author
  avatar + its real name with a short kind label, since these are vouched
  for by the handler's author. Software Application shows the app name
  (not the package-id d-tag) and is labelled "App"; Git repositories and
  NIP-text events use their name/title, falling back to the d-tag until the
  event loads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FLfM7JUTr2vNiNUi4Ukmqn
2026-06-30 01:41:42 +00:00
vitorpamplona
d564dac0eb chore: sync Crowdin translations and seed translator npub placeholders 2026-06-30 01:25:28 +00:00
Vitor Pamplona
1183ac7a00 Merge pull request #3421 from vitorpamplona/claude/nip07-permission-screen-bug-f501wz
Fix first-connect dialog suppression to allow retry after cooldown
2026-06-29 21:23:02 -04:00
Vitor Pamplona
43a6dbaf44 Merge pull request #3420 from vitorpamplona/claude/browser-console-log-design-rq4x48
Console panel: add visibility toggle, elevation, and state sync
2026-06-29 20:31:07 -04:00
Claude
8b4f5e28f4 fix(napplet): let users re-trigger NIP-07 connect after cancelling
Pressing Back on the "Connect to Nostr" first-connect dialog resolves to
AppConnectResult.Cancelled, which added the app's coordinate to an in-memory
`sessionCancelled` set. That set suppressed every future connect prompt for the
entire broker lifetime, so a later request — e.g. the user re-clicking "login"
in the in-app browser — was silently denied and the dialog never reappeared.
Because a Cancel persists nothing, the app also never showed up in Connected
Apps, leaving the user with nothing to clear to recover.

Replace the permanent suppression with a short, self-clearing cooldown
(`cancelledUntil` map): a Cancel suppresses re-prompts only briefly so the
burst of requests a page/napplet fires on load doesn't relaunch the dialog per
request, while a deliberate retry seconds later prompts again. The clock is
injectable for deterministic tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDcts4HzwSff4fy6oSVA6D
2026-06-30 00:16:31 +00:00
Claude
7d7789c8c9 fix(browser): console toggle + on-top pull tab in full-screen browser
The full-screen direct-WebView browser (NappletBrowserActivity, launched when
you type an address) and the sandbox host (NappletHostActivity) used an older
console design that diverged from the embedded tabs' Compose chrome:

- The Console row in NappletControlSheet was a plain action row and the bottom
  pull-up grabber was always visible. Make Console a Switch toggle (like the Tor
  row / the Compose TopControlSheet), and hide the whole NappletConsolePanel
  until the toggle is on — turning it on reveals the sheet already pulled up,
  mirroring BottomConsoleSheet.
- The console grabber/panel sat at elevation 0 while the top sheet's panel is at
  6dp, so an open top sheet drew over the console when they overlapped (e.g. in
  landscape). Elevate the console sheet above the top sheet so its pull tab and
  log render on top, matching the Compose layer where BottomConsoleSheet is
  composed after TopControlSheet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9kfpNrBRB8WJNi67NHGGX
2026-06-30 00:14:19 +00:00
Claude
250f918ebe feat(nip89): parse and display app-handler t/i/a/client tags
NIP-89 handler cards (kind 31990) previously only surfaced the supported
kinds and platform links, and even the platform links were silently dropped
when the link tag had no entity type. This widens both parsing and display:

- Fix PlatformLinkTag.match to accept 2-element link tags (entity type is
  optional per NIP-89), so e.g. NostrHub's `["android", "intent:..."]` links
  are no longer discarded.
- Parse the `i` supported-NIP tags (NostrHub points them at the NIP spec
  markdown files) into a new SupportedNipTag, plus accessors for `t`
  categories, `a` related addresses, and the `client` tag.
- Extend AppDefinitionEvent.build() with categories/supportedNips/
  relatedAddresses/client so creation stays symmetric with parsing.
- Render the new data in RenderAppDefinition: category chips, compact
  tappable rows for related addressable events (source repo, store listing,
  ...), a "via <client>" line, and NIP chips with a "+N" overflow that opens
  a bottom sheet listing every supported NIP linking to its spec.

The deprecated `alt` tag is intentionally not surfaced on the handler card.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FLfM7JUTr2vNiNUi4Ukmqn
2026-06-29 23:57:40 +00:00
Claude
6355784c98 Merge remote-tracking branch 'origin/main' into claude/podcast-event-kinds-merge-vv24gd 2026-06-29 23:51:54 +00:00
Vitor Pamplona
4326d84887 Merge pull request #3415 from vitorpamplona/claude/git-repo-readme-code-tabs-e4uf6c
Add git smart-HTTP browser for NIP-34 repositories
2026-06-29 19:50:24 -04:00
Claude
fdcfc05ba2 refactor(git): collapse status/PR-update indexes to map().stateIn
Now that observeEvents re-emits the whole matching list each time,
GitStatusIndex and GitPullRequestUpdateIndex no longer need the imperative
launch/collect-into-MutableStateFlow wrapper carried over from the old
newEventBundles version. Each is now a single observeEvents().map { reduce }
.stateIn(scope, Eagerly, null) — dropping startIfNeeded(), the AtomicBoolean
double-start guard, and the MutableStateFlow/asStateFlow pair.

Eagerly (not WhileSubscribed) is required: callers read .value synchronously
(isClosedOrResolved, the feed filters, the home open-count derivations) and
must not see a stale map when nobody is collecting. All startIfNeeded() call
sites removed; stateIn shares one upstream subscription across collectors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 23:43:25 +00:00
Claude
9a6c535cbd feat(git): bookmarked repositories as a standalone screen
Replace the third "Repositories" tab on the default bookmark screen with a
dedicated entry on the bookmark-lists screen, mirroring how Pinned Notes
works: a row in ListOfBookmarkGroupsFeedView that opens its own
BookmarkedRepositoriesScreen via the new Route.BookmarkedRepositories.

The row shows the bookmarked-repo count from
gitRepositoryListState.publicRepositoryAddressSet; the screen renders the
BookmarkRepositoriesFeedViewModel feed (moved to a repositories/ package),
invalidates on bookmark changes, and preloads any uncached repo
announcements via the EventFinder. Reverts the tab added to
BookmarkListScreen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 23:30:40 +00:00
Claude
a2f918c1eb feat(git): bookmarked repos tab + observeEvents for GitStatusIndex
- GitStatusIndex now subscribes to a kind-1630..1633-filtered
  LocalCache.observeEvents instead of LocalCache.live.newEventBundles,
  matching the GitPullRequestUpdateIndex change: the indexed observable
  seeds from the cache index and re-emits the full list on each new status
  event, so the manual onStart full-cache scan and per-bundle type
  filtering are gone and the collector just reduces to latest-per-target.
- Bookmark screen gains a third "Repositories" tab listing the user's
  bookmarked (NIP-51 kind 10018) git repositories. New
  BookmarkRepositoriesFeedFilter resolves the public repository address set
  to addressable notes (newest first); the screen invalidates it on
  publicRepositoryAddressSet changes and preloads any uncached repo
  announcements via the EventFinder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 23:20:19 +00:00
Claude
0d8936e8d3 refactor(git): index PR updates via LocalCache.observeEvents
GitPullRequestUpdateIndex now subscribes to a kind-1619-filtered
LocalCache.observeEvents instead of LocalCache.live.newEventBundles. The
indexed observable seeds its matching set from the cache index (via init())
and re-emits the full list on each new PR update, so the manual onStart
full-cache scan and the per-bundle type filtering over every event of every
kind are both gone. The collector just reduces the list to the
latest-per-parent map. PR updates are rare, so recomputing the whole map per
emission is cheaper than scanning every bundle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 23:06:11 +00:00
Claude
2316e72573 fix(git): bookmark feedback, social divider, and disappearing-bar reset
- Top-bar repo bookmark toggle now switches between BookmarkAdd (with +)
  and Bookmark glyphs instead of two identical  glyphs, so the icon
  visibly changes shape (not just tint) when starred/unstarred.
- Add a thin HorizontalDivider after the ReactionsRow on the repo home.
- Code browser: opening/closing a file or changing folders swaps the
  scrollable in place, landing the new view at the top with no scroll
  delta, which left the disappearing top bar stranded at its hidden
  offset over a blank band. Expose the scaffold bar state via
  LocalDisappearingBarState and reset it to visible on each in-place view
  change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 23:01:16 +00:00
Claude
f14ddd9e45 fix(git): import KMP Dispatchers.IO in GitRepositoryListState
The commonMain GitRepositoryListState used Dispatchers.IO without importing
the multiplatform kotlinx.coroutines.IO extension, so it resolved to the
JVM-only member and broke the iOS native compile
(:commons:compileKotlinIosSimulatorArm64). Matches BookmarkListState.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 22:44:14 +00:00
Claude
2f9162a96f feat(git): New Issue is a full screen; fix square FAB shape
- Converts the New Issue composer from an AlertDialog to a dedicated screen
  (new Route.GitRepositoryNewIssue + GitNewIssueScreen with its own top bar
  and a Create action). The Issues FAB now navigates to it.
- The extended FAB rendered square because the app theme sets shapes.large
  (the extended-FAB default shape) to 0.dp; pin an explicit RoundedCornerShape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 22:06:02 +00:00
Claude
3e0c688b8c feat(git): htree open-in-browser fallback; remove maintainers; tighten gaps
- Repos that can't be cloned over http(s) (e.g. Iris's htree://) now show a
  "Hosted externally" notice with an open-in-browser link instead of an empty
  dashboard — on the home, the Code screen, and the feed repo card.
- Removed the "Maintained by" row from the project home.
- Tighter home section spacing (12 → 8dp) and a smaller bottom padding on the
  feed repo card so the last-commit line sits closer to the reaction row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 21:06:24 +00:00
Claude
a4bd541522 feat(git): snapshot cache, FAB, disappearing-bar fix, title subtitle, spacing
- Snapshot cache: a process-wide GitRepoSnapshotCache keyed by repo address.
  The browser ViewModel serves an already-fetched default-branch snapshot
  synchronously, so the stats render in share-to-image and don't re-fetch when
  switching screens.
- Issues/PR screens: filter chips now live inside the disappearing top bar
  (via the scaffold's belowBar slot) so they hide with it instead of leaving a
  static black band; the feed uses normal content padding.
- New issue is now an extended FAB on the Issues screen.
- Top bar shows the repo description as a single-line subtitle under the name;
  removed the duplicate description from the home body.
- Tighter spacing: home sections, code header rows (branch row / search /
  breadcrumb).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 20:50:07 +00:00
Claude
76ed4bc685 Merge remote-tracking branch 'origin/main' into claude/podcast-event-kinds-merge-vv24gd 2026-06-29 20:09:32 +00:00
Claude
2378d70326 Merge remote-tracking branch 'origin/main' into claude/git-repo-readme-code-tabs-e4uf6c 2026-06-29 20:09:01 +00:00
Vitor Pamplona
2152d5d5d6 Merge pull request #3416 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-29 15:47:30 -04:00
vitorpamplona
4060bfac39 chore: sync Crowdin translations and seed translator npub placeholders 2026-06-29 19:43:35 +00:00
Vitor Pamplona
f5879ab107 Merge pull request #3418 from vitorpamplona/claude/cashu-wallet-wizard-0zr280
Add Cashu wallet find-or-create wizard with cross-relay discovery
2026-06-29 15:40:59 -04:00
Claude
a687e03461 Merge remote-tracking branch 'origin/main' into claude/cashu-wallet-wizard-0zr280
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt
2026-06-29 19:26:56 +00:00
Claude
124dcafdf9 fix(cashu): re-sign on adopt so a deleted wallet can't be re-deleted
The find-or-create wizard can surface a wallet the user previously
DELETED — a relay that missed the kind:5 still serves the kind:17375 to
the crawl. adoptDiscoveredWallet rebroadcast that event verbatim (same id,
same created_at), which loses to the prior NIP-09 deletion two ways:
DeletionEvent.build emits both an `e` tag (old id) and an `a` tag (the
replaceable 17375:pubkey: address), so relays reject the duplicate id and
re-delete every version with created_at <= the deletion's the moment the
kind:5 propagates back — on relays and in our own LocalCache. The
"reactivated" wallet would then silently vanish.

Adopt now re-signs a FRESH kind:17375 + kind:10019 (via publishWalletEvents)
with the discovered wallet's own mints and P2PK key. A new id escapes the
`e`-tag delete and created_at=now escapes the `a`-tag delete, while the
same key + mints preserve the nutzap address and all recoverable funds
(the NUT-13 seed derives from the key, not the event id). Falls back to a
verbatim rebroadcast only if the wallet can't be decrypted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqmMR2QiULS5QGosSgSQAe
2026-06-29 18:18:38 +00:00
Claude
0bfe9d370e fix(cashu): return to the Wallet hub after deleting the wallet
Deleting the Cashu wallet popped back to CashuWalletScreen, which on an
empty wallet auto-launches the find-or-create wizard — so the user was
funneled straight back into creating the wallet they just deleted.

Navigate to the top-level Wallet hub (Route.Wallet) via newStack instead,
which pops the Cashu screens off the back stack so the wizard never
composes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqmMR2QiULS5QGosSgSQAe
2026-06-29 18:13:18 +00:00
Claude
d2dfd044cf feat(cashu): add "Wallet Created" celebration before the mint picker
In the find-or-create wizard, choosing "Create a new wallet" jumped
straight to the mint manager. Add a short celebratory interstitial first:
an animated check badge springs in (bouncy overshoot) behind an expanding
pulse ring, "Wallet Created" + "Now pick a few mints to host your sats"
fade up, a haptic fires, and a "Pick mints" button continues to the mint
selection.

The screen is purely presentational — the kind:17375 still isn't
published until the user adds a mint on the next screen — so "Pick mints"
uses popUpTo to replace the interstitial in the back stack, avoiding an
awkward return to the celebration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqmMR2QiULS5QGosSgSQAe
2026-06-29 18:02:31 +00:00
Claude
29303b18c2 feat(git): make recent-activity rows and the last-commit line clickable
- Recent-activity rows navigate to the issue/PR they represent (routeFor).
- The last-commit strip is now tappable: on the home it opens the Code
  screen; on the feed repo card it opens the repository.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 17:50:03 +00:00
Claude
09633abae5 feat(git): home polish + repo-card dashboard in the feed
Home screen:
- Nav cards: tighter vertical spacing; badges now count only OPEN issues/PRs,
  derived from the live GitStatusIndex (started on the home so the split is
  correct without visiting the Issues screen first).
- Moved the reaction row to after the recent-activity pulse.
- Added the standard 3-dot note menu (MoreOptionsButton) to the top bar.

Feed card (RenderGitRepositoryEvent):
- Replaced the web/clone links with the same stat tiles + language bar +
  last-commit strip used on the home, loaded from a lazily-fetched shallow
  snapshot. (Factory made internal so the card can build the browser VM.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 17:45:18 +00:00
Claude
45f36a8159 refactor(git): use the standard ReactionsRow; tighten file rows
- Social bar now renders the app's canonical ReactionsRow (reply, boost,
  like, zap, zapraiser, reaction gallery) instead of a bespoke subset, so
  the repository announcement behaves exactly like any other note.
- Code browser file rows: replace the heavy 32dp boxed icon with a plain
  20dp icon and tighten spacing/padding, reducing the oversized horizontal
  gap before the file name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 16:56:24 +00:00
Claude
f0100a35b3 refactor(git): move repo identity into the top bar title
The owner avatar + project name was duplicated as the home's first content
line and the top-bar title. Consolidates it into the top bar: a small owner
avatar (tappable to the profile) + project name. The home hero now carries
only the description and topic/fork chips.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 16:51:51 +00:00
Claude
52f830dd2e fix(git): make the "Mine" repo filter show the user's own repositories
The repositories feed never special-cased TopFilter.Mine, so it inherited
the shared Mine→all-follows fallback — "Mine" behaved identically to "All
Follows", making both selectors look unresponsive when toggled.

Mirrors the music/badges/communities/nsites pattern:
- GitRepositoriesFeedFilter: when the list is Mine, match repositories
  authored by the logged-in user (feed + applyFilter).
- GitRepositoriesSubAssembler: when the list is Mine, query the user's own
  repositories by author against their outbox relays (new
  filterGitRepositoriesMine), bypassing the follow-list machinery.

"All Follows" already routed through the standard follows filter; it now
visibly differs from "Mine".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 16:10:10 +00:00
Claude
5dc09513f6 refactor: build the hub's REQ inline; round the create-podcast FAB
- FilterMyPodcast now builds its two Filters directly instead of routing through
  the topNav-oriented filterPodcastEventsByAuthors helper: episodes+trailers by
  author, and the kind:30078 show metadata constrained to #d=["podcast-metadata"].
  Same wire output, but the #d constraint and the reason for two filters are now
  visible at the call site rather than hidden behind a shared map parameter.
- The "create a podcast" FAB on the Podcasts feed was using the Material3 default
  shape (a rounded square); set shape = CircleShape to match every other FAB in
  the app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-29 16:06:45 +00:00
Claude
39a8b7473e fix(cashu): populate mint suggestions in the create-wallet picker
The mint picker (CashuMintsScreen) already renders NIP-87 directory
suggestions, but in the find-or-create wizard's "create a new wallet"
path they showed up empty while the edit-mints screen had them.

Cause: openMintDirectory() subscribed the directory against a one-shot
snapshot of the account's outbox relays (acc.outboxRelays.flow.value). A
freshly-restored account reaching the create path often still has its
relay lists loading, so the snapshot was empty, hit
CashuMintDirectoryState's empty-relay early-out, and never retried.
Outbox-only relays also don't reliably carry the broad NIP-87 mint
announcements.

Fix: subscribe reactively to the union of the user's OUTBOX relays and
their INDEXER relays. The flow re-subscribes the moment relays arrive,
and indexer relays aggregate NIP-87 mint data and fall back to a curated
default set — so the picker is populated even for a brand-new user with
no wallet and no recommendations of their own yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqmMR2QiULS5QGosSgSQAe
2026-06-29 16:01:55 +00:00
Claude
b591984118 refactor(git): unify project home into one dashboard design
Removes the old boxed "Overview" section block (About/Links/Topics/
Maintainers cards) that was stacked on top of the new dashboard, which made
the home read as two designs. Its useful content now lives in the dashboard
language:

- New RepoHero: owner avatar + "name / project" title (GitHub-style), with
  description and topic chips.
- RepoMaintainersRow: a compact maintainer avatar cluster.
- Drops the Links card (clone/web URLs) as low-value.

Deletes GitRepositoryOverview.kt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 15:45:33 +00:00
Claude
d270b6a69f feat(git): make the project-home social row interactive
Replaces the read-only zap/reaction/comment counts with the canonical
ZapReaction / LikeReaction / ReplyReaction actions, so the repository home
now supports one-tap zaps (with the amount dialog), reactions, and replying
to the repo announcement — each with its live counter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 15:38:37 +00:00
Claude
2ad4af2479 feat(git): rich project-home dashboard (stats, languages, social pulse)
Turns the repository home into a data-rich dashboard combining git facts
with the Nostr social layer:

- Social row: live zap / reaction / comment counts on the repo announcement
  (via observeNoteZaps/Reactions/ReplyCount).
- Stat tiles: branches, tags, file count, and last-updated relative time.
- Language breakdown bar: proportional colored segments computed from the
  snapshot's file tree by extension (new GitRepoSnapshot.walkFileNames()).
- Last-commit strip: tip commit summary, author, time and short SHA, exposed
  up-front via the new GitRepoSnapshot.tipCommit (no extra fetch).
- Nav cards now show open issue / PR counts.
- Recent-activity pulse: newest issues/patches merged from the feeds.

quartz: GitRepoSnapshot gains tipCommit (parsed in open()) and
walkFileNames() to enumerate blob paths for the language bar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 15:32:47 +00:00
Claude
d2225c73cc feat(git): replace repo tab bar with project home + drill-in screens
Replaces the five-tab repository screen (Readme/Code/Overview/Issues/Patches)
with a scrollable Project Home and three dedicated screens:

- GitRepositoryScreen is now the home: repository facts (from the former
  Overview), Code / Issues / Pull Requests navigation cards, and the README
  rendered inline — all in one scroll.
- New GitRepositoryCode/Issues/Pulls screens, each owning its own
  disappearing top bar, reached via new addressable routes.

This removes tab overflow and the swipe-paging between heavy screens, and
fixes the per-tab header issues structurally: the Code browser's branch/tag
and file-search header now live inside the scroll list, so they track the
disappearing top bar instead of staying pinned below it.

README and Overview were refactored into embeddable, non-scrolling sections
(GitReadmeSection, GitRepositoryOverviewSections) so the home can compose
them in a single scroll container.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 00:15:51 +00:00
Claude
144f543014 fix(git): move browser ViewModel factory app-side for KMP lifecycle
The KMP lifecycle-viewmodel artifact used by commons doesn't expose the
create(Class<T>) ViewModelProvider.Factory override (only the desktop/JVM
target hit this), so the factory now lives in amethyst alongside the
viewModel() call, mirroring the NestViewModel pattern. The commons
ViewModel keeps only platform-agnostic state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 00:00:31 +00:00
Claude
d6ec458117 fix(git): close Issues/PR feed top gap; add "Mine" repo feed filter
The status feed drew its filter-chip header at the top of the content area
(under the disappearing top bar) while the feed's LazyColumn separately
re-applied the full bar-height inset as content padding, leaving an empty
band above the first item. The header now consumes the scaffold top inset
itself and the inner feed renders with the top inset zeroed, matching the
Code tab's padding pattern.

Also adds a "Mine" option to the git repositories top-nav filter so the
feed can show only the logged-in user's own repositories. The feed side
already resolves TopFilter.Mine generically; this just exposes it via a
dedicated gitRepositoryRoutes catalog (kind3 + Around Me + Global + Mine).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-28 23:55:35 +00:00
Claude
64567c7056 refactor: move V4VSplitEditorState to commons for cross-front-end reuse
The value-for-value split editor's state holder is pure snapshot state over
quartz types + a commons User — no Account, LocalCache, AccountViewModel, or
Android dependency — so per the commons architecture (state holders belong in
commons, CLI-safe where practical) it moves to commons.podcasts. A future
Desktop/iOS V4V editor can now drive the same state; the editor composable stays
platform-side (it needs AccountViewModel + user search).

This is the only podcast app-layer file that's free of amethyst-only
foundations: the rest of the podcast UI / ViewModels / feed filters /
subscriptions are coupled to AccountViewModel, LocalCache, Account, the
per-user subscription framework, or Android media/upload — the same foundations
every feature in the app shares, none of which live in commons — so they stay in
amethyst (as does, for the same reason, the analogous music composer). The
podcast protocol itself was already fully shared: all 50 quartz podcast files
live in commonMain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-28 23:01:29 +00:00
Claude
714d202e7d refactor(git): move code-browser ViewModel + syntax highlighter to commons
Relocates the two app-agnostic pieces of the NIP-34 code browser into commons
so the desktop front end can reuse them verbatim:

- GitRepositoryBrowserViewModel (+ GitBrowseState) → commons jvmAndroid
  nip34Git package. Pure StateFlow ViewModel over quartz's GitHttpClient;
  no Android/AccountViewModel/INav dependency.
- CodeHighlighter → commons commonMain nip34Git/ui. Pulls the Apache-2.0
  dev.snipme:highlights dependency into commons commonMain.

Amethyst composables now import both from commons. The screen-level
composables stay app-side, matching the commons convention that shared UI
never takes AccountViewModel/INav.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-28 22:55:56 +00:00
Claude
31f62a1787 Merge remote-tracking branch 'origin/main' into claude/podcast-event-kinds-merge-vv24gd 2026-06-28 22:29:42 +00:00
Claude
5c29c277c8 Merge remote-tracking branch 'origin/main' into claude/git-repo-readme-code-tabs-e4uf6c 2026-06-28 22:29:16 +00:00
Claude
5aaaa90cd7 feat(git): bookmark repos + label filter & counts on status feed
Adds NIP-51 (kind 10018) repository bookmarking with a star toggle in the
git repository top bar, backed by a new GitRepositoryListState in commons.
Removal rebuilds the public tag set and re-signs so encrypted private
bookmarks are preserved without decryption.

Adds a label-filter chip row and open/closed item counts to the Issues and
Patches & PRs status feeds. The active feed's distinct labels drive the
chips; selecting one filters the rendered list, and a stale selection is
dropped when switching status.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-28 21:57:31 +00:00
Claude
32ac685387 feat: add Nostr users to V4V splits by search, with avatar + name
The split editor previously only took raw, hand-typed Lightning addresses / node
pubkeys — no Nostr users, no avatars, no search. Bring it up to the Amethyst
standard used by zap-splits.

Adding a recipient now leads with a user search (reusing UserSuggestionState +
ShowUserSuggestionList): type a name or @handle, pick a person, and they're added
rendered with their avatar (BaseUserPicture) and display name (UsernameDisplay),
with their lud16 lightning address resolved automatically at save time. Picking a
user with no Lightning address is rejected with a toast. A manual "Add address"
fallback remains for raw destinations — a node pubkey for keysend, or a non-Nostr
lightning address — which keep the type toggle + text field.

Each recipient still shows its live percentage of the total weight and an
optional fee flag. (Recipients loaded from an existing value block arrive as raw
addresses, since the wire format stores only the Lightning destination.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-28 21:56:38 +00:00
Claude
cd73020dd8 feat: V4V split editor in the podcast show and episode composers
Lets a creator define their value-for-value splits in-app, closing the
create→get-paid loop: set up recipients on the show (and override per episode),
publish, and listeners' boosts/streams fan out to those destinations.

Adds a reusable V4VSplitEditorState + V4VSplitEditor composable: a card with one
row per recipient (name, Lightning-address vs node/keysend toggle, address,
weight, optional fee) and an "Add recipient" action, showing each recipient's
live percentage of the total weight. toPodcastValue() rebuilds the PodcastValue
on save (null when there are no payable recipients); a loaded block's suggested
amount/currency/enabled are carried through untouched.

Wired into both composers, replacing the previous preserve-only passthrough:
- Show editor: edits the show-level split (kind:30078 value block).
- Episode editor: edits the episode-level override (in More details).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-28 21:29:00 +00:00
Claude
402c2fd3b4 feat: commit history in the git repository code browser
Add a History action to the Code tab that shows a git-log-style list of the
branch's recent commits, and opens the diff a commit introduced (vs its first
parent) with the shared diff viewer.

quartz: GitCommit + a commit-object parser (skips multi-line headers like a
signed gpgsig), and GitHttpClient.loadHistory() — a shallow tree:0 fetch
(commits only) walked most-recent-first. The fetch filter is generalized from
a blob:none boolean to a filter spec so tree:0 can be requested. Unit-tested
against a real SSH-signed commit object.

amethyst: GitCommitLog (log list + per-commit diff), a History button on the
repo header, and ViewModel hooks (loadHistory / commitDiff).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-28 21:27:12 +00:00
Claude
6db98c2a73 feat: back the podcast authoring hub with a dedicated REQ
The "Your podcast" hub previously listed only what happened to be in LocalCache,
so on a fresh install (or before the feed had loaded the creator's events) it
could show an empty or stale catalog.

Add a MyPodcast subscription (mirrors the OnePodcast assembler trio) that, while
the hub is on screen, keeps a REQ open for the creator's OWN Podcasting-2.0
catalog on their outbox relays: the addressable episodes (30054) and trailers
(30055) by author, plus the show-metadata kind:30078 constrained to
#d=["podcast-metadata"] (reusing the existing constant so the overloaded app-data
kind isn't pulled wholesale). Registered in RelaySubscriptionsCoordinator.

The hub now also reacts to LocalCache.live.newEventBundles — when the creator's
own episodes/trailers/metadata arrive over the REQ, the lists refresh in place
rather than only on resume.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-28 21:17:02 +00:00
Claude
9207209b0b feat: word-level intra-line highlighting in git diffs
Emphasize what changed inside a modified line, not just the whole line.
A new pure IntralineDiff helper pairs each delete with its corresponding
add within a hunk and trims the common prefix/suffix to the differing
middle; GitDiffView layers a stronger add/delete background over just that
character span, on top of the existing syntax highlighting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-28 21:14:57 +00:00
Claude
a44ee6b312 feat: finish git PR/patch review — computed diffs and status actions
Pull requests reference a clone URL + commit rather than embedding a patch,
so their changes were invisible. Now the app computes and renders them.

- quartz: a pure Myers O(ND) line-diff (LineDiff) producing the same
  GitDiffHunk model the embedded-patch parser uses, unit-tested against git
  -U3 output and with a reconstruction property check.
- quartz: GitHttpClient.computeDiff(cloneUrl, head, base?) — fetches both
  commit trees, finds changed files by oid, batch-fetches the differing
  blobs and line-diffs each into a ParsedPatch (base = merge base, or HEAD).
- amethyst: a "View changes" section on the PR card that loads the diff over
  the git client and renders it with the shared GitDiffView.
- amethyst: GitStatusActions generalizes the issue open/close controls to
  issues, patches and PRs — patches/PRs additionally get "Mark merged"
  (GitStatusAppliedEvent). Visible to the author and repo owner/maintainers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-28 20:33:31 +00:00
Vitor Pamplona
447d48bb83 Merge pull request #3395 from vitorpamplona/claude/keen-dijkstra-tzvqgd
Add Nostr signer permissions & relay auth management UI
2026-06-28 16:30:41 -04:00
Claude
97df84ee65 feat: in-app Android authoring for Podcasting-2.0 podcasts
Adds a full create/edit experience so a creator can publish a podcast from the
phone, authoring as themselves (Podcasting-2.0 model: the account is the creator,
episodes/trailers are addressable and editable in place).

A "Your podcast" hub (mic FAB on the Podcasts feed) shows the creator's show or a
create CTA, the new-episode/new-trailer/edit-show entry points, and lists their
published episodes and trailers (tap an episode to edit). Three composers:

- Episode (kind:30054): cover + audio upload through Blossom/NIP-96 (auto-fills
  duration/title from the picked file's metadata), title, summary, and a
  collapsible "More details" section for season/number, video, transcript,
  chapters, and topics. Create + edit + delete; edits preserve the original
  pubdate and any value-for-value splits.
- Show metadata (kind:30078, d=podcast-metadata): cover + the channel fields,
  categories/funding as comma lists, episodic/serial toggle, and explicit /
  complete / locked switches. One per account, create-or-edit in place; the
  podcast GUID and value block are preserved.
- Trailer (kind:30055): title, a short audio/video clip (upload or URL), season.

The upload + media-probe mechanics are shared across the three composers in
PodcastComposerMedia, mirroring the music-track composer's pattern. Publishing
goes through account.signAndComputeBroadcast so events land in LocalCache and
broadcast to the creator's outbox relays. This pairs the existing CLI
(`amy podcast20`) with a native mobile authoring path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-28 20:20:24 +00:00
Claude
f25138fe7a feat: edit a NIP-34 repository announcement from the repo screen
Add an owner-only Edit action to the git repository screen's top bar that
opens a settings dialog. Because the repository event (kind 30617) is
addressable, saving republishes it under the same d-tag — preserving the
earliest-unique-commit, relays, maintainers and personal-fork flag — while
letting the owner edit the name, description, clone/web URLs and topics.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-28 20:14:54 +00:00
Claude
cd934fb0ea feat: git code browser branch/tag switch, file search, image preview; issue labels
Code browser:
- Branch & tag switching: the client now exposes all refs from ls-refs;
  a branch/tag picker on the repo header reloads the tree at the chosen
  ref (GitRepositoryBrowserViewModel.switchRef).
- In-tree filename search: a search field filters the whole tree
  (GitRepoSnapshot.searchFiles) and shows matching paths.
- Image preview: png/jpg/gif/webp/bmp blobs render via Coil instead of the
  binary notice.

Issues:
- Labels at creation: the new-issue composer takes a comma/space separated
  label list, written as NIP-34 `t` tags.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-28 20:12:00 +00:00
Claude
62f8cbe48a fix: resolve nsite icons and titles by trying both napplet and nsite manifest kinds
NSite manifests (kinds 15128/35128) were never found because all coordinate
construction hardcoded napplet kinds (15129/35129). The connected apps list
and detail screen therefore showed no icon or title for nsite entries.

- Add rememberManifestIconModel(author, identifier) in NappletFavoriteIcon.kt:
  tries napplet coord first, falls back to nsite coord. resolveIconBlob already
  handled all four event types — just needed the right coordinate.
- Replace rememberNappletManifest with rememberManifestEvent in
  ConnectedAppsScreen.kt: watches both napplet and nsite notes, returns
  whichever carries an event. NappletAppCard dispatches on the event type
  to extract title/icon from NappletManifest, RootSiteEvent, or NamedSiteEvent.
- ConnectedAppDetailScreen.AppIdentityHeader: swap hardcoded kind+
  rememberNappletIconModel call for rememberManifestIconModel(author, identifier).
- resolveNappletMeta in NappletManifestLookup.kt: widen the cache filter to
  include nsite kinds and dispatch title/icon extraction across all four types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-28 20:07:11 +00:00
Claude
95a2d9c44e Revert "feat: let ExoPlayer handle audio focus (pause on calls / other media apps)"
This reverts commit bce50d1d01.
2026-06-28 19:45:41 +00:00
Claude
7f583c3988 feat: surface git pull-request updates (kind 1619) in the repo screen
NIP-34 pull-request update events (kind 1619) revise a PR with a newer
commit / merge base, but the repository screen neither subscribed to them
nor reflected them, so updated PRs looked stale.

- Subscribe to GitPullRequestUpdateEvent.KIND in RepositoryContentKinds so
  updates reach the repo screen.
- Add GitPullRequestUpdateIndex (mirrors GitStatusIndex): tracks the latest
  update per parent PR id from LocalCache, exposed as a StateFlow.
- Fold the latest update into the parent PR card: the current commit, merge
  base and clone URLs now reflect the newest revision, with a "Revised"
  marker on both the PR card and the compact Patches-tab row.

Updates are folded into their parent PR rather than listed as separate rows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-28 19:44:05 +00:00
Claude
bce50d1d01 feat: let ExoPlayer handle audio focus (pause on calls / other media apps)
The shared feed/podcast/music ExoPlayer previously set no audio attributes and
did not handle audio focus, so a phone call or another media app starting would
not pause playback — meaning V4V streaming payments could keep accruing during a
call even though the user wasn't really listening.

Set USAGE_MEDIA audio attributes with handleAudioFocus = true on the pooled
player. ExoPlayer now pauses on focus loss (a call, another app's playback) and
ducks for transient interruptions; pausing flips isPlaying false, so the
streaming-payment accrual stops with it for free.

Tradeoff: in Media3 a muted player still requests focus while playWhenReady is
true, so muted feed autoplay now requests audio focus too. Acceptable for
correct call/interruption behavior; can be scoped to audio-only players later if
it proves disruptive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-28 19:37:46 +00:00
Claude
c464c7cdba feat: replace capability toggle with dialog and fix napplet icon loading
- Capability rows now open a dialog with radio options (Ask each time /
  Always allow / Never allow) instead of the confusing Switch+revoke
  button combo; requiresPerUseConsent capabilities omit "Always allow"
- AppIdentityHeader now reactively loads napplet/nsite icons via
  rememberNappletIconModel using the full kind:pubkey:identifier coord

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-28 16:22:01 +00:00
Claude
dedabe6451 fix: only stream V4V sats while audio is audible, not merely "playing"
isPlaying stays true when the player is muted (the voice player's mute button
sets volume to 0) or when the system media volume is at 0 — so the previous gate
could keep spending sats per minute while the user hears nothing (e.g. they
muted and pocketed the phone). Hitting pause and locking the screen were already
safe (pause flips isPlaying false; a screen-locked podcast that keeps playing is
audible listening), but muting was a real silent-spend hole.

Tighten the per-minute accrual gate to require the audio is genuinely audible:
playing AND no playback error AND in-app controller volume > 0 AND system
STREAM_MUSIC volume > 0. The whole read is guarded, so a released controller or
missing AudioManager resolves to "not audible" and stops accrual.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-28 16:14:41 +00:00
Claude
bee418fd17 fix: clarify capability toggle and revoke button semantics in ConnectedAppDetail
The toggle ON/OFF state was ambiguous (users couldn't tell if OFF meant
"ask me" or "permanently deny"). The revoke/Block icon looked like a deny
action but actually resets to "ask me each time".

- Add "Allow always" (primary) / "Never allow" (error) label above the
  Switch so the toggle's two states are explicit
- Swap MaterialSymbols.Block for MaterialSymbols.Refresh on the reset
  button — Refresh reads as "start over / go back to asking"
- Update its content description to "Ask me each time"
- Rename "Blocked" to "Requires per-use approval" for per-use-consent
  capabilities to explain why there's no toggle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-28 16:06:36 +00:00
Claude
1fe9bcec68 feat: per-minute V4V streaming payments, gated strictly to real playback
Adds the streaming half of Podcasting-2.0 value-for-value: a "Stream sats"
toggle on the episode player that, while on, pays the value split once per full
minute of playback at a chosen sats/minute rate (boostagram action "stream").

The hard requirement is that it must never pay while the user isn't listening,
so accrual is bound tightly to genuine playback rather than a free-running timer:

- The control lives inside the player composable, so navigating away, scrolling
  it out of a feed, or tearing down the screen disposes it and stops streaming.
- Each second the engine re-reads the live MediaController.isPlaying and only
  accrues when audio is actually playing and there's no playback error. The
  player already pauses itself on background / off-screen / audio-focus loss /
  error, so every one of those halts accrual for free. A released controller
  reads as not-playing (guarded).
- Only whole, actually-played minutes are billed; a partial minute is dropped
  when the session ends (never rounded up). This rule is a pure, unit-tested
  unit (PodcastStreamingAccrual).
- The toggle defaults OFF and uses plain remember (not rememberSaveable), so it
  never silently resumes after a rotation or process death — the user re-opts in.
- Streaming is gated to an in-app wallet (NWC / CLINK debit); we never auto-fire
  an external wallet intent every minute. Per-minute errors are swallowed (no
  toast spam) while one-off boosts still surface errors.

The selected rate is always shown on the toggle and a live "streamed N sats this
session" counter makes the spend visible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-28 16:03:56 +00:00
Claude
39727d819e feat: flagship git review — diff viewer, issue management, polished Issues tab
Build out the repository screen into a project-review hub.

PR/patch diff viewer:
- quartz: UnifiedDiffParser turns a NIP-34 patch (kind 1617) `git
  format-patch` body into a commit message + structured per-file diffs,
  bounding each hunk by its header counts so the mbox "-- " signature is
  never miscounted. Unit-tested against a real git-generated patch.
- amethyst: GitDiffView renders a GitHub-style file-by-file diff —
  stat summary, collapsible file cards, +/- line coloring, old/new line
  numbers, and per-line syntax highlighting reused from the code browser
  (size-guarded). Wired into the patch card; feeds keep the compact preview.

Issue management:
- New-issue composer (subject + body) that builds, signs and broadcasts a
  GitIssueEvent via the account signer; reachable from the Issues tab.
- Author/maintainer Open/Close controls on the issue card, publishing
  GitStatusOpen/Closed events that flow back through GitStatusIndex.

Issues tab polish:
- Open/Closed filter chips, label (#topic) chips on each row.

Adds git_diff_files_changed plural and issue/diff strings. No new icons.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-28 16:00:35 +00:00
Claude
f502b09b55 feat: rename See more/less to Show/Hide Event and make JSON scrollable
The raw event JSON toggle in the signer consent dialog is now labeled
"Show Event" / "Hide Event" instead of the generic "See more" / "See less".
The expanded JSON block also gains horizontal scroll (softWrap=false +
horizontalScroll) so wide event JSON doesn't get clipped on narrow screens.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-28 15:57:52 +00:00
Claude
a71ba61370 feat: execute Podcasting-2.0 value-for-value (V4V) Lightning splits
Adds payment execution to the V4V value blocks that were previously
display-only. A "Send value" button on the value card opens the account's
zap-amount picker; choosing an amount fans the weighted shares out to every
recipient, mirroring how a NIP-57 zap-split is paid.

quartz (pure, tested):
- PodcastValue.computeShares() — splits a total across recipients by relative
  weight, honoring `fee` recipients that take their split as a percent off the
  top. Returns PodcastValueShare (recipient + millisats).
- PodcastBoostagram — the satoshis.stream keysend metadata blob carried in TLV
  record 7629169, with the registered field names and unset fields omitted.
- PODCAST_TLV_RECORD / TYPE_NODE / TYPE_LNADDRESS constants.

amethyst:
- V4VPaymentHandler — the execution engine. lnaddress recipients resolve to a
  BOLT-11 via LNURL-pay and pay through the user's default source (NWC, CLINK
  debit, or external wallet intent), same rails as a zap. node recipients pay
  by NWC keysend (pay_keysend) carrying the boostagram TLV plus any per-recipient
  custom TLV; keysend is NWC-only, so node recipients are skipped with a clear
  error when no NWC wallet is configured.
- AccountViewModel.payV4V() wrapper + the "Send value" amount picker on the
  value card, wired for both episode and show value blocks.

V4V recipients are raw Lightning destinations, not Nostr users, so there is no
zap request and no zap receipt — just the payment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-28 15:43:22 +00:00
Claude
3cc0ce09d4 feat: show website favicon and domain in all browser-connected app views
Web app entries (browser:https://...) now display:
- The captured favicon from BrowserIconRegistry (same source as the
  bottom-nav favourite website icon) in ConnectedAppsScreen,
  ConnectedAppDetailScreen, and all three permission/consent dialogs
- The domain name (host) as the title instead of the full URL, via
  OmniboxInput.hostOf() in loadDetailState, NappletConsentSummary,
  buildSignerConsentInfo, and buildConnectInfo
- A globe icon (FavoriteApp.WebApp) instead of the grid icon
  (FavoriteApp.NostrApp) in all consent dialog headers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-28 15:41:27 +00:00
Claude
059f373ef5 feat: polish git repository README + Code browser UI
Make the repository browser flagship-grade rather than utilitarian:

- Shared status components: spinner-backed loading box and an icon-led
  message box with a styled retry action, replacing plain centered text.
- Code tab: a repo info bar (branch + short-commit chips, item count),
  a scrollable chip breadcrumb, and polished entry rows with tinted icon
  tiles, monospace filenames, folder/file color distinction, a trailing
  chevron on folders, and hairline dividers.
- File viewer: a line-number gutter with horizontally scrollable,
  syntax-highlighted code, a language/path bar with copy-to-clipboard,
  and themed surfaces; richer binary/error states.
- README tab: spinner loading + icon-led empty/error states.

Adds a git_repo_item_count plural and copy/text strings. No new icons.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-28 15:25:22 +00:00
Claude
6f9bf33595 fix: show globe icon and domain for browser-connected web apps
The permission ledger stores browser-visited origins as `browser:<url>`
where "browser" is a sentinel non-pubkey. All previous code treated every
entry as a napplet, causing NPub.create("browser") to fail and
LocalCache.checkGetOrCreateAddressableNote to return null for every entry
-- so icons and titles never resolved.

Now ConnectedAppCard splits on author == "browser": web-app entries get a
globe icon (FavoriteApp.WebApp), the domain extracted from the URL as
title, and no npub row. Napplet entries (real hex pubkeys) continue using
the reactive manifest lookup. The relay subscription now also filters out
"browser" from the authors set so no invalid pubkey is sent to relays.

ConnectedAppDetailScreen receives the same fix in AppIdentityHeader, using
FavoriteApp.WebApp for browser entries so the globe icon appears there too.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-28 15:17:04 +00:00
Claude
291fda5728 feat: add README and Code tabs to the git repository screen
Render the repository README in the first tab and add a Code tab that
browses the repo's file tree and renders source files (syntax-highlighted),
reading directly from the NIP-34 clone URL over the git smart-HTTP v2
protocol (works with GRASP/ngit bare servers as well as GitHub/GitLab).

quartz (jvmAndroid): a from-scratch git smart-HTTP v2 client — pkt-line
codec, packfile parser with OFS/REF delta resolution and SHA-1 oids,
tree/commit parsers, and a high-level browser that fetches a shallow
filter=blob:none snapshot (one request for the whole tree) and lazily
pulls file blobs on demand. Offline tests run against real captured
GitHub wire bytes plus a git-generated OFS-delta pack.

amethyst: README tab (rich markdown), Code tab (folders-first browser
with breadcrumb navigation + a file viewer that renders markdown or
syntax-highlighted source), a browser ViewModel, new UI strings, and a
Folder material symbol (font subset regenerated). Syntax highlighting
uses dev.snipme:highlights (Apache-2.0, permissive).

Tabs are now: README, Code, Overview, Issues, Patches & PRs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-28 14:46:32 +00:00
Claude
0d51dff41a feat: live relay subscription for connected-apps manifest screen
Replaces the one-shot fetchAll with a ComposeSubscriptionManager that
holds an open relay subscription to NIP-5D manifests (kinds 15129/35129)
for exactly the set of authors stored in the permission ledger, while
ConnectedAppsScreen is in composition.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-28 14:03:17 +00:00
Claude
5984d20042 feat: verify NIP-F4 podcast authors against their kind:10064 counter-claims
A show's kind:10154 metadata can name any pubkey as an author (host, co-host,
editor) via `p` tags, but those claims are unverified — the show can list
anyone. NIP-F4 lets the named author publish their own kind:10064
AuthoredPodcastsEvent listing the podcasts they actually author, which closes
the loop.

On the single-podcast header, render each claimed author as a row (avatar,
name, role) and cross-check it: the author's 10064 is fetched + observed
lazily via observeNoteEvent, and a "verified" check badge appears only when
that 10064 lists this podcast's pubkey.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-28 13:48:47 +00:00
Claude
7b2399eb51 feat: inline Podcasting-2.0 chapter list (expand-to-load)
Turn the episode "Chapters" affordance from a link-out into an inline,
timestamped chapter list.

quartz:
- PodcastChapters / PodcastChapter (@Serializable) parse the off-event
  podcast-namespace chapters.json (version + startTime/title/img/url/toc).
  Lenient parse with a malformed-input test.

amethyst:
- PodcastChaptersSection fetches the chapters document with the app's
  preview HTTP client (Tor/proxy aware) off the main thread and renders
  `timestamp — title` rows in a tinted card; empty/failed renders nothing.
- The episode card's "Chapters" chip now toggles this section instead of
  opening the URL. Fetch is lazy — gated behind the toggle — so scrolling a
  feed never triggers network.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-28 01:38:00 +00:00
Claude
1f5650fe73 feat(amethyst): bookmark podcasts via the existing NIP-51 bookmark list
Rather than build a parallel favorites/subscribe stack for NIP-F4's kind:10054
list, reuse the bookmark list (kind 10003) that already holds multiple kinds via
a/e references — a public bookmark matches the "soft public recommendation"
intent of the favorites list, and the whole chain (Account.addPublicBookmark
branching on addressable vs regular notes, the kind-agnostic Bookmarks feed that
resolves both e- and a-tags) already supports it.

Add a PodcastBookmarkButton toggle and place it in the show and episode card
title rows. Works across all podcast kinds: NIP-F4 shows (10154) / episodes (54)
and Podcasting-2.0 shows (30078) / episodes (30054); bookmarked podcasts then
appear in the standard Bookmarks screen, rendered through the same cards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-28 01:12:56 +00:00
Claude
05b3e3e25e fix: make ConnectedAppsScreen fully reactive for title and icon
The previous approach baked title/iconUrl into ConnectedAppEntry at
load time (a snapshot) and passed pubkey:identifier to
rememberNappletIconModel which expects kind:pubkey:dtag — so both
title and icon never updated from LocalCache.

Now:
- ConnectedAppEntry only holds coordinate + signerPolicy
- Each ConnectedAppCard builds the full kind:pubkey:dtag coordinate
  (kind 15129 for root napplets with empty identifier, 35129 for named)
- rememberNappletManifest observes the live AddressableNote in
  LocalCache so title/iconUrl update reactively as manifests arrive
- rememberNappletIconModel receives the correct full coordinate so
  blossom blob icons load exactly like FavoriteAppsScreen
- The relay fetch now covers all connected apps (not just those with
  missing iconUrl) so manifests arrive promptly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-28 01:09:00 +00:00
Claude
d90165a4f6 feat: Podcasting-2.0 value-for-value (V4V) splits — parse, display, publish
Make the Podcasting-2.0 `value` block first-class across read and publish. Actual
Lightning execution (keysend to node recipients, LNURL fan-out to lnaddress
recipients, weighted by split) is a separate wallet/NWC effort and is NOT done
here — this lands the data model, display, and authoring.

quartz:
- PodcastValue / PodcastValueRecipient (@Serializable): amount, currency,
  recipients[] (name, type node|lnaddress, address, split weight, fee, custom*).
- Episode `["value", "<json>"]` tag (ValueTag) + accessor/builder; show value is
  parsed from the kind:30078 JSON. Exposed via the shared abstraction as
  PodcastEpisode.episodeValue() and PodcastShow.showValue() (interface defaults,
  so NIP-F4 returns null). Round-trip + JSON-parse tests.

amethyst:
- PodcastValueSplits: a tinted "Value-for-Value" card listing each recipient
  with its address and computed share, rendered on both the episode and show
  cards when a value block is present.

cli:
- `podcast20 episode`/`metadata` gain `--value-json` to publish the block;
  malformed JSON is rejected as bad_args. Verified end-to-end against the CLI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-28 00:35:10 +00:00
Claude
e0a6ed5ffc fix: i18n kind names in signer consent; live icons in connected apps list
- Extract kindDisplayName() to KindDisplayName.kt and add kindNameFor()
  helper that picks the translated string resource when available,
  falls back to KindNames English map, then "k<number>"
- NostrSignerOpLabels: use kindNameFor() so "sign for Notes (kind: 1)"
  is translated instead of always English
- ConnectedAppsScreen: wire rememberNappletIconModel so the card icons
  load from blossom just like FavoriteAppsScreen does
- Remove the now-duplicate kindDisplayName() definition from
  RelayInformationScreen.kt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-28 00:01:02 +00:00
Claude
e8edd94317 test: verify NIP-22 comments work on podcast episodes; add RootScope marker
Verification of kind:1111 comments on podcast episodes: they already work
end-to-end (parse, build, route to the thread screen, thread assembly, composer,
and the reply/reaction subscriptions all treat any kind as a valid root — nothing
gates on the RootScope marker). Added a quartz test that drives the real
CommentEvent.replyBuilder path and asserts:
- a comment on a Podcasting-2.0 episode (30054) roots on its `a`/`A` address,
- a comment on a NIP-F4 episode (54) roots on its `e`/`E` event id,
- both are kind:1111 and carry the root-kind tag.

Also closes a small consistency gap: every other commentable content type
(articles, all video kinds, pictures, highlights, wiki, polls, …) implements the
RootScope marker, but the podcast events did not. Add it to PodcastEpisodeEvent,
Podcasting20EpisodeEvent and Podcasting20TrailerEvent. Harmless today (no code
does `is RootScope`), but it documents intent and future-proofs any such check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-27 23:20:18 +00:00
Claude
f2fbe260d5 feat: show kind name in signer consent label; fix icon race in ConnectedAppsScreen
- NostrSignerOp.SignKind label now renders "sign for <Kind Name> (kind: N)"
  using KindNames.nameFor(), falling back to "sign kind N event" for unknowns
- Fix ConnectedAppsScreen race: merge two LaunchedEffects into one so
  items is set before observeEvents fires and before the relay fetch runs;
  previously both could find items==null and return early, leaving icons blank
2026-06-27 23:17:56 +00:00
Claude
7fd0741778 fix: center-align text in deny/block OutlinedButtons
Remove the Modifier.fillMaxWidth() + TextAlign.Start that was forcing
button labels to the left edge; buttons now use the default centered layout.
2026-06-27 23:09:31 +00:00
Claude
8573e4fd2f feat(cli): publish Podcasting-2.0 podcasts via amy podcast20
Add a separate command group for authoring the Podcasting-2.0 (podstr) kinds,
kept distinct from the NIP-F4 `podcast` commands because the models differ —
here the logged-in account is the creator and signs everything with its own key,
and episodes/trailers are addressable (d-tag) events.

  amy podcast20 metadata --title T [...]   kind:30078 show metadata (JSON body)
  amy podcast20 episode  --title T --audio URL[,URL] [...]   kind:30054 episode
  amy podcast20 trailer  --title T --url URL [...]           kind:30055 trailer
  amy podcast20 list [USER] [--limit N]    metadata + episodes + trailers

Episodes accept the full rich tag set (video, episode/season, transcript,
chapters, topics, duration); d-tags and the RFC2822 pubdate are auto-generated
when omitted. Thin assembly only — added Podcasting20PodcastMetadata.build() in
quartz so JSON-body construction stays out of cli (covered by a round-trip test).

Verified end-to-end against the running CLI: all three commands build, sign and
emit the expected kinds (30078/30054/30055) with correct d-tags and the --json
single-line contract.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-27 23:05:01 +00:00
Claude
546d4ce9c8 Merge remote-tracking branch 'origin/main' into claude/cashu-wallet-wizard-0zr280 2026-06-27 23:00:26 +00:00
Claude
2e81a71f19 refactor(napplet): replace newEventBundles with LocalCache.observeEvents for manifest updates
LocalCache.observeEvents<Event>(filter) handles both the initial snapshot and
subsequent insertions via the observables registry, replacing the manual
bundle-scan over newEventBundles. Fewer moving parts and the filter is scoped
to manifest kinds rather than scanning every arriving event bundle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:53 +00:00
Claude
debe3c3a48 feat(napplet): rich app cards with dynamic manifest loading in Connected Apps list
- Add authorPubKey (npub) to ConnectedAppEntry and show it in each card below the
  domain, so users can verify who published the napplet/nsite
- Increase icon to 48dp and use surfaceVariant card background to match the detail
  screen's AppIdentityHeader style
- After the initial cache-based load, subscribe to LocalCache.live.newEventBundles
  and re-resolve metadata (title + icon) for all entries whenever a manifest event
  (kind 15129 / 35129) arrives — handles manifests delivered by any relay subscription
- For apps whose icon is not yet cached, issue a one-shot relay fetch (kinds 15129/35129
  for the relevant author set) via account.client.fetchAll; inject results into
  LocalCache.justConsume so the newEventBundles observer picks them up and updates
  the list without a full reload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:53 +00:00
Claude
1086f4cf5e fix(napplet): unify DataStore cache key to file path, fixing connected-apps crash
allPolicies() keyed the LargeCache on the filename ("nsp_HASH") while storeFor()
keyed it on the coordinate string. Both point at the same .preferences_pb file,
so getOrCreate created two live DataStore instances for one file —
DataStore's own singleton guard then threw IllegalStateException.

Fix: use file.absolutePath as the cache key in both code paths so the second
call always returns the already-open DataStore instance instead of creating a
new one.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:53 +00:00
Claude
94ac370379 fix(napplet): consistent button styling, fix DataStore crash, icon loading, dead-code cleanup
- Make all four consent actions (Always Allow / Allow Once / Deny Once / Always Deny)
  full-width OutlinedButton/Button so they are visually consistent; remove TextButton
  with left-aligned text that clashed with the centered primary buttons
- Move domain/coordinate label into the header Column directly below the subtitle so
  the URL is contextually grouped with the app identity rather than floating near buttons
- Fix DataStore multiple-instances crash: add nappletPermissionStore and signerPermissionStore
  as lazy singletons in AppModules; warm them up on IO thread to avoid StrictMode
  DiskReadViolation; update NappletBrokerService and connected-apps screens to use the
  shared singletons instead of creating independent instances
- Propagate iconUrl through NappletConsentInfo / NappletSignerConsentInfo / NappletConnectInfo
  data classes; resolve napplet metadata (title + icon) in the broker-side builders
  (NappletConsentSummary, NostrSignerOpLabels) so dialogs receive it ready to display
- Replace manual buildEventJson (org.json) with JacksonMapper.toJsonPretty(EventTemplate)
  in NostrSignerOpLabels; add toJsonPretty(EventTemplate<*>) overload to JacksonMapper
- Delete dead NappletPermissionsScreen.kt and NappletSignerPermissionsScreen.kt (never
  navigated to); remove Route.NappletPermissions and its composable registration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:52 +00:00
Claude
840237fa72 fix(napplet): convert capability consent to floating dialog, match consent style
Replace the AlertDialog with the same Surface(extraLarge)/Dialog pattern
used by the signer consent and connect dialogs. Shows app icon + name +
capability category in the centered header, operation detail (with any
content preview) in a selectable surfaceVariant box, and the same button
hierarchy: Always allow (Button, primary) / Allow once (FilledTonalButton)
then Never allow / Not now as left-aligned TextButtons below a divider.
When the capability is per-use only (payments), Allow once is promoted
to the primary Button.
2026-06-27 22:58:52 +00:00
Claude
9dcfc3a0ed fix(napplet): convert connect screen to floating dialog, match consent style
Replace the full-screen semi-transparent overlay with a floating card
Dialog (same Surface/shape/elevation as the signer consent dialog) so
both permission prompts look consistent. Header now shows the app icon,
name, and "wants to connect to your Nostr account" subtitle instead of
the generic "Connect to Nostr" headline. Block button style matches the
deny row in the signer consent.
2026-06-27 22:58:52 +00:00
Claude
af2fcc46b6 feat(napplet): promote Always Allow to primary action in signer consent
Move AllowForOp to the primary Button (filled) since always allowing the
operation is the preferred choice, with AllowOnce as the secondary
FilledTonalButton. The time-bound and allow-all options stay in "More
options", keeping the nuclear allow-all less discoverable.
2026-06-27 22:58:52 +00:00
Claude
c7566f2685 fix(napplet): center connect dialog, fix label color and domain display
- Wrap content in a scrollable Box with Alignment.Center so the dialog
  is vertically centered instead of stuck at the top
- Explicitly set onSurface color on the PolicyOption label so it stays
  visible regardless of Surface background tint
- Fix buildConnectInfo to show the napplet identifier (e.g. "browser")
  in the block button instead of the raw coordinate prefix (pubkey)
2026-06-27 22:58:52 +00:00
Claude
343263cb2c fix: use CommonsR for napplet_untitled after resource move to commons
The string moved to commons/src/androidMain/res/values/strings.xml
in the upstream merge; update the two remaining call sites.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:52 +00:00
Claude
d05247541d refactor: extract shared napplet abstractions, remove what-comments
- Extract resolveNappletMeta() to NappletManifestLookup.kt, replacing
  three private copies of the same manifest lookup across
  ConnectedAppsScreen, ConnectedAppDetailScreen, NappletPermissionsScreen,
  and NappletSignerConsentActivity.
- Extract PolicyCard composable to PolicyCard.kt, shared between
  ConnectedAppDetailScreen and RelayAuthSettingsScreen (was duplicated).
- Extract NappletCapability.symbol() to NappletCapabilityExt.kt, shared
  between ConnectedAppDetailScreen and NappletPermissionsScreen.
- Drop what-comments on kind 1/6/7 lines in NostrSignerPermissionLedger.
- Reword TrustedRelayListState stateIn comment to note private-tag absence
  on first boot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:52 +00:00
Claude
330379e53f fix(napplets): address four code-review findings before merge
- Cancel on first-connect dialog now suppresses re-prompting for the
  rest of the session (sessionCancelled set under signerConsentLock)
  instead of showing the dialog on every subsequent request.
- PARANOID signer policy no longer silently bulk-grants ALLOW_ALWAYS
  for all capabilities; the capability ledger is left empty so each
  capability prompts individually, matching user intent.
- NostrSignerOp.Decrypt default changed from ALLOW → ASK in
  reasonableDecision(); the branch is currently unreachable
  (toSignerOp() never produces Decrypt) but ASK is the safer default
  if a decrypt request type is added in future.
- TrustedRelayListState seeds its StateFlow from the synchronously
  available cached relay set, eliminating a startup window where
  IF_IN_MY_LIST incorrectly denied auth to relays in the user's list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:52 +00:00
Claude
52f1b8723c feat(napplets): require Amethyst consent for all signer types, auto-approve encrypt/decrypt
Remove the signer self-gating bypass that allowed external (Amber/NIP-55)
and remote (NIP-46) signers to skip Amethyst's per-napplet consent UI.
All signer types now go through Amethyst's consent dialogs first; the
external signer then adds its own approval on top (double-prompting).
This lets users differentiate signing requests by app inside the external
signer, since Amethyst itself is the requesting app.

Also expand the REASONABLE policy to auto-approve Encrypt and Decrypt
operations, matching the intent that common/private-key operations that
apps routinely need are pre-approved at the "reasonable" trust level.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:51 +00:00
Claude
2786c5b683 fix(relayauth): expand IF_IN_MY_LIST to all live relay lists minus blocked
Replace the localRelayServers-only check with account.trustedRelays
(nip65, private outbox, local, dm, search, indexer, proxy, trusted,
broadcast relay lists combined) minus account.blockedRelayList. Uses
normalizeRelayUrlOrNull for consistent URL comparison.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:51 +00:00
Claude
162ee51955 feat(ui): modernize napplet and relay auth permission screens
- ConnectedAppDetailScreen: replace emoji policy icons (❤👍🕶) with
  themed MaterialSymbols (Favorite/Shield/Lock); show domain instead of
  raw coordinate in AppIdentityHeader
- ConnectedAppsScreen: remove redundant Column wrapper around SuggestionChip
- NappletSignerConsentActivity: add centered FavoriteAppIcon header with
  title + description; promote "Allow once" to FilledTonalButton; collapse
  time-based/granular grants behind a "More options" toggle; resolve app
  icon from cache using coordinate
- RelayAuthSettingsScreen: replace plain radio buttons with styled PolicyCard
  composable (bordered card, icon, check mark); wrap per-relay overrides in
  surfaceVariant Surface with dividers; fix duplicate if/if → if/else;
  add TextOverflow.Ellipsis to URL text

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:51 +00:00
Claude
9a117d6166 feat(napplets): show app icon in Connected Apps list and detail
Replace the static placeholder glyph with the napplet manifest's
icon() URL loaded via FavoriteAppIcon (Coil AsyncImage + glyph
fallback). Both ConnectedAppsScreen and ConnectedAppDetailScreen now
resolve iconUrl alongside title from the event cache and display the
real app icon when available.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:51 +00:00
Claude
d57c3f71db fix: update quartz auth tests to match new two-arg signWithAllLoggedInUsers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:51 +00:00
Claude
0e6e7901c2 fix: update KtorRelayTest to match new signWithAllLoggedInUsers signature
The lambda now receives (relay, template) after RelayAuthenticator was
updated to pass the relay URL for per-relay auth policy checks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:51 +00:00
Claude
328790ac9f feat: time-bound signer grants, last-used tracking, and relay AUTH settings (NIP-42)
- Add AllowForSession and AllowUntil(expiresAt) signer grant types so users
  can grant temporary access (session, 24h, 30d) from the consent dialog
- Track per-app lastUsed timestamp in NostrSignerPermissionStore and update
  it on every granted signing operation
- Auto-expire timed grants: decide() clears expired op decisions before
  returning, so no background sweep is needed
- Add NappletBroker.sessionAllows in-memory set for session grants (cleared
  on broker destroy, never persisted)
- Implement full NIP-42 relay auth policy system:
  - RelayAuthPolicy enum (ALWAYS / NEVER / IF_IN_MY_LIST) stored in
    AccountSettings and persisted in LocalPreferences
  - RelayAuthDecision (ALLOW / DENY) per-relay overrides in DataStore
  - RelayAuthPermissionLedger combining global policy + per-relay overrides
  - DataStoreRelayAuthPermissionStore writing to relay_auth.preferences_pb
- Wire relay auth into AuthCoordinator: subscribeLedger/unsubscribeLedger
  lets each logged-in account contribute its own policy; signWithAllLoggedInUsers
  now receives the relay URL so it can check the ledger before signing
- Update RelayAuthenticator (quartz) to pass relay URL in the signing lambda
- Add RelayAuthSubscription composable that subscribes both the account and
  its ledger when a screen is active
- Add RelayAuthSettingsScreen: global policy radio picker + per-relay
  override list with toggle and remove; reachable from Settings
- Add Route.RelayAuthSettings, AppNavigation wiring, and SettingsCatalog entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:51 +00:00
Claude
77dacd9e68 feat: add Connected Apps settings screen with per-app permission detail
Adds a unified "Connected Apps" entry to Account Settings that surfaces
all web apps that have connected to the user's Nostr key (both capability
grants and signer trust levels) in one place.

- Route.ConnectedApps (list) + Route.ConnectedAppDetail(coordinate) (detail)
- ConnectedAppsScreen: merges NappletPermissionLedger + NostrSignerPermissionLedger,
  shows one card per app sorted alphabetically with trust-level chip
- ConnectedAppDetailScreen: identity header, editable trust-level picker
  (Full Trust / Reasonable / Paranoid), op override rows with revoke, capability
  switches, and a "Forget this app" button that clears all permissions
- Settings catalog entry under Account (Apps symbol, keyword-searchable)
- NappletsTopBar Tune icon now navigates to ConnectedApps instead of
  the old capability-only NappletPermissions screen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:51 +00:00
Claude
e03fef36e4 refactor: replace hand-rolled JSON builder with org.json.JSONObject
buildEventJson was manually escaping quotes, newlines, and backslashes
which is fragile. org.json.JSONObject.toString(2) handles all escaping
correctly and produces the same pretty-printed output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:50 +00:00
Claude
e5763b947c fix: polish napplet signer permission UIs
- NappletSignerConsentActivity: replace AlertDialog (broken 5-button
  layout) with a custom Dialog + Surface using heightIn + verticalScroll;
  color-coded allow (primary) / deny (error) action rows; monospace
  "See more" toggle that reveals full raw event JSON with SelectionContainer
  so users can inspect and copy the data being signed/encrypted
- NappletConnectActivity: wrap column in verticalScroll so the trust-level
  options are not clipped on small screens or large font sizes
- NappletSignerPermissionsScreen: show localized op labels ("sign kind 1
  event") and decision labels ("Allow"/"Ask"/"Deny") instead of raw key
  strings and enum names; fix per-op delete touch target to 48dp (M3 min)
- NappletSignerConsentInfo: add rawData field carrying full event JSON for
  sign/encrypt or decrypted plaintext for future decrypt operations
- NostrSignerOpLabels: populate rawData; add buildEventJson helper
- strings: napplet_op_decrypt → "read your private messages" (the consent
  is to expose already-decrypted content, not to perform decryption);
  add napplet_consent_wants_to, see_more/see_less, decision labels

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:50 +00:00
Claude
b48cad67e5 feat: add Nostr signer permission system for napplets/nsites
Implements per-app permission management for the internal nsec signer when
webapps/napplets/nsites connect via Amethyst's built-in key:

- Three trust levels on first connect (FULL_TRUST, REASONABLE, PARANOID)
  with a UI dialog (NappletConnectActivity) matching the design spec
- Per-operation consent dialogs (NappletSignerConsentActivity) for
  sign-kind/encrypt/decrypt with Allow once, Don't ask again, Deny options
- Per-app DataStore storage (DataStoreNostrSignerPermissionStore) using
  SHA-256-hashed filenames so 1000s of apps don't bloat a single file
- NostrSignerPermissionLedger applies policy decisions: REASONABLE
  auto-allows kinds 1/6/7; FULL_TRUST auto-allows all non-payment ops
- NappletBroker extended with first-connect gate and per-op signer gate,
  serialized by a dedicated signerConsentLock mutex
- Permission management screen (NappletSignerPermissionsScreen) to review
  and revoke stored per-app signer permissions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:50 +00:00
Vitor Pamplona
82f40e0ed1 Merge PR: Fix embedded WebView theming (dark-page corruption + live theme switch)
Merges nostr proposal 81ee3e7c into main:
- fix(napplet): drop algorithmic darkening so dark-by-default sites (e.g.
  ditto.pub) aren't corrupted — it collided with nightThemedContext and inverted
  their nav bars to light. prefers-color-scheme: dark still works without it.
- feat(embed): follow the app theme live in embedded web tabs by rebuilding the
  warm sessions on a DARK/LIGHT flip, instead of requiring an app restart.

Device-verified on ditto.pub + brainstorm.nosfabrica.com, Dark<->Light.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 18:47:05 -04:00
Vitor Pamplona
925c5e454a feat(embed): follow the app theme live in embedded web tabs
An embedded tab's WebView resolves its theme from the context it is built with
(nightThemedContext), once, at construction — a runtime config change does not
re-flip the renderer — so a live app-theme switch never reached an already-warm
surface; it took a full app restart.

Watch the resolved DARK/LIGHT theme and, on a real flip, rebuild the warm
sessions in the new theme:
- EmbeddedTabHost.themeEpoch + rebuildAllForTheme() tears down the warm
  controllers but keeps activeId, so the visible tab re-activates the instant its
  screen re-acquires (no blanked-out surface).
- EmbeddedTabThemeWatcher (mounted by AppNavigation next to the tab layer)
  collects uiPrefs.theme + isSystemInDarkTheme() and triggers the rebuild.
- WebAppScreen / NostrAppScreen key their controller on themeEpoch (NostrApp also
  re-mints its launch params); the preloader re-warms off-screen tabs; the tab
  layer keys each surface on (id, controller) so a rebuilt session gets a fresh
  SandboxedSdkView.

The page reloads in the new theme (unavoidable — the theme is fixed at WebView
construction). Device-verified Dark<->Light with no app restart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 18:43:05 -04:00
Vitor Pamplona
c6575a0888 fix(napplet): drop algorithmic darkening so dark-by-default sites aren't corrupted
setAlgorithmicDarkeningAllowed(true) was added to force-darken pages that don't
implement prefers-color-scheme. Combined with nightThemedContext (which forces the
embed WebView's isLightTheme=false), it now also runs on pages that are ALREADY
dark but don't declare CSS color-scheme support — e.g. ditto.pub, which ships
<html class="dark"> by default — and algorithmically inverts their nav bars to
light, leaving "dark content, light bars".

prefers-color-scheme: dark is driven by isLightTheme (nightThemedContext)
INDEPENDENTLY of algorithmic darkening — device-verified: embedded pages still
report prefersDark=true with darkening off — so dropping it keeps real dark-aware
sites dark while no longer corrupting dark-by-default ones. The trade-off (a site
with no dark mode of its own renders light instead of being force-inverted)
matches how a real mobile browser behaves.

Removed from all four embed/host WebView configs (browser + nsite/napplet,
embedded + full-screen) along with the now-unused WebSettingsCompat import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 18:43:05 -04:00
Claude
88ba824a6e feat: read and surface rich Podcasting-2.0 episode tags
Parse the episode tags podstr emits beyond the basics — video, episode
number, season, transcript and chapters — and surface them in the UI.

quartz:
- Add VideoTag, EpisodeNumberTag, SeasonTag, TranscriptTag, ChaptersTag plus
  accessors and builder DSL on Podcasting20EpisodeEvent.
- Extend PodcastEpisode with episodeVideo / episodeNumber / episodeSeason /
  episodeTranscriptUrl / episodeChaptersUrl as interface defaults, so NIP-F4
  needs no change. PodcastAudio is now documented as audio-or-video media.
- Tests for round-trip + interface access and an all-absent default case.

amethyst:
- Extract shared PodcastBadge / PodcastLinkChip (PodcastChips.kt) and reuse
  them in the show card for visual consistency.
- Episode card: a season/episode badge, a "Video" badge, and Transcript /
  Chapters link chips that open the off-event documents; the player now falls
  back to the video source when an episode ships no audio.
- Compact show-page row: an "S2 · E5" prefix on the date line and the same
  audio→video media fallback.

Read-only. New strings for season/episode, video, transcript and chapters.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-27 22:28:28 +00:00
Claude
7ea8af973d feat: surface richer podcast show metadata with a modern card
Parse and render the Podcasting-2.0 show fields the kind:30078 metadata carries
beyond the basics:

quartz:
- Extend PodcastShow with author, categories, funding URLs, explicit, complete
  and copyright as interface defaults — so NIP-F4 (kind:10154) needs no change
  and just returns empties, while Podcasting-2.0 overrides them.
- Podcasting20PodcastMetadata now parses categories, funding[], copyright,
  type, complete, locked, email and guid from the JSON content (value/V4V is
  still skipped). Covered by expanded tests incl. an all-absent default case.

amethyst:
- Rebuild the podcast metadata card: an author byline, tinted pills for
  Completed / Explicit / genre categories, a prominent filled "Support the show"
  button opening the funding URL, clickable website chips with a globe icon, and
  a subtle copyright footer — all Material3, no new icons/font subset needed.

Read-only. New strings: podcast_explicit/completed/premium/support_show/by_author.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-27 22:08:03 +00:00
Vitor Pamplona
4b6e4be942 Merge pull request #3413 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-27 17:46:21 -04:00
Claude
76b05bbb85 feat(amethyst): render Podcasting-2.0 trailers (kind:30055)
Trailers were parsed and cached but invisible. Surface them:

- PodcastTrailerListItem: a compact trailer row with a "Trailer" badge (and
  season, when present) that plays the media through the shared episode audio
  player via PodcastAudio. Used both on the show page and as the inline
  renderer (NoteCompose dispatches kind:30055 to it).
- OnePodcastEpisodesFeedFilter now also pulls kind:30055 trailers for the
  show's pubkey; PodcastScreen renders trailer rows distinctly from episodes
  and excludes them from the header's episode count.
- FilterOnePodcast requests the show author's full output — adds kind:30054
  (a pre-existing gap) and kind:30055 alongside the NIP-F4 kinds.

Two new strings (podcast_trailer, podcast_trailer_season). Read-only; trailers
stay scoped to the show page and aren't mixed into the global episodes feed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-27 21:12:47 +00:00
vitorpamplona
9792c5c75e chore: sync Crowdin translations and seed translator npub placeholders 2026-06-27 21:03:45 +00:00
Vitor Pamplona
6ef7d21132 Merge pull request #3414 from vitorpamplona/claude/search-bar-transparency-75as8b
Add surface background color to SearchBar Column
2026-06-27 17:01:48 -04:00
Vitor Pamplona
0e36d416a6 Merge PR: fix(video) — drop errored players from warm pool, crop blurhash to true aspect
Merges nostr proposal 6650c40b into main:
- Square blurhash placeholder: render with ContentScale.Crop when the imeta dim
  is known, so it fills the correctly-sized box instead of letterboxing a
  component-grid-square bitmap inside a taller portrait box.
- "Can't play this video" flash: never return a warm-pooled ExoPlayer carrying a
  stale PlaybackException — releasePlayer drops one that errored before pooling,
  acquirePlayer drops one whose decoder died asynchronously while warm.
- Adds playback-error lifecycle logging used to trace both issues from logcat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:56:34 -04:00
Vitor Pamplona
9acb53d574 fix(video): drop errored players from warm pool and crop blurhash to true aspect
Two inline-video bugs surfaced by a portrait Damus post (imeta dim 720x1280, a
rotated H.264 file) that showed a square blurhash in a too-tall box and flashed
"Can't play this video":

- Square blurhash placeholder: the placeholder bitmap is decoded at the
  blurhash's DCT component-grid aspect (e.g. a 5x5 grid -> a square bitmap), not
  the real media shape. When the true ratio is known (from imeta dim) the box is
  already sized correctly, so render the placeholder with ContentScale.Crop to
  fill it instead of letting FillWidth letterbox a square inside the taller
  portrait box.

- "Can't play this video" flash: a warm-pooled ExoPlayer could be handed back
  still carrying a stale PlaybackException. releasePlayer now drops a player that
  errored before being pooled; acquirePlayer drops one whose decoder died
  asynchronously while it sat warm (surface reclaim / codec loss). Either way a
  clean cold/fresh player is used and the stale error never reaches a controller.

Also adds playback-error lifecycle logging (PlaybackError tag with a flattened
cause chain, a live-controller counter, and cold-load + acquire-time
stale-error markers) that made both issues traceable from logcat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:53:34 -04:00
Claude
393ff9ba8b fix: make search filter bar opaque
The top bar on the Search screen (search field + filter row with the 3
scope buttons) had no background, so the feed scrolled visibly behind
the segmented buttons and the gaps in the bar.

Apply the theme surface color to the SearchBar Column, before
statusBarsPadding() so the status-bar inset is filled too, matching the
default Material3 top-app-bar container color used elsewhere.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LkfRpnNyeeo3AVEaX71oRX
2026-06-27 20:47:07 +00:00
Claude
fc2a6588c2 feat(amethyst): subscribe to Podcasting-2.0 show metadata (kind:30078 #d)
Closes the read path for podstr-style podcast shows so they arrive proactively
instead of only rendering when already cached.

makePodcastsFilter now emits two REQs: the existing NIP-F4 kind:10154 shows plus
a kind:30078 REQ constrained to `#d=["podcast-metadata"]` (the app-data kind is
overloaded, so the d-tag constraint is mandatory). To carry that constraint, an
optional `additionalTags` map is threaded through every topNav podcast
subassembly variant (authors, follows, muted-authors, global, hashtag, geohash,
all-communities, single-community); variants that already pin their own tags
(#t/#g/#a) merge it via the new mergeFilterTags helper. The default is null, so
episode and NIP-F4 REQs are byte-identical to before.

Covered by MergeFilterTagsTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-27 20:26:32 +00:00
Claude
b840e3496f feat: merge NIP-F4 and Podcasting-2.0 podcast SHOWS into one list
Companion to the episode merge: unify show-level metadata across both drafts.

quartz:
- Add a spec-neutral PodcastShow interface (title/image/description/websites).
- NIP-F4 PodcastMetadataEvent (kind:10154) implements it directly.
- Add Podcasting20PodcastMetadata, a read-only view over a kind:30078 NIP-78
  app-data event with d="podcast-metadata" whose channel fields live in a JSON
  content blob (lenient parse; unknown keys like value/funding/categories are
  ignored). resolvePodcastShow()/isPodcastShowEvent() adapt either kind.

amethyst:
- PodcastsFeedFilter merges kind:10154 with kind:30078 (d="podcast-metadata"),
  both from LocalCache.addressables, gated by isPodcastShowEvent so the 30078
  scan ignores unrelated app-data.
- RenderPodcastMetadata renders via PodcastShow; NoteCompose dispatches a
  podcast-metadata app-data note to it and keeps the text fallback otherwise.

Read support only. The kind:30078 metadata still needs a d-constrained relay
subscription to arrive proactively — that touches the topNav subassembly
plumbing and is left as the remaining step; shows already in cache merge and
render today.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-27 20:16:38 +00:00
Vitor Pamplona
3adce5caf8 Merge pull request #3412 from vitorpamplona/claude/app-ui-theme-customization-evwwkb
Add customizable accent colors, fonts, and font sizes
2026-06-27 16:00:11 -04:00
Vitor Pamplona
5f8ce0e692 Merge pull request #3411 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-27 16:00:03 -04:00
vitorpamplona
ac255d5cc6 chore: sync Crowdin translations and seed translator npub placeholders 2026-06-27 19:59:26 +00:00
Vitor Pamplona
e81d76e3f3 Merge pull request #3410 from vitorpamplona/claude/nsites-napplets-preload-iwhh1j
Add loading progress bar and error logging to napplet/browser
2026-06-27 15:57:28 -04:00
Claude
373aa7d740 perf: keep ColorScheme.isLight O(1) after accent-color change
isLight fans out to hundreds of themed-color getters on hot note/chat/feed
render paths. The accent-color work had switched it to background.luminance(),
which adds per-call gamma math. Since the accent never touches background
(only primary/secondary) and the dark palette's background is exactly
Color.Black, a single reference comparison is just as accent-robust and
restores the original constant-time cost.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REGsru6cnm6wUzqm12Rh2d
2026-06-27 19:56:53 +00:00
Claude
bb31f0381b feat(amethyst): surface Podcasting-2.0 episodes in the merged podcast feed
Wire kind:30054 (and kind:30055 trailers) end-to-end so Podcasting-2.0
episodes appear alongside NIP-F4 kind:54 episodes in one list:

- LocalCache consumes both new kinds as addressable replaceables.
- PodcastEpisodesFeedFilter and OnePodcastEpisodesFeedFilter now merge
  kind:54 (LocalCache.notes) with kind:30054 (LocalCache.addressables),
  gating on the shared PodcastEpisode interface.
- The episode renderer, compact list row, and inline audio player read
  through PodcastEpisode / PodcastAudio, so one render path serves both
  kinds; NoteCompose dispatches kind:30054 to it.
- Relay subscriptions request kind:30054 alongside kind:54.

Read support only — Amethyst still publishes NIP-F4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-27 19:56:34 +00:00
Vitor Pamplona
0ed15be975 Merge pull request #3408 from vitorpamplona/claude/nsite-tor-warning-removal-hp52fw
Remove network routing confirmation dialog from napplet host
2026-06-27 15:55:17 -04:00
Vitor Pamplona
fb357937b8 Merge pull request #3409 from vitorpamplona/claude/default-webclient-list-3sm1ne
Browser: add Discover section with hardcoded web apps & followed nSites/nApplets
2026-06-27 15:53:05 -04:00
Claude
1069fb0701 refactor: move Profile Gallery Style setting to Profile UI settings
The gallery style selector is profile-specific, so it now lives on the
Profile UI settings screen alongside the other profile display toggles
instead of the general Application Preferences screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REGsru6cnm6wUzqm12Rh2d
2026-06-27 19:50:51 +00:00
Claude
bdacc28663 feat: add app UI theme customization (accent color, font, font size)
Adds three new appearance settings to Application Preferences, alongside
the existing Theme selector:

- Accent Color: Purple (default), Blue, Green, Orange, Red, Pink. Drives
  the Material primary/secondary/tertiary colors so buttons, links, FABs
  and switches follow the chosen hue. Purple preserves the original look
  (purple primary + teal secondary).
- Font: System Default, Sans Serif, Serif, Monospace. Applied to the full
  Material typography and to bare Text via LocalTextStyle.
- Font Size: Small, Normal (default), Large, Huge. Scales all text through
  LocalDensity.fontScale without affecting dp-based layout.

Plumbed through the existing UiSettings -> UiSettingsFlow ->
UiSharedPreferences (DataStore) pipeline and the AmethystTheme composable.
New fields default to the current behavior and are appended, so existing
stored settings deserialize unchanged.

ColorScheme.isLight now derives from background luminance instead of a
fixed primary, so the light/dark check keeps working when a non-purple
accent is selected. The primary-derived tint extensions (links, new-item
background, secondary button) now compute from the live scheme so they
track the accent color.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REGsru6cnm6wUzqm12Rh2d
2026-06-27 19:28:18 +00:00
Claude
b01e1eb6bc feat(napplet): show top loading bar, keep splash until first paint, log load errors to console
The napplet/nsite host (NappletHostActivity) removed its loading splash the
moment the index probe succeeded and then mounted a WebView with no progress
tracking at all, so during the seconds the shell + bundle take to load (notably
over Tor) the user saw only the WebView's dark colorBackground — a black screen
with no sign anything was happening, especially in dark theme.

- Add a thin browser-style determinate progress bar pinned to the top edge,
  driven by WebChromeClient.onProgressChanged and hidden at 100%, to both the
  napplet/nsite host and the URL browser (NappletBrowserActivity).
- Mount the WebView under the loading splash and keep the splash (now opaque)
  until first paint (onPageCommitVisible) instead of removing it on mount, so
  there is never a blank/dark gap between probe-success and the shell's first
  frame. This mirrors the pattern the URL browser already used.
- Add a developer console (NappletConsolePanel) to the napplet/nsite host,
  wired through the existing onConsole hook in NappletControlSheet, and forward
  the page's console.* output to it.
- Surface failed resource fetches (onReceivedError / onReceivedHttpError) as
  ERROR lines in the console on both hosts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D4iYA4Qf5guWZyKexkcmhb
2026-06-27 19:20:54 +00:00
Claude
fb76705f71 feat: discover followed nsites & napplets in the browser
Add two Discover sections to the browser launcher home that surface the
NIP-5A sites and NIP-5D apps published by the people the user follows,
reusing the same feed + follow-list filter as the dedicated nSites and
nApplets screens (set those to All Follows for a pure follows list).

The launcher subscribes the nsite/napplet assemblers while open, observes
the addressable manifest store, keeps the followed authors' (or own, in
the Mine case), drops ones already pinned, and caps each section. Rows
mirror the web Discover row — manifest icon, title, description, and a
star to pin — launching Nostr-natively through the sandboxed host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0151Uczec41LhTogxkgoAhKa
2026-06-27 19:10:57 +00:00
Claude
217f4ad9ca fix: drop confirm dialog on nsite Tor network switch
Tapping the "loads over Tor" row on an nSite's pull-down sheet popped a
confirm dialog explaining the routing change. Users already know what Tor
is, so toggle the routing directly on tap (still relaunches the session to
rebuild the proxy + content server) and remove the now-unused strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177rhf2L93YRcq6NrWkkM4Q
2026-06-27 19:06:37 +00:00
Claude
64abf2d544 feat: search the discover list in the omnibox + add 3-dot menu to results
The omnibox now ranks the hardcoded Discover web apps alongside
favorites and history, so typing finds a suggested app even before its
first visit (the ranker dedupes by host, and favorites/history outscore
a plain default, so an already-pinned/visited app never doubles up).

Search results are split into Favorites / Recent / Discover groups using
the visited-URL set so the headers stay accurate, and every result row
now carries the same 3-dot menu as the idle Recent cards: pin/unpin to
favorites, plus remove-from-history for visited sites. The favorite
toggle is shared with the Recent rows via one helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0151Uczec41LhTogxkgoAhKa
2026-06-27 18:51:22 +00:00
Vitor Pamplona
6dd8771c2a Merge pull request #3407 from vitorpamplona/claude/cashu-icon-alignment-19eflw
Align Cashu icon with reaction gallery icons in Nutzap
2026-06-27 14:50:17 -04:00
Claude
756648aef1 feat: add HiveTalk to discover web apps
HiveTalk (hivetalk.org) — Nostr-native, Lightning-powered video
conferencing. Reachability and own apple-touch-icon verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0151Uczec41LhTogxkgoAhKa
2026-06-27 18:44:50 +00:00
Claude
152f81602c fix: align cashu nutzap icon with reaction icons in notifications
The nutzap gallery in MultiSetCompose used WidthAuthorPictureModifier
(55dp, flush-right) for its cashu icon column, while the like/boost
reaction galleries above it use NotificationIconModifier (55dp with a
5dp end inset). That left the cashu glyph sitting ~5dp further right
than the reactions it stacks under. Switch the cashu Box to the same
NotificationIconModifier so the icons line up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qQdaD3DHRQNDKe2BUnEiM
2026-06-27 18:38:20 +00:00
Claude
3d822dac07 feat(quartz): merge NIP-F4 and Podcasting-2.0 podcast kinds into one episode list
Amethyst's podcast support (NIP-F4) and derekross/podstr use incompatible
identity models: NIP-F4 makes each podcast its own keypair with regular
kind:54 episodes, while the Podcasting-2.0 draft signs editable, addressable
kind:30054 episodes with the human creator's key. The two cannot share a wire
kind, but a client can still render them in one list.

Add the Podcasting-2.0 episode (kind:30054) and trailer (kind:30055) event
classes plus their tags, and introduce a spec-neutral `PodcastEpisode`
abstraction (with `PodcastAudio`) that both kind:54 and kind:30054 implement.
Feeds and UI can now depend on the shared interface and surface both kind sets
in a single, ordered podcast/episode list. NIP-F4 remains Amethyst's publish
format; this only adds read/parse support for the Podcasting-2.0 kinds.

Register both new kinds in EventFactory and KindNames. Covered by round-trip,
spec-example-JSON, and a unified-list test proving both kinds flow through the
shared abstraction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-27 18:38:10 +00:00
Claude
2d6221fd10 feat: show curated descriptions for discover web apps
Render the browser "Discover" section as full-width rows (icon + name +
one-line description) instead of bare icon cells, matching the Recent
row layout which already carries a subtitle. Each suggestion now has a
short curated description (trimmed from the app's own meta description)
so unfamiliar apps explain themselves; tapping a row opens the app, and
a trailing star pins it to favorites.

Auto-pulling page <title>/description was rejected: many of these apps
are client-rendered SPAs that serve an empty <title>, and several titles
are long marketing strings — curated short names read better in the list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0151Uczec41LhTogxkgoAhKa
2026-06-27 18:34:53 +00:00
Claude
5029acccee feat: expand suggested web apps list with icons and reachability check
Grow the browser "Discover web apps" list to the full set of
browser-openable Nostr web apps from the nostrapps.com directory plus
several requested additions, and give each entry its own logo.

- Each suggestion now carries iconUrl set to the app's own declared
  apple-touch-icon / icon (PNG or SVG, individually verified to return an
  image), so the grid matches the favicon look of Favorites/Recent
  without any third-party favicon service. Apps whose only icon is an ICO
  (Coil has no ICO decoder) or that couldn't be resolved stay icon-less
  and fall back to the globe glyph until their favicon is captured on
  first visit.
- Added: nymchat, nostr.build, nostrcheck, zap.cooking, x21, divine.video,
  brainstorm, zappix, plektos, zaptrax, zaplytics, podstr, ghostr, mutable,
  metadata, plebsvszombies, blobbi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0151Uczec41LhTogxkgoAhKa
2026-06-27 18:12:18 +00:00
Claude
f206b0bb46 feat: suggest a default list of Nostr web apps in the empty browser
Add a "Discover web apps" section to the browser launcher home, shown
after the Recent block, with a hardcoded list of popular Nostr web apps
drawn from the nostrapps.com directory. Gives new users (whose Favorites
and Recent are empty) somewhere to start instead of a bare empty screen.

- New DefaultWebClients in commons (URL + label entries), grouped by
  category; extensions/signer-only tools are excluded and every URL is a
  confirmed canonical domain. No remote icons are loaded on the idle
  screen — favicons are captured the normal way once a site is opened.
- Render the list via a new suggestedAppItems grid (long-press offers
  "Add to favorites"); already-favorited apps are filtered out.
- FavoriteAppCell now takes a menu slot so the favorites grid and the
  suggestions grid can offer different long-press actions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0151Uczec41LhTogxkgoAhKa
2026-06-27 17:07:18 +00:00
Claude
53c2dc0f37 Merge remote-tracking branch 'origin/main' into claude/cashu-wallet-wizard-0zr280 2026-06-27 16:55:49 +00:00
Vitor Pamplona
2e47eaf3a6 Merge pull request #3406 from vitorpamplona/claude/git-repositories-feed-7xov6e
Add Git Repositories feed screen with community and author filtering
2026-06-27 12:50:28 -04:00
Vitor Pamplona
02aa877f4f Merge pull request #3405 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-27 12:45:07 -04:00
vitorpamplona
fc95f64458 chore: sync Crowdin translations and seed translator npub placeholders 2026-06-27 16:40:09 +00:00
Vitor Pamplona
be1b0bb851 Merge pull request #3404 from vitorpamplona/fix/loading-account-ui-thread-stall
fix(perf): unfreeze the cold-start "Loading account" screen in debug builds
2026-06-27 12:37:41 -04:00
Claude
a81825b5cd feat: add Git Repositories feed
Add a new top-level feed for NIP-34 Git repository announcements
(kind 30617), mirroring the Pictures/Videos/Workouts feeds.

- GitRepositoriesFeedFilter scans LocalCache addressables for kind 30617
- Full top-nav filter support (follows, authors, global, hashtag,
  geohash, communities, muted) via a per-relay sub-assembler set
- Wired into AccountFeedContentStates, the relay subscription
  coordinator, bottom-bar preloaders, navigation, drawer and the
  persisted per-feed follow-list setting
- Reuses the shared RenderGitRepositoryEvent card via the standard feed
  render path, enriched with topic chips and a personal-fork badge

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GS371vPHy3PhMfQyeAHmZC
2026-06-27 16:27:48 +00:00
Vitor Pamplona
981fe528dc chore(debug): add Compose composition tracing deps (debug-only)
Adds androidx.compose.runtime:runtime-tracing and tracing-perfetto so
recompositions show up as named slices in Perfetto system traces — the
tool used to attribute the cold-start feed first-paint cost to specific
composables. debugImplementation only (not shipped); all Apache-2.0.
Usage (runtime-enable broadcast) documented inline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 12:26:45 -04:00
Vitor Pamplona
de1362561f fix(perf): collect debug memory snapshot off the main thread
The debug-only MemoryUsageChip ("X/YMB" top-bar indicator, gated on
isDebug) polls collectMemorySnapshot() every 2s from a produceState
block, which runs on the main thread. That reads coil3.disk.DiskLruCache
.size(), a @Synchronized call. On cold start the Coil disk cache holds
that monitor for several seconds (journal init + the burst of image
writes from the initial relay event flood), so the UI thread blocked
inside size() — the "Loading account" frame couldn't repaint until it
returned. Profiling showed a single ~8s render frame and the UI thread
"blocking from coil3.disk.DiskLruCache.size()".

Collect the snapshot via withContext(Dispatchers.IO) so the synchronized
read blocks a background thread instead of the UI. The "Loading account"
stall on cold start drops from ~15-20s to ~5s. Debug-only path, so this
never affected release builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 12:26:44 -04:00
Claude
25470e798a Merge remote-tracking branch 'origin/main' into claude/cashu-wallet-wizard-0zr280 2026-06-27 14:41:40 +00:00
Vitor Pamplona
9d0f444b6f Merge pull request #3403 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-27 08:56:59 -04:00
vitorpamplona
0afb4ada07 chore: sync Crowdin translations and seed translator npub placeholders 2026-06-27 12:55:24 +00:00
Claude
07963a10b6 Merge remote-tracking branch 'origin/main' into claude/cashu-wallet-wizard-0zr280 2026-06-27 12:54:01 +00:00
Vitor Pamplona
7684c3725f Merge pull request #3400 from roguehashrate/feat/payment-targets
added support for more payment targets
2026-06-27 08:53:33 -04:00
Vitor Pamplona
3832f31e52 Merge pull request #3401 from vitorpamplona/fix/video-decode-stall-fallback
fix(video): show browser-fallback overlay when a decoder silently stalls
2026-06-27 08:53:10 -04:00
Vitor Pamplona
ede3b798a3 Merge pull request #3402 from vitorpamplona/claude/web-app-preload-check-ev4r28
Cache favorited nsite/napplet manifests for offline resolution
2026-06-27 08:52:36 -04:00
Vitor Pamplona
c0fadf9473 fix(video): show browser-fallback overlay when a decoder silently stalls
Some codec failures never surface as a PlaybackException: a software HEVC
decoder that can't keep up (e.g. iPhone-recorded hvc1 video on a device
without a HEVC hardware decoder) just parks the player in STATE_BUFFERING
forever — the buffer fills to the LoadControl cap, the playhead never leaves
0, and no error is ever raised. WatchPlaybackErrors only listened for
onPlayerErrorChanged, so the existing RenderPlaybackError "Open in browser"
overlay never showed and the user stared at a blank buffering box.

Add a decode-stall watchdog that polls the controller and synthesizes a
PlaybackException (ERROR_CODE_DECODING_FORMAT_UNSUPPORTED) once the player
sits in STATE_BUFFERING, wanting to play, with >=2s of media buffered ahead
(decoder is fed, not network-starved) yet a frozen playhead for 8s. The
buffer-ahead guard distinguishes a hung decoder from genuine network
starvation, whose buffer is depleted and so is never flagged.

Recovery is automatic: the overlay clears on the STATE_READY transition and,
belt-and-suspenders, the watchdog drops it the instant the playhead advances
again — so a slow device that eventually decodes "just plays." Also narrow
the recovery-clear to STATE_READY only (clearing on STATE_BUFFERING would
wipe the synthetic error instantly) and clear on onMediaItemTransition so a
pooled player starting a new video resets cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 08:49:29 -04:00
roguehashrate
cac768175c added support for more payment targets 2026-06-26 21:35:06 -05:00
Claude
7340d93f0c feat: preload and cache pinned nsite/napplet manifests
Pinned web apps in the bottom nav warm reliably because EmbeddedTabFactory
only needs their URL, but a pinned nsite/napplet (FavoriteApp.NostrApp) could
not warm: favorites store only a kind:pubkey:dtag coordinate, and nothing
pulled that addressable manifest into LocalCache until the user opened the
napplet/nsite discovery screen. So embedParams() returned null and the
EmbeddedTabPreloader gave up.

Add FavoriteAppManifestPreloader, mounted once in the logged-in shell
(independent of the API-30 embedded-surface gate, since the full-screen
launcher benefits too). For each NostrApp favorite it drives the existing
EventFinder (via observeNote) to fetch the manifest's coordinate into
LocalCache, so the preloader and launcher can resolve it.

Also cache the resolved manifest event JSON device-locally in
FavoriteAppsRegistry (a second DataStore key, same single-key shape as the
favorites list) and seed LocalCache from it when relays stay silent shortly
after launch, so a pinned nsite/napplet resolves instantly and offline on the
next cold start. The cached copy is re-verified (wasVerified=false) before it
enters the cache, and refreshed whenever a newer manifest arrives.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LroBCry1UiXWf9Y4fk4b9h
2026-06-27 01:43:38 +00:00
Vitor Pamplona
d8ab76d536 Merge pull request #3399 from vitorpamplona/claude/android-accountviewmodel-store-xy21sw
refactor: scope account ViewModelStore with Lifecycle 2.11 rememberViewModelStoreOwner
2026-06-26 21:02:57 -04:00
Claude
b3a4e13b85 refactor: scope account ViewModelStore with Lifecycle 2.11 rememberViewModelStoreOwner
The per-account ViewModelStore was managed by a hand-rolled registry
(StoreOwnerRegistry + ScopedViewModelStoreOwner + a RememberObserver) that
tracked configuration changes manually. Its own TODO admitted it could not
clear a store detached around a configuration change, so AccountViewModels
(and their child ViewModels, feed states and relay subscriptions) leaked and
stayed active after switching accounts.

Replace the whole registry with androidx.lifecycle 2.11's
rememberViewModelStoreOwner (already on the classpath at 2.11.0). The owner is
keyed by the account public key via key(): while an account stays logged in
the owner survives recompositions and configuration changes (it is parented to
the Activity's LocalViewModelStoreOwner); when the account changes the previous
owner leaves the composition and its ViewModelStore is cleared immediately.

Deletes ~95 lines of lifecycle plumbing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154te1AiD1Ykz1HCa8ao2Vo
2026-06-27 00:50:32 +00:00
Claude
45de9b1b69 Merge remote-tracking branch 'origin/main' into claude/cashu-wallet-wizard-0zr280 2026-06-27 00:42:20 +00:00
Vitor Pamplona
23a1c8af13 Merge pull request #3398 from vitorpamplona/claude/fix-remember-feed-content-padding-7ipbs4
Move rememberFeedContentPadding to commons.ui.layouts
2026-06-26 20:41:33 -04:00
Claude
1c11e5463d feat(cashu): polish wizard UI with motion and a recovery payoff
The wizard was functional but plainer than the rest of the app (which
uses motion APIs in ~88 files). Add tasteful, codebase-consistent polish
to the find-my-funds flow:

- Determinate scan progress: the relay crawl (which can sweep hundreds
  of relays) now shows a LinearProgressIndicator that fills as relays
  complete, plus a count that ticks up — instead of a bare spinner that
  read as "hung".
- AnimatedContent phase transitions: Crawling → Analyzing → result
  cross-fade/slide rather than snapping. Keyed on phase type so the
  in-phase counter updates don't re-trigger the animation.
- Count-up balance reveal: "recoverable" and "Recovered N sats" figures
  animate up from zero the first time they appear — the payoff of the
  flow lands instead of jumping in.
- Haptic on success: a LongPress haptic fires when a wallet is adopted
  or funds are recovered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqmMR2QiULS5QGosSgSQAe
2026-06-27 00:41:11 +00:00
Claude
a101f53935 fix: correct rememberFeedContentPadding import path in GitItemListRow
The import referenced the non-existent
com.vitorpamplona.amethyst.ui.layouts package. The function lives in
commons under com.vitorpamplona.amethyst.commons.ui.layouts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q1LfVV9ZRuktCa7XQTzsLe
2026-06-27 00:40:18 +00:00
Vitor Pamplona
8607a44ebd Merge pull request #3390 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-26 20:37:33 -04:00
Vitor Pamplona
1fc98e0687 Merge pull request #3396 from vitorpamplona/claude/reproducible-build-dsigxh
Make Arti native library builds reproducible
2026-06-26 20:37:15 -04:00
Claude
6bb2f8045d build: pre-merge audit fixes for reproducibility work
Bugs / inconsistencies found while reviewing the branch for merge:

- dependenciesInfo comment falsely claimed Play "still derives this data
  server-side, nothing is lost." Not true: includeInBundle=false means the
  .aab carries no dependency metadata, so Play Console's dependency-insights /
  SDK-vulnerability alerts go unpopulated (uploads still succeed). Corrected
  the comment and the BUILDING.md framing (it called the blob "the one
  remaining blocker" when the Arti .so was the bigger one).

- Version-bump workflow was broken: the README told you to run
  `build-arti.sh --clean` to refresh Cargo.lock, but the build is now --locked
  (fails on a stale lock) and the clone moved to the canonical /tmp path. Added
  a dedicated `--regen-lock` mode (clone + cargo generate-lockfile, no NDK
  needed) and pointed the docs at it. Verified it reproduces the committed lock
  byte-for-byte.

- verify-reproducible.sh: new helper that builds twice and diffs to prove
  byte-for-byte reproducibility; uses portable sha256 (sha256sum/shasum) and
  plain `sort` so it runs on macOS too.

- README verify recipe referenced paths that only resolved from the repo root
  while telling you to cd into tools/arti-build — replaced with the helper.

- rust-toolchain.toml listed four Android targets but only two ABIs ship a
  .so; trimmed to match (check_prerequisites adds any other on the fly).

- BUILDING.md: documented that the bundled Arti .so is reproducible-from-source
  and that secp256k1/webrtc are version-pinned Maven prebuilts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JtjUcSjjpu4auFndw1QKeU
2026-06-27 00:19:26 +00:00
vitorpamplona
cc72e8045a chore: sync Crowdin translations and seed translator npub placeholders 2026-06-27 00:14:28 +00:00
Vitor Pamplona
ceca5d52ae Merge pull request #3394 from vitorpamplona/claude/composable-memory-ci-build-jxpwkv
Move UI components to commons module for better code sharing
2026-06-26 20:12:09 -04:00
Claude
94677d1677 refactor: extract self-contained layout/preview leaves to :commons
Continues moving genuinely platform-agnostic leaves out of the :amethyst app
module so they compile once in :commons instead of across all six app variants.

Moved (no Android coupling, no foundation deps):
  - ui/layouts/DisappearingBarState, DisappearingBarNestedScroll, PaddingMerge
    -> commons commonMain (com.vitorpamplona.amethyst.commons.ui.layouts)
  - ui/components/UrlPreviewState
    -> commons jvmAndroid (it references commons.preview.UrlInfoItem, which
       lives in the jvmAndroid source set)

Consumers (incl. the existing DisappearingBar*Test unit tests, which stay in
:amethyst and now import from commons) updated to the new packages. No behavior
change.

Verified: :commons, :amethyst compilePlayDebugKotlin + compilePlayDebugUnitTest,
and :desktopApp:compileKotlin build clean; spotless applied.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SNKcfjNszUZPQShYJjfmnf
2026-06-27 00:06:52 +00:00
Vitor Pamplona
2dc80f899e Merge pull request #3393 from vitorpamplona/claude/repo-tabs-status-split-p2ducw
Add Open/Closed filter to Git repo Issues and Patches tabs
2026-06-26 19:40:59 -04:00
Claude
ed3a893d9d build: build Arti at a canonical path for cross-environment reproducibility
Empirical finding: with the toolchain pin, locked deps, and
--remap-path-prefix all in place, two host builds of libarti_android.so at
the *same* path are byte-for-byte identical, but two builds at *different*
paths still differ — not in any embedded string (no path leaks into the
binary) but in the order rustc lays out functions/data, which it derives
from the real on-disk artifact paths. --remap-path-prefix only rewrites
embedded strings, not that internal ordering.

So compile in a fixed location (/tmp/amethyst-arti-build, overridable via
ARTI_REPRO_DIR) in both build-arti.sh and build-arti-host.sh. Any checkout
then produces matching bytes, which is what lets F-Droid / a verifier build
at the same canonical path and reproduce the shipped .so. This mirrors how
Rust libraries are reproduced elsewhere (F-Droid builds Rust at a fixed
path too).

Corrects the README, which previously implied path remapping alone gave
path-independent output.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JtjUcSjjpu4auFndw1QKeU
2026-06-26 23:40:18 +00:00
Vitor Pamplona
13892db3ae Merge PR: fix(account): adding a read-only npub no longer clobbers a signing account
Merges nostr proposal e283c388 into main (replaces the closed e05208d9 with a
minimal guard). Adding/scanning your own read-only npub for a pubkey you already
hold the nsec for no longer downgrades the signing account — on Android it
wiped cached lists + disabled notifications, on desktop it orphaned the key.
Guarded at the single persistence point on each platform; desktop regression
test verified failing without the guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 19:24:01 -04:00
Claude
f7a15a8407 build: make the Arti (Tor) native build reproducible
The libarti_android.so shipped in the APK is the one binary we compile
ourselves, and it was the remaining blocker to a verifiable build: a Rust
cdylib is only reproducible when the compiler, the dependency graph, and the
embedded build paths are all pinned. None were.

Pin all three:
- rust-toolchain.toml pins rustc (rustup auto-installs it + the Android
  targets), so codegen is stable across machines.
- Cargo.lock is now generated and committed (501 packages); both build
  scripts run `cargo --locked` so transitive versions can't drift.
- repro-env.sh (sourced by build-arti.sh and build-arti-host.sh) rewrites
  host-specific absolute paths with --remap-path-prefix, disables incremental
  compilation, and sets a fixed SOURCE_DATE_EPOCH derived from the Arti tag.

With these, an independent rebuild of the pinned tag reproduces the committed
.so bit-for-bit, which is what lets F-Droid / Zapstore verify it from source
instead of trusting a prebuilt blob. README documents the pins and a
two-path build-and-diff verification recipe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JtjUcSjjpu4auFndw1QKeU
2026-06-26 23:01:48 +00:00
Vitor Pamplona
0d0aba90c9 fix(account): don't let adding a read-only npub clobber an existing signing account
Accounts dedup by npub (= pubkey), so adding/scanning your own read-only npub for
a pubkey you already hold the nsec for would overwrite the signing account:
- Android: setDefaultAccount rewrote the per-npub file from fresh read-only
  settings — wiping the account's cached follow/relay/mute lists and flipping
  hasPrivKey off, which silently disables its push notifications (every
  notification path early-returns on !hasPrivKey). The account looked lost ("can't
  post anymore") even though the nsec survived on disk.
- Desktop: saveCurrentAccount overwrote signerType to ViewOnly, orphaning the
  stored key and routing every later switch through loadReadOnlyAccount.

Guard the downgrade at the single persistence point on each platform: when the
account being made current is read-only and a SIGNING account already exists for
the same pubkey, keep the signing account and switch to it instead. A signing
account already subsumes a read-only one, so this loses nothing.

- Android LocalPreferences.setDefaultAccount now returns the settings that
  actually became current; AccountSessionManager.loginAndStartUI shows that.
- Desktop AccountManager.saveCurrentAccount reuses switchAccount() to reload the
  signing account. Covered by a regression test (verified failing without the
  guard).

This replaces the closed proposal e05208d9, which solved the same underlying bug
with a much heavier accountId rework (separate npub/nsec switcher entries — a
niche feature) that itself shipped a logout(deleteKey=true) data-loss bug.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 19:00:51 -04:00
Claude
a8db6674c4 feat: compact Issue/PR list rows on the git repository screen
Render the repository screen's Issues and Patches & PRs tabs with a
lightweight one-line-per-item row (author picture, name, NIP-05, subject,
time, status pill and the shared 3-dot options) instead of the full
NoteCompose renderer, which is tuned for items shown inside a regular
feed. Injected via RefresheableFeedView's existing onLoaded slot, so the
feed filters and status-split view models are untouched.

The rows reuse and re-layer the same gating NoteCompose applies — event
loading (WatchNoteEvent), mute/block/report hiding
(CheckHiddenFeedWatchBlockAndReport) and the long-press quick-action menu
— so blocked authors and reported items are hidden here exactly as
elsewhere. No body or media is rendered, so sensitive content never
reaches this list.

Adds GitPatchEvent.subject(), which parses the patch title from the
git-format-patch Subject header (stripping the [PATCH n/m] prefix and
unfolding continuation lines), with unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013gs6pxiq58X18Fkz9wdZhU
2026-06-26 22:55:46 +00:00
Vitor Pamplona
2ee0d91b71 Merge PR: feat(cli): amy namecoin resolve + servers verbs
Merges nostr proposal 488e8447 (v2) into main: adds `amy namecoin resolve`
+ `amy namecoin servers` (stateless ElectrumX Namecoin resolution over the
quartz NamecoinNameResolver). The v2 revision fixes the `--server` override to
reuse the shared NamecoinSettings.parseServerString so it keeps
usePinnedTrustStore=true (self-signed Namecoin servers otherwise fail TLS),
plus strict --timeout parsing and accurate docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 18:31:42 -04:00
Claude
8b74a3e24e refactor: extract 7 pure leaf composables from :amethyst to :commons
First Tier A slice of the UI/components extraction. Moves the genuinely
platform-agnostic leaf composables — those with zero :amethyst
dependencies and no Android coupling — from the app module into the
shared :commons KMP module (commonMain):

  ClickableTexts, ForwardingPainter, GenericLoadable, GlowingCard,
  LoadingAnimation, TranslationConfig, ZonedSwipeModifier  (~616 LOC)

These now live under com.vitorpamplona.amethyst.commons.ui.components and
compile once in :commons (cacheable, incremental) instead of being part
of every one of the six :amethyst variant compilations (play/fdroid ×
debug/release/benchmark). That shrinks the app-module Kotlin compilation
unit — the root cause of the CI Kotlin-daemon OOM — rather than renting
headroom with heap flags.

Consumers updated to import from the new package; no behavior change.
Verified: :commons, :amethyst compile{Play,Fdroid}DebugKotlin, and
:desktopApp:compileKotlin all build clean; spotless applied.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SNKcfjNszUZPQShYJjfmnf
2026-06-26 22:29:37 +00:00
Vitor Pamplona
112ef0536a fix(cli): --server reuses NamecoinSettings.parseServerString (keeps pinned trust store)
`amy namecoin resolve --server` hand-rolled its own ElectrumX server-string
parser that constructed `ElectrumxServer(host, port, useSsl)` and left
`usePinnedTrustStore` at its `false` default. The Namecoin ElectrumX servers
use self-signed certs, so a TLS connection with the default system trust
manager fails the handshake — meaning `--server electrumx.testls.space:50002`
could not connect even though that exact host resolves fine via the default
list. It also duplicated logic already in `commons`, violating the cli
thin-assembly-layer rule.

Delegate each comma-separated entry to the shared
`NamecoinSettings.parseServerString` (the same parser the Android/Desktop
Settings use), so the CLI inherits both the `host:port[:tcp]` syntax and
`usePinnedTrustStore = true`. The README claim that it "reuses the same …
pinned trust store as the apps" is now actually true for `--server` overrides.

Also: reject a non-integer `--timeout` as bad_args instead of silently
falling back to the default, and document exit code 2 + the `host:port[:tcp]`
syntax accurately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 18:27:32 -04:00
mstrofnone
305d6bc733 feat(cli): amy namecoin resolve + servers verbs
Add Namecoin NIP-05 resolution to the amy CLI as a stateless verb
group, matching the Android and Desktop apps' resolution surface.

  amy namecoin resolve IDENT [--server URL[,URL]] [--timeout SECS]
  amy namecoin servers

IDENT accepts the same shapes the apps accept: raw `d/` / `id/`
names, bare `.bit` domains, and `alice@example.bit` NIP-05-style
local-parts. Output is the resolved Nostr pubkey + relay list (+ the
resolved Namecoin name + matched local-part) as machine-readable
JSON (with `--json`) or human-readable text.

The verb is stateless — no account, no `~/.amy/`, no relays — so it
dispatches alongside `decode`/`encode`/`verify`/`nip`/`kind` before
account resolution and the secret store.

Zero new logic in cli/: the implementation is a thin command-file
wrapper around quartz's existing `NamecoinNameResolver` +
`ElectrumXClient` + the canonical `DEFAULT_ELECTRUMX_SERVERS` set
the apps already ship with, including the pinned trust store for
the self-signed Namecoin ElectrumX ecosystem.

amy is headless so no UI piece is wired in. The `--server` flag
accepts `host`, `host:port`, `tcp://`, `tls://`, `ssl://` per entry
(defaults to TLS on 50002); empty / malformed entries fail with
`bad_args` rather than silently using the default set, so a fat-
fingered override can't go unnoticed.

Outcomes from `NamecoinResolveOutcome` map to amy error codes:
  Success           -> emit JSON, exit 0
  NameNotFound      -> error not_found
  NoNostrField      -> error no_nostr_field
  MalformedRecord   -> error malformed_record (+ namecoin_name extra)
  ServersUnreachable-> error servers_unreachable
  InvalidIdentifier -> error invalid_identifier
  Timeout           -> error timeout

Smoke-tested end-to-end on macOS arm64 against the live ElectrumX
fleet:

  $ amy --json namecoin resolve d/testls
  {"identifier":"d/testls","namecoin_name":"d/testls",
   "local_part":"_","pubkey":"460c25e6…","relays":[]}

  $ amy namecoin servers
  count:   6
  servers:
    - host: electrumx.testls.space
      port: 50002
      tls:  yes
    …

No new runtime deps. The "no Compose UI in the amy image" CI
assertion still passes — `NamecoinNameResolver` + `ElectrumXClient`
are pure JVM (kotlinx.coroutines + kotlinx.serialization, both
already on the CLI classpath via :quartz).

Tests: the resolver, ElectrumX client, identifier parser, and the
default server set already have JVM tests under
`quartz/src/jvmTest/.../namecoin/` — no new core code in this PR,
so the existing coverage applies. CLI verbs are exercised via the
shell harnesses in `cli/tests/`; a Namecoin harness fits the same
pattern but isn't included here.

Parity matrix in `cli/ROADMAP.md` flags `name_history` and the
Namecoin Core JSON-RPC backend as pending separate PRs — both
already exist on Android and Desktop but aren't on upstream main
yet (open PRs against this repo carry them).
2026-06-26 18:23:13 -04:00
Claude
b9fa242e68 feat: split git repo Issues and Patches & PRs tabs by status
Within the repository route's Issues and Patches & PRs tabs, add an
Open / Closed & Resolved segmented selector so items are partitioned by
their latest NIP-34 status. Open covers no-status, open (1630) and draft
(1633); Closed & Resolved covers closed (1632) and applied/merged (1631).

The feed filters now take a showClosed flag and consult GitStatusIndex.
Because a status event (kinds 1630-1633) doesn't mutate the issue/patch
note, the additive feed update can't move an item between buckets on its
own, so each view model watches GitStatusIndex.latestByTarget and forces
a full re-partition whenever it changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013gs6pxiq58X18Fkz9wdZhU
2026-06-26 22:22:08 +00:00
Vitor Pamplona
f5793937a7 Merge PR: fix(desktop): resolve Namecoin names in the home tab search bar
Merges nostr proposal ae364d99 into main: adds Namecoin (.bit) name
resolution to the desktop home-tab search bar (desktopApp FeedScreen.kt).
Network IO runs off the UI thread, stale lookups cancel via effect re-keying,
and it reuses the shared NamecoinNameResolver. (Nit deferred: extract a shared
rememberNamecoinResolution helper to de-dup with SearchScreen.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 18:19:23 -04:00
Vitor Pamplona
c67413544e Merge pull request #3392 from vitorpamplona/claude/ship-accrescent-6t68th
Add Accrescent APK set build & remove cleartext traffic flag
2026-06-26 18:02:53 -04:00
Vitor Pamplona
a56b5eff20 Merge pull request #3391 from vitorpamplona/docs/agent-ngit-pr-workflow
docs(claude): document the ngit + GitHub PR workflow for agents
2026-06-26 18:02:36 -04:00
Vitor Pamplona
36ae65765d docs(claude): document the ngit + GitHub PR workflow for agents
Adds a `.claude/skills/ngit-pr` skill and a pointer from CLAUDE.md so
agents know how to create, review, revise, and merge PRs in this repo.

This repo can publish a PR two ways and the difference is easy to get
wrong: a GitHub remote (canonical `main`, normal `gh` flow) and a
git-over-nostr remote (`ngit`, where PRs are nostr proposals on
gitworkshop.dev and a push fans out to GitHub + the GRASP servers).

The skill identifies remotes by URL (names vary per clone; a collaborator
may have only one), explains which path to use, and documents the
three-mains alignment gate (GitHub vs the lagging nostr `main` vs local
`main`) that the nostr create/revise/merge flows all depend on — the
thing that otherwise causes rejected pushes and revisions that never
appear on gitworkshop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:55:34 -04:00
Vitor Pamplona
f219153a7d Merge PR: fix(tor): wire Onion-Location interceptors into every OkHttp client
Merges nostr proposal e5865428 (v2) into main:
- fix(tor): wire Onion-Location interceptors into every OkHttp client
  (onionCache made non-nullable; OnionInterceptorWiringTest)
- refactor(napplet): route blob fetches through the shared OkHttpClientFactory
- fix(napplet): route brokered resource fetches by the applet's own Tor mode

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:41:28 -04:00
Vitor Pamplona
1dbfd78b44 fix(napplet): route brokered resource fetches by the applet's own Tor mode
The consolidation passed `useProxy = true` for every brokered `resource.bytes`
fetch, forcing them through Tor whenever Tor was active — regardless of the
napplet/nSite's actual network mode. That overrides the user's explicit choice:
an nSite running in "open web" mode would still have its blob fetches tunneled,
inconsistent with how its own WebView page loads.

The authoritative per-applet preference already exists main-side in
NappletNetworkRegistry.useTor(coordinate) (locked napplets pinned to Tor;
nSites follow the persisted per-site toggle, which relaunches on change) — the
same source NappletLauncher reads to set the WebView proxy. Thread the calling
applet's coordinate through NappletResourceGateway.fetch so the broker can
resolve it, and pick the shared client with
getHttpClient(useProxy = NappletNetworkRegistry.useTor(coordinate)). This
mirrors the host's own `effectiveProxy = if (useTor) proxyPort else -1` exactly,
so a brokered fetch now routes like the applet's page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:32:57 -04:00
Vitor Pamplona
c797033ba5 refactor(napplet): route blob fetches through the shared OkHttpClientFactory
The t4 onion-location proposal hand-wired OnionLocationInterceptor +
OnionUrlRewriteInterceptor into NappletResourceFetcher's private,
torPort-keyed OkHttpClient. That reached onion-routing parity but
duplicated the exact wiring OkHttpClientFactory already does, and the
private client still missed the local Blossom cache redirect, the shared
connection pool / HTTP-2 keepalive, and SurgeDns.

Inject the app-wide client instead: NappletResourceFetcher now takes a
() -> OkHttpClient and the broker supplies
`okHttpClients.getHttpClient(useProxy = true)` — the same DualHttpClientManager
path the image pipeline uses. Behavior-preserving for Tor (proxied when
Tor is active, clearnet when not) and, since these are sha256 blobs, the
shared Blossom-cache redirect is now a feature, not a loss. Drops the
private client + its cache and the hand-wired interceptors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:32:57 -04:00
mstrofnone
6209378b83 fix(tor): wire Onion-Location interceptors into every OkHttp client
Pins the Onion-Location interceptor wiring introduced in #3368 across
every OkHttp client the app builds and closes two gaps the audit
surfaced:

* OkHttpClientFactory.onionCache was nullable with a null default.
  A future call site constructing the factory without explicitly
  passing the cache would silently disable onion-routing for the
  entire HTTP role (image, upload, money, NIP-05, preview, push).
  Tightened to required (matches DualHttpClientManagerForRelays).

* NappletResourceFetcher built a raw OkHttpClient with no interceptors.
  Napplet HTTPS / blossom fetches over Tor would hit clearnet exit
  nodes even when the destination advertised an onion. Wired through
  the app-wide OnionLocationCache so a hint learned anywhere in the
  app applies, and vice versa.

ElectrumX is intentionally excluded: it uses raw Socket/SSLSocket,
not OkHttp, so the Onion-Location HTTP header does not apply. Tor
routing for ElectrumX continues to go through the Tor-aware
SocketFactory plus the Namecoin _tor field on the record (a
stronger, blockchain-anchored trust path than a passive HTTP hint).

IsEmulator is made null-safe (each Build.* field coalesced to "") so
unit tests can stand up the affected classes without NPEing on the
JVM default-values stub of android.os.Build.

New OnionInterceptorWiringTest (11 cases) covers:
  * locationInterceptor records the header under the clearnet host
    (HTTPS path and WebSocket 101 upgrade)
  * locationInterceptor with no header writes nothing
  * rewriteInterceptor passes through unknown hosts
  * rewriteInterceptor https -> https.onion preserves scheme
  * rewriteInterceptor https -> http.onion downgrades safely
  * rewriteInterceptor passes through unparseable cache values
  * cache round-trip and shared-instance invariant
  * compile-time pin that both classes remain okhttp3.Interceptor

Build:
  ./gradlew --no-daemon spotlessCheck                                       OK
  TZ=UTC ./gradlew --no-daemon :amethyst:testPlayDebugUnitTest              763 tests, 0 failures
  ./gradlew --no-daemon :commons:verifyKmpPurity :quartz:verifyKmpPurity    OK
2026-06-26 17:32:57 -04:00
Claude
284b7b143e ci: build signed Accrescent APK set (.apks) on release
Accrescent only accepts a signed APK set of split APKs generated by
bundletool from an AAB — not the AAB itself and not a monolithic APK.
After signing the F-Droid AAB, run bundletool build-apks (--mode=default)
to emit dist/amethyst-fdroid-<tag>.apks, signed with the same release
keystore secret the other signing steps use. It is attached to the GitHub
Release via the existing dist/* glob.

Upload to Accrescent stays manual (drag the .apks into the developer
console): Accrescent has no publish API or CI CLI yet — both are on their
roadmap but unreleased. A build-time guard warns if the APK set exceeds
Accrescent's 128 MiB automated-check limit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rE4k6sUN39emsofSv1Y1M
2026-06-26 21:27:16 +00:00
Vitor Pamplona
2e69f85255 Merge pull request #3389 from vitorpamplona/claude/pr-rendering-layout-uafuio
Style Git pull request clone URLs with labelMedium typography
2026-06-26 17:19:45 -04:00
Vitor Pamplona
1788c93358 Merge pull request #3388 from vitorpamplona/claude/browser-nsite-preload-ekriqv
Preload favorited nSites/nApplets to avoid "isn't loaded yet" toast
2026-06-26 17:05:21 -04:00
Claude
e70688841c fix: preload favorited nsite/napplet manifests in the browser
Favorites store only the addressable coordinate (kind:pubkey:dtag); the
launch path re-resolves the live event from LocalCache at tap time. When
that event hadn't streamed in yet, tapping a favorited nsite/napplet showed
"isn't loaded yet" and never recovered on its own — the only thing that
pulled the event into the cache was visiting the nsite/napplet feed (it
subscribes by author), which is why opening that feed and coming back made
the favorite suddenly launchable.

Add PreloadFavoriteNostrApps, which subscribes each favorited coordinate to
the shared EventFinder (the same lifecycle-aware loader observeNote uses) so
the manifests fetch via the author's outbox relays as soon as the launcher
opens. Wire it into the Browser tab and the Favorite Apps tab. The loader
drops each coordinate once its event arrives, so this is a one-shot fetch,
not a standing feed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XH7D6xUhHDKUbrYHoBnZyL
2026-06-26 20:53:46 +00:00
Claude
9002c63916 feat: reorder PR card with repo header on top and size clone links
Move the project's name (repository) row to the top of the pull-request
card, above the type/status row. Render the clickable clone download
links at the same font size as the branch/commit/merge-base meta rows so
the metadata block reads as one consistent sequence. Apply the same link
sizing to the PR update card for consistency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FjxBXAx2NQE6xi3TLBNJ23
2026-06-26 20:44:12 +00:00
Claude
11a2474338 fix: drop redundant usesCleartextTraffic flag for Accrescent compliance
Accrescent's automated checks reject any app whose manifest sets
android:usesCleartextTraffic="true". The attribute is already inert on
this app: it is ignored on API 24+ whenever a networkSecurityConfig is
present, and minSdk is 26, so cleartext is governed entirely by
network_security_config.xml (base-config cleartextTrafficPermitted=true).

Removing the attribute is behavior-preserving — user-configured ws://
relays on local IPs and the 127.0.0.1 Tor SOCKS proxy keep working via
the network security config — while satisfying Accrescent's check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rE4k6sUN39emsofSv1Y1M
2026-06-26 20:33:52 +00:00
Claude
69457ec10d build: make release APKs reproducible
AGP embeds a dependency-metadata blob (the resolved dependency tree, a
protobuf encrypted with a Google public key) into the APK/AAB signing
block by default. That ciphertext is non-deterministic, so it was the one
remaining thing preventing our release artifacts from being rebuilt
bit-for-bit by a third party.

Disable it via dependenciesInfo { includeInApk = false; includeInBundle =
false }. Combined with the already-pinned toolchain (AGP/Kotlin/R8/JDK 21),
the absence of any build-time clock in BuildConfig, and a deterministic
version name, release APKs now reproduce exactly — letting F-Droid /
Zapstore independently verify our developer-signed builds. Play derives
this dependency data server-side, so nothing is lost there.

Document the guarantee and a diffoscope-based verification recipe in
BUILDING.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JtjUcSjjpu4auFndw1QKeU
2026-06-26 20:28:38 +00:00
Vitor Pamplona
52a467db5f Merge pull request #3387 from vitorpamplona/claude/zap-card-bg-color-kz8szr
Fix activity card background propagation to embedded notes
2026-06-26 16:26:02 -04:00
Claude
d6fc3232b2 fix: keep zap/reaction card inner content on the card's tint
The embedded note and comment inside the lightning / nutzap / onchain-zap
and reaction activity cards were handed the parent feed / MultiSetCard
background state, so they drew black (the app background) or flashed the
new-note highlight instead of letting the card's orange (or like-tinted)
wash show through.

ActivityCardFrame now exposes a stable transparent background state to its
content for the inner note and comment to draw on; the hand-built onchain
card uses a matching local state. Nothing inside the card's layout changes
background from the feed anymore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GEUsgVr5e31qYeqNSjgHif
2026-06-26 19:37:43 +00:00
Vitor Pamplona
19debffe15 Merge pull request #3384 from vitorpamplona/claude/nsite-napplet-icons-okz5y6
Show nSite/nApplet icons from bundled blobs in favorites
2026-06-26 15:16:43 -04:00
Claude
c7e14676f2 Merge remote-tracking branch 'origin/main' into claude/nsite-napplet-icons-okz5y6 2026-06-26 19:11:40 +00:00
Claude
2fddb73365 Merge remote-tracking branch 'origin/main' into claude/cashu-wallet-wizard-0zr280 2026-06-26 19:08:04 +00:00
Claude
e8539df81e Merge remote-tracking branch 'origin/main' into claude/nsite-napplet-icons-okz5y6
# Conflicts:
#	amethyst/src/main/res/values-fr-rCA/strings.xml
2026-06-26 19:07:03 +00:00
Vitor Pamplona
b703330c6a Merge pull request #3386 from vitorpamplona/claude/amethyst-lint-translations-dend0l
Move browser strings to commons for shared localization
2026-06-26 15:06:54 -04:00
Vitor Pamplona
7fc5c92760 Merge pull request #3385 from vitorpamplona/fix/account-dir-cleanup-on-delete
fix: delete per-account dir on account removal + sweep orphans at startup
2026-06-26 15:05:42 -04:00
Claude
0092b4f726 perf: single-pass icon-path selection; resolve on event not NoteState
NappletIconPath.choose scanned the whole path list once per conventional name
(O(PRIORITY x N)), allocating a filter list and recomputing each basename up to
20 times per path. A manifest is a whole static site, so N can be large. Collapse
to one O(N) pass with a name->rank map: basename computed once per path, no
intermediate lists, and the loose-raster fallback is skipped entirely once an
exact match is in hand. Behavior is unchanged (the 11 selection tests still pass).

Also key the favorite-icon resolution on the manifest event rather than the whole
NoteState, so paths()/choose() don't re-run on unrelated metadata bumps (a
reaction or zap tracked on the note).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaPdt8EnSrSBuzdudrxzTH
2026-06-26 19:00:53 +00:00
Claude
5a297f17a1 fix: move orphaned browser/napplet translations to :commons
The browser/napplet strings (browser_address_hint, browser_console_title,
browser_console_title_short, browser_console_clear, napplet_untitled) were
moved to :commons, but their per-locale translations were left behind in
amethyst's values-*/strings.xml. With the default keys gone from amethyst,
lint flagged them as ExtraTranslation (80 errors across 16 locales).

Move the translations into commons/src/androidMain/res/values-*/strings.xml
so the default key and its translations live in the same module, preserving
the existing translation work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uza7sGxYPZtY43Ln2yH8FQ
2026-06-26 18:58:08 +00:00
Claude
2fac0dae1d Merge remote-tracking branch 'origin/main' into claude/cashu-wallet-wizard-0zr280 2026-06-26 18:56:50 +00:00
Vitor Pamplona
0afc83dcfb Merge pull request #3383 from vitorpamplona/claude/amethyst-draft-signing-bug-9cx0zx
Fix draft deletion by holding strong reference to draft notes
2026-06-26 14:52:24 -04:00
Vitor Pamplona
58b551265c fix: delete per-account dir on account removal + sweep orphans at startup
Account deletion cleaned up the saved-accounts list and encrypted prefs but
never removed the on-disk files/accounts/<pubkey>/ directory (the MLS/Marmot
stores created in AccountCacheState.loadAccount). Every deleted or logged-out
account leaked its folder, so the on-disk account count drifted far above the
number of accounts shown in the switcher (16 dirs vs 6 saved on a test device).

- AccountCacheState: add deleteAccountFiles(pubkey) to remove the directory and
  pruneOrphanAccountDirs(keepPubkeys) to clear dirs no longer backed by a saved
  account.
- AccountSessionManager.logOff: delete the files in both delete branches.
- AppModules: one-time startup sweep, keyed by the hex of every saved account,
  to clean up folders leaked before this fix.
- LocalPreferences.savedAccounts(): make the lazy init race-safe with a
  dedicated mutex + double-checked locking. Multiple startup coroutines
  (account load, always-on notification service, the new orphan sweep) call it
  concurrently; the old check-then-act could run the IO read in parallel and
  double-write ALL_ACCOUNT_INFO during the legacy migration.

Verified on-device: 16 -> 6 account dirs after one launch, stable across
restarts, no startup regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:47:23 -04:00
Claude
119517bd3a fix: only resolve the draft note when there is a version to save
The versions collector resolved draftNote = account.getOrCreateDraftNote(...)
on every emission, including the content-less initial tick (~1s after the
composer opens). That touched the lateinit account before any user input; if
it were ever unset at that moment the throw would kill the collectLatest
coroutine and silently stop all draft saves for that composer.

Move the resolve inside the `if (it > 0)` guard, co-located with the save, so
account is only read once a real edit exists. The open-a-draft-then-send-
without-editing case is still covered by the explicit refresh in load().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aj4ajZ4uQ58Bqts7riJ5i
2026-06-26 18:41:23 +00:00
Claude
0186aad1f4 fix(cashu): audit fixes for wallet wizard
Three issues found in an adversarial audit of the wizard:

1. (correctness) recoverFromSeed bumped the NUT-13 counter only when
   unspent proofs remained, but nextCounterAfterScan reflects every slot
   the mint signed regardless of spent state. Adopting a fully-spent
   wallet on a fresh device would leave the counter at 0, so the next
   mint would reuse an already-signed slot and produce an unspendable
   proof. Now bumps past every signed slot (matches the old
   restoreFromMint behavior); the delta>0 check still no-ops when nothing
   was signed.

2. (UX trap) the wallet screen's auto-launch-into-wizard guard used
   `remember`, which resets when the screen leaves composition on
   forward navigation — so backing out of the wizard re-fired the effect
   and trapped the user in an inescapable loop. Switched to
   rememberSaveable so the guard survives the round trip.

3. (robustness) wrapped the wizard's analyze() in try/catch so an
   unexpected throw surfaces an error + retry instead of spinning on
   "Analyzing…" forever.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqmMR2QiULS5QGosSgSQAe
2026-06-26 18:31:37 +00:00
Claude
bb968f74f2 fix: make nsite/napplet favorite icon reactive to manifest arrival
The first cut resolved the manifest once inside remember(coordinate), so if the
event wasn't in LocalCache at first composition (e.g. a cold start, where it
streams in from relays a moment later) the null result was cached for the life
of the composition and the icon never appeared — a silent fall-back to the glyph.

Observe the addressable note's metadata StateFlow instead, mirroring the webapp
favicon path (which re-resolves on the BrowserIconRegistry key set): when the
manifest lands or updates in LocalCache, the icon resolves and the blob fetch
kicks off. checkGetOrCreateAddressableNote returns null only for a malformed
coordinate, so the early return stays structurally stable per call site.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaPdt8EnSrSBuzdudrxzTH
2026-06-26 18:23:52 +00:00
Claude
172df295f8 refactor(cashu): rename AddCashuWalletScreen to CashuMintsScreen
The screen is no longer an "add wallet" entry point — it's a settings-only
mint manager (first-time setup now goes through the find-or-create wizard).
Rename the composable AddCashuWalletScreen → CashuMintsScreen and the route
WalletAddCashu → CashuWalletMints to match its actual role.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqmMR2QiULS5QGosSgSQAe
2026-06-26 18:21:22 +00:00
Claude
94b99e0973 refactor: derive the draft note from the versions flow, decouple DraftTagState
DraftTagState goes back to pure tag/version state — it no longer knows about
AddressableNote or needs an account-aware builder. Instead each composer
derives draftNote from the debounced versions collector it already runs:
on each emission it maps the current tag to its live cache note via
account.getOrCreateDraftNote(current). The ViewModel field holds the strong
reference that keeps LocalCache's weak entry alive until a deletion needs it.

load() refreshes draftNote after set(oldTag) because set() doesn't bump
versions, covering the open-a-draft-then-send-without-editing case.

deleteDraftInner takes the nullable note again and still only signs when the
note holds a real, non-deleted DraftWrapEvent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aj4ajZ4uQ58Bqts7riJ5i
2026-06-26 18:16:42 +00:00
Vitor Pamplona
799bbe68ef Merge pull request #3382 from vitorpamplona/claude/amethyst-logo-redesign-nlefdg
Redesign service notification icon with network topology
2026-06-26 14:11:55 -04:00
Claude
5b8a42020c feat: adopt orbit-ring service notification icon; drop preview scaffolding
Make the orbit design (centred gem ringed by three "server" nodes) the
always-on relay-service notification icon by writing it into the existing
amethyst_service drawable, so NotificationRelayService picks it up unchanged.

Remove the icon bake-off scaffolding now that a design is chosen:
- delete the alternative drawables (amethyst_service2..6)
- delete the DEBUG-only ServiceIconPreviewNotifications helper
- drop its trigger and now-unused imports from MainActivity

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kvjst6qVHuQ8rKD3LtvTzi
2026-06-26 18:04:42 +00:00
Claude
6b6ee26a88 style: maximize centred gem in the service-icon options
Grow the central Amethyst gem in each motif (orbit/sync/hub/waves) to the
largest size that still clears the surrounding elements. For the hub icon the
spokes now start farther from the centre to give the gem room.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kvjst6qVHuQ8rKD3LtvTzi
2026-06-26 17:57:31 +00:00
Claude
111686884a fix(i18n): drop CDATA from French "Block & Hide" strings
block_hide_user and report_dialog_block_hide_user_btn wrap their value in
<![CDATA[...]]> in the English source only because the text contains a literal
'&' (Block & Hide User). The French translations kept the CDATA wrapper but the
text has an apostrophe (l'utilisateur) instead — and AAPT2 fails to flatten a
CDATA-wrapped apostrophe ("Can not extract resource from ParsedResource" /
"Invalid unicode escape sequence"), breaking mergePlayDebugResources.

Drop the now-pointless CDATA and use a normally-escaped apostrophe (l\'utilisateur)
— identical runtime string, valid under every AAPT2 version, and consistent with
the escaped-apostrophe style the rest of these files already use.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaPdt8EnSrSBuzdudrxzTH
2026-06-26 17:45:21 +00:00
Claude
cde3b2b047 feat(cashu): add find-or-create wallet wizard with cross-relay discovery
The Cashu "Add wallet" screen had become a mint manager, and its Create
path published a fresh kind:17375 that could clobber a portable NIP-60
wallet the user already owned in another client (kind:17375 is
replaceable).

When no wallet is loaded, drive the user into a new find-or-create wizard
that crawls every relay (modeled on the Event Sync tool) for the user's
existing kind:17375 wallets and branches on the result:

- 0 wallets: offer to create a new one.
- 1 wallet: verify + balance-probe it, adopt as the main wallet and
  rebroadcast to outbox so it's easy to find next time.
- >1 wallets: the newest becomes the main wallet; older/duplicate wallets
  are verified, their recoverable balances probed via NUT-09/NUT-07, and
  the user gets one-tap "Recover funds to main wallet" per old wallet.

The mint manager (AddCashuWalletScreen) is now reachable only from Cashu
Wallet Settings, once a wallet exists; the no-wallet and "add wallet"
picker paths route through the wizard instead.

Implementation:
- Split CashuWalletOps.restoreFromMint into scanRecoverableProofs (no
  publish, for the balance probe) + publishRecoveredProofs; foreign-seed
  recovery never bumps the main wallet's NUT-13 counter.
- New CashuWalletDiscovery crawler reuses fetchAllPages + a fresh
  NostrClient so crawled events don't pollute LocalCache.
- CashuWalletState gains decrypt/probe/recover/adopt helpers.
- Extract AccountViewModel's relay-crawl closures (crawlRelayDb,
  buildCrawlClient) so Event Sync and the wizard share them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqmMR2QiULS5QGosSgSQAe
2026-06-26 16:22:18 +00:00
Claude
eb6dfc7a4a feat: show nsite/napplet bundled icon on favorite tabs
Favorited web apps replace the bottom-nav glyph with the site's favicon, but
nsites (NIP-5A) and napplets (NIP-5D) fell back to the generic grid glyph.

The webapp trick (capturing onReceivedIcon from the WebView) can't work here:
nsites/napplets render inside a cross-origin sandboxed iframe under a trusted
shell, so onReceivedIcon only ever reports the shell's main-frame icon, never
the applet's. Instead, derive the icon from the manifest's own bundled blobs —
content-addressed, sha256-verified, and Tor-routed like the rest of the site.

- quartz: NappletIconPath picks the best conventional icon path (favicon/icon/
  apple-touch-icon, raster over ico/svg, shallower path wins) from a manifest's
  path tags; exposed as iconBlob() on the four nsite/napplet event kinds. Unit
  tested.
- amethyst: rememberNappletIconModel resolves the live manifest from LocalCache,
  prefetches the icon blob into the shared verified cache off the composition
  thread, and returns a file:// model. AppBottomBar and FavoriteAppsScreen feed
  it into FavoriteAppIcon for NostrApp favorites, mirroring the webapp path.

Priority: bundled blob > manifest icon URL tag > type glyph.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaPdt8EnSrSBuzdudrxzTH
2026-06-26 15:55:12 +00:00
Claude
002c8b6d30 style: enlarge service-icon options to fill the icon frame
Scale up the orbit/sync/hub/waves motifs and their centred gems so each
drawing occupies as much of the 512 viewport as possible (rings and nodes
pushed near the edge, larger central gem), keeping a small margin so strokes
don't clip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kvjst6qVHuQ8rKD3LtvTzi
2026-06-26 15:53:11 +00:00
Claude
b5706564d9 refactor: build the draft note from the tag inside DraftTagState
Instead of capturing the AddressableNote returned by each save (and on
load) and re-assigning it, DraftTagState now builds the note from the
current tag via an injected (tag -> AddressableNote) builder and rebuilds
it whenever the tag changes. Because that note is the live cached object
for the address, its event tracks the draft automatically as it is saved
or removed, so:

- the note is never null inside the state (lateinit, wired by start());
- createAndSendDraftIgnoreErrors no longer needs to return the note, and
  load no longer needs to capture it — set(oldTag) rebuilds it;
- the writer's existence check becomes "is there a real, non-deleted
  draft event in the note" (DraftWrapEvent.isDeleted()), which also stops
  a second blank-delete from re-signing an already-emptied draft.

ViewModels just wire draftTag.start(account::getOrCreateDraftNote) in
init() and reference draftTag.note; the per-VM field and held() are gone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aj4ajZ4uQ58Bqts7riJ5i
2026-06-26 15:38:55 +00:00
Claude
e1c152ada8 feat: add four more service-icon options + on-device preview harness
Add four additional always-on service notification icon candidates, each
keeping the centred brand gem with a "background service / connected to
servers" motif around it:

- amethyst_service3: orbit ring with three server nodes
- amethyst_service4: two looping sync arrows (running)
- amethyst_service5: hub & spoke to five nodes (connected to relays)
- amethyst_service6: concentric broadcast waves

To compare all candidates on a real device, add a DEBUG-only helper
(ServiceIconPreviewNotifications) that posts one always-on-style ongoing
notification per icon (same channel style, ongoing/silent/low priority),
triggered from MainActivity.onCreate under BuildConfig.DEBUG.

NOTE: the preview harness (ServiceIconPreviewNotifications + the
MainActivity hook) is temporary scaffolding for the icon bake-off and is
meant to be removed once a design is chosen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kvjst6qVHuQ8rKD3LtvTzi
2026-06-26 15:33:15 +00:00
Vitor Pamplona
6b7e541f55 Merge pull request #3380 from vitorpamplona/claude/adoring-babbage-stzzck
Auto-save Cashu wallet on mint add/remove, remove manual key picker
2026-06-26 11:16:48 -04:00
Claude
a242411df6 refactor: own the draft note reference inside DraftTagState
The strong reference that keeps a saved draft alive (so LocalCache's weak
reference can't collect it before deletion) was duplicated as a draftNote
field across all eight composer ViewModels, each re-clearing it in
cancel(). The note's lifecycle is 1:1 with the draft tag, so DraftTagState
is its natural owner: it now holds the AddressableNote, exposes held() to
set it, and drops it in rotate() — which every cancel() already calls.

ViewModels now reference draftTag.note / draftTag.held(...) and no longer
carry their own field or reset logic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aj4ajZ4uQ58Bqts7riJ5i
2026-06-26 15:15:42 +00:00
Claude
b4d39a7e52 feat: auto-publish Cashu wallet on mint add/remove, drop Save button
The Add/Edit Cashu wallet screen no longer has a Save button. The NIP-60
wallet (kind:17375) and nutzap info (kind:10019) are now published the
instant the first mint is added, and re-published on every later add or
remove — so the list on screen always matches what's on relays.

This removes the confusing two-step flow where a typed/selected mint plus a
chosen key still left Save disabled until the user discovered the "+" button.

The explicit P2PK key picker (auto-generate / paste) is gone from this
screen: a nutzap key is generated automatically on first creation. Advanced
key import/rotation still lives in the settings Danger Zone.

Key safety: publishMints reuses the wallet's existing P2PK key on every
re-publish. The first publish's key is cached in the ViewModel and guarded
by a mutex, so rapid successive adds — firing before the new kind:17375
round-trips back through LocalCache — can't generate a second key and
rotate it, which would orphan inbound nutzaps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX5wkwXbvoQpadALx1ZVjk
2026-06-26 15:09:20 +00:00
Vitor Pamplona
356e1431d8 Merge pull request #3378 from vitorpamplona/fix/embed-ime-selection
Embedded text selection: native-parity IME + selection UI + magnifier, and platform-bug fixes
2026-06-26 11:08:42 -04:00
Vitor Pamplona
65f5292f6b Merge pull request #3379 from vitorpamplona/claude/notecompose-zap-split-preview-dgn0js
Show boosted notes as compact preview in activity cards
2026-06-26 11:08:17 -04:00
Claude
50dbbeeecc fix: hold a strong reference to the draft note for reliable deletion
LocalCache.addressables keeps AddressableNotes via WeakReference, so a
saved draft could be garbage-collected between creation and deletion.
The previous existence check (look the draft up by tag before signing)
would then find nothing locally and skip the deletion, leaving an orphan
draft on the relays.

Each composer ViewModel now holds a strong reference to its draft note:
createAndSendDraftIgnoreErrors returns the consumed AddressableNote, and
load()/editFromDraft captures the note when editing an existing draft.
deleteDraftInner takes that held note directly (sourcing the dTag and
relays from it) instead of a tag lookup, so it can always reach the draft
it needs to delete and still signs nothing when there is no draft.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aj4ajZ4uQ58Bqts7riJ5i
2026-06-26 14:59:44 +00:00
Vitor Pamplona
11e1e17655 docs(embed): record session fixes in the native-parity plan
Mark the full-screen round-trip corruption bug FIXED (process-global pauseTimers
+ attached WebView.destroy), and add the no-blink word-select, page-selection-
clears-on-focus, caret-handle-drag (positionChangeIgnoreConsumed), hybrid
word+char extend, and embed app-theme (nightThemedContext) fixes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:57:36 -04:00
Claude
bacb8a57ad feat: add circular-badge service icon as amethyst_service2 option
Keep the original hollow-outline amethyst_service icon and add the new
circular badge (solid disc with the gem punched out of its centre) as a
separate amethyst_service2 drawable, so the always-on notification icon
can be switched between the two without losing either design.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kvjst6qVHuQ8rKD3LtvTzi
2026-06-26 14:57:22 +00:00
Claude
bf2dc03a50 fix: escape apostrophe inside CDATA in fr-rCA strings
The Crowdin-synced values-fr-rCA/strings.xml had unescaped apostrophes inside the
CDATA sections of block_hide_user and report_dialog_block_hide_user_btn, which
aapt2 rejects ("Invalid unicode escape sequence"), breaking resource compilation.
Escape them as \' to match the known-good values-fr translations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4rrFiWApq8EFpk4fSuTFH
2026-06-26 14:54:09 +00:00
Vitor Pamplona
f458fdaa8a Merge pull request #3377 from vitorpamplona/claude/amethyst-git-notifications-rqfkux
Add Git pull request event rendering support (NIP-34)
2026-06-26 10:53:30 -04:00
Claude
120c31ac95 feat: always show short preview for posts inside zap activity cards
The lightning, nutzap, and onchain activity cards embed the zapped post via
RenderZappedPost with makeItShort = true. The 2-line compact preview, however,
only triggered when the logged-in user authored the post
(makeItShort && isLoggedUser(author)). When the user is merely a zap-split
beneficiary of someone else's post, that check failed and the post rendered in
full instead of the intended compact preview.

Gate the short preview on the boosted-note flag as well, so any post embedded in
one of these activity cards (the only makeItShort callers that pass
isBoostedNote = true) is always shown as a 2-line preview, regardless of author.
Other makeItShort callers (Report, community post approval, attestation, reply
composition, compose screens) all use isQuotedNote instead, so their behavior is
unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4rrFiWApq8EFpk4fSuTFH
2026-06-26 14:49:47 +00:00
Vitor Pamplona
43aebcca63 fix(embed): make embedded + full-screen WebViews follow the app theme
The embedded in-app browser and napplet/nSite surfaces rendered web content in
the device theme, ignoring the app's DARK/LIGHT preference — a site with dark
support stayed light when the app was dark (and vice versa). The full-screen
activities had the same latent gap (they followed the device, not the app).

Root cause: WebView's dark decision (prefers-color-scheme via algorithmic
darkening) reads the context's THEME (?android:attr/isLightTheme), not just the
Configuration uiMode. The off-window SurfaceControlViewHost surface context
carries neither the host window's theme nor its night mode, so the renderer came
up light. The old applyNightMode() used UiModeManager.setNightMode — a
permission-gated no-op — so the theme never reached the WebView at all.

Fix: build every embed/host WebView from nightThemedContext() — a
ContextThemeWrapper over a forced-night/day Configuration with a DayNight theme,
so the theme's isLightTheme resolves from the app's resolved theme. Shared in
EmbedWebViewTheme.kt; used by NappletBrowserService, NappletHostService,
NappletBrowserActivity, and NappletHostActivity. Removed the dead applyNightMode
no-op from all four. (Verified on device with a throwaway SurfaceControlViewHost
repro: config-only context does NOT work; setForceDark is a no-op at targetSdk
37; setApplicationNightMode does nothing; the DayNight ContextThemeWrapper is
what flips the renderer, even across the cross-process embedded surface.)

Device-verified: all four surfaces follow the app theme even when it differs
from the device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:49:44 -04:00
Claude
3c041d2915 feat: modern git cards for pull requests, plus issue/patch polish
NIP-34 pull requests (kind 1618) and pull-request updates (kind 1619)
previously had no renderer and fell through to the plain text-note
branch, so a PR notification opened onto an unstyled markdown blob with
none of its structured data shown. Add dedicated cards that reuse the
existing patch/issue card vocabulary (bordered container, type chip,
status pill, embedded repository header) and surface the PR-specific
metadata the event carries:

- Pull Request card: "Pull Request" chip with a merge glyph, status
  pill, subject title, branch name, current commit, merge base, and
  clone-URL download rows.
- PR Update card: "PR Update" chip, repository header, new commit /
  merge base, clone URLs, and an explanatory line (updates carry no
  body content).

While here, modernize the existing cards consistently:

- Factor the shared markdown body, metadata row, and subject title into
  reusable composables (GitMarkdownBody / GitMetaRow / GitSubjectTitle).
- Show the issue subject as a proper title. The old code cast the event
  to TextNoteEvent to read the subject, which always returned null
  (GitIssueEvent is not a TextNoteEvent), so issue subjects were never
  displayed; read it from GitIssueEvent.subject() instead.
- Render the patch commit as an iconed metadata row.

Wire the two new kinds into NoteCompose and the thread detail view, add
the CallMerge / Commit / AltRoute Material symbols (font subset
regenerated), and add the new string resources.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCXL6btUeZZmSP1TCYkKh
2026-06-26 13:39:05 +00:00
Claude
df5361d220 fix: don't sign a draft deletion when no draft exists
deleteDraftInner() unconditionally signed a deleted-draft wrap event plus
a deletion event, even when no draft for that tag existed. With an
external NIP-55/NIP-46 signer, clearing a composer to blank could then
prompt for a signature even when "Automatically create drafts" is off,
since the blank-text branch of sendDraftSync() always calls delete.

Skip both signatures when the draft does not exist in the cache. The
existence lookup reuses the addressable note already fetched for relay
hints, so it adds no extra work, and real drafts are still deleted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aj4ajZ4uQ58Bqts7riJ5i
2026-06-26 13:38:20 +00:00
Claude
7234324332 fix: escape apostrophes in fr-rCA CDATA strings
Two French-Canadian CDATA string resources carried a raw apostrophe
(`l'utilisateur`), which aapt2 rejects with "Invalid unicode escape
sequence", breaking every Android resource merge (merge*Resources) and
therefore the whole Android build. Escape them as `\'` like the other
apostrophes in the file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCXL6btUeZZmSP1TCYkKh
2026-06-26 13:09:47 +00:00
Claude
173a422d86 fix: notify on NIP-34 pull requests (kind 1618/1619)
New pull request events (GitPullRequestEvent, kind 1618) and pull
request updates (GitPullRequestUpdateEvent, kind 1619) were never wired
into the notification pipeline, which only handled patches (1617) and
issues (1621). As a result repo maintainers received no notification
when a PR was opened against their repository, even though the PR event
p-tags the repo owner.

Add both kinds in all four places that gate notifications:

- FilterNotificationsToPubkey: request the kinds from relays so the
  notification subscription actually pulls PR events.
- NotificationFeedFilter.NOTIFICATION_KINDS: let cached PR events through
  to the in-app Notifications tab.
- NotificationFeedFilter.tagsAnEventByUser: treat PR/PR-update as
  notifiable (mirrors the existing patch/issue handling).
- EventNotificationConsumer: dispatch PR/PR-update via notifyMention so
  push notifications fire.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCXL6btUeZZmSP1TCYkKh
2026-06-26 13:00:39 +00:00
Claude
fa12576a8c fix: respect "Automatically create drafts" setting in comment composer
CommentPostViewModel.sendDraftSync() unconditionally signed and
published a draft event whenever the user typed in a comment, ignoring
the "Automatically create drafts" setting. Every other composer
ViewModel guards this path with
accountViewModel.settings.automaticallyCreateDrafts(); add the same
check here so disabling the setting stops generic draft events from
being signed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aj4ajZ4uQ58Bqts7riJ5i
2026-06-26 12:29:37 +00:00
Vitor Pamplona
69e9590fe7 Merge pull request #3374 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-26 08:25:50 -04:00
Claude
27191c3a36 fix: enlarge gem in service notification icon by 10%
The punched-out gem now spans ~57% of the disc diameter (was ~52%),
giving it slightly more presence within the circular badge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kvjst6qVHuQ8rKD3LtvTzi
2026-06-26 02:06:49 +00:00
vitorpamplona
f5bb04b643 chore: sync Crowdin translations and seed translator npub placeholders 2026-06-26 01:34:37 +00:00
Vitor Pamplona
f2cc46641c Merge pull request #3376 from vitorpamplona/claude/keen-franklin-h4kah5
feat: query zapstore's relay in the global Apps feed and keep its filter specific
2026-06-25 21:32:20 -04:00
Vitor Pamplona
6995082334 fix(embed): full-screen round-trip no longer corrupts embedded WebViews
Opening an embedded page full-screen and returning left every embedded WebView
in the :napplet process broken: dead DNS (ERR_NAME_NOT_RESOLVED), DOM reads
returning empty (a field that visibly shows text reports value==""), dead
text-selection highlight, and broken IME (caret stuck at 0, can't delete).

Two process-global defects in the full-screen hosts were corrupting the shared
WebView state the embedded surfaces rely on:

- pauseTimers()/resumeTimers() are PROCESS-GLOBAL — they pause/resume JS,
  layout and parsing timers for every WebView in the process. The full-screen
  activities (and the napplet embed pause/resume path) called them on their own
  lifecycle, so returning from full-screen froze the embedded surfaces, which
  have no resume of their own. Replaced with per-WebView onPause()/onResume()
  (which pause only that surface's JS/DOM — still satisfies the napplet
  background-security goal). No process-global timer calls remain.
- WebView.destroy() was called while the WebView was still attached to the
  window, which corrupts the shared multiprocess renderer. Detach (removeView)
  and stopLoading() before destroy() in both full-screen activities.

This also resolves the long-standing "selection highlight dead after a
full-screen excursion" blocker — same root cause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:25:55 -04:00
Vitor Pamplona
aa520c361f feat(embed): hybrid word+char granularity for in-field selection handles
Completes parity #5. `fieldExtend` kept a pure word-snap, so the in-field
start/end handles couldn't be fine-tuned to a single character. Now it keeps
per-drag state (reset on a >250ms gap or edge switch) and matches native
`Editor` word-selection drags: the gesture baselines at the current selection
edge, sweeping past that word's far boundary snaps to the next whole word
(never stopping mid-gap), and moving within / back from the furthest-reached
word gives character precision. Symmetric for both handles. Page-text extend
stays character-granular.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:25:55 -04:00
Vitor Pamplona
dec1e0bec1 feat(embed): selection loupe + native-parity fixes for embedded text selection
Builds out host-drawn text selection for embedded napplet/nsite/browser
surfaces toward native parity, and fixes the bugs found while exercising it.

- Magnifier loupe (#4): EmbeddedMagnifier + provider-side pixel capture
  (EmbeddedMagnifierProbe) shipped over IPC for both embed paths; the
  caret/selection handles drive it via OnMagnify.
- SelectionUiState: single source of truth for the overlay show/hide rules
  (insertion caret / in-field range / page-text range + dragging/scrolling).
- EmbeddedSelectionDrag: suspends the nav drawer's edge swipe while a handle
  is dragged (auto-scroll #9).

Bug fixes:
- No more overlay blink on word-select: the shim's selection-reveal scrolls
  (a textarea auto-scrolling to show a forming/re-asserted range) no longer
  trip the hide-on-scroll path, and the hide self-heals instead of being
  re-armed indefinitely.
- RemoteImeView debounces the range-lost signal so a transient collapse that
  gets re-asserted doesn't flicker the handles/toolbar.
- Focusing a field clears any page-text selection (shim + host), so the stale
  page handles/Copy bar no longer linger above — and stop stealing drags from —
  the field overlays; also cancels any in-flight scroll-hide on focus.
- Caret insertion-handle drag now actually moves the caret: read the pointer
  delta with positionChangeIgnoreConsumed() before consuming, so the value
  isn't zeroed by our own consume (or the sandbox surface consuming the move).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:25:55 -04:00
Vitor Pamplona
8232cd50a3 test(embed): add IME/selection test harness + document how to run it
tools/ime-test/index.html is a single-page input+textarea harness with an
on-page log timestamping focus/selection/input/composition events, paint
latency, long-tasks, and main-thread blocks — the instrumentation used to pin
the erase / caret-jump / first-letter-freeze bugs and what we'll use to profile
the magnifier. README documents serving it (python http.server on 8765,
10.0.2.2 for emulator / adb reverse for USB) and opening it as an embedded tab
via the in-app browser address bar. Plan gets a matching "How to test" section.

Dev tool only — nothing under tools/ ships, so the [ImeDiag] strings stay out of src/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:25:55 -04:00
Vitor Pamplona
d351376e51 docs(embed): plan for native-parity text selection in embedded surfaces
Inventory native Android text-field features (insertion/selection handles,
floating toolbar, magnifier, smart selection, etc.) with activation rules and
our current coverage; prioritize the magnifier and a centralized SelectionUiState.
Document the open full-screen round-trip bug that kills highlight paint across
all :napplet embeds, what was already ruled out, and the next hypotheses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:25:55 -04:00
Vitor Pamplona
baddd5b3bd fix(embed): IME typing + host-drawn text selection for embedded surfaces
Embedded WebView surfaces (:napplet process, SurfaceControlViewHost) can't
host the soft keyboard or present Chrome's own selection UI, so editing and
selection are relayed to the main process. This lands the working set of that
relay:

- shim.js: fix React-controlled input erase by writing through the native
  HTMLInputElement/HTMLTextAreaElement value setter (so React's value tracker
  stays in sync); make the host authoritative for selection re-assert (the
  editable selectionchange handler only mirrors); report field/caret geometry
  and page-text selection geometry; add pageExtend + caret coords (border-width
  corrected) for drag-to-extend and the insertion handle.
- RemoteImeView: land caret where tapped on focus (requestFocus before applying
  remote state); host-authoritative selection re-assert within a time window;
  setText only when text actually changed; wire copy/cut/paste/select-all and
  edit callbacks.
- EmbeddedTabLayer: stop resizing the surface on IME show (removes the ~1s
  first-letter freeze); draw the selection overlay — toolbar, teardrop
  selection handles, and the insertion (cursor) handle.
- EmbeddedImeBridge / Embedded{Napplet,Browser}Controller: carry selection +
  caret geometry and the page-selection event across the Messenger channel.

Known limitation (not fixed here): after a field's page is opened in its own
full-screen activity and the user returns, the selection-highlight paint stays
off across all embedded surfaces. DOM selection, the toolbar, and copy still
work — only the native highlight is gone. This is a WebView/Chromium behavior
in off-window surfaces and is not reachable from the app layer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:25:55 -04:00
Vitor Pamplona
91df3c53ae Merge pull request #3375 from vitorpamplona/claude/compiler-instruction-limit-0ky2hw
Fix compiler instruction limit in AccountSettings constructor
2026-06-25 21:25:21 -04:00
Claude
8b2b43d3c4 feat: redesign always-on service notification icon as a circular badge
The persistent relay-service notification previously reused a hollow gem
outline, which looked unpolished. Replace it with a full, solid disc that
has the brand gem punched out of its centre as negative space (slightly
smaller, with a comfortable surrounding ring).

The enclosing circle gives the always-running service its own distinct,
continuous "badge" feel while keeping the recognisable Amethyst gem shape.
Built from the real gem path so it stays on-brand; a single evenOdd path
turns the gem into a hole and restores its facet dot as a solid island.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kvjst6qVHuQ8rKD3LtvTzi
2026-06-26 01:19:16 +00:00
Claude
890516263f feat: query zapstore's relay in the global Apps feed and keep its filter specific
The global Apps feed (NIP-82 software apps) was sending a kinds-only REQ
filter with limit=200. zapstore's relay scores each REQ filter for
specificity and rejects anything that scores below 3, treating a kinds-only
filter with a limit >= 100 as "too vague" — so the whole subscription was
refused.

- Drop the global Apps filter limit from 200 to 99 so a kinds-only filter
  clears the bar (kinds +1, limit < 100 +2 = 3).
- Always include wss://relay.zapstore.dev in the global Apps feed relays,
  since it indexes the full app catalog, unless the user has added it to
  their blocked relay list (NIP-51 kind 10006).
- Re-evaluate the subscription when the blocked relay list changes so
  blocking/unblocking zapstore takes effect without an app restart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01US2aK3igzfeo4xtcvg3mKc
2026-06-26 01:17:05 +00:00
mstrofnone
d4cc4c67de fix(desktop): resolve Namecoin names in the home tab search bar
The home tab inline search bar in FeedScreen.kt previously ignored
Namecoin identifiers (e.g. mstrofnone.bit) — only the full Search screen
resolved them via LocalNamecoinService. Users typing a .bit name into
the home search pill saw 'no results found' instead of the resolved
profile.

This wires the same Namecoin resolution pattern used by SearchScreen.kt
into FeedTabsHeader:

- Detect Namecoin identifiers with NamecoinNameResolver.isNamecoinIdentifier
- Resolve via LocalNamecoinService.resolveDetailed (cancelling stale lookups
  when the user keeps typing)
- Render a compact InlineNamecoinResultRow above the regular search results
  showing Loading / Resolved / NotFound / Error states
- Clicking the resolved row navigates to the user profile, matching the
  full Search screen behaviour
2026-06-26 10:58:44 +10:00
Claude
0b06754297 fix: avoid compiler instruction limit in account loading
innerLoadCurrentAccountFromEncryptedStorage awaited ~27 parallel parse
Deferreds directly inside the single 70-argument AccountSettings(...)
constructor expression. Each await is a suspension point, so the coroutine
state machine had to spill and restore the entire partially-evaluated
operand stack (dozens of already-built MutableStateFlow args) at every one
of them. That inflated the generated invokeSuspend method past the JVM
per-method limit:

  Method exceeds compiler instruction limit: 57866 in
  LocalPreferences$innerLoadCurrentAccountFromEncryptedStorage$result$1.invokeSuspend

Resolve every Deferred into a local val first, then build AccountSettings
as a single straight-line, suspension-free expression. Each await now sits
at a statement boundary with a near-empty operand stack, so only a small,
linear set of locals is spilled. Parallel parsing behaviour is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H6VdHHFRNYXKXFcW7j5sF7
2026-06-26 00:45:46 +00:00
Vitor Pamplona
f79877adc6 Merge pull request #3371 from vitorpamplona/claude/laughing-volta-umdxsu
Add and update translations across 50+ locales
2026-06-25 20:25:21 -04:00
Claude
908e8bc1ee fix: remove invalid backslash escapes from all locale string files
Android's AAPT2 resource processor only recognizes a small set of valid
escape sequences: \, \', ", \n, \t, \r, \@, \?. All other \X sequences
(backslash-space, \-, \., \(, \), $, etc.) render as a literal backslash
followed by the character in the UI.

These were introduced by the batch AI translation. Remove the backslash
before every character that does not need escaping across 38 locale files.
2026-06-26 00:21:17 +00:00
Claude
e5a83a4123 fix: remove duplicate translation entries in hu-rHU and sl-rSI
The appended translation blocks from the previous batch-translation session
overlapped with existing Crowdin translations already present in the files,
causing 31 duplicate keys in values-hu-rHU and 3 in values-sl-rSI.
Keep the first (original Crowdin) occurrence of each key.
2026-06-26 00:13:39 +00:00
Claude
f2a7548434 fix: move shared browser/napplet strings to :commons to eliminate cross-module duplicate
AGP 9.2.1 treats resources defined in both an app module and a library module
with the same key (qualifiers="") as an error in non-debug builds.

`browser_address_hint`, `browser_console_title_short`, `browser_console_title`,
`browser_console_clear`, and `napplet_untitled` were defined in both
`:amethyst/values/strings.xml` and `:nappletHost/values/strings.xml`.
Both `:amethyst` and `:nappletHost` depend on `:commons`, so the canonical
home for these shared strings is `commons/src/androidMain/res/values/strings.xml`.

Update callers in both modules to use `com.vitorpamplona.amethyst.commons.R as
CommonsR`. Locale translations in amethyst's `values-*/` directories remain as
Android resource overlays (app module overrides library module at merge time).

Fixes: Found item String/browser_console_clear more than one time (packageFdroidBenchmarkResources)
2026-06-26 00:11:31 +00:00
Claude
a1732155b1 fix: restore browser/napplet strings removed from values/strings.xml
The previous commit incorrectly removed browser_address_hint,
browser_console_clear, browser_console_title, browser_console_title_short,
and napplet_untitled from values/strings.xml. These are needed for
amethyst's own browser and napplet screens — both amethyst and nappletHost
use them independently, and Android allows app-level strings to coexist
with same-named library strings (app wins).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hfm5Lf3FfwDgPn5oiMVqUo
2026-06-26 00:10:54 +00:00
Claude
f8a9920884 fix(i18n): resolve aapt2 duplicate resource errors in hu and sl-rSI
Remove 2959 strings from values-hu/strings.xml that were fully duplicated
in values-hu-rHU/strings.xml (added by prior commit), causing aapt2 to
error on `browser` and all other keys for the hu-HU build target.

Remove browser_console_clear, browser_address_hint, browser_console_title,
browser_console_title_short, and napplet_untitled from the app's default
values/strings.xml — these are already defined in the :nappletHost library
module, and having them in both causes aapt2 duplicate-key errors when the
library resources are merged into the app. Locale translations in
values-*/strings.xml continue to override the library defaults at runtime.

Fixes CI failures reported in PR #3371.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hfm5Lf3FfwDgPn5oiMVqUo
2026-06-26 00:10:53 +00:00
Claude
223458c3c1 feat: complete translations for 9 community-started locales
All 9 locales now have 0 missing translatable strings:
- Swahili (sw): ~2604 strings added
- Bengali Bangladesh (bn-rBD): ~2530 strings added
- Esperanto (eo, eo-rUY): ~2626 strings added, eo-rUY mirrored from eo
- Tamil (ta, ta-rIN): ~2683 strings added, ta-rIN mirrored from ta
- Latvian (lv-rLV): ~2823 strings added
- Uzbek (uz-rUZ): ~2900 strings added (fixed unescaped apostrophes in loan words)
- Serbian (sr-rSP): ~2931 strings added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hfm5Lf3FfwDgPn5oiMVqUo
2026-06-26 00:10:53 +00:00
Claude
843d39675d feat: fill translation gaps for fa, in, ru, ru-rUA locales
- Farsi (fa): 19 missing strings now complete
- Indonesian (in): 9 missing strings (push notification UI) now complete
- Russian (ru): 144 missing strings + 1 plural now complete
- Russian Ukraine (ru-rUA): 467 missing strings + 1 plural now complete

All four locales now show 0 missing translatable strings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hfm5Lf3FfwDgPn5oiMVqUo
2026-06-26 00:10:53 +00:00
Claude
bd86d6af20 fix(i18n): shorten over-long translated strings across 23 locales
266 string replacements across 41 locale files to reduce UI overflow risk.
Strings shortened to fit labels and menu items; technical terms (relay,
DM, NIP, Tor, pubkey, Cashu) kept as-is per project convention.

Languages updated: ar, cs, de, el, es, fa, fi, fr, hi, hu, in, it, nl,
pl, pt-BR, pt-PT, ru, sl, sv, sw, ta, th, uk, uz + regional siblings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hfm5Lf3FfwDgPn5oiMVqUo
2026-06-26 00:10:53 +00:00
Claude
9d41793a43 fix: correct XML escaping and type mismatches in locale string resources
- Escape unescaped `?` in calendar_rsvp_maybe_prefixed (it, ru, ru-rRU,
  ru-rUA) — Android treats `?` at string start as a theme-attr reference
- Escape unescaped `@` in my_name (fi, tr, tr-rTR) and
  quick_action_copy_user_id (zh) — same issue with `@` references
- Convert <plurals> to <string> for accounts_found, num_selected,
  follow_accounts in ru/ru-rRU/ru-rUA — English source uses <string>,
  so type-mismatched <plurals> were silently removed by AAPT2
- Fix orphaned key in el-rGR: error_parsing_json_from_lightning_
  address_check_the_user_s_lightning_address_with_user renamed to
  correct _setup_with_user suffix matching the English source

All locale files now build without errors or warnings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hfm5Lf3FfwDgPn5oiMVqUo
2026-06-26 00:10:52 +00:00
Claude
a7f6ba0ef0 feat: complete translations for 14 remaining locales
Fill all missing strings in Arabic (ar-rSA), Greek (el-rGR), Persian
(fa, fa-rIR), Finnish (fi-rFI), Indonesian (in, in-rID), Italian
(it-rIT), Japanese (ja, ja-rJP), Korean (ko-rKR), Russian (ru, ru-rRU,
ru-rUA), Thai (th, th-rTH), Turkish (tr, tr-rTR), Ukrainian (uk,
uk-rUA), Vietnamese (vi-rVN), and Traditional Chinese Taiwan (zh-rTW).

Most locale files now have all 2961 strings matching the English source.
Missing strings fall back to English per Android resource resolution.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hfm5Lf3FfwDgPn5oiMVqUo
2026-06-26 00:10:51 +00:00
Claude
bff80daa94 feat: complete translations for 26 locale files
Fill all missing strings in cs, cs-rCZ, de, de-rDE, es, es-rES, es-rMX,
es-rUS, fr, fr-rCA, fr-rFR, hi-rIN, hu, hu-rHU, nl, nl-rBE, nl-rNL,
pl-rPL, pt-rBR, pt-rPT, sl-rSI, sv-rSE, zh, zh-rCN, zh-rHK, zh-rSG.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hfm5Lf3FfwDgPn5oiMVqUo
2026-06-26 00:10:51 +00:00
Vitor Pamplona
74446e39cd Merge pull request #3373 from vitorpamplona/claude/adoring-shannon-h3pkfe
Render zap receipts as full notes in MultiSetCompose
2026-06-25 20:07:03 -04:00
Claude
1614e01f0e feat: render a lone zap notification as a full note, not a bare card
The single-zap/single-nutzap branch of MultiSetCompose dropped the bare
RenderLnZap/RenderNutzap activity card straight into the feed, with no
author column on the left, so it didn't read like the surrounding notes.

Reuse NoteCompose on the zap-receipt note instead: it wraps the same zap
card as the note body and gives it the standard note chrome — author
picture on the left, username header, reactions row. Padding is handed to
NoteCompose for this branch so it isn't doubled against the card column.

NoteCompose's author column previously resolved baseNote.author, which for
a kind-9735 zap receipt is the recipient's lightning provider (the signer),
not the zapper. Resolve the sender from the embedded zap request the same
way the thread's master note already does, so the picture on the left is
the actual zapper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cg8mHZDCfGmGAVDaeJxPFz
2026-06-26 00:02:37 +00:00
Vitor Pamplona
97efa23548 Merge pull request #3372 from vitorpamplona/claude/elegant-ride-orwxko
Scale outline gem icon to match filled gem size
2026-06-25 20:02:32 -04:00
Claude
0ae1cb03c4 fix: enlarge always-on notification gem icon to match other icons
The outlined service gem was authored at ~half the 512x512 viewport size,
so it rendered noticeably smaller (both width and height) than every other
notification small icon, which fill the viewport. Wrap the path in a group
that scales it ~1.8x about its centre, leaving a margin so the stroke stays
within bounds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVA2C5FyYWSmU7EzdFaAD2
2026-06-25 23:55:19 +00:00
Claude
6b3e216ea3 fix: let Save commit a typed-but-unadded Cashu mint
When adding a Cashu wallet, the mint URL typed into the input field was
only committed to the mint list when the user pressed the "+" button. The
Save button was gated on the list being non-empty, so a user who typed a
mint, chose a key option, and tried to Save found the button disabled with
no obvious reason — they had to discover the "+" button first.

Save now folds a pending mint from the input field into the list, making
"+" optional. The button also enables when the input is non-blank.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX5wkwXbvoQpadALx1ZVjk
2026-06-25 23:00:06 +00:00
Vitor Pamplona
82e1b99036 Merge pull request #3370 from vitorpamplona/claude/laughing-bardeen-amkvlr
Render single zaps/nutzaps as large cards in MultiSetCompose
2026-06-25 18:35:31 -04:00
Claude
91883da4ab fix: stop nutzaps from rendering twice in the notification feed
NutzapEvent was missing from the textNoteCards exclusion filter in
convertToCard, so every nutzap was both grouped into the MultiSetCard /
NutzapUserSetCard path AND fell through to a standalone NoteCard, which
renders kind 9321 as a big RenderNutzap card. The result was a duplicate:
the same nutzap appeared once in the grouped card and once as an
individual note — the way a reply would.

LnZapEvent is already excluded for exactly this reason; nutzaps were
simply overlooked when the nutzap grouping was added. Exclude NutzapEvent
too so it only renders through the grouped path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X8ZruubvykNhfmrJXJA8s7
2026-06-25 22:29:58 +00:00
Claude
a83db4c18f feat: render a lone zap/nutzap notification as a large activity card
A MultiSetCard bundles many notifications on a post into one compact stream
of icons, which is great when there are several. But a freshly-arrived zap
is usually alone in its own card (the additive feed path builds a
single-item MultiSetCard, and only a full rebuild groups same-post
notifications together), so it rendered as one tiny gallery icon.

When a MultiSetCard carries a single lightning zap or nutzap and nothing
else, render the existing large activity card (RenderLnZap / RenderNutzap)
— the same big, gradient "appreciation" display already used for onchain
zaps and the thread view — instead of the one-icon gallery. As soon as a
rebuild groups several notifications onto the same post (size > 1), it
falls back to the compact gallery as before.

This is purely a rendering decision in MultiSetCompose; the card-building
and grouping logic is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X8ZruubvykNhfmrJXJA8s7
2026-06-25 22:05:49 +00:00
Vitor Pamplona
df21563b11 Merge pull request #3367 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-25 17:31:32 -04:00
vitorpamplona
d841277978 chore: sync Crowdin translations and seed translator npub placeholders 2026-06-25 21:25:00 +00:00
Vitor Pamplona
c7e859a9a2 Merge pull request #3369 from vitorpamplona/claude/serene-clarke-19cbbz
Shift Cashu icon left and tint orange in nutzap displays
2026-06-25 17:23:28 -04:00
Claude
f4ee8aac44 feat(ui): tint nutzap cashu icon orange in notification and reaction galleries
The cashu outline icon next to author avatars in the nutzap galleries
rendered in the default content color, while the lightning equivalent
(ZappedIcon) uses BitcoinOrange. Tint the cashu mark BitcoinOrange in the
reaction-row gallery (NutzapGallery), the notification multi-set gallery
(MultiSetCompose), and the notification user-set card (NutzapUserSetCompose)
so nutzaps read as value transfers consistent with lightning zaps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QEFq9cUPECuB8F56sTtWXu
2026-06-25 21:22:10 +00:00
Claude
c3a57f94a6 fix(icons): nudge Cashu icon another 0.3 left
Shift the mark a further 0.3 units left (net -1.6 from the original).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QEFq9cUPECuB8F56sTtWXu
2026-06-25 21:16:56 +00:00
Claude
1ea1b369ea fix(icons): nudge Cashu icon another 0.1 left
Shift the mark a further 0.1 units left (net -1.3 from the original).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QEFq9cUPECuB8F56sTtWXu
2026-06-25 21:13:35 +00:00
Claude
7b3c5325ff fix(icons): ease Cashu icon shift back to 5% left
10% felt over-shifted; move the mark right by 1.2 units so the net offset
from the original is -1.2 (5% of the 24x24 viewport) instead of -2.4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QEFq9cUPECuB8F56sTtWXu
2026-06-25 21:09:30 +00:00
Claude
782935320e fix(icons): recenter Cashu outline icon by shifting it 10% left
The cashew + pixel-shades mark was visually weighted to the right of the
24x24 viewport. Shift every x-coordinate in both paths (the stroked cashew
body outline and the filled pixel sunglasses) by -2.4 units so the drawing
sits balanced within the icon bounds. Shape is unchanged; only position.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QEFq9cUPECuB8F56sTtWXu
2026-06-25 21:07:04 +00:00
Vitor Pamplona
d6e2d45eca Merge pull request #3368 from vitorpamplona/claude/eager-albattani-p3sxmq
Add Onion-Location support for transparent Tor routing
2026-06-25 16:57:48 -04:00
Claude
e59babe085 fix: three bugs in onion-location routing
- OnionLocationCache: add 24-hour TTL so stale .onion mappings expire
  instead of permanently breaking Tor connectivity when a server rotates
  its onion address

- OnionLocationInterceptor: demote from network to application interceptor
  so chain.request().url.host is always the original clearnet hostname.
  As a network interceptor it ran after OnionUrlRewriteInterceptor had
  already rewritten the host to .onion, causing cache refreshes from
  onion-routed responses to be stored under the wrong key and never used

- OnionUrlRewriteInterceptor: make http/https scheme mapping explicit and
  symmetric with the ws/wss arm; document that http:// onion for an
  https:// clearnet host is intentional (Tor circuit provides E2E security)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkQwqqjjDJmeAChYRK4tuV
2026-06-25 20:16:01 +00:00
Claude
5e831cadb4 test: verify OkHttp accepts .onion pseudo-TLD in toHttpUrl()
Documents that OnionUrlRewriteInterceptor's runCatching { toHttpUrl() }
call succeeds for Tor v2/v3 .onion hosts so a future OkHttp upgrade that
breaks .onion parsing is caught before the feature silently regresses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkQwqqjjDJmeAChYRK4tuV
2026-06-25 19:45:40 +00:00
Vitor Pamplona
f5e6a251f7 Merge pull request #3365 from vitorpamplona/claude/happy-brown-tlu9nq
Extract AddRecommendationSection and WalletTypeCardContent composables
2026-06-25 15:23:15 -04:00
Claude
9b3e51f978 feat: passive Onion-Location discovery and .onion URL rewriting via OkHttp
Adds three components wired into both OkHttp factory chains (relays and
media/NIP-11):

- OnionLocationCache: ConcurrentHashMap<host, onionUrl> shared across
  all OkHttp clients. No Nostr-specific protocol changes needed.

- OnionLocationInterceptor (network interceptor, always active): reads
  the Onion-Location HTTP header from every response — WebSocket 101
  handshakes, NIP-11 documents, image servers, anything — and populates
  the cache. Discovery is a zero-cost by-product of traffic that already
  happens.

- OnionUrlRewriteInterceptor (application interceptor, Tor clients only):
  on outbound requests, checks the cache for the target host and rewrites
  to the .onion address when available. Derives the correct ws/wss scheme
  from the cached Onion-Location URL scheme so TLS expectations match.

First connection goes clearnet (or Tor-to-clearnet), populating the
cache. The next reconnect transparently uses the .onion address so Tor
connections avoid exit nodes entirely. Network transition reconnects
(already handled by RelayProxyClientConnector) retry with the cached
.onion on the fresh circuit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkQwqqjjDJmeAChYRK4tuV
2026-06-25 19:06:59 +00:00
Claude
4540b1d6de fix: extract HeaderRowTitle to reduce Compose lambda MAXLOCALS in OnchainSection
HeaderRow's Row content lambda was generating MAXLOCALS=74, causing
GC overhead OOM in the Kotlin daemon during parallel compilation.
Extracting the Column block into a named HeaderRowTitle composable
reduces the Row lambda to two direct calls, cutting its bytecode size.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-25 18:42:51 +00:00
Vitor Pamplona
48b9671fd5 Merge pull request #3366 from vitorpamplona/claude/favorite-web-apps-review-cn82yb
Rename web/napplet screens and controllers; introduce HostProfile
2026-06-25 14:28:59 -04:00
Claude
7d163b89c9 refactor: replace napplet host websiteMode boolean with a HostProfile type
The "locked nApplet vs open nSite" distinction was a `websiteMode: Boolean`
threaded through an Intent extra and re-branched at eight independent sites
across the launcher, the content server, both host surfaces, and the chrome.
That is one coupled security posture (capabilities, CSP, NIP-07 injection,
off-origin policy, network UI) expressed as scattered, drift-prone flags — and
boolean-blind, since the "website" is actually the *more* capable mode.

Introduce `HostProfile` (NAPPLET | WEBSITE), resolved once in the trusted main
process and carried over the Intent/Messenger boundary as its name. Every
coupled consequence now reads from one place:
- `declaredCapabilities(requires)` — THE broker grant, minted into the token
- `appCsp` / `injectsNip07` / `allowsOffOrigin` / `exposesNetwork`

Pure mechanical mapping (every branch 1:1, no behavior change). The wire extra
`EXTRA_WEBSITE_MODE` boolean becomes `EXTRA_HOST_PROFILE` string — safe, as it
is in-app process-to-process IPC, never persisted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HrxLCMcQADUPnm8Sj9ZJ63
2026-06-25 18:19:16 +00:00
Claude
6437391df8 fix: extract large Compose lambdas to reduce OOM in Kotlin compiler
WalletTypeCard$lambda$0 (AddWalletScreen.kt) was generating a bytecode
method with MAXLOCALS=66, causing the JVM bytecode optimizer to exhaust
memory when three compile tasks ran in parallel on CI.

Extract the Card content into WalletTypeCardContent and extract the
add-recommendation item block in CashuMintRecommendationsScreen into
AddRecommendationSection (moving newRecommendationInput state there).
Both changes split large generated methods into smaller ones, reducing
per-task memory pressure in the Kotlin daemon.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MiKQ1CzErYcUBEbPrRudZQ
2026-06-25 17:53:19 +00:00
Vitor Pamplona
418ea3fc46 Merge pull request #3362 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-25 12:04:51 -04:00
Claude
9a064984a5 refactor: rename web-app surfaces to distinguish them from the native App Store
The in-app "app" surfaces had colliding, sometimes inaccurate names. NIP-89
native apps showed as "Apps" while the in-app favorites showed as "Favorite
apps", and the single-app host screens were named `FavoriteWebAppScreen` /
`FavoriteNappletScreen` even though they open any url/coordinate (favorited or
not) and the "Napplet" one also renders nSites.

Establish one taxonomy:
- Native app store -> "App Store" (NIP-89), in-app Nostr web client -> "Web app",
  plus the existing nApplet / nSite. "Favorite" is now only the star toggle and
  the pinned grid, not a screen.
- Code axis: `WebApp` (url-based) and `NostrApp` (coordinate-based nSite/nApplet).
  The cross-process sandbox infra (`napplet/`, `nappletHost/`) keeps "Napplet".

Renames:
- Routes `FavoriteWebApp`/`FavoriteNostrApp` -> `WebApp`/`NostrApp`
- Screens `FavoriteWebAppScreen`/`FavoriteNappletScreen` -> `WebAppScreen`/`NostrAppScreen`
- Controllers `EmbeddedBrowserController`/`EmbeddedNappletController`
  -> `EmbeddedWebAppController`/`EmbeddedNostrAppController`
- Factory `acquireBrowser`/`acquireNapplet` -> `acquireWebApp`/`acquireNostrApp`
- Model `FavoriteApp.WebUrl` -> `FavoriteApp.WebApp`; `WebUrlNetworkRegistry`
  -> `WebAppNetworkRegistry`
- Strings: `software_apps`/`route_software_apps` "Apps" -> "App Store";
  `favorite_apps_empty` reworded to name web app / nApplet / nSite

Persistence is untouched: favorite id prefixes ("url:"/"nostr:"), DataStore
names ("favorite_apps"/"weburl_network"), and serialized type tags ("url"/
"nostr") are all kept stable. Taxonomy documented in
amethyst/plans/2026-06-25-web-app-naming.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HrxLCMcQADUPnm8Sj9ZJ63
2026-06-25 16:01:44 +00:00
vitorpamplona
49e56da1aa chore: sync Crowdin translations and seed translator npub placeholders 2026-06-25 16:01:36 +00:00
Vitor Pamplona
204a8e00ee Merge pull request #3364 from vitorpamplona/claude/modest-lovelace-35fe6k
Fix bottom sheet grabber positioning and add console toggle state
2026-06-25 11:59:24 -04:00
Claude
3836cf4b88 fix: console UI — switch in top sheet, grabber at panel top when open
- TopControlSheet: replace SheetItem with SheetSwitchItem for the
  console row, adding a Switch that reflects the current
  consoleShowing state; add consoleShowing param and wire it from
  EmbeddedTabLayer
- BottomConsoleSheet: move the grabber Column before AnimatedVisibility
  so it sits at the top of the expanded panel (standard bottom-sheet
  handle behaviour) and animates upward as the console opens

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GxQdArv6h6SezciUWeoR38
2026-06-25 15:24:23 +00:00
Vitor Pamplona
bdd8041776 Merge pull request #3363 from vitorpamplona/claude/great-babbage-d0hgki
Reorganize navigation drawer and settings menu items
2026-06-25 10:23:06 -04:00
Claude
8978e0d023 feat: reorder settings sections and drop API-30 gate on drawer Browser
Reorder Account Settings: group network (relays/sync/follows, media/nests),
interactions (reactions/zaps), media playback, identity (DVMs/badges/payments),
then safety/i18n.

Reorder App Settings: group UI (privacy/prefs), composition, personalization
(home/reactions/bottom-bar/profile), then advanced (calendar/OTS/namecoin).

Also drop the now-redundant Build.VERSION_CODES.R gate on BROWSER in
DrawerNavigateItems, matching the fix landed in main (PR #3361).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdbTQuhuk2g5XGWEqmKLRz
2026-06-25 14:09:53 +00:00
Vitor Pamplona
156f205801 Merge pull request #3359 from vitorpamplona/claude/quirky-fermat-cwxjy6
Add theme preference support to napplet/browser WebViews
2026-06-25 10:07:53 -04:00
Claude
dfe74b983e style: apply spotless formatting to NavBarItem.kt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdbTQuhuk2g5XGWEqmKLRz
2026-06-25 13:54:59 +00:00
Claude
b4a68f4b78 feat: reorder drawer menu items
Move BROWSER to the Navigate section (between VIDEO and DISCOVER).
Reorganise Feeds into logical clusters: content types first, then
live/social, communities/chats, calendar, apps/tools, packs, misc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdbTQuhuk2g5XGWEqmKLRz
2026-06-25 13:54:59 +00:00
Claude
46ecde05f2 chore: merge main — resolve PreviewUrl conflict, prefer imported Route
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198rKcuv32DEoUPpLqsBYbx
2026-06-25 13:53:39 +00:00
Vitor Pamplona
7e0bc87dc7 Merge pull request #3361 from vitorpamplona/claude/browser-default-new-users-o5t3kw
Remove API 30+ requirement from Browser feature
2026-06-25 09:50:54 -04:00
Claude
955688c3df chore: spotless import reordering from main merge
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198rKcuv32DEoUPpLqsBYbx
2026-06-25 13:38:15 +00:00
Claude
8ea1dcb02f fix: drop the API-30 gate on the Browser tab
The Browser tab is a launcher: each opened site loads full-screen in its
own direct-WebView NappletBrowserActivity, which has no API-30 dependency
(it never uses SurfaceControlViewHost and only feature-detects WebView
capabilities). The API-30 gate genuinely belongs to the *embedded*
favorite-app tabs (NappletBrowserService → privacy-sandbox surface), so
keep FAVORITE_APPS gated and remove the gate from BROWSER:

- BROWSER is now unconditional in the default bottom bar and the drawer.
- BrowserScreen no longer shows the "unsupported" fallback below API 30.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRdmDri69mLJiHpJQpJU73
2026-06-25 13:29:25 +00:00
Claude
c5193d268e chore: merge main — keep both theme and isFavorite params in NappletBrowserActivity
Resolved conflicts from main adding isFavorite to NappletBrowserActivity.intent()
and FavoriteAppLauncher.launchUrl() while our branch added the theme parameter.
Both params are now present together.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198rKcuv32DEoUPpLqsBYbx
2026-06-25 13:28:25 +00:00
Vitor Pamplona
c1da1e3ce1 Merge pull request #3360 from vitorpamplona/claude/confident-bohr-fgk8h8
Clean up imports: organize and remove unused declarations
2026-06-25 09:23:02 -04:00
Claude
156aea632c fix: spotless import ordering and remove fully-qualified class name
Move java.* imports after kotlinx.* imports in MainActivity and
BouncingIntentNav per ktlint ordering rules. Replace fully-qualified
Route reference in PreviewUrl with an import.
2026-06-25 13:22:04 +00:00
Vitor Pamplona
db13633fcc Merge pull request #3315 from LubuSeb/lubu/url-comments-304
Add URL-scoped comment feeds
2026-06-25 09:13:19 -04:00
Claude
e62f7a4b30 feat: enable algorithmic darkening for web content in napplet WebViews
Adds WebSettingsCompat.setAlgorithmicDarkeningAllowed(true) to all four
WebView setup functions (NappletHostActivity, NappletHostService,
NappletBrowserActivity, NappletBrowserService) so web pages that do not
implement prefers-color-scheme are algorithmically darkened when the
process is in night mode, matching the user's chosen theme.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198rKcuv32DEoUPpLqsBYbx
2026-06-25 13:08:56 +00:00
Vitor Pamplona
2f240e0b87 Merge pull request #3358 from vitorpamplona/claude/always-on-service-logo-t2d2zy
Use hollow gem icon for relay service notification
2026-06-25 08:53:54 -04:00
Claude
daf6a2d022 fix: resolve SYSTEM theme to concrete DARK/LIGHT before crossing process boundary
Instead of passing the raw ThemeType name ("SYSTEM") to the :napplet process,
resolve it to the actual dark/light value in the main process by reading
context.resources.configuration.uiMode before the launch. The napplet process
now always receives "DARK" or "LIGHT" and sets UiModeManager.nightMode
unconditionally, making prefers-color-scheme reliable on all ThemeType choices.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198rKcuv32DEoUPpLqsBYbx
2026-06-25 12:53:03 +00:00
Vitor Pamplona
4b71649fcf Merge pull request #3357 from vitorpamplona/claude/fervent-darwin-r8i6dw
Add favorite toggle to web browser and embedded napplets
2026-06-25 08:45:00 -04:00
Vitor Pamplona
dda8f2d3f6 Merge pull request #3354 from vitorpamplona/claude/funny-clarke-nrzpq4
Extract MintPickerItem composable in CashuWalletScreen
2026-06-25 08:41:19 -04:00
Vitor Pamplona
09ee225bf7 Merge pull request #3356 from davotoula/fix/note-grid-layout-issues
Fix/note grid layout issues
2026-06-25 08:38:00 -04:00
davotoula
020c08d561 Code review:
- reuse mediaSizingModifier in GifVideoView
2026-06-25 13:32:56 +02:00
Claude
2ec072419a fix: keep animated media inside its gallery grid cell
Animated GIF/AVIF media in the multi-image gallery rendered with its own
intrinsic aspect ratio via fillMaxWidth(), ignoring the ContentScale.Crop
the grid asks for
2026-06-25 13:29:06 +02:00
davotoula
d71c035669 fix(napplet): log failed temp-file cleanup in NappletBlobCache.put 2026-06-25 12:05:13 +02:00
David Kaspar
67ffbaec86 Merge pull request #3355 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-25 09:21:57 +01:00
davotoula
8d036e0ac0 chore: sync Crowdin translations and seed translator npub placeholders 2026-06-25 08:12:20 +00:00
David Kaspar
0f587eb918 Merge pull request #3351 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-25 09:09:55 +01:00
Claude
50138fe6cb feat: distinct status-bar icon for the always-on relay service
Notification small icons are rendered by the system as flat, alpha-only
silhouettes, so the always-on relay-connection service showed the exact
same solid gem as real notifications (DMs, zaps, mentions). The persistent
service therefore kept reading as a fresh notification.

Add an outlined (hollow) gem variant, `amethyst_service`, and point the
foreground service notification at it so it's clearly distinguishable at a
glance while staying on-brand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141R1Km7YsA2miFoxLkXLQk
2026-06-25 03:27:36 +00:00
Claude
5662c89d11 fix: extract ConsoleLogRow to fix Kotlin compiler OOM in BottomConsoleSheet
The Text() calls inside the logs.forEach loop were 8 lambdas deep
(Box > Column > AnimatedVisibility > Surface > Column > Column > forEach > Row),
which caused java.lang.OutOfMemoryError in the Kotlin bytecode optimizer
(ConstantConditionEliminationMethodTransformer). This broke CI when
PR #3353 merged. Extract the Row content into a top-level ConsoleLogRow
composable to reduce nesting depth.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmiRNcMEync2rVVbrPzWt
2026-06-25 02:45:39 +00:00
Claude
fd1c41b8bb feat: default bottom bar to Home, Messages, Wallet, Browser, Notifications
Change the default bottom navigation for new users from
Home/Messages/Shorts/Discover/Favorite Algo Feeds/Notifications to
Home/Messages/Wallet/Browser/Notifications. The Browser entry is gated
to API 30+ since it renders a cross-process surface, matching the
drawer's existing gating.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRdmDri69mLJiHpJQpJU73
2026-06-25 02:40:03 +00:00
Claude
85388e3c4b feat: pass theme preference to napplet browser and activity surfaces
The :napplet sandboxed process does not share memory with the main process,
so UiModeManager night mode set in the main app does not propagate there.
Pass the user's ThemeType name ("DARK"/"LIGHT"/"SYSTEM") via Intent extras
and Messenger IPC bundle keys, then apply UiModeManager.nightMode in each
receiving surface (NappletHostActivity, NappletBrowserActivity,
NappletHostService, NappletBrowserService) before any views or WebViews are
created. This makes prefers-color-scheme and the activity chrome match the
user's chosen theme in all napplet/nsite/web-app surfaces.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198rKcuv32DEoUPpLqsBYbx
2026-06-25 01:38:11 +00:00
Claude
d33d1e36fd feat: move favorite to pull-down sheet, remove from OmniBar
Remove the star icon from the browser URL bar (OmniBar) and add a
favorite toggle row to both pull-down sheet surfaces instead:

- Compose TopControlSheet (embedded web tabs and napplets): shows
  filled/outline star with "Add to favorites" / "Remove from favorites"
  sourced from FavoriteAppsRegistry; isFavorite state flows reactively
  through EmbeddedTabChrome so the label updates without reopening.

- Native NappletControlSheet (full-screen NappletBrowserActivity): same
  toggle backed by a new MSG_TOGGLE_WEB_FAVORITE IPC message handled in
  NappletBrokerService; initial state is passed via intent so the star
  opens in the correct filled/outline state for the launch URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SBpBE7bQJ2JRni6sYG8UDo
2026-06-25 01:16:06 +00:00
Claude
fe392402c3 fix: extract MintPickerItem to fix Kotlin compiler OOM in MintPicker
The deeply-nested Text() call inside DropdownMenu > forEach > DropdownMenuItem
generated bytecode with MAXSTACK=26, which caused java.lang.OutOfMemoryError in
the JVM bytecode frame analyzer (ConstantConditionEliminationMethodTransformer).
Extracting DropdownMenuItem into a top-level private composable breaks the lambda
nesting depth and lets the compiler succeed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmiRNcMEync2rVVbrPzWt
2026-06-25 01:03:29 +00:00
vitorpamplona
443bfba65c chore: sync Crowdin translations and seed translator npub placeholders 2026-06-25 00:31:29 +00:00
Vitor Pamplona
b1aef7d6ac Merge pull request #3353 from vitorpamplona/claude/serene-hypatia-48qdoz
Add JavaScript console logging to embedded and full-screen browsers
2026-06-24 20:29:33 -04:00
Vitor Pamplona
2d1e9a9e1f Merge pull request #3352 from vitorpamplona/claude/eloquent-shannon-0fzkag
Implement tiered memory trimming with debug UI and feed size limits
2026-06-24 18:21:12 -04:00
Claude
f2f80cd88d fix: stop clearing Robohash/richtext caches on every app background
TRIM_MEMORY_UI_HIDDEN fires on every app switch, not only under memory
pressure. Clearing Robohash (CPU-intensive SVG assembly) and
CachedRichTextParser to zero there forces a full rebuild on every
resume, causing visible jank.

Restructure the trim tiers to match Android's intent:

  UI_HIDDEN  (20) — just backgrounded, no pressure:
      trim Coil image cache to 1/2 only; leave parsed-text and
      avatar caches warm so resume is instant.

  BACKGROUND (40) — mild background pressure:
      trim images to 1/4, richtext→100, robohash→20, nip11→200.

  MODERATE   (60) — system is hurting:
      clear images, richtext→50, robohash→10, nip11→100.

  COMPLETE   (80) — kill imminent:
      clear everything including nip11.

Foreground levels (RUNNING_LOW/RUNNING_CRITICAL) are unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019h2c44rAwexuUEP3kky2F3
2026-06-24 22:00:52 +00:00
Claude
ea2dbd9a77 fix: keep lastNotes intact on CardFeedContentState.trimToSize()
Clearing lastNotes caused the next additive update to call
refreshSuspended(), reloading the full filter limit (~500 notes) from
LocalCache and immediately undoing the trim.

With lastNotes intact the fast additive path stays active: only
genuinely new notifications are appended, so the card list remains near
maxItems until the next full feed key change or navigation event.

The Note refs in lastNotes are the same object instances already held
by LocalCache. They are freed when MemoryTrimmingService prunes
LocalCache and the following refreshSuspended() replaces lastNotes with
the pruned set.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019h2c44rAwexuUEP3kky2F3
2026-06-24 21:42:14 +00:00
Claude
e17c95eda1 fix: clear lastNotes on CardFeedContentState.trimToSize() to release Note refs
CardFeedContentState.lastNotes holds strong references to every raw Note
that passed the notification filter, used for additive dedup
(filteredNewList.minus(lastNotesCopy)). Trimming only the Card list left
all those Note objects pinned — they couldn't be GC'd even if LocalCache's
SoftCache had evicted them.

Fix: clear lastNotes/lastAccount alongside the Card list truncation.
The next additive update finds lastNotes == null, skips the fast path,
and calls refreshSuspended() — one controlled rebuild from LocalCache
that also resets the dedup set to the current state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019h2c44rAwexuUEP3kky2F3
2026-06-24 21:35:14 +00:00
Claude
101ca698d9 fix: polish BottomConsoleSheet UI
- Show source filename and line number as dimmed secondary text on each
  log entry (matches native NappletConsolePanel behaviour)
- Replace hardcoded #FF9800 orange with a dark-mode-aware amber pair:
  #E65100 in light mode, #FFB74D in dark mode
- Cap panel height at 40% of screen height (was fixed 240dp) so large
  phones display more log lines

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QXpdfwj2cujnEa7HPzQXj
2026-06-24 20:57:07 +00:00
Claude
57a63bd4b2 fix: stop wiping relay info cache on every app background
At UI_HIDDEN (fires on every app switch), nip11Cache.trimToSize(0)
was clearing all successfully-fetched NIP-11 relay documents, forcing
10–50 redundant HTTP fetches on the next foreground resume.

- Change UI_HIDDEN trim target from 0→100; the 100 most-recently-used
  relay docs are kept across a background/foreground cycle.
- Stop trimming relayInformationEmptyCache in Nip11CachedRetriever:
  it holds only lightweight display-name+favicon-url placeholder objects
  (no network data), so trimming saves negligible memory but silently
  re-triggers NIP-11 HTTP fetches for every relay on resume.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019h2c44rAwexuUEP3kky2F3
2026-06-24 20:44:00 +00:00
Claude
45f1fd91a3 feat: add console.log panel accessible from top pull-down control sheets
Captures JavaScript console output (log/warn/error/debug) from embedded
WebViews and surfaces it via a bottom pull-up sheet, triggered by a new
"Console (N)" row in the existing top pull-down control sheets.

Embedded browser (Compose):
- NappletBrowserService: attaches WebChromeClient to intercept console
  messages and forwards them to the client process via new MSG_CONSOLE_LOG
  IPC message in NappletBrowserContract
- EmbeddedBrowserController: implements new ConsoleBridge interface,
  stores up to 200 entries in a SnapshotStateList observable by Compose,
  handles the incoming IPC message
- TopControlSheet: adds optional consoleCount/onConsole params; shows a
  "Console (N)" row when onConsole is provided
- BottomConsoleSheet: new Compose pull-up panel anchored at the bottom,
  with level-coloured monospace log entries and a Clear button
- EmbeddedTabLayer: wires ConsoleBridge → TopControlSheet → BottomConsoleSheet

Full-screen activity browser (native Views):
- NappletBrowserActivity: attaches WebChromeClient, wires NappletConsolePanel
  and updates the control sheet count label on each new entry
- NappletControlSheet: adds optional onConsole callback and updateConsoleCount()
- NappletConsolePanel: new native-View bottom pull-up panel with scrollable
  log entries, grab-to-open gesture, and Clear button; capped at 200 entries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QXpdfwj2cujnEa7HPzQXj
2026-06-24 20:34:43 +00:00
Claude
5435345f7d feat: trim richtext, robohash, and relay-info caches under memory pressure
Add trimToSize(maxItems) to:
- CachedRichTextParser: trims richTextCache (500) and isMarkdownCache
  (200) proportionally
- CachedRobohash: trims the ImageVector LruCache (100)
- Nip11CachedRetriever: trims both the document and empty-placeholder
  caches (1000 each)

Wire all three into AppModules.trim() tiered by OS pressure level:
  RUNNING_LOW      → 50% capacity
  RUNNING_CRITICAL → 20% capacity
  UI_HIDDEN+       → evict all (app not visible, safe to clear)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019h2c44rAwexuUEP3kky2F3
2026-06-24 20:11:28 +00:00
Claude
8407684d9d feat: trim Coil memory cache and ExoPlayer warm pool under memory pressure
- ExoPlayerPool.releaseWarmPool(): evicts all paused-with-buffer (warm)
  players back to the cold pool. Safe under pressure because warm players
  are idle; active (checked-out) players are never in either pool.

- PlaybackService.onTrimMemory(): when level >= RUNNING_CRITICAL, drains
  both pool instances' warm slots via releaseWarmPool(). The Service
  receives onTrimMemory() directly from Android so no routing through
  AppModules is needed.

- AppModules.trim(): trims Coil's in-memory image cache proportional to
  OS pressure level:
    RUNNING_LOW  → trimToSize(maxSize / 2)
    RUNNING_CRITICAL → trimToSize(maxSize / 4)
    UI_HIDDEN+   → trimToSize(0)  [app backgrounded, safe to clear]

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019h2c44rAwexuUEP3kky2F3
2026-06-24 20:11:28 +00:00
Claude
5b5bc5c70c fix: raise critical memory feed trim size from 50 to 200
50 was too aggressive — users would lose most of their scroll history on
any critical-pressure event. 200 is a better balance: still releases
~60% of the 500-note strong references per feed while keeping a
reasonable amount of history visible without a reload.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 20:11:28 +00:00
Claude
057b6266d9 feat: trim feed lists to 50 items under critical memory pressure
Feed lists hold ImmutableList<Note> references. At 40+ feeds × 500 notes
each, that's 20k strong Note references that prevent GC from collecting
objects that LocalCache has already pruned. Under RUNNING_CRITICAL the
feeds are shrunk to 50 items, releasing ~19k references per account.

Implementation:
- FeedContentState.trimToSize(maxItems) — truncates the loaded list in-place
- CardFeedContentState.trimToSize(maxItems) — same for notification card feeds
- AccountFeedContentStates.trimFeedsToSize(maxItems) — fans out to all feeds
- AppModules.trimLevelEvents: SharedFlow<Int> — broadcasts the OS level
- AccountFeedContentStates subscribes and calls trimFeedsToSize(50) at
  RUNNING_CRITICAL, letting the next scroll/refresh repopulate from cache

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 20:11:27 +00:00
Claude
4eb195fa5e fix: reorder pruning tiers in MemoryTrimmingService
Tier 2 (medium, >= RUNNING_LOW): pruneHiddenEvents + pruneHiddenMessages
  — muted/blocked content is known-safe to drop at low pressure.

Tier 3 (critical, >= RUNNING_CRITICAL): pruneOldMessages + pruneRepliesAndReactions
  — these are more aggressive since they remove content the user may
  still scroll back to, so reserve them for genuine memory emergencies.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 20:11:27 +00:00
Claude
281955fb17 fix: move cleanObservers to Tier 1 in MemoryTrimmingService
cleanObservers() only removes flows not currently held by the UI, so
it carries no visible side effects and is safe to run on every trim
regardless of pressure level. Move it from Tier 3 (RUNNING_CRITICAL)
to Tier 1 (always) so unused observer links are freed even on mild
memory signals.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 20:11:27 +00:00
Claude
f038e54fa2 feat: scale LocalCache pruning aggressiveness to OS memory-pressure level
MemoryTrimmingService.doTrim() previously ran the full pruning suite
regardless of how severe the OS signal was. Now it tiers the work by
ComponentCallbacks2 level so low-pressure signals don't pay the cost of
aggressive observer teardown:

  Tier 1 (always): cleanMemory + pruneExpiredEvents + prunePastVersionsOfReplaceables
  Tier 2 (>= RUNNING_LOW): + pruneOldMessages + pruneRepliesAndReactions
  Tier 3 (>= RUNNING_CRITICAL): + cleanObservers + pruneHiddenEvents/Messages

The `level` is now threaded from Amethyst.onTrimMemory → AppModules.trim(level)
→ MemoryTrimmingService.run(…, level) → doTrim(…, level). The existing
scheduled-trim call site (AppModules.trim) defaults to RUNNING_CRITICAL so
its behaviour is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 20:11:27 +00:00
Claude
21e61e48ae feat: add debug-only memory usage chip to top nav bar
Shows JVM heap used/max as a color-coded label (green/amber/red) in the
top bar actions on debug builds. Tapping opens a dialog with a full
breakdown: native heap, Coil memory/disk cache sizes, and LocalCache
counts (notes, users, addressables, chatrooms). Polls every 2 seconds
via produceState. Adds MemorySnapshot data class and
collectMemorySnapshot() to DebugUtils for reuse by future tooling.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 20:11:01 +00:00
Vitor Pamplona
6539841e5e Merge pull request #3350 from vitorpamplona/claude/jolly-mccarthy-jyeu87
Browser: add visit history, omnibox suggestions, and favicon capture
2026-06-24 14:46:58 -04:00
Vitor Pamplona
cad987a99e fix(embed): napplet/nsite load overlay + draw it over the surface
Mirrors the web-app load-recovery fix to the napplet/nsite path and fixes a
layer bug that kept the overlay from ever showing.

The embedded surface (SandboxedSdkView) is drawn by EmbeddedTabLayer, which
sits *above* the nav screens in the shell. So a loading/error overlay placed in
the favorite screen was covered by the surface's opaque pre-first-frame
background — the black void persisted. Move the overlay into EmbeddedTabLayer,
drawn over the active tab's bounds (where the chrome sheet already lives), so it
actually covers the surface. Also fixes the overlay sizing (fillMaxSize, not
matchParentSize, which collapsed to zero inside the reserved Box).

- Promote load state to the EmbeddedSurfaceController interface (loadStatus /
  onLoadStatusChanged / retry), so EmbeddedTabLayer renders one overlay for both
  the browser and napplet controllers. Shared EmbeddedLoadStatus +
  EmbeddedLoadOverlay.
- NappletHostService now reports main-frame load state (start/finish/error) over
  a new MSG_LOAD_STATE; EmbeddedNappletController relays it and exposes retry()
  (= reload the verified content).
- The web-app path keeps its about:blank → canonical-URL self-heal; the napplet
  path has no client-supplied URL to drop, so retry = reload.

Verified on device: with the network cut, the brainstorm tab shows
"Couldn't load this app." + Retry; restoring the network and tapping Retry loads
the page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:35:44 -04:00
Vitor Pamplona
fb4a2e0858 fix(browser): recover embedded web-app tab stuck on about:blank
A favorite web app pinned to the bottom bar at runtime could come up on a
blank surface (black, then white after a manual reload) and never recover.
Its warm browser session settled on about:blank — its real URL was dropped on
the way in — and the chrome Reload button calls WebView.reload(), which just
re-loads about:blank instead of the favorite's page.

Fixes:
- The provider now reports main-frame load state (start/finish/error) over a
  new MSG_LOAD_STATE. When a favorite session settles on about:blank while it
  has a real URL, the controller re-navigates to the canonical URL once.
  Gated on a real startUrl, so the generic browser's intentional about:blank
  new-tab page is left alone. Adds controller.retry() (navigate-to-canonical,
  not reload) for the chrome retry path.
- FavoriteWebAppScreen now draws a loading spinner until a real page paints,
  and an error + Retry overlay when the main frame fails or the load stalls
  (12s) — so a slow, blank, or failed load is no longer a silent black/white
  void.

Scoped to the browser/WebUrl path; the napplet/nsite path is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:03:33 -04:00
Claude
2df0c4d6bb feat: favicons in bottom nav + 3-dot menu on recent rows
- Pinned web favorites in the bottom navigation now show the captured favicon
  instead of the generic globe (falling back to the globe until one is
  captured). Resolved via BrowserIconRegistry, same as the launcher cards.
- Each Recent row in the browser home gains a 3-dot overflow menu to add the
  URL to favorites (or remove it if already favorited) and to remove it from
  history. Replaces the prior long-press-to-remove with a discoverable menu;
  the row icon shows a star once the site is favorited.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LyxWy2k3AT1LiZSvMsiDx
2026-06-24 16:04:46 +00:00
Vitor Pamplona
db99891c27 Merge pull request #3349 from vitorpamplona/claude/epic-carson-wjc0xm
Fix bottom bar fallback to use default entries instead of empty list
2026-06-24 11:43:36 -04:00
Claude
4f6a21c55d feat: favorites-first browser home + recents, with captured favicons
Builds on the omnibox work to modernize the launcher list now that favorites
and visit history both exist:

- Idle browser home (BrowserHome): pinned favorites on top under a "Favorites"
  header, then a "Recent" section from the visit history — all in one grid so
  they scroll together. Long-press a recent to drop it.
- Typed suggestions are grouped: a highlighted "Favorites" group first (subtle
  primary-container tint + medium weight), then "Recent". Favorites still rank
  first via the existing frecency boost.
- Real favicons: captured from the WebView that already loaded the page in the
  keyless :napplet browser host (so they ride the page's own Tor-routed network
  path — the main app never fetches host/favicon.ico itself), scaled and
  relayed as PNG bytes over a new MSG_RECORD_ICON IPC, and stored per-host by
  BrowserIconRegistry (main process, filesDir). Favorite cards, suggestion
  rows, and recent rows all show them, falling back to a glyph.

FavoriteAppIcon gains an optional iconModel; FavoriteAppCell is reusable via a
new LazyGridScope.favoriteAppItems extension so the browser home and the
Favorite Apps tab share one cell. Thumbnails deferred to a follow-up.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LyxWy2k3AT1LiZSvMsiDx
2026-06-24 15:35:40 +00:00
Claude
851fb82735 fix: only reset bottom bar to defaults on parse errors, not intentional empty selection
The ifEmpty guard incorrectly reset a user-saved empty bar to defaults.
A successfully parsed empty list (user actively removed all items) is now
preserved. Only blank/unset keys and unrecognizable formats fall back to
DefaultBottomBarEntries.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 15:32:18 +00:00
Claude
4a907c750c fix: reset bottom bar to defaults when parsed config has 0 items
Blank stored strings and successfully-parsed empty JSON arrays both
left the bottom bar invisible. Now both cases fall back to
DefaultBottomBarEntries so navigation is never silently lost.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 15:14:22 +00:00
Claude
7a03a47d1d feat: omnibox autocomplete, visit history, and in-page address bar for the browser
Refines the browser URL-bar experience across the launcher and the in-page
browser chrome:

- Shared URL normalization (commons OmniboxInput): dedupes the logic that was
  copied between BrowserScreen and NappletBrowserService, recognizes bare
  domains/localhost/IPs, falls back to a (configurable) DuckDuckGo search, and
  flags .onion as Tor-only so the launcher forces Tor for it.
- Omnibox suggestions (commons OmniboxSuggestions): ranks favorites + visit
  history by prefix/substring match, favorite boost, and frecency; deduped by
  host. The launcher body turns into a suggestion list as you type.
- Inline ghost-text completion in the address field (TextFieldValue selection),
  completing a typed host fragment to the top-ranked host.
- Visit history (BrowserHistoryRegistry, main process): a device-local,
  bounded, DataStore-backed store. Pages are recorded ONLY on a clean
  main-frame load — relayed from the keyless :napplet browser host over a new
  MSG_RECORD_HISTORY IPC — so misspelled/unresolved addresses never enter it.
- In-page editable address bar (websites only) in NappletControlSheet, showing
  the live URL + a security glyph (Tor/https/plain) and loading what the user
  types. nsite/napplet hosts pass no navigate callback, so they never get one.

Pure logic is covered by unit tests in commons.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LyxWy2k3AT1LiZSvMsiDx
2026-06-24 15:02:01 +00:00
Claude
4e6f6c104b fix: improve browser omnibar URL handling and keyboard behavior
- Disable autocorrect and autocapitalization in the URL bar so domain
  names are not mangled by the keyboard's spell-checker
- Align normalizeUrl with NappletBrowserService logic: bare domain
  names (no space, contains dot) get https:// prepended; everything
  else falls through to a DuckDuckGo search query

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LyxWy2k3AT1LiZSvMsiDx
2026-06-24 14:25:45 +00:00
Vitor Pamplona
048439201e Merge pull request #3347 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-24 10:05:25 -04:00
vitorpamplona
7b890a0eff chore: sync Crowdin translations and seed translator npub placeholders 2026-06-24 14:01:39 +00:00
Vitor Pamplona
f298750b79 Merge pull request #3348 from vitorpamplona/claude/webview-menu-custom-url-ikrgbz
Add full-screen direct-WebView browser and embedded napplet/nSite tabs
2026-06-24 09:58:59 -04:00
Claude
01ba2dc909 docs: document the final embedded-tab/browser/IME napplet architecture
This branch added a lot the existing napplet plans don't describe: embedded warm
bottom-bar tabs on a cross-process SurfaceControlViewHost, an arbitrary-URL
browser host, a soft-keyboard IME proxy, the :nappletHost module split, the
per-session service refactor, the two control-sheet twins, and per-site Tor
routing.

- Add amethyst/plans/2026-06-24-napplet-embedded-tabs.md capturing the final
  architecture and file map.
- Note on the 2026-06-19 sandbox-host plan that its rendering model is superseded
  (trust model unchanged), pointing at the new doc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-24 13:48:41 +00:00
Claude
3460c58274 feat: show a back arrow in the Browser top bar when pushed from the drawer
The Browser is reachable both as a bottom-bar tab (top-level, no back arrow)
and from the navigation drawer (pushed onto the back stack). In the latter case
its omnibox now leads with a back arrow that pops, matching the convention the
other launcher/feed top bars already follow (NappletsTopBar et al.): show
ArrowBackIcon when nav.canPop(), nothing otherwise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-24 13:33:21 +00:00
Claude
8b9fa10a1b style: standardize the two top-sheet twins (spacing, colors, Tor switch)
The embedded surfaces' Compose TopControlSheet and the full-screen activities'
native NappletControlSheet are deliberate twins in different processes/modules
(Compose in the main app vs hand-built Views in the Compose-free :napplet
sandbox host), so they can't share a composable — but they should render
identically. Bring them in line:

- Uniform row rhythm: every action/Tor row now uses the same 10dp vertical
  padding in both. The Compose switch row was 6dp while items were 12dp; both
  are now 10dp, matching the native rows.
- Native Tor row now uses a real framework Switch as the state indicator (like
  the embedded row) instead of an icon whose tint/alpha encoded on/off; the icon
  is a steady muted tint and the whole row is the toggle target.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-24 13:20:25 +00:00
Vitor Pamplona
3fa17e1cb7 Merge pull request #3346 from greenart7c3/claude/nip-2381-bunker-connect-psiq2t
NIP-46: Add optional client metadata to connect requests
2026-06-24 08:58:28 -04:00
Claude
da4207319b fix(nip46): pin client metadata to 4th connect param, backfill empties
Per NIP-46 (nostr-protocol/nips#2381), client metadata is the 4th
positional connect param. When it is present but the optional secret or
permissions are not, those slots are now back-filled with empty strings
so the metadata always lands at index 3. Parsing maps empty placeholders
back to null. When no metadata is sent, the array stays as short as
possible for backward compatibility.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PYpupiVAq4VyHDdjrYyPdi
2026-06-24 12:08:06 +00:00
Claude
4ae2debd6b refactor(cli): drop client-metadata log from bunker connect handler
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PYpupiVAq4VyHDdjrYyPdi
2026-06-24 11:28:14 +00:00
Claude
22c8c45190 fix: harden embedded napplet/nsite/browser hosts against audit-found races and IME bugs
Sandbox host services (per-session correctness + leaks):
- NappletHostService/NappletBrowserService: guard broker-reply delivery against
  a stale tab (drop replies whose session was replaced) and wrap postMessage in
  runCatching so a torn-down WebView can't crash the relay; tear down each tab's
  content server + WebView on session close and in onDestroy; refuse to build an
  orphan WebView for an unknown session.
- NappletContentServer.close(): shut the OkHttp dispatcher + evict the pool off
  the hot path so a closed tab doesn't leak connections/threads.
- NappletBlobHttp: bound a blob fetch end-to-end with a callTimeout so a stalled
  Tor exit can't pin the WebView worker thread indefinitely.
- UiAdapter close(): hop to the main thread before destroying the WebView.

Embedded IME (shim.js + RemoteImeView):
- Surrogate-pair-safe diff so an edited astral char (emoji, CJK-supplement) is
  never split into a lone surrogate in the synthesized InputEvent data.
- Real contenteditable support: map char offsets through Ranges and replace in
  place instead of overwriting textContent (which destroyed structure + caret).
- Dedup selectionchange against the last applied selection so our own setSel
  doesn't echo back to the host as a fresh edit.
- RemoteImeView flushes synchronously at the outermost batch close, preserving
  the composing region across a compose+commit in the same frame.

Embedded layer + preloader:
- Resize the cross-process surface to the snapped imeAnimationTarget instead of
  the animated ime inset, so it doesn't reconfigure every keyboard-slide frame.
- yield() between favorites in the startup sweep so building WebViews doesn't
  monopolize the frame.
- Per-site Tor/open-web registries expose awaitReady(); the preloader awaits
  hydration before its first routing decision so a cold start can't route a
  site the user pinned to the open web through Tor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-24 04:27:50 +00:00
Claude
5eb39b141a refactor: session-scope NappletHostService so multiple napplet/nsite tabs are correct
Mirror the browser host's per-session refactor for the napplet/nsite embed
provider. A single NappletHostService instance is shared by every embedded tab
(same bound Intent), but it kept one set of fields (client messenger, config,
content server, WebView, broker bridge), so with >1 napplet tab open the
controls (reload/back/pause/resume), navigation state, "allow always" notices,
NIP-07 traffic, and IME all routed to whichever tab was created last.

Collect all per-surface state into a NappletTab keyed by a client-stamped
session id (KEY_SESSION_ID on MSG_CREATE_SESSION and every control message):

- Controls/pause/resume resolve the target tab by id and act on its own WebView.
- Content server, shell handshake (declaredDomains), launch token, and page
  state/notices are per tab; onShellMessage resolves the tab by its WebView.
- Each tab gets its OWN reply Messenger, so broker responses AND unsolicited
  relay pushes come back tagged to the right tab — no id rewriting, per-tab
  origin/fire-seq state.
- onSessionClosed drops the tab and destroys only its own WebView; the broker is
  bound once for the whole service.

EmbeddedNappletController generates a unique id and stamps it on all messages.
Both embed hosts (browser + napplet) are now fully per-tab correct, including the
keyboard for multiple simultaneous tabs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-24 04:00:54 +00:00
Claude
09cd69ba7c feat: soft keyboard in embedded napplet/nsite tabs (same proxy, via the shell)
Extend the embedded keyboard to napplet/nsite surfaces. The shim's IME agent now
installs on any embedded surface (gated by __nappletImeProxy), reaching native
over whichever transport it has — the direct bridge for the browser, the trusted
shell relay for napplets — both through send().

- NappletContentServer gains an imeProxy flag that injects __nappletImeProxy
  before the shim; NappletHostService sets it (embedded), the full-screen
  NappletHostActivity leaves it off (native keyboard).
- NappletHostService relays ime.* between the applet (via the shell bridge) and
  the client (MSG_IME_EVENT / MSG_IME_OP) — the shell already forwards all
  message types, so no change to the trusted shell page.
- EmbeddedNappletController implements EmbeddedImeBridge, so EmbeddedTabLayer's
  RemoteImeView drives it exactly like the browser.

Correct for a single nsite/napplet tab. Multiple simultaneous napplet tabs share
the host service's single client/bridge pointer (same limitation as reload/back/
NIP-07 there) — making that per-tab needs the session-scoping the browser host
already got; tracked as a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-24 03:50:47 +00:00
Claude
e6bbecdea2 refactor: embedded keyboard to Flutter's editing-state model (batched, full coverage)
Rework the host-side input proxy to mirror Flutter's TextInputPlugin /
InputConnectionAdaptor instead of forwarding individual IME ops:

- RemoteImeView ships the whole editing STATE (text + selection + composing
  region) rather than per-op messages, coalesced across IME batch boundaries
  (beginBatchEdit/endBatchEdit nesting, like Flutter's batchEditNestDepth). A
  TextWatcher + onSelectionChanged flush captures every mutation, so the soft
  keyboard, HARDWARE keyboards, autofill, paste, and context-menu edits are all
  covered uniformly — they all mutate the same real Editable. Composing region is
  read from the platform via BaseInputConnection.getComposingSpan*.
- The shim adopts that state and synthesizes the matching DOM input/composition
  events (insertText / insertCompositionText / deleteContentBackward /
  insertReplacementText, with compositionstart/update/end) so web frameworks
  react as if typed natively — going beyond Flutter, whose consumer is a Dart
  widget. A common prefix/suffix diff classifies each change.
- Beyond Flutter: backed by a REAL EditText, so the platform answers
  getTextBeforeCursor/getExtractedText, suggestions, and spell-check for free
  rather than hand-rolling a ListenableEditingState.
- showSoftInput is posted after focus settles (avoids the show no-op race).

This collapses the op vocabulary to ime.set (state) + ime.action and removes the
commit/compose/delete/key/setSelection messages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-24 03:44:15 +00:00
Claude
f0d5e7a626 feat: soft keyboard in the embedded browser via a host-window input proxy
The embedded browser renders cross-process through SurfaceControlViewHost, which
forwards touch but not the soft keyboard (the embedded window can't be an IME
target, and androidx.privacysandbox.ui never wires IME). So focusing a field in
an embedded page did nothing.

Bridge the keyboard instead: host it in the main app window and relay editing to
the page.

- Shim IME agent (embedded browser only, gated by __nappletImeProxy): tracks the
  focused editable, reports focus/blur/external-change, and applies host ops
  (commit / compose / delete / key / editor-action) with real input & composition
  events. Scrolls the field into view on focus.
- NappletBrowserService relays ime.* envelopes between the page bridge and the
  client (MSG_IME_EVENT / MSG_IME_OP), per tab.
- EmbeddedBrowserController implements EmbeddedImeBridge (parses events, sends ops).
- RemoteImeView: an invisible EditText in the main window that takes the keyboard
  for the active tab. Keeps a real local Editable (so the platform handles
  composing/suggestions/selection) while an InputConnection wrapper forwards every
  op to the page. Maps web input types / enterKeyHint to inputType/IME action.
- EmbeddedTabLayer hosts the proxy bound to the active tab and shrinks the active
  surface by the IME height so the page can scroll the field clear of the keyboard.

Covers <input>/<textarea> fully and contenteditable best-effort (plain text).
Napplet/nsite embeds still need their own wiring (the shell path); this is the
browser surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-24 03:13:25 +00:00
Claude
e3e2482954 feat: preload bottom-bar tabs at startup so the first tap is instant
Warm every pinned bottom-bar favorite at app startup instead of lazily on first
visit, so a browser favorite (ditto, amy-lm, …) has already downloaded and is
local by the time the user taps it.

- EmbeddedTabFactory: the single place that builds a warm controller, shared by
  the favorite screens and the new preloader so they produce the same session
  keyed by the same id (whoever runs first wins; the other reuses it).
- EmbeddedTabPreloader (mounted next to EmbeddedTabLayer): sweeps the bottom-bar
  favorites and acquires each. Retries for a bounded window so a napplet whose
  event hasn't synced, or a Tor proxy still connecting, gets a chance to settle.
- Privacy gate: a Tor-routed site is never preloaded over clearnet while Tor is
  merely still connecting — it waits for the proxy port. When Tor is off, or the
  user opted the site out, clearnet is the real route, so it preloads at once.
- Seed an approximate viewport (EmbeddedTabHost.seedBoundsIfUnset) so preloaded
  surfaces download as a full-size page rather than at the 1dp off-screen
  fallback; the first real visit corrects the bounds.

Browser favorites fully preload+download. Napplet/nsite favorites get a warm
session but stay JS-paused until opened (the existing background-gating security
rule for "allow always" apps), so they don't run in the background.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-24 02:24:26 +00:00
Claude
408eb3a948 refactor: scope embedded browser sessions per-tab in the shared service
A single NappletBrowserService instance is shared by every embedded browser tab
(they bind the same Intent), but it kept one set of fields (webView, client
messenger, NIP-07 bridge state, reply messenger), so with >1 browser tab open:
controls (reload/Tor/back/navigate) hit whichever tab opened last, URL updates
went to the wrong address bar, and NIP-07 responses/pushes could land in the
wrong page.

Collect all per-surface state into a BrowserTab keyed by a client-stamped
session id (KEY_SESSION_ID on MSG_CREATE_SESSION and every control message):

- Controls resolve the target tab by session id and act on its own WebView.
- pushUrl delivers to that tab's own client messenger.
- Each tab gets its OWN reply Messenger, so broker responses AND unsolicited
  relay pushes come back already tagged to the right tab (the broker just echoes
  replyTo) — no id rewriting, and per-tab originTokens/mint state.
- onSessionClosed drops the tab and destroys only its own WebView.

EmbeddedBrowserController generates a unique session id and stamps it on all
messages. Tor note: the WebView proxy override is process-global (Android has no
per-WebView proxy), so toggling Tor still affects every tab; only the toggled
tab is reloaded. The napplet host service has the analogous shared-instance
shape (black-out already fixed) but isn't session-scoped yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-24 02:02:37 +00:00
Claude
b474869213 fix: second embedded browser/napplet tab no longer blacks out the first
A single NappletBrowserService / NappletHostService instance is shared by every
embedded tab (they bind the same Intent), but each tab opens its own session
with its own WebView; the service's `webView` field is only a "latest" pointer.

The audit-batch "destroy stale WebView before rebuild" in createXWebView was
therefore destroying a *sibling* tab's live WebView whenever another browser/
napplet tab opened a session — exactly the repro: open A, switch to B (B's
create destroys A's WebView), back to A → black, B (created last) stays fine.

- Drop the destroy-on-create entirely; each session's WebView lives until that
  session closes.
- onSessionClosed now destroys the closing session's OWN WebView (passed in)
  and clears the shared pointer only if it still referenced it, so evicting one
  tab can't tear down another either.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-24 01:31:15 +00:00
Claude
984c4e4e8c fix: embed surface no longer blacks out on double-tap / first open
Two black-surface reports traced to the active-tab bookkeeping:

- Double-tapping a bottom-bar tab pops and re-adds the SAME route, so the
  outgoing screen disposes AFTER the incoming one has already called
  setActive(id). clearActiveIfMatches(id) then nulled the active id the new
  instance just set (same id "matches"), leaving no active tab — every warm
  surface gets shoved off-screen and the embed goes black. setActive now returns
  a monotonic ownership token and the disposer clears only if it's still the
  latest claim (clearActiveIfOwner), so a re-nav can't null the new owner.
  clearActiveChrome got the same twin guard.
- Revert the reportBounds active-id guard added in the audit batch: it tied
  contentBounds to the same fragile activeId, so a first-ever embed whose bounds
  reported while the id was momentarily unset stayed at Rect.Zero (parked
  off-screen → black). Bounds are reported unconditionally again; the two
  screens cover the same content area, so there's nothing to clobber.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-24 01:10:18 +00:00
Claude
a0ad9082e5 fix: don't stretch the full-screen pull-down grabber across the whole width
A vertical LinearLayout defaults its children to MATCH_PARENT width, so the
collapsed grabber chip's rounded background spanned the entire screen. Give the
grabber explicit WRAP_CONTENT layout params (centered) so only the chip shows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-24 01:02:27 +00:00
Claude
41843dd8e8 fix: stop bottom bar resetting on upgrade; make pull-tab a dismissable drawer
Two regressions from this session's audit batch:

1. Bottom nav bar reset. Adding stable @SerialName discriminators to
   BottomBarEntry changed the persisted polymorphic "type" value from the
   fully-qualified class name to "builtIn"/"favorite", so configs written by an
   earlier build no longer decoded — and the fallback returned an empty list,
   blanking the bar. decodeBottomBarItems now migrates the old fully-qualified
   discriminators to the short names (recovering the user's customized bar), and
   any unrecognizable value falls back to the defaults instead of empty. Locked
   with BottomBarEntrySerializationTest.

2. Pull-down sheet interfered with page taps. The expanded top sheet is a
   full-width drawer with no way to dismiss except the grabber, so it sat over
   the page. Hoist its expanded state into EmbeddedTabLayer (reset per tab) and
   draw a full-area dismiss scrim behind the open sheet; collapsed, only the
   small grabber is interactive and page taps pass through.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-24 00:32:44 +00:00
Claude
ba2af75b4c feat: replace full-screen sandbox corner chip with top pull-down sheet
The full-screen browser/napplet-host activities still showed the old corner
pill/globe — which sits exactly where a site puts its own login avatar. Add a
native-View NappletControlSheet (the twin of the embedded tabs' Compose
TopControlSheet): a small grabber at the top edge that pulls down to the page's
controls, and wire both :napplet full-screen activities to it.

- NappletControlSheet: title row (shield/globe), optional Tor row, reload, and
  optional "what it can access". Tor supports an inline toggle (browser) or a
  tap-through to a confirm dialog (nSite host, where switching rebuilds the
  session). Tap or vertical drag to expand/collapse.
- NappletBrowserActivity / NappletHostActivity: drop buildFloatingChip/chipGlyph
  for buildControlSheet(); attach at Gravity.TOP, full width.
- Add short napplet_net_tor_label / napplet_net_open_label strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-24 00:10:29 +00:00
Claude
af9e8b885d refactor: apply embedded-tab audit fixes (durability, perf, leaks, cleanup)
Durability & correctness:
- FavoriteAppsRegistry: tombstone removals made before async hydration so a
  just-deleted favorite can't be resurrected by the disk merge.
- BottomBarEntry: stable @SerialName discriminators so persisted bottom-bar
  configs survive class renames/moves.
- EmbeddedTabHost: guard reportBounds/setActiveChrome by active id so a
  cross-fading outgoing screen can't clobber the incoming tab's bounds/chrome.
- EmbeddedNappletController: replay a parked-before-bound pause after session
  create so a never-shown applet doesn't come up running.
- NappletBrowserService: bind the broker once (no leaked binding on re-create),
  destroy a stale WebView before rebuilding, and reload only after the async
  proxy override actually applies. NappletHostService: same WebView-reuse guard.
- NappletBrokerService: cap concurrent foreground leases so a misbehaving
  sandbox can't pin Tor/relays with unbounded arbitrary keys.

Perf:
- Screens publish a remembered EmbeddedTabChrome; host short-circuits identical
  publishes so the tab layer isn't recomposed every frame.
- AppBottomBar resolves favorites via an id-indexed map, not a per-entry scan.
- TopControlSheet keyed on the active tab so its expand state resets per tab.

Cleanup:
- Delete dead BrowserHostActivity + EmbeddedBrowserSurface + AppControlPuck and
  their manifest entry; drop the unused `ready` surface state; null controller
  refs on unbind; refresh stale z-order/host docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-24 00:05:34 +00:00
Claude
89a32e078f fix: audit batch 1 — drop diagnostics, browser foreground heartbeat, Tor host key
- Remove the shipped scroll/zoom diagnostics from NappletBrowserService (per-touch
  MotionEvent log and per-page zoom log).
- NappletBrowserActivity now renews its foreground lease on a 30s heartbeat like
  NappletHostActivity, so the broker's 90s watchdog can't reap it (tearing down
  Tor/relays) while the browser is genuinely foreground.
- Persist the per-host Tor choice against the host actually displayed (webView.url),
  not the start URL, so an in-page navigation doesn't save the choice to the wrong site.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 23:51:18 +00:00
Claude
7d5f68d115 Merge remote-tracking branch 'origin/main' into claude/webview-menu-custom-url-ikrgbz 2026-06-23 23:41:38 +00:00
Claude
9ebe16d7a1 feat: top pull-down control sheet for embedded tabs
Now that the surface is z-ordered below the Compose layer (alpha17), chrome can
finally draw over the page — so replace the slim top bar with a top pull-down
sheet, per request. Collapsed it's just a small grabber centered at the top edge
(out of the top-right corner, where sites put their own avatar/menu); pull it
down or tap to reveal the page's controls: route over Tor, reload, "what it can
access" (sandboxed napplets/nsites), and open full screen.

The active tab publishes its controls as EmbeddedTabChrome; EmbeddedTabLayer
draws the TopControlSheet over the active tab's bounds, after the surfaces so it
sits on top. Applies to both embedded web and napplet/nsite tabs.

The full-screen activities still carry the native corner chip — converting those
to the same pull-down is the next step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 23:38:10 +00:00
Claude
90150e72e4 fix: upgrade privacysandbox.ui to alpha17 (embedded drag/scroll input)
The embedded surface forwarding taps but cancelling drags was a known bug in
androidx.privacysandbox.ui alpha10: with the provider surface z-ordered above,
"the gesture is exclusively received by the provider window and not transferred
to the client window" (alpha13 release notes). alpha15 then "set the default
Z-ordering to below" and "added support for the UI provider to receive
MotionEvents in this mode after being received by the client window" — i.e. the
drag-input path we needed.

Bump alpha10 → alpha17 and adapt the changed API: openSession takes SessionData
instead of a windowInputToken IBinder, Session adds notifySessionRendered, and
the session-state listener became setEventListener(SandboxedSdkViewEventListener)
(ready now flips on onUiDisplayed). The direct-WebView browser is unaffected
(it doesn't use this library).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 23:16:02 +00:00
Claude
1531f3baad feat: direct-WebView browser activity (scroll, zoom, keyboard)
Confirmed on a real device: the streamed SurfaceControlViewHost surface forwards
taps but drops scroll/zoom/keyboard gestures — a hard limitation of
androidx.privacysandbox.ui on current Android. No tweak fixes it.

Add NappletBrowserActivity: a full-screen browser that hosts the WebView
*directly* in its own window in the keyless :napplet process, so scrolling,
pinch-zoom, and the soft keyboard (windowSoftInputMode=adjustResize) all work
natively. It carries over NappletBrowserService's per-origin NIP-07 bridge and
Tor proxy, plus NappletHostActivity's trusted chip, loading screen, and
foreground hold — so it stays just as keyless (page JS runs in :napplet, every
window.nostr call is brokered + consent-gated per origin in the main process).

Web favorites and URL launches now open this activity instead of the streamed
BrowserHostActivity. Per-host Tor choice persists via a new MSG_SET_WEB_TOR
broker message (the :napplet process relays it to WebUrlNetworkRegistry).

The embedded bottom-row web tab still uses the streamed surface (it must, to
live inside MainActivity) and so still can't scroll — that's a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 22:46:31 +00:00
Claude
b8786091dd fix: no black flash when switching between embedded tabs
Parked (inactive) warm tabs were shrunk to 1dp off-screen, so activating one
resized its surface from 1dp to full size — forcing the SurfaceControlViewHost to
re-render at the new size, which flashed black for ~1s (page appears → black →
reappears) on every tab switch.

Keep parked tabs at the SAME size as the active tab and only shift them
off-screen, so bringing one back is a pure translation: no resize, no re-render,
no black flash. They stay full-size and warm while parked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 22:25:44 +00:00
Claude
41376bac71 fix: restore input on embedded surfaces; controls in a slim bar
orderProviderUiAboveClientUi(false) broke input entirely — with the streamed
surface ordered below the window, the SurfaceControlViewHost stops receiving
touch, so neither the WebView nor the Compose puck got clicks (and the touch
logs went silent). Revert it: the surface must stay z-ordered on top for input.

That makes floating chrome over an embedded surface impossible (the surface is
always above any Compose UI in the window), so move the control puck into a slim
top bar above the surface on all streamed surfaces (embedded web/nsite/napplet
tabs and the full-screen browser). No title — the app titles itself. The native
NappletHostActivity keeps its floating chip (direct WebView, no surface).

Keep the EmbeddedSurfaceTouchHolder scroll fix (claims the gesture from host-side
ancestors). Drop the can't-show Compose loading overlay and the dead chrome-in-
layer machinery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 22:01:11 +00:00
Claude
c44a18c803 fix: embedded surface scrolling, occluded chrome, and black load flash
Diagnosed from on-device logs: touch crosses the boundary but every drag gets
ACTION_CANCEL — a host-side ancestor intercepts the scroll, and the cross-process
WebView can't requestDisallowInterceptTouchEvent itself. Wrap the surface in
EmbeddedSurfaceTouchHolder, which claims the gesture on touch-down so the page
scrolls.

The floating puck couldn't sit over an embedded surface because the surface is
z-ordered on top of the window — so it was either hidden (full-screen browser)
or pushed below an empty reserve band that read as a black bar (embedded tabs).
Order the provider UI below the window UI (orderProviderUiAboveClientUi(false);
input still flows via the SurfaceControlViewHost token) and render the chrome on
top: the active tab publishes its controls as EmbeddedTabChrome and EmbeddedTabLayer
draws the puck over the surface — no reserve band, no black bar.

Add a themed loading placeholder (controller.ready flips on session Active) over
the surface in the tab layer and the full-screen browser, so binding + first
paint shows the app background + a spinner instead of a black box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 21:47:54 +00:00
Claude
cb8bbad38d feat: floating control puck for running app surfaces + remember Tor per web client
Replace the full-width top bar on every running app surface (embedded web/nsite/
napplet tabs and the full-screen browser + napplet activities) with a small
floating control puck. Apps already title themselves, so instead of repeating
the name we keep one always-visible trusted marker — the sandbox shield for
napplets/nsites (the anti-phishing affordance the page can't draw over), a globe
for the plain browser — that expands on tap to reveal the actions (Tor, reload,
pop-out, access sheet, close).

- New shared AppControlPuck composable backs the three Compose surfaces;
  NappletHostActivity gets the native-View equivalent.
- Embedded tabs inset their reserved surface bounds by the puck height
  (AppControlPuckReserve) so the warm surface, drawn over those bounds above the
  nav tree, doesn't cover the puck. Full-screen activities float it on top.
- EmbeddedTabTopBar is removed (no longer used).

Also remember the Tor on/off choice per web client: some sites' servers reject
Tor exits, so a user opting one out must have it stick. New WebUrlNetworkRegistry
(main process, keyed by host, device-local) mirrors NappletNetworkRegistry; the
browser reads it for the initial route and writes on toggle, in both the embedded
tab and the full-screen browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 21:29:15 +00:00
Claude
2115129a72 chore: add zoom diagnostic to embedded browser
Logs the WebView's current scale, density, and pixel widths on page finish, to
confirm whether the 400% zoom is a density/viewport mismatch from streaming the
WebView through SurfaceControlViewHost. Temporary, alongside the touch/session
diagnostics.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 21:03:17 +00:00
Claude
0b5d7925fe chore: temporary diagnostics for embedded browser scrolling
Adds two temporary logs to pin down why the embedded browser doesn't scroll:
- provider side (NappletBrowserService): logs each MotionEvent that reaches the
  remote WebView, so we can see if touch crosses the SurfaceControlViewHost
  boundary at all (returns false, never consumes).
- client side (EmbeddedBrowserController): logs the SandboxedSdkView session
  state transitions (Idle/Loading/Active/Error).

To be reverted once the cause is confirmed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 20:58:09 +00:00
Claude
17c6aba934 fix: theme the sandbox WebView background instead of flashing white
The embedded browser / nsite / napplet WebView runs in the keyless :napplet
process, which has no access to the main app's Compose theme, so before a page
painted it showed the WebView default white — jarring against Amethyst's (often
dark) background.

Pass the theme background color (MaterialTheme.colorScheme.background) across the
process boundary and apply it to the WebView, and paint the SandboxedSdkView
placeholder with it too so there's no white flash before the first frame. The
full-screen NappletHostActivity resolves the color locally from its themed
context. Covers the embedded browser tab, the full-screen browser activity, and
the embedded nsite/napplet tab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 20:52:18 +00:00
Claude
d99ae48847 feat: lease-expire sandbox foreground holds so a dead host can't pin the network
The cross-process foreground hold relied on the `:napplet` host delivering its
onPause "false" to release. If that process dies while foreground (a crash, an
OS kill) it never sends it, and the broker would hold the main process resumed —
Tor/relays/AUTH up — forever.

Turn the hold into a renewing lease. A resumed host re-reports foreground on a
30s heartbeat; the broker stamps each launch token's last-seen time and a
watchdog reaps any lease older than a 90s TTL, releasing its hold. A live app
keeps renewing so it's never wrongly dropped; a dead one stops renewing and is
reaped within the TTL, bounding any leak to one window instead of forever.

Only the cross-process napplet/nSite path needs this — BrowserHostActivity runs
in the main process, so if it dies the hold and every connection die with it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 20:28:36 +00:00
Claude
62f0adaa76 feat: keep Tor/relays/AUTH up while a sandbox surface is foreground
Opening a full-screen napplet/nSite (`:napplet` process) or browser host
(main process) backgrounds MainActivity, stopping its ManageRelayServices /
ManageWebOkHttp collectors. The underlying WhileSubscribed flows then scale
everything down on their timers — Tor's port (~2s), the relay pool (~30s),
dropping the relay AUTH sessions with it — even though the user is still on a
Nostr surface that brokers NIP-07 + relays back through that very process.

Add a ref-counted SandboxForegroundHold (main process): while held it
subscribes to exactly the flows the resumed UI subscribes to, keeping Tor, the
relay pool, and AUTH up; it releases when the last surface leaves so normal
background scaling resumes. Main-process activities call it directly; the
`:napplet` host can't touch that lifecycle, so it signals foreground over a new
MSG_SET_FOREGROUND IPC and the broker holds on its behalf (token-set keyed,
released on unbind).

Switching between several open surfaces (old.onPause -> new.onResume, plus the
async IPC hop) would dip the count to 0 and back to 1. A short release linger
keeps the same collectors running straight through that dip, so swapping
between open napplets/nSites/browser apps holds the network perfectly steady.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 20:06:12 +00:00
Claude
a26a7c9552 fix: constrain the Tor onion to 24dp (was rendering at intrinsic size)
The ic_tor vector painter rendered at its intrinsic size because the Icon had
no size modifier, so it was oversized in the embedded web tab and the
full-screen browser. Pin it to the standard 24dp to match the reload icon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 19:42:31 +00:00
Claude
baf5c542a0 feat: favorite tabs use the app's own icon and drop the bottom-bar label
Two bottom-nav polish items:
- No label on favorite tabs — they now match the built-in items, which are
  icon-only.
- Use the app's own icon. FavoriteApp gains an optional iconUrl (the
  nsite/napplet manifest icon, captured when you favorite from its card and
  persisted). A shared FavoriteAppIcon renders that icon, falling back to a
  type glyph (the napplet/nsite mark, or the globe for a plain URL) when
  there's no icon or it fails to load. Used in both the bottom bar and the
  Favorite Apps grid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 19:34:50 +00:00
Claude
5c37f57a31 refactor: use the Tor onion for the Tor toggle, shield only for sandbox access
Per review, drop the shield-as-Tor (and the security sheet that hosted it):
Tor is now the app's standard ic_tor onion (TorToggleButton, lit when on Tor,
dimmed on the open web), matching how Tor appears on relays and in settings.
The shield is reserved for the nsite/napplet tab, where it genuinely means
"what it can access".

- New shared TorToggleButton (onion).
- EmbeddedTabTopBar takes a `leading` slot: the web tab puts the Tor onion
  there, the napplet/nsite tab puts its access shield.
- BrowserHostActivity (full-screen) uses the same onion toggle instead of the
  shield/sheet. WebAppSecurityDialog and its strings are removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 19:24:24 +00:00
Claude
2d3dd21695 fix: crash measuring embedded sandbox WebView (LayoutParams cast)
The surface adapters set the WebView's layoutParams to a plain
ViewGroup.LayoutParams, but the SurfaceControlViewHost container measures its
children with measureChildWithMargins, which casts to MarginLayoutParams —
crashing the :napplet process with a ClassCastException on first layout.

Use FrameLayout.LayoutParams (a MarginLayoutParams) in both
NappletHostUiAdapter and NappletBrowserUiAdapter (openSession + notifyResized).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 19:19:41 +00:00
Claude
1c60760c06 refactor: full-screen browser uses the same shield = security/privacy sheet
The full-screen BrowserHostActivity still used the shield (Security icon) as a
bare Tor toggle (pink when on Tor, plain when on the open web) — the same
icon-overload we removed from the embedded tabs. Switch it to the shared
"Security & privacy" pattern: the shield opens the WebAppSecurityDialog with
the Tor toggle inside, matching FavoriteWebAppScreen. The page keeps its
back/close + reload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 19:14:28 +00:00
Claude
b3e5e9e693 fix: inset the browser launcher omnibox below the status bar
The launcher's address field is a plain Row in the Scaffold topBar slot, so
(unlike a Material3 TopAppBar) it didn't apply the status-bar inset and drew
under the status bar. Add statusBarsPadding() to the omnibox row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 18:59:30 +00:00
Claude
77e41ddc00 refactor: one consistent top bar for embedded web/nsite/napplet tabs
The web-app tab and the nsite/napplet tab had different bars, and both used
the same MaterialSymbols.Security "shield" for different things — Tor on the
web tab, sandbox access on the napplet tab — which read as the same icon
meaning two things. (Tor's real mark is the ic_tor onion used elsewhere.)

- Add a shared EmbeddedTabTopBar (sandbox shield · title · reload · pop-out)
  used by both tabs, so they're visually identical.
- The shield now consistently means "security & privacy": it opens a sheet.
  Tor moves into that sheet (a live toggle on the web-app tab; the napplet
  sheet keeps its capability list + network line), so the shield is never
  confused with a Tor toggle and there's no standalone Tor icon to mistake
  for it.

(The full-screen BrowserHostActivity pop-out still uses its own bar; can
harmonize that next if wanted.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 18:11:17 +00:00
David Kaspar
2311a4cd1b Merge pull request #3345 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-23 19:06:44 +01:00
Claude
601ba0a942 fix: stop embedded sandbox WebViews from double-applying system-bar insets
The full-screen NappletHostActivity already consumes system-bar + cutout
insets at its root so its WebView doesn't pad the page for the status/nav
bars a second time (targetSdk 35+ auto-applies received insets to web
content). The embedded surfaces — the browser tab and the new embedded
nsite/napplet tabs — have no such root: the WebView is the surface view, so
it received and re-applied those insets, leaving an empty band under the
status and navigation bars even though the host already places the surface
in the inset-free content area.

Add WebView.dropSystemBarInsets() (zeroes system-bar + display-cutout
insets, keeps IME for keyboard resize) and apply it in both
NappletBrowserService and NappletHostService when the session WebView is
built.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 17:59:05 +00:00
Claude
25b530559e Merge remote-tracking branch 'origin/main' into claude/webview-menu-custom-url-ikrgbz 2026-06-23 17:38:09 +00:00
Claude
6b0bb9e859 refactor: unify the bottom bar into one ordered list of built-ins + favorites
Replaces the parallel bottomBarItems (List<NavBarItem>) + bottomBarFavoriteIds
with a single ordered List<BottomBarEntry>, so built-in destinations and
favorite apps live in one list and can be pinned and drag-reordered together.

- BottomBarEntry = BuiltIn(NavBarItem) | Favorite(favoriteId). The favorite id
  already encodes the route's parameters (the url / addressable coordinate), so
  each entry maps deterministically to its Route — BuiltIn via NavBarCatalog,
  Favorite via Route.FavoriteWebApp/FavoriteNostrApp. (Storing the raw Route
  isn't an option: the sealed Route parent isn't @Serializable, so a List<Route>
  can't be persisted without annotating the whole ~100-subtype hierarchy.)
- UiSettings/UiSettingsFlow carry bottomBarItems: List<BottomBarEntry>;
  UISharedPreferences serializes it as JSON, with a legacy comma-separated
  NavBarItem fallback so existing configs still load.
- BottomBarSettingsScreen now shows one reorderable list mixing built-ins and
  favorites; the separate favorites section is gone.
- AppBottomBar renders entries in saved order; warm-keep membership derives
  from the list's favorite entries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 17:21:52 +00:00
vitorpamplona
8ffc65d727 chore: sync Crowdin translations and seed translator npub placeholders 2026-06-23 17:00:04 +00:00
Vitor Pamplona
ff2c6f6313 Merge pull request #3344 from davotoula/fix/markdown-detect-heading-after-blank-line
fix: render markdown when a heading/list follows a blank line
2026-06-23 12:58:14 -04:00
davotoula
a97529d7af fix: detect markdown when a heading/list follows a blank line
isMarkdown() treated the second newline of a blank line as a non-space
character, flipping the line-start tracker off. ATX headings, blockquotes,
and list markers that follow the standard blank-line spacing went
undetected, so NIP-23 long-form articles made of prose plus section
headings rendered as raw text. Exclude newline/carriage-return from the
line-start guard so a blank line stays at line start.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:23:22 +02:00
Claude
55bc75512a feat(nip46): support optional client metadata in connect request
Implements nostr-protocol/nips#2381: a client MAY attach an optional
4th positional parameter to the NIP-46 `connect` request carrying a
JSON-stringified `{name, url, image}` object, mirroring the fields
already present in `nostrconnect://` URIs. This lets a bunker:// paired
signer show who is asking to connect.

- quartz: add BunkerClientMetadata and a clientMetadata field on
  BunkerRequestConnect; serialize it as the 4th param (omitted when
  empty) and parse it back, degrading malformed/empty JSON to null.
- quartz: NostrSignerRemote carries and sends clientMetadata on
  connect() and threads it through fromBunkerUri().
- commons: BunkerLoginUseCase.execute() accepts optional clientMetadata.
- desktopApp: advertise Amethyst's metadata on bunker login.
- cli: the receiving bunker logs the connecting client's identity
  (display-only; never gates the ACK on it, since the client pubkey is
  unauthenticated in bunker:// pairing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PYpupiVAq4VyHDdjrYyPdi
2026-06-23 16:17:19 +00:00
David Kaspar
7d77a057d6 Merge pull request #3343 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-23 17:16:18 +01:00
davotoula
dd062861f3 chore: sync Crowdin translations and seed translator npub placeholders 2026-06-23 16:14:26 +00:00
David Kaspar
9c1392a2e0 Merge pull request #3341 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-23 17:12:37 +01:00
vitorpamplona
832951b90c chore: sync Crowdin translations and seed translator npub placeholders 2026-06-23 15:58:03 +00:00
Vitor Pamplona
b7cbc03b48 Fixes the compilation error 2026-06-23 11:55:40 -04:00
Vitor Pamplona
bf10498caf Merged 2026-06-23 11:50:36 -04:00
Claude
e4f2c5305d feat: keep pinned embedded tabs warm via a persistent surface layer
Implements keep-warm (approach A) so a bottom-row embedded tab keeps its
full state across tab swaps, scoped to bottom-row apps per review.

Why a persistent layer: androidx.privacysandbox.ui's SandboxedSdkView
closes its session in onDetachedFromWindow, so a session torn down the
moment a tab leaves composition is unavoidable if the surface lives inside
the per-screen composable. Instead, a single app-shell overlay
(EmbeddedTabLayer) holds every warm session's SandboxedSdkView attached the
whole time — the active one positioned over the current tab's reserved
content area, the rest parked off-screen but alive. No provider changes
needed: the WebView never detaches, so its JS state survives.

- EmbeddedTabHost: process-level holder of warm sessions (keyed by
  FavoriteApp.id), the active id, and the active content bounds.
- EmbeddedSurfaceController unifies the browser + napplet controllers so the
  layer can attach/park/teardown either; the napplet controller pauses its
  applet while parked (onHidden) and on app-background.
- FavoriteWebAppScreen / FavoriteNappletScreen no longer host the surface;
  they reserve the content area (reporting window bounds) and drive the warm
  controller. The trusted napplet chrome stays in the main process.

Scope (per review): only bottom-bar favorites stay warm — a tab whose app
isn't a bottom-bar favorite is evicted (restarted) when it leaves
(EmbeddedTabHost.retainOnly, driven by the settings list). Genuine memory
pressure (onTrimMemory) drops all warm sessions; mere backgrounding does not.

Needs on-device verification of the surface overlay (alignment, touch
pass-through to the bars, warm re-show) — validated here by build + assemble.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 15:32:15 +00:00
Claude
b373a297b6 refactor: configure favorite-app bottom-bar tabs from the settings page
Reworks how favorites get into the bottom bar, per review: instead of a
grid Pin/Unpin that auto-appended to the bar, favorites are now activated
as a dedicated "Favorite apps" section in the bottom-bar settings page —
kept separate from the built-in destinations because favorites are dynamic
data, not the fixed NavBarItem enum.

- UiSettings/UiSettingsFlow/UISharedPreferences gain bottomBarFavoriteIds
  (a device-local list of FavoriteApp ids), persisted alongside the
  existing bottomBarItems.
- BottomBarSettingsScreen gets a "Favorite apps" section: one toggle per
  favorite to activate/deactivate it as a bottom-bar tab.
- AppBottomBar renders the favorite tabs from that settings list instead of
  a registry-side pinned set.
- FavoriteAppsRegistry drops the pinned-set machinery; the grid drops its
  Pin/Unpin item. The "All apps" grid remains a built-in destination.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 15:22:52 +00:00
Vitor Pamplona
1eca812aec Fixes tests 2026-06-23 11:08:24 -04:00
Vitor Pamplona
1365be12a5 Fixes tests 2026-06-23 11:05:04 -04:00
Claude
e82f1057c8 feat: embed nsites/napplets as in-process tabs; pin them to the bottom bar
Completes the favorites system: a favorited nsite/napplet can now be
pinned to the bottom bar and render as an embedded, swap-in-place tab —
no longer only a full-screen activity launch.

New sandbox surface (:napplet, keyless), mirroring the browser embed:
- NappletHostService hosts the verified-blob WebView (same content server,
  shell bridge, and single launch-token broker path as NappletHostActivity)
  and ships it as a SandboxedUiAdapter surface, so applet JS still runs only
  in the keyless process — never where the keys live.
- NappletHostUiAdapter / NappletEmbedContract are the SurfaceControlViewHost
  adapter and the Messenger contract; the create-session bundle reuses
  NappletHostContract's EXTRA_* keys, so the embedded and full-screen host
  paths launch from identical, main-process-minted parameters.

Main process:
- NappletLauncher.buildLaunchParams extracts the verified param/token minting
  so both the activity intent and the embedded session share it.
- EmbeddedNappletController binds the service and attaches the surface
  (mirror of EmbeddedBrowserController).
- FavoriteNappletScreen draws the TRUSTED CHROME (sandbox shield, app name,
  "what it can access") in the main process around the surface — the sandbox
  must never draw chrome the user is meant to trust — plus a pop-out to the
  full-screen host. Capability consent still flows through the existing
  main-process broker + consent activity, unchanged and host-agnostic.

Security parity with the full-screen host:
- The applet's JS + timers are paused while the app is backgrounded
  (lifecycle ON_STOP/ON_START → MSG_PAUSE/MSG_RESUME), so an "allow always"
  napplet can't act on the user's behalf when they aren't looking.
- Granted sensitive ops (publish/upload/pay) surface a notice toast.

Both favorite kinds are now pinnable; the bottom bar routes WebUrl →
FavoriteWebApp and NostrApp → FavoriteNostrApp, each embedding in place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 14:59:44 +00:00
Claude
0606566238 feat: pin favorite web apps as embedded, swap-in-place bottom-bar tabs
Builds on the favorites system so a favorite can live in the bottom row
as a real tab instead of only launching a full-screen activity.

- FavoriteAppsRegistry gains a pinned-ids set (persisted device-locally,
  alongside the favorites list). Only WebUrl favorites are pinnable today
  — they're the ones that embed in-process — so a pinned tab always swaps
  in place and never launches an activity from the bottom row. Removing a
  favorite unpins it.
- Route.FavoriteWebApp(url) + FavoriteWebAppScreen render the embedded
  :napplet browser surface as an in-app tab: the app bottom bar stays, so
  switching to/from it is an ordinary tab swap. A pop-out action hands the
  same URL to the full-screen BrowserHostActivity for users who want it as
  its own window.
- AppBottomBar appends pinned favorites as tabs after the built-in items,
  navigating via navBottomBar (marked a tab root, so the bar stays).
- The Favorite Apps grid gains a Pin/Unpin action for WebUrl favorites.

Known follow-ups (intentionally out of this commit): NostrApp favorites
can't embed as tabs yet (they need an embedded nsite/napplet surface in
nappletHost), and embedded tabs rebuild on return rather than staying warm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 14:40:51 +00:00
Claude
c21b592cb1 feat: favorite web apps — launcher, full-screen host, and bottom-bar grid
Introduces a device-local "favorite apps" system that unifies nsites,
napplets, and arbitrary web clients behind one model and three
presentation shells, while removing the editable address bar from any
running app (it now lives only in the browser launcher).

Core spine:
- FavoriteApp (commons): a sealed model with two cases — NostrApp
  (nsite/napplet, keyed by addressable coordinate so it survives code
  updates) and WebUrl. napplet-vs-nsite is recomputed from the live
  event at launch, never stored.
- FavoriteAppsRegistry (amethyst): device-local, DataStore-backed,
  StateFlow source of truth; main process only, hydrated at app start.
- FavoriteAppLauncher: dispatches a favorite to its one launch path —
  full-screen BrowserHostActivity for a URL, sandboxed NappletLauncher
  (re-resolved from LocalCache) for an nsite/napplet.

Presentation:
- BrowserHostActivity: full-screen, single-app host in the main process
  that embeds the keyless :napplet browser surface. Its own task/recents
  entry (documentLaunchMode=intoExisting), no editable URL — locked to
  the app it opened with, keeping one NIP-07 trust context per instance.
- BrowserScreen is now a launcher: an omnibox that opens each URL in its
  own host activity, plus the shared favorites grid. The address bar is
  gone from the content surface.
- FavoriteAppsScreen + FAVORITE_APPS bottom-bar item: a grid of big
  launch buttons, reused inside the browser launcher.
- StaticWebsiteCard gains a header-actions slot; a star toggle on each
  nsite/napplet card pins it (strings/store stay in the app layer).

EmbeddedBrowserSurface extracts the chrome-free surface + controller
helper so the tab, the launcher, and the host activity share one piece
of cross-process glue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 14:30:49 +00:00
Vitor Pamplona
f6ea3e1999 Merge pull request #3342 from vitorpamplona/claude/funny-cannon-txzzqf
Load author relay lists for missing addressable notes
2026-06-23 09:58:49 -04:00
Vitor Pamplona
3b969e4b71 Merge pull request #3335 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-23 09:44:56 -04:00
Claude
128eed4a25 refactor: reuse UserFinderFilterAssembler instead of duplicating relay-list logic
Replace the self-contained AddressableAuthorRelayLoaderSubAssembler
(which duplicated UserOutboxFinderSubAssembler) with a thin bridge that
injects UserFinderQueryState entries directly into the existing
UserFinderFilterAssembler.

When EventFinderFilterAssembler detects an AddressableNote stub whose
author relay list is unknown, it subscribes that author to userFinder.
UserOutboxFinderSubAssembler already handles the kind-0/10002 fetch and
relay resolution — no logic is duplicated. Subscriptions are cleaned up
when the note loads or the EventFinder key is removed.
2026-06-23 13:34:15 +00:00
Claude
785af41c99 refactor: remove dead full-screen browser path superseded by embedded surface
The full-screen NappletHostActivity browser-mode and NappletLauncher.launchBrowser
were added alongside the embedded browser but never wired into any UI. The embedded
surface (NappletBrowserService rendered via SurfaceControlViewHost) supersedes them
and draws the address bar in the trusted main process rather than inside the sandbox,
so this removes the weaker, unused surface.

Removed:
- NappletHostActivity browser-mode (setupBrowser, address bar, per-origin bridge) —
  reverted the host activity to its pre-browser state.
- NappletLauncher.launchBrowser and the EXTRA_BROWSER_MODE/EXTRA_BROWSER_URL extras.
- The now-unused nappletHost browser strings.

Kept (used by the embedded path): the broker per-origin token mint
(MSG_MINT_BROWSER_TOKEN / MSG_BROWSER_TOKEN / KEY_BROWSER_ORIGIN) in NappletBrokerService
and NappletIpc, so NIP-07 consent stays scoped per visited origin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 13:12:00 +00:00
Claude
d63bf87d2a feat: add in-app browser tab rendered from the keyless napplet sandbox
Adds a "Browser" navigation destination (drawer + pinnable bottom-nav item,
API 30+) that opens any URL. The page renders in the sandboxed, keyless
`:napplet` process and is streamed into the main activity as a cross-process
surface via androidx.privacysandbox.ui (SurfaceControlViewHost) — only pixels
and input cross the boundary, never the WebView's JS context or the NIP-07
bridge. The trusted address bar is drawn by the main process around the
embedded surface, so the sandbox can never spoof the URL.

NIP-07 `window.nostr` is injected the same way nSite website mode does it, but
scoped per visited origin: each origin gets its own broker-minted launch token
(keyed by the trusted source origin), so a grant to one site never leaks to
another.

- NappletBrowserService (`:napplet`): hosts the live-URL WebView, exposes it as
  a SandboxedUiAdapter, and relays the per-origin NIP-07 bridge to the broker.
- NappletBrowserUiAdapter: wraps the WebView session for privacysandbox.ui.
- NappletBrokerService: mints a per-origin synthetic identity so NIP-07 consent
  is scoped per host.
- EmbeddedBrowserController + BrowserScreen: bind the service, render the
  SandboxedSdkView, and drive the trusted address bar (navigate/reload/back/Tor).
- shim.js: a direct-bridge transport so the injected shim works in a top-level
  page that has no trusted shell parent.
- Browser nav item hidden below API 30 (SurfaceControlViewHost requirement).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 03:39:06 +00:00
vitorpamplona
82a20c012a chore: sync Crowdin translations and seed translator npub placeholders 2026-06-23 03:10:29 +00:00
Vitor Pamplona
402439f09f Merge pull request #3340 from vitorpamplona/claude/github-ci-failures-yja302
fix(build): resolve :nappletHost benchmark variant via matchingFallbacks
2026-06-22 23:08:53 -04:00
Claude
1f4ff91419 fix(build): give :nappletHost a benchmark variant for :amethyst benchmark builds
The new plain-AGP :nappletHost library only declared debug/release build
types (unlike the KMP :commons/:quartz/:nestsClient libs, which match any
build type). The :amethyst app's `benchmark` build type therefore had no
matching :nappletHost variant, breaking fdroidBenchmark/playBenchmark
resolution and Test/Build CI on main.

Declare a matching `benchmark` build type (initWith release) in :nappletHost
so the app resolves a real variant. Library AARs aren't signed, so no
signing config is needed here. :nappletHost is the only plain-AGP library
:amethyst depends on, so no consumer-side matchingFallbacks is required.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tzXcqVUcAPhyDDFak4SQ8
2026-06-23 02:50:48 +00:00
Claude
4574005019 feat: fetch author relay list when addressable event is missing
When a kind-16 repost (or any event) has an a-tag pointing to an
addressable event by an unknown author (no NIP-65 relay list loaded),
potentialRelaysToFindAddress() returned an empty set and the event was
never found.

AddressableAuthorRelayLoaderSubAssembler mirrors
UserOutboxFinderSubAssembler but is driven by EventFinderQueryState.
It detects AddressableNote stubs whose author relay list is missing and
emits kind-0/10002 filters to indexer/search relays. On EOSE it calls
invalidateFilters(), causing NoteEventLoaderSubAssembler to re-run with
the now-populated outbox relays and fetch the addressable event.
2026-06-23 01:46:58 +00:00
Claude
0866d38065 fix(build): resolve :nappletHost benchmark variant via matchingFallbacks
The new plain-AGP :nappletHost library only declares debug/release build
types (unlike the KMP :commons/:quartz libs, which match any build type).
The :amethyst app's `benchmark` build type had no fallback, so Gradle could
not resolve a matching :nappletHost variant for fdroidBenchmark/playBenchmark,
breaking Test/Build CI on main.

Add `matchingFallbacks += "release"` to the benchmark build type so it falls
back to a library's release variant when no benchmark variant exists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tzXcqVUcAPhyDDFak4SQ8
2026-06-23 01:21:31 +00:00
Vitor Pamplona
068e12b5f6 Merge pull request #3339 from vitorpamplona/claude/awesome-pasteur-xwiwad
Implement NIP-5A/5D napplet & nsite sandbox host with capability broker
2026-06-22 20:46:16 -04:00
Claude
2b80bf49e3 feat(napplet): broker the NAP inc topic bus
Adds the `inc` pub/sub bus so napplets that declare it boot and can exchange
topic events: `inc.subscribe {topic}` / `inc.unsubscribe {topic}` register
interest, `inc.emit {topic, payload}` fans out an `inc.event {topic, payload,
sender}` to OTHER subscribed napplet sessions (never echoing the sender) — the
kehto runtime's inc contract.

- Router edge ops (gated on the INC declaration alone, like identity.watch —
  no per-call consent): SubscribeInc/UnsubscribeInc/EmitInc outcomes.
- Protocol: readTopic/readPayloadRaw + encodeIncEvent.
- NappletIncBus in the broker service routes across the live napplet sessions
  (the one service every sandbox binds), keyed by reply Messenger.
- Tests for inc routing + declaration gating; updated capability/router tests
  that asserted the old "inc/theme/notify are unknown" behavior.

NOTE: napplets run foreground-only/one-at-a-time, so cross-napplet delivery is
usually a no-op in practice; the bus is correct if sessions ever overlap. It is
app-wide (not author-scoped) — a future refinement could namespace topics by
author. Unblocks feed/profile-viewer/chat/bot. See the plan doc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-23 00:37:44 +00:00
Claude
1599f34308 feat(napplet): broker the NAP notify domain
Napplets can now create/list/dismiss user-facing notifications:
`notify.create { title, body }` → `notify.created { id }` (the bespoke
past-tense reply the client listens for, not the generic `.result`),
`notify.list` → `notify.listed { notifications }`, `notify.dismiss` fire-and-forget.

- NotifyCreate/NotifyList/NotifyDismiss requests, NotifyCreated/NotifyListed
  responses, NappletNotifyGateway + NappletNotification; broker executes them
  (consent-gated, ask-once).
- Protocol encodes the past-tense reply types.
- Android: NappletNotificationStore (per-coordinate, main-process, survives
  broker rebuilds — a napplet only ever sees/dismisses its own) + a best-effort
  system-tray notification.
- Consent summary + string for notify.

Unblocks the toaster demo. See the plan doc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-23 00:30:42 +00:00
Claude
4b8b2ac513 feat(napplet): broker the NAP theme domain
Napplets can now read the host's current theme via `theme.get` →
`theme.get.result { theme: { colors: { background, text, primary } } }`,
mapping it to their CSS variables. This is the universal boot gate for
real-world napplets (e.g. kehto/web's demos all `requires: theme` and abort
if shell.supports('theme') is false).

- NappletCapability gains THEME (+ NOTIFY/INC, wired in following commits);
  adds requiresConsent (false for SHELL/THEME — cosmetic/negotiation never prompt).
- ThemeGet request, Theme response, NappletThemeGateway; broker executes it
  with no consent prompt.
- Android gateway returns Amethyst's brand purple with a dark/light bg+text pair.
- Capability label/description/icon + strings for theme/notify/inc.

See amethyst/plans/2026-06-23-napplet-nap-theme-notify-inc.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-23 00:23:26 +00:00
Claude
20b42d3443 fix: prefetch off main thread; use real Tor logo in sandbox bar
- StrictMode DiskReadViolation: PrefetchManifestBlobs read context.cacheDir
  (ensurePrivateCacheDirExists touches disk) on the composition dispatcher.
  Move it into withContext(Dispatchers.IO); the prefetch was already IO-bound.
- Sandbox top-bar network indicator now uses the app's real Tor logo
  (ic_tor, copied into :nappletHost since it can't depend on :amethyst) via an
  ImageView, instead of the onion/globe emoji. Lit when routing through Tor,
  dimmed when the site loads over the open web.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 23:30:30 +00:00
Claude
7eb0949e5c feat: route nSite WebView traffic through Tor by default, per-site opt-out
nSites now load over Tor by default when Tor is active — closing the gap where
only blob fetches were Tor-routed while the site's own web traffic (fetch/img/
script) went out the system network and could leak the user's IP. Users can opt
a specific site out to the open web (e.g. a site that breaks or is slow over
Tor); the choice is remembered per site and makes everything for that site
direct (web + blobs).

- NappletHostActivity sets a process-wide WebView SOCKS proxy override
  (socks5://127.0.0.1:<torPort>) for website-mode nSites, or clears it for open
  web. Best-effort + on-device-verifiable: SOCKS-over-WebView support varies by
  WebView version, so it's isolated to applyWebViewProxy() and never breaks the
  site if unsupported.
- Top-bar onion (🧅 Tor / 🌐 open web) shows the routing and toggles it; the
  dialog explains the IP-privacy trade. Shown only for nSites when Tor is active.
- NappletNetworkRegistry: main-process per-site preference (coordinate-keyed,
  DataStore-backed, Tor-default). The launcher reads it; the broker persists the
  sandbox's toggle resolved through the launch token. The key-free sandbox never
  touches it.
- Toggling persists via a new MSG_SET_NETWORK_MODE IPC, then relaunches the host
  so the proxy + content server rebuild cleanly for the new mode.
- "Open web" also routes blob fetches direct (effective proxy -1); locked
  napplets always keep Tor for blobs (no toggle exposed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 23:10:12 +00:00
Vitor Pamplona
d0d1577bfe fix(napplets): remove the stray white band at the bottom of the applet WebView
Two edge-to-edge issues on the napplet/nsite host, both surfacing as a white strip below
the applet on a light page:

- Double bottom inset. The root inset listener turned the system-bar/cutout insets into
  padding but returned them un-consumed, so the child WebView (which on targetSdk 35+
  auto-applies any insets it receives to its web content) padded the bottom a SECOND time.
  Zero those types before they reach the WebView, keeping IME flowing so keyboard resize
  still works.

- Over-scroll reveal. Forcing a scroll past the content edge stretched the view and
  exposed the shell document's background behind the applet iframe. WebView.overScrollMode
  only governs the outer frame, so also inject `overscroll-behavior: none` into the applet
  document (alongside the shim) to kill the stretch at the source, theme-independently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:01:42 -04:00
Claude
c7d876096f feat: NIP-07 window.nostr provider for nSites
nSites now open in "website mode": a normal web app with normal network
access plus a NIP-07 window.nostr provider, so standard Nostr web apps can
"log in with Amethyst" and sign as the active user. Napplets are unchanged
(locked, declared-only sandbox).

- window.nostr (shim.js) installs only when the host sets __nappletNip07
  (website mode). getPublicKey/getRelays reuse the existing consent-gated
  identity reads; signEvent is a new sign-only op honoring the app-supplied
  created_at (no publish — the web app sends to relays itself).
- NappletRequest.SignEvent + nostr.signEvent decode; broker signs as the
  user and returns the signed event without publishing. pubkey is still
  fixed by the signer, so the app can never sign as another identity.
- Website mode: content server defers off-origin requests to the WebView
  and drops the app CSP (normal network); locked napplets keep connect-src
  'none' and 404 off-origin.
- Launcher grants IDENTITY + RELAY (consent-gated) for website mode,
  independent of the nSite's empty manifest requires.
- Consent dialog shows the kind + content preview for a sign request.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 22:49:58 +00:00
Claude
5e4250bfbc fix(napplets): expand the "what it can access" section straight down, not from the left
The disclosure used AnimatedVisibility's default transition and the inner servers
Column didn't fill width, so the section faded/expanded while the server list also
grew in horizontally from the left — an inconsistent, weird effect. Make the
transition explicit (fade + expandVertically/shrinkVertically anchored at Top, so
it opens/closes straight down like the card) and fill width on the servers column
so nothing slides in sideways.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 22:26:47 +00:00
Claude
42edc13be2 feat(napplets): back gesture pages back in the WebView, then exits to Amethyst
The sandbox host now routes the back gesture into the applet/site's own history
first: an OnBackPressedCallback calls webView.goBack() while there's history to
pop (in-page links, iframe navigations, and history.pushState all count), and
only disables itself — letting system back return to Amethyst — once the WebView
is at its first page. The callback's enabled state tracks canGoBack() via
doUpdateVisitedHistory / onPageFinished. Works with predictive back.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 22:23:36 +00:00
Vitor Pamplona
a66821d59e docs(napplets): record the per-applet storage-origin change in the security plan
Document the move from the opaque `allow-scripts`-only sandbox to a real per-applet
origin (https://<sha256(author:identifier)>.napplet.local, allow-same-origin) under
"Fixed in this pass", plus the residual risks it introduces: the load-bearing invariant
that the applet origin must stay distinct from the shell/bridge origin, persistent client
storage as a new persistence/exfil surface, the origin-id derivation, and service workers
now being reachable-but-unwired.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:20:57 -04:00
Vitor Pamplona
53d6a2f8b4 fix(napplets/nsites): render real SPAs in the sandbox (blank-page + reload-loop)
Static-site / SPA nApplets & nSites (Vite/nsyte/CRA/webpack output) rendered as a
blank page, then — once that was fixed — as a fast reload-loop blink. Three layered
causes, each found on-device via logcat:

1. Sub-path serving. The applet loaded under https://napplet.local/app/, but bundlers
   emit absolute asset URLs (/assets/app.js, /fonts/…) that resolve against the origin
   ROOT, so every script/style/font 404'd. nSites are defined to be hosted at the domain
   root; serve there.

2. Opaque-origin storage. The applet ran in an `allow-scripts`-only iframe, so its origin
   was opaque ("null"): module scripts + asset fetches were CORS-blocked, and reading
   localStorage/IndexedDB/serviceWorker threw SecurityError — which crash-loops every SPA
   (gruuv: "cache version 0 < 23 → reset → reload", forever, because IndexedDB never
   worked so the version never persisted).

Fix: give each applet its OWN real, persistent, isolated origin — a per-applet subdomain
https://<id>.napplet.local (id = sha256(author:identifier)), framed by the shell with
`allow-scripts allow-same-origin`. A real origin restores localStorage/IndexedDB/SW and
makes the applet's own assets same-origin (no CORS). Isolation is preserved because the
origin is DISTINCT from the shell's: the native bridge stays origin-restricted to the
shell (napplet.local), so the cross-origin applet still can't reach it or read the shell
DOM, and per-applet subdomains keep applets' storage isolated from each other. The shell
HTML's iframe src + CSP frame-src are bound to the specific applet origin at serve time.

Also add an in-memory localStorage/sessionStorage polyfill to the injected shim as
belt-and-suspenders for any context where DOM storage is still unavailable.

By-design sandbox enforcement is unchanged and correctly blocks the rest (external CDN
scripts, direct relay WebSockets via connect-src 'none', external images) — apps must go
through the napplet SDK for those.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:18:56 -04:00
Claude
176c96ef01 perf(napplets): faster opens — overlap WebView init, parallel prefetch, CAS fast-serve
Three load-time wins on the nApplet/nSite open path:

1. Overlap WebView/Chromium init with the index probe. The host now creates +
   warms the WebView (and binds the broker) in onCreate, so its slow first-in-
   process init runs concurrently with the IO availability probe instead of
   serially after it; the probe just attaches the ready WebView.
2. Prefetch in parallel, index-first. NappletBlobPrefetcher downloads a
   manifest's blobs with bounded concurrency (5) and fetches index.html first,
   instead of strictly sequentially — faster first paint on cold opens.
3. Serve CAS hits without re-hashing. The content server short-circuits a
   content-addressed cache hit (verified on write, addressed by sha256) straight
   to the response, skipping the resolver's per-serve sha256 over the whole blob
   — the dominant CPU cost for large bundles. Network (cache-miss) path still
   fully verifies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 22:07:09 +00:00
Claude
31bd34e838 feat(napplets): prefetch blobs to disk on render + loading/unavailable screen
Faster opens: while an nApplet/nSite card is on screen, eagerly download +
sha256-verify all of its blobs (Tor-routed) into a shared content-addressed
cache, so tapping Open serves from disk. Prefetch is de-duplicated (in-flight +
on-disk) and cancellation-aware (stops when the card scrolls away).

- New :nappletHost pieces: NappletBlobCache (content-addressed, multi-process-safe
  atomic store, replacing the single-process OkHttp DiskLruCache), NappletBlobHttp
  (shared Tor OkHttp + size-capped download), NappletBlobPrefetcher.
- NappletContentServer now reads the shared cache first, falling back to a Tor
  download that refills it; still re-verifies every blob on serve.
- Cards trigger prefetch via a LaunchedEffect in the StaticWebsite render fns.

Nicer launch: NappletHostActivity probes the index (resolving + caching it)
behind a loading screen (monogram + title + spinner), then shows the app, or a
clear "couldn't load — try again" screen if the publisher's servers are offline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 21:32:48 +00:00
Vitor Pamplona
4b838fec72 feat(napplets/nsites): link browse feeds to the TopNav filter at the relay level
The nApplet (NIP-5D) and nSite (NIP-5A) browse feeds ignored the top-nav
follow-list selection at the relay layer: a single SingleSubEoseManager fired a
hardcoded all-authors REQ against the user's home relays, and the screen merely
re-filtered the dump client-side. Selecting Follows / a People-list never changed
what was fetched, so manifests on an author's own write relays were invisible.

Bring them to Pictures-style parity:

- Replace the SingleSubEoseManager with PerUserAndFollowListEoseManager, watching
  defaultX FollowList + the new liveX FollowListsPerRelay outbox flow and rebuilding
  the per-relay REQ on change.
- Add makeN{applets,sites}Filter dispatchers + subassemblies (Global, Authors,
  Follows, MutedAuthors). These manifests carry no topical tags, so the tag-based
  selections (hashtag/geohash/community) correctly fall through to no subscription.
- Add liveNappletsFollowListsPerRelay / liveNsitesFollowListsPerRelay (OutboxLoaderState).
- New authorOnlyRoutes spinner option set: author-based filters only (no geohash,
  hashtag, community, relay, interest-set or AroundMe — none can match these events).
- "Mine" is intercepted per-feed (SubAssembler + screen), querying the user's own
  pubkey against their outbox relays — the shared TopFilter.Mine flow resolves to
  all-follows, so it can't be used directly.

Fix a FATAL duplicate-key crash: these replaceable/addressable kinds were observed
through the versioned note store (observeEvents) and keyed by per-version event id.
Switch to observeNotes, which yields one AddressableNote per address (latest
version), keyed by the stable address (idHex) and auto-updating in place.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:06:43 -04:00
Claude
4218a7c1af refactor(napplets): extract sandbox runtime into :nappletHost module
The `:napplet` sandbox runtime (NappletHostActivity, NappletContentServer,
NappletIpc, NappletKeyActions) now lives in a new :nappletHost Android library
that depends only on :commons + :quartz — NEVER :amethyst. So the sandbox code
is compile-time incapable of importing Amethyst.instance / LocalCache / Account,
turning the "two-process, no secrets in the sandbox" rule from a convention into
a build-graph guarantee.

- New module + NappletHostContract (Intent-extra keys + broker service FQN), so
  the launcher (amethyst) and activity (module) share the launch contract with no
  dependency cycle. The activity binds the broker by class name.
- Capability labels for the "what it can access" sheet are resolved by the
  launcher (which has app resources) and passed in, so the module needs no
  capability string resources. Host-only strings moved into the module.
- amethyst depends on :nappletHost; the broker-side (NappletBrokerService,
  gateways, NappletLaunchRegistry) stays in :amethyst. Manifest declares the
  activity by FQN (keeps @style/Theme.Amethyst resolvable).
- Docs updated (CLAUDE.md + security plan).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 20:45:19 +00:00
Claude
55cbb72a99 docs(napplets): document the two-process model for future contributors/AIs
Add a heads-up to the Amethyst Application KDoc and .claude/CLAUDE.md that the
app runs in two OS processes (main + :napplet), that Android reuses the single
Application class in both, and that statics/objects (Amethyst.instance,
LocalCache, NappletLaunchRegistry) are per-process — so code must not assume
`instance` exists off the main process or share singletons across the boundary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 20:18:34 +00:00
Claude
cdd5e916c5 fix(napplets): don't crash the :napplet process on memory-trim
Android instantiates the single Amethyst Application in every process, so the
sandbox (`:napplet`) shares it and onCreate early-returns there, leaving the
lateinit `instance` (AppModules) unset. But onTrimMemory — delivered to every
process on real devices — and onTerminate called `instance` unconditionally,
crashing the sandbox with UninitializedPropertyAccessException on a trim.
Gate both with the cached sandbox-process check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 20:08:20 +00:00
Claude
6d367526a5 feat(nsites): dedicated nSites browse feed (mirrors nApplets)
Add a bottom-nav nSites feed for NIP-5A static sites (kinds 15128/35128),
mirroring the nApplets feed: a follow-list FeedFilterSpinner in the top bar
(persisted as defaultNsitesFollowList + liveNsitesFollowLists author-matcher)
and rows rendered through the shared NoteCompose path, so they reuse the author
header, StaticWebsiteCard (with Open), and reaction bar.

New: NsitesScreen, NsitesTopBar, the Nsites discovery datasource trio, Route.Nsites,
NavBarItem.NSITES. Wired into AppNavigation, the relay coordinator, and the
follow-list settings persistence.

Also includes the earlier display rebrand to nApplet/nSite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 20:03:44 +00:00
Claude
8c4eae6f5a chore(napplets): rebrand display text to "nApplet" / "nSite"
User-facing strings only — "Napplet(s)" → "nApplet(s)" and the static-site
label → "nSite". Code identifiers, resource keys, CLI verbs (amy napplet/nsite),
and unrelated profile "Website" labels are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 19:52:00 +00:00
Vitor Pamplona
e5ee09f060 Merge pull request #3337 from vitorpamplona/claude/peaceful-mendel-drkneu
Show blocked/muted user indicator on profile tabs
2026-06-22 15:43:50 -04:00
Claude
8ad5244c1e feat: show blocked/muted state instead of empty feed on profile
When viewing the Notes or Replies tab of a muted/blocked user, display a
"UserBlockedFeed" screen with an Unblock button rather than loading the
LazyColumn feed (which would show only dividers after filtering removes all
hidden posts).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ChNCLiDX58paTKhReYCYq6
2026-06-22 19:23:40 +00:00
Claude
ab2ca77998 fix(napplets): harden the sandbox trust boundary (identity, private lists, visibility)
Three security hardenings from the nsite/napplet review:

1. Launch-token identity binding. The broker no longer trusts the identity +
   declared capabilities sent on each IPC message from the (less-trusted)
   :napplet process. The main process now mints a random token at launch
   (NappletLaunchRegistry), hands only that to the sandbox, and resolves it back
   to the trusted identity/declared set. A compromised sandbox can act only as
   the napplet it was launched as — closing cross-napplet coordinate spoofing
   (storage + permission ledger).

2. Stop leaking private lists. identity.getMutes/getBlocked read the decrypted
   flow, which includes the user's PRIVATE mutes/blocks. Return only the events'
   public tags (MuteListEvent.publicMutes / PeopleListEvent.publicUsersIdSet).

3. Make grants visible + anti-phishing chrome. A persistent trusted sandbox bar
   (shield + name + tap for "what it can access") the applet can't draw over, and
   a live toast when a granted publish/upload/payment runs — so an allow-always
   grant can't act silently. Also keeps the host out from under the system bars.

Findings + residual risks (session-scoped grants, per-origin resource consent)
tracked in amethyst/plans/2026-06-22-napplet-nsite-security.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 18:47:19 +00:00
Vitor Pamplona
6a07d21a23 Merge upstream/main into claude/awesome-pasteur-xwiwad
Brings in apps-feed-card 3-dot options menu (upstream #3336).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:37:06 -04:00
Vitor Pamplona
d01455f3c9 Merge remote napplet updates into local fix branch
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:29:24 -04:00
Vitor Pamplona
07d25c02db fix(napplet): serialize consent prompts so concurrent requests don't drop
When a napplet issued several consent-gated calls at once (the common case: it
reads relays + storage + identity on load), each launched a NappletConsentActivity
concurrently. The host can only show one, so the rest were delivered to the
single-top activity and silently dropped — their broker calls hung forever
(storage stuck pending; a subscription's consent lost, yielding 0 events).

Gate the consent-prompt path behind a Mutex on the (per-account, reused) broker
so prompts queue one at a time. After taking the lock, re-read the ledger so a
sibling request for the same capability honors the just-recorded grant instead
of prompting again. Only the prompt is serialized — execute() and already-granted
paths stay parallel. Per-use capabilities (payments) still re-prompt every time.

Adds a regression test asserting 5 concurrent same-capability requests yield
exactly one prompt and never two dialogs at once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:28:33 -04:00
Vitor Pamplona
44deeb1ee0 fix(napplet): launch the sandbox host by reading shell/shim from assets
NappletHostActivity runs in the isolated `:napplet` process, which early-returns
from Amethyst.onCreate to stay key-free and so never initializes the
compose-resources Android context. `Res.readBytes` then threw
MissingResourceException, crashing the host 100% on launch (the napplet feature
could never open).

Read shell.html/shim.js straight from the APK assets (where compose-resources
packages them) via the Activity context instead of the suspending Res accessor.
NappletWebContract now exposes the relative paths + RESOURCE_ASSET_ROOT so the
paths stay single-sourced.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:28:32 -04:00
Claude
c2c00e7867 fix(napplets): respect system bar / cutout insets in the host WebView
NappletHostActivity is edge-to-edge by default on recent Android, so the
sandboxed applet/nsite content drew under the status and navigation bars.
Pad the WebView by the system-bar + display-cutout insets so the content
sits in the safe area.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 18:27:04 +00:00
Vitor Pamplona
f58cda166d Merge pull request #3336 from vitorpamplona/claude/modest-ritchie-lodbn3
Add more options button to software app note rendering
2026-06-22 14:22:17 -04:00
Vitor Pamplona
a277f6a65f Merge upstream/main into claude/awesome-pasteur-xwiwad
Brings in nestsClient MoqLiteSession Ended-announce race fix (upstream #3334).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:15:30 -04:00
Claude
554fa16743 feat: add 3-dot options menu to apps feed card 2026-06-22 17:20:10 +00:00
Claude
a2eae42c07 feat(napplets): app-store-style card + icon manifest tag; demote capabilities
Redesign the shared StaticWebsiteCard (used by the feed AND the napplets browse
screen) to look like an app entry instead of a manifest dump: square app icon
(with a colored monogram fallback), name, a NAPPLET/WEBSITE type label, a short
description, and an Open button. The technical details users don't care about —
declared capabilities, Blossom servers, source URL — move behind a tap-to-expand
"What it can access" disclosure; capabilities are still re-confirmed at the
consent prompt when actually used and remain fully manageable in the permissions
screen.

Add an `icon` tag (NIP-5A/5D) end-to-end:
- quartz: IconTag + siteIcon() accessor/builder, NappletManifest.icon(), and an
  icon param on all four site/napplet build() factories (+ round-trip test).
- amy: `--icon URL` on `nsite/napplet publish`, surfaced in the publish output.
- card: renders the icon via Coil, monogram fallback when absent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 17:13:41 +00:00
Claude
4cd71b140c refactor(napplets): render browse rows via shared NoteCompose
Replace the bespoke NappletCard with NoteCompose — the same path the main feed
uses for kind 15129/35129 events. This reuses the author header, the shared
StaticWebsiteCard (title/description/source/servers/capability list + Open button
wired to NappletLauncher), and the standard reaction bar (reply/boost/like/zap),
instead of a second hand-rolled card that could drift from the feed and was
missing NoteCompose's timestamp, hidden-user handling, zap-amount menus, etc.

The new top bar + follow-list filter (defaultNappletsFollowList / matchAuthor)
are genuinely new and kept as-is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 16:47:05 +00:00
Claude
7aa5f10a72 feat(napplets): richer browse cards + follow-list filter bar
The Napplets browse screen now matches the other feed screens:

- Top bar gains a follow-list FeedFilterSpinner (drawer/back · filter · search +
  manage-permissions), persisted in account settings as defaultNappletsFollowList
  and applied via a new Account.liveNappletsFollowLists author-matcher so you can
  scope the list to a people set (All/Follows/custom).
- Each row is a rich card: author avatar + name, title, description, the declared
  capability chips, and the standard reaction bar (reply/boost/like/zap) wired to
  the canonical cache Note — so napplets get the same social actions as any event.

Follow-list plumbing mirrors the existing categories (AccountSettings field +
change fns, LocalPreferences persist/load, FollowListPrefs). LoggedInUserPictureDrawer
is now internal so the new NappletsTopBar can reuse the drawer opener.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 16:42:16 +00:00
Vitor Pamplona
9dd2fb5ba9 Merge pull request #3334 from vitorpamplona/claude/modest-carson-lcgcsu
MoqLiteSession: send Ended announce on publisher close race
2026-06-22 12:25:14 -04:00
Claude
03aac0025d fix(nestsClient): write Ended announce when publisher.close() races registerAnnounceBidi
When publisher.close() acquires the gate lock before registerAnnounceBidi,
the announce bidi list is empty and no Ended message is sent. Fix by writing
Ended + finishing the bidi in registerAnnounceBidi when publisherClosed is
already true, mirroring what close() does for bidis it owns at close time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015t3bsJruGerz53yafZyp9n
2026-06-22 15:56:26 +00:00
Claude
d60a618653 feat(amy): list an author's napplets / nsites
Add `amy napplet list <author>` and `amy nsite list <author>`: fetch the
author's root + named manifests (15129/35129 for napplets, 15128/35128 for
nsites), keep the latest per identifier, and emit a summary of each (kind, d,
title, description, path count, servers, requires/aggregate, event id,
created_at). Thin assembly over ctx.drain + the quartz manifest accessors.

Harness README notes `amy napplet list` for enumerating what you've published.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 15:38:21 +00:00
Claude
7bc95d836c feat(amy): nsite/napplet serve (local preview); drop standalone publish.sh
Add `amy nsite serve` and `amy napplet serve <author> [--d ID] [--port N]`:
fetch the manifest and serve its content over a local HTTP server, resolving
each request through quartz StaticSiteResolver (blob downloaded from Blossom
and sha256-verified per request, same as the device host) with SPA fallback to
index.html. Lets you open a published site/napplet in a browser to confirm it
loads and routes. (Static content only — a napplet's window.napplet.* runtime
still needs the Amethyst host; documented in the command + harness README.)

Implemented as thin cli glue (StaticSiteServe) over the resolver + commons
BlossomClient + the JDK HTTP server.

Also retire tools/napplet-test/publish.sh now that `amy napplet publish` is the
single source of truth; the harness README documents publish + serve via amy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 15:35:14 +00:00
Claude
ece1f43975 feat(amy): publish napplets and nsites (ship a directory)
Add `amy nsite publish <dir>` and `amy napplet publish <dir>` so a static-site
or napplet directory can be shipped to Nostr in one command, building on the
new CLI/Blossom infrastructure.

- commons (jvmMain) StaticSitePublisher: the reusable "upload a tree" half —
  walks a directory (or single file), content-addresses each file, BUD-02
  signed-uploads it via BlossomClient, and maps it to an absolute web path
  (/index.html, /assets/app.js, …). Returns the NIP-5A path→sha256 tags.
- cli StaticSitePublish: thin shared flow — uploads via the commons publisher,
  hands the path tags to a kind-specific builder, signs with the account key,
  and broadcasts. nsite builds 15128/35128 (+ x aggregate); napplet builds
  15129/35129 (aggregate + requires already added by the quartz builder).
- nsite/napplet `publish` verbs wired into their routers.

Test harness README now recommends `amy napplet publish tools/napplet-test`,
keeping publish.sh as a no-amy fallback. Unit test covers the path mapping;
cli + commons build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 15:31:28 +00:00
Claude
08947510d9 Merge remote-tracking branch 'origin/main' into claude/awesome-pasteur-xwiwad 2026-06-22 15:04:44 +00:00
Claude
b0332c9ff3 test(napplet): add on-device test harness (napplet + publish script)
A self-contained napplet (tools/napplet-test/index.html) that calls every
window.napplet.* API and renders each result on screen, for verifying the
NIP-5D host end to end on a real device — including the new
identity.getList/getZaps/getBadges, identity.onChanged, keys.onAction, and
resource.bytes nostr: paths.

publish.sh uploads it to a Blossom server (BUD-02) and publishes the NIP-5D
named-napplet event (kind 35129) via nak; README documents the flow and a
per-feature verification checklist. Tooling only — no app code or deps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 15:04:28 +00:00
Vitor Pamplona
59361c378e Merge pull request #3333 from vitorpamplona/claude/loving-hopper-rn553t
Add comprehensive CLI command suite and Cashu wallet support
2026-06-22 11:03:43 -04:00
Claude
2c4fd48c96 fix(cli): realign amy nutzap relays with upstream NIP-65 inbox model
Upstream reworked kind:10019 to advertise the account's NIP-65 inbox (read)
relays as nutzap-receiving relays (was outbox). Realign amy:
`cashu wallet create` now defaults nutzapRelays to a new
Context.nip65ReadRelays() (kind:10002 read relays, falling back to outbox)
instead of outboxRelays(), so amy's kind:10019 matches the Android wallet's
again. The event's publish destination (anyRelays / sendLiterallyEverywhere)
is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-22 14:58:51 +00:00
Claude
a5eabbf1a2 Merge remote-tracking branch 'origin/main' into claude/loving-hopper-rn553t 2026-06-22 14:53:23 +00:00
Claude
df15414424 Merge remote-tracking branch 'origin/main' into claude/awesome-pasteur-xwiwad
# Conflicts:
#	amethyst/src/main/res/values/strings.xml
2026-06-22 14:50:16 +00:00
Claude
188746485d docs(cli): correct nak parity tally (24 full / 3 partial / 7 missing)
Re-introspected the real nak binary: 34 functional commands. Fixes the
stale count — adds nsite to full (amy has NsiteCommands), corrects missing
to 7, and clarifies group/nip29 is an intentional MLS/Marmot divergence,
not a gap. Drops the stale "validate" cheap-win (key validate shipped).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-22 14:45:14 +00:00
Claude
cd14994859 fix(nutzaps): default kind:10019 relay tags to inbox list, drop dm
The advertised `relay` tags in our own kind:10019 now copy the NIP-65
inbox relay list only, rather than inbox + DM. Inbox relays are the
canonical "where others reach me" set, so they are the natural default
for "where to send me nutzaps". DM relays stay in the wallet's inbound
subscription as a safety net, but don't belong in the public kind:10019.

The publish destination of the kind:10019 event itself is unchanged — it
still broadcasts to our outbox via sendLiterallyEverywhere; only the relay
tags inside the event changed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHcZ2gv8ro9Q2bEiTSiHD8
2026-06-22 09:38:31 -04:00
Claude
33fdd34cf0 fix(nutzaps): advertise inbox+dm relays in kind:10019, not outbox
Complete the NIP-65 outbox-model alignment for NIP-61: the `relay` tags in
our kind:10019 are, per spec, the relays where the recipient *reads*
incoming token events — i.e. inbox-side relays others publish to. We were
advertising our outbox (write) relays there.

Both publish paths (initial wallet creation in CashuWalletViewModel and
P2PK key rotation in CashuWalletState.recreateNutzapKey) now advertise the
union of our NIP-65 inbox + DM relays. This mirrors the relay set the
wallet subscribes to for inbound kind:9321, so senders following our
kind:10019 publish exactly where we listen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHcZ2gv8ro9Q2bEiTSiHD8
2026-06-22 09:38:31 -04:00
Claude
4e582d198d fix(nutzaps): receive nutzaps on inbox/dm/kind:10019 relays, not outbox
Inbound NIP-61 nutzaps (kind:9321) are messages other people send *to*
the user, so per the NIP-65 outbox model they must be read from the
user's inbox-side relays, not their outbox. The Cashu subscription used a
single relay set (outbox) for both the user's own NIP-60 events and
inbound nutzaps, so a sender following NIP-61 correctly (publishing to
the relays advertised in the recipient's kind:10019, or to the
recipient's NIP-65 inbox) could be missed.

Split the subscription relay sets per filter:
  - own NIP-60 wallet/token/history events keep reading from outbox,
    where the user published them (needed to restore on a fresh device);
  - inbound kind:9321 nutzaps now read from the union of the user's
    NIP-65 inbox + DM relays + the `relay` tags in the user's own
    kind:10019. The last one is NIP-61's source of truth for "where to
    send me nutzaps" and may be written by another client to a relay set
    unrelated to our NIP-65 lists, so we listen there too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHcZ2gv8ro9Q2bEiTSiHD8
2026-06-22 09:38:31 -04:00
vitorpamplona
bd7c5c78cc chore: sync Crowdin translations and seed translator npub placeholders 2026-06-22 09:38:31 -04:00
Claude
43565beb2b style: spotless formatting for CardFeedContentState 2026-06-22 09:38:31 -04:00
Claude
2918dc5497 fix: replace LocalDate.ofInstant (API 34) with Instant.atZone chain (API 26+) 2026-06-22 09:38:31 -04:00
Claude
7a3c35c645 docs: document cashu, admin, serve, fetch code mode, key validate
Update the amy docs to reflect the new command surface:

- cli/README.md — add the Cashu (NIP-60/61), Relay management (NIP-86
  admin), and Run-a-relay (serve) sections; document fetch's nip19/nip05
  code mode and key validate.
- cli/DEVELOPMENT.md — add cashu.json to the on-disk layout and pin the
  command-family --json contracts (cashu keys/error codes + pointer to the
  cashu plan, admin {relay,method,result}, serve startup object).
- .claude/skills/amy-expert/SKILL.md — extend the "where things live" tree
  with AdminCommand/ServeCommand/cashu/, the commons/cashu + relayManagement
  shared modules, and the allowed :geode dependency.
- .claude/CLAUDE.md — note cli may depend on :geode (for serve), never on
  :amethyst/:desktopApp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-22 02:14:11 +00:00
Claude
e883d99f53 feat(napplet): implement keys.onAction (key binding + keys.action push)
Bind a napplet's registered keyboard/command actions to real hardware-key
combos and fire them back as keys.action pushes, so napplet.keys.onAction
actually triggers (previously registration was acked but never fired):

- protocol: RegisterAction / ActionRegistered carry the key combo (binding,
  from the SDK's action.defaultKey); the codec decodes defaultKey and echoes
  binding in the result; encodeKeysAction push envelope added.
- broker: registerAction returns the honored binding (still no key access for
  the applet; KEYS stays a declared-only, no-prompt capability).
- NappletKeyActions (host): a registry that parses combos like "Ctrl+Shift+S"
  / "Cmd+Enter" / "F2" and matches them against KeyEvents.
- NappletHostActivity: binds an action only after the broker authorizes it
  (from the keys.registerAction.result), unbinds on keys.unregisterAction, and
  overrides dispatchKeyEvent to turn a matching combo into a keys.action push
  via the shell bridge. Unmatched keys fall through to the WebView, so the
  applet's own text inputs keep working. Touch-only devices simply never match.

shim already passed the full action (incl. defaultKey) and wired onAction to
the keys.action push, so no shim change was needed. Conformance test now
covers the defaultKey decode + binding round-trip; all napplet suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 01:37:39 +00:00
Claude
1a1fb9ff32 feat(napplet): implement identity.onChanged push
Wire napplet.identity.onChanged end to end so an applet is notified when the
active user's public key changes (account switch / connect / disconnect):

- shim: onChanged registers a handler and opens a watch (identity.watch) on the
  first handler; closing the last one stops it (identity.unwatch). identity.changed
  pushes are dispatched to the handlers with the new pubkey.
- router: identity.watch (gated on the IDENTITY declaration) / identity.unwatch
  become WatchIdentity / UnwatchIdentity outcomes — a push subscription, like
  relay.subscribe, that never reaches the broker.
- NappletIdentityWatch (host): collects the active account's pubkey from the
  session manager and pushes identity.changed on each subsequent change (the
  current value is dropped — the applet already has it via getPublicKey). Torn
  down on unwatch and on service destroy.
- codec: encodeIdentityChanged push envelope.

Router unit tests cover watch (declared/undeclared) and unwatch; commons tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 01:32:07 +00:00
Claude
443c35762a feat(napplet): implement identity.getList/getZaps/getBadges + resource nostr:
Fill in the identity reads that previously fell through to Unsupported, and
add nostr: resolution to resource.bytes — both reading from what Amethyst
already has locally, matching the @napplet/nap shapes.

identity (AccountIdentityReader):
- getList(listType): public tag values (e/a/p/t/word/r/emoji) of the user's
  NIP-51 replaceable list of that type (bookmarks 10003, pins 10001, mute
  10000, interests 10015, communities 10004, channels 10005, emojis 10030).
- getZaps: ZapReceipt[] {eventId, sender, amount, content?} from kind-9735
  receipts p-tagging the user in the cache.
- getBadges: Badge[] {id, name?, description?, image?, thumbs?, awardedBy}
  from kind-8 awards p-tagging the user, resolved against their kind-30009
  definitions in the cache.

resource.bytes (NappletResourceFetcher):
- nostr: URIs (NIP-19) resolve to the referenced event's JSON
  (application/json). nembed carries it inline; note/nevent/naddr resolve
  from the cache then a bounded relay fetch; npub/nprofile resolve the
  author's kind-0. Still no direct network for the applet.

The wire codec already routed these (generic IdentityRead + identityResultField,
ResourceBytes), so no protocol change was needed. Commons napplet tests stay green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 01:27:53 +00:00
Claude
9a5e9090c0 feat(cli): amy key validate + fetch nip19/nip05 outbox resolution
- `key validate PUBKEY` — nak's `key validate`: parse an npub or 64-hex
  pubkey and report {valid, pubkey, npub}; never errors on bad input
  (reports valid:false) so scripts branch on the field.
- `fetch <nevent|naddr|nprofile|npub|note|nip05>` — code mode that resolves
  relays the way the Android app opens a shared link: the relay hints
  embedded in the nip19 code UNION the author's NIP-65 write (outbox)
  relays, draining the author's kind:10002 on a cache miss. This is nak's
  `fetch` (nip19-hint resolution); filter mode is unchanged.

Verified: key validate accepts fiatjaf's npub/hex and rejects garbage +
bad-checksum npubs; `fetch <npub>` drained the author's kind:10002,
queried their actual advertised write relays, and returned kind:0.

nak parity: 23 full / 3 partial / 6 missing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-22 01:06:01 +00:00
Claude
4453985f68 refactor(napplet): split the host Service and Activity by responsibility
The two largest Android napplet files mixed many concerns. Decompose each
into focused collaborators, behavior-preserving (both napplet test suites
stay green):

NappletBrokerService (641 -> 195 lines) is now a thin IPC shell — Messenger
transport, per-account broker cache, lifecycle — delegating to:
- gateways/AccountNappletGateways: the account -> NappletBroker adapter that
  wires all six gateway impls (relay publish/query, consent, wallet/NWC,
  resource, identity, upload).
- gateways/NappletResourceFetcher: data:/https:/blossom: resource fetching
  with the Tor-aware OkHttp client (sha256-verified blossom blobs).
- gateways/AccountIdentityReader: identity.* reads -> JSON (public data only).
- NappletConsentSummary: NappletRequest -> localized consent dialog text.
- NappletLiveSubscriptions: the live relay subscription registry (open/close/
  closeAll, single-EOSE latch, per-sub client for correct teardown).

NappletHostActivity (483 -> 336 lines) keeps the Activity lifecycle, WebView
hardening, and the shell<->broker bridge; the resource edge moves to:
- NappletContentServer: serves the shell + verified blobs (CSP headers, SPA
  fallback, shim injection) and owns the disk-cached blob HTTP client.

No wire or policy change; all decode/consent/exec still flows through the
shared NappletRequestRouter + NappletBroker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 00:45:00 +00:00
Claude
47cc76d9f1 feat(cli): amy admin (NIP-86) + serve (embed geode relay)
Two new nak-parity commands:

- `amy admin RELAY METHOD [args]` — NIP-86 Relay Management API over NIP-98
  HTTP auth. Full method set: ban/unban + allow/unallow pubkey, ban/allow
  event, allow/disallow kind, block/unblock IP, change name/description/icon,
  and all list-* queries. Reuses quartz's Nip86Client (request build + NIP-98
  auth + parse) and the Nip86Retriever HTTP path — extracted from amethyst to
  commons/jvmAndroid so amy and the Android relay-management screen share it.

- `amy serve [--host --port --path --db --admin]` — runs a Nostr relay by
  embedding geode (the standalone Ktor relay on quartz's relay-server code).
  In-memory by default (ephemeral, like nak serve); --db FILE for SQLite. The
  account's own pubkey is always an admin, so `amy admin` works against it out
  of the box. cli gains a :geode dependency (geode depends only on :quartz) and
  kotlinx-serialization-json (to render NIP-86 JSON results).

Verified end-to-end: `amy serve` + `amy admin ws://127.0.0.1:PORT
supported-methods|change-name|ban-pubkey|list-banned-pubkeys` round-trip
cleanly over real HTTP + NIP-98 against the live geode relay.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-22 00:19:29 +00:00
Claude
42a82c7e8b feat(cli): amy cashu receive/send/maintenance/mint-rec tiers
Complete the Cashu command surface, every verb a thin wrapper over the
shared commons CashuWalletOps the Android wallet runs:

  cashu receive ln SATS [--mint]      start mint, return bolt11 + kind:7374
  cashu receive complete QUOTE_ID     poll mint; on settle, mint proofs
  cashu receive resume QUOTE_ID       alias of complete
  cashu receive token TOKEN           redeem a cashuB token
  cashu receive nutzap-sweep [--mint] redeem inbound NIP-61 nutzaps
  cashu send ln INVOICE [--mint]      melt to a bolt11 (scrubs first)
  cashu send token SATS [--mint --memo]  export a cashuB token (scrubs first)
  cashu send nutzap USER SATS [--zapped --message]  P2PK-locked nutzap
  cashu maintenance scrub [--mint]    NUT-07 + NIP-09 prune spent proofs
  cashu maintenance restore MINT_URL  NUT-09 restore from seed
  cashu maintenance migrate-keysets [--mint]  consolidate onto active keyset
  cashu mint-rec show [--author] / add URL [--dtag --review] / remove ID

- scrubStaleProofs extracted into CashuWalletOps so Android's
  CashuWalletState.scrubLocallyStaleProofs and amy share one impl.
- Context.cashuRestore mirrors CashuWalletState.restoreFromMint (seed +
  NUT-13 counter bump); Context.cashuSeed warms the per-run seed.
- receive complete recovers the mint amount by decoding the quote's
  bolt11 (kind:7374 stores only the quote id), so it works statelessly.

Verified against mint.minibits.cash + live relays: receive ln returns a
real invoice, complete/resume poll a pending quote, mint-rec round-trips,
and every error path (insufficient_funds, no_mint, mint_quote_gone) is
clean. Happy-path mint/melt completions need a payable bolt11 (interop
harness, PR 9).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-22 00:10:40 +00:00
Claude
b6f9630883 refactor(napplet): single-source the web contract and feed card in commons
Two shared extractions so the future desktop host reuses the exact same
sandbox and feed UI as Android, with no chance of drift:

Shared web contract:
- Move shell.html + shim.js into commons composeResources
  (files/napplet/), read via Res.readBytes on any platform.
- New NappletWebContract (commons/commonMain) single-sources the whole
  web contract: the shell/shim loaders plus the internal origin/host/URLs
  and both Content-Security-Policies (SHELL_CSP, APP_CSP). The Android
  host preloads the bytes in onCreate and reads every origin/CSP constant
  from NappletWebContract instead of its own duplicated constants and
  assets.open() calls.

Shared feed card:
- New StaticWebsiteCard (commons/.../ui/note) renders the inert NIP-5A /
  NIP-5D preview card: self-contained with commons compose-resource
  strings, LocalUriHandler for links, and inlined card chrome. It takes
  an isNapplet flag and an onOpen launch slot, so it never executes applet
  code itself.
- amethyst's note/types/StaticWebsite.kt becomes thin event->card
  adapters that supply the sandboxed onOpen launch.

Card strings move to commons strings.xml. Both napplet test suites
(commons jvmTest + amethyst) stay green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 00:10:22 +00:00
Claude
0cff3bf8e2 refactor(napplet): extract host-agnostic NappletRequestRouter to commons
Move the decode → broker → encode orchestration out of Android's
NappletBrokerService.handleMessage and into a pure, transport-free
NappletRequestRouter in commons/jvmAndroid. It returns a small Outcome
(Ignore / Reply / OpenSubscription / CloseSubscription / Push) that each
host acts on, so the Android service and the future desktop host share
the routing brain and can't drift on wire behavior.

The service now resolves the broker and dispatches on the Outcome,
supplying only the Messenger transport and the live relay subscription.
openLiveSubscription takes the decoded filters from the router instead of
re-decoding the payload, and the now-redundant process() is removed.

Unit-tested in commons/jvmTest (NappletRequestRouterTest).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-21 23:59:53 +00:00
Claude
72a7f59d14 fix(cli): align amy cashu publish + nutzap relays with Amethyst
Two behavioral divergences from the Android wallet, found while auditing
for parity:

- Publish targets: Amethyst sends every cashu event via
  sendLiterallyEverywhere (all the user's relays). amy published only to
  outboxRelays(); switch to anyRelays() (outbox + inbox + keypackage), the
  CLI's closest analog, so the wallet lands on the same broad relay set.
- Nutzap relays on create: Amethyst always advertises the account's outbox
  relays in kind:10019 (so senders publish nutzaps where the user reads).
  amy defaulted to none; default to outboxRelays() unless --relay overrides.

Also align cashuSnapshot()'s store query with commons'
CashuWalletFilterAssembler exactly (six authored kinds by authors=[pk],
inbound nutzaps by #p) so amy projects the same event set the app
subscribes to. Verified the emitted kind:10019 now carries relay/mint/
pubkey tags identical to NutzapInfoEvent.build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 23:54:48 +00:00
Vitor Pamplona
a971f4389e perf(notifications): cut per-card composition cost on the notifications feed
The notifications feed is dominated by MultiSetCards, each rendering a gallery
of up to 30 author avatars. Profiling a loaded account showed the per-author
work on the main thread, not the GPU, as the scroll-jitter driver.

Three feature-preserving cuts:

- Hoist account-global reads out of the per-author avatar. The auto-play-gif
  setting and the logged-in follow set were collected once *per author* (~60
  redundant Flow collectors / coroutine launches per card). They are now
  collected once per gallery and passed down via LocalAuthorGalleryRenderContext.

- Dedupe the per-author metadata subscription. Each avatar fired
  UserFinderFilterAssemblerSubscription twice (via observeUserPicture +
  observeUserContactCardsScore); both observers gained a `subscribe` flag so the
  gallery subscribes once per author.

- Replace the per-event DateTimeFormatter day-bucketing in convertToCard with a
  LocalDate.toEpochDay() Long key (identical grouping, no Instant/ZonedDateTime/
  String allocation per reaction/zap/repost), and hoist the ZoneId lookup.

Measured before/after on a Samsung SM-T220 (loaded account, identical scripted
scroll, dumpsys gfxinfo): Slow-UI-thread events ~13 -> ~5 (~60% fewer),
95th-pct frame ~52ms -> ~38ms, janky frames ~13% -> ~10%. GPU timings unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:36:59 -04:00
Claude
8bb537438f feat(cli): amy cashu wallet/mint/balance on shared NIP-60/61 code
Add the offline Cashu command tier to amy, all driven by the shared
commons wallet code so amy exercises the same path as the Android app:

  amy cashu wallet create [--mint URL] [--mints a,b] [--privkey HEX] [--relay r1,r2]
  amy cashu wallet show
  amy cashu wallet export-key
  amy cashu wallet destroy
  amy cashu mint ping URL          (stateless)
  amy cashu mint info URL          (stateless)
  amy cashu balance [--mint URL]

- create/destroy reuse commons CashuWalletOps.publishWalletEvents /
  deleteWallet; show/balance reuse the CashuWalletReader projection over
  the local event store; mint ping/info hit quartz's MintHttpClient.
- Context gains cashuOps() (wired to the file NUT-13 counter store + a
  per-run seed cache) and cashuSnapshot(); DataDir gains cashu.json.
- Extraction D: CashuKeysetCounterStore contract in commons +
  FileCashuKeysetCounterStore (atomic ~/.amy/<account>/cashu.json).

PRs 4 of cli/plans/2026-05-28-cashu-cli.md (extractions A–D + offline
tier). receive/send/maintenance/mint-rec + interop harness still pending.
Verified end-to-end against mint.minibits.cash and live relays.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 22:30:59 +00:00
Claude
d111c2589d refactor(commons): extract CashuWalletReader projection for amy reuse
Add a pure, stateless CashuWalletReader in commons that projects a stream
of NIP-60/61/87 events into a WalletSnapshot (wallet/nutzap-info events,
decrypted mints, unspent token entries, history, pending quotes, nutzaps,
recommendations, plus balance + per-mint balances).

Android's CashuWalletState keeps its incremental dirty-tracking and
StateFlow plumbing but now delegates the two tricky computations —
del-rollover over decrypted tokens (computeUnspent) and the
destroyed/expired pending-quote filter (computePending) — to the shared
reader instead of carrying its own copies. amy will call project() once
per command over its event store.

Extraction C of cli/plans/2026-05-28-cashu-cli.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 22:20:25 +00:00
Claude
fc7db088b2 refactor(commons): extract CashuWalletOps to commons for amy reuse
Move the NIP-60/61 wallet orchestration layer (CashuWalletOps + its
result types: TokenEntry, MintQuoteStarted, MintCompleted, MeltCompleted,
SendTokenCompleted, RedeemCompleted, NutzapSent, RestoreOutcome,
MigrationResult, CreatedWallet, describeMintError) out of amethyst into
commons/jvmAndroid/cashu/ops.

The class already had zero Android dependencies — it takes signer,
publish, okHttpClient, secretFactory, and the NUT-13 counter callbacks as
constructor params. It lands in the jvmAndroid source set (not commonMain)
because it composes quartz's jvmAndroid CashuMintOperations/MintHttpClient
and uses ConcurrentHashMap. Both Android (amethyst) and the JVM CLI (amy)
can now drive the exact same wallet code path.

This is Extraction B of cli/plans/2026-05-28-cashu-cli.md. Android
callers (CashuWalletState, the wallet ViewModels, AccountViewModel)
updated to the new package; no behavioral change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 22:17:04 +00:00
Claude
0b76518ef2 refactor(napplet): move wire codec to commons for desktop reuse + desktop host plan
Set the desktopApp up to host napplets/nsites by maximizing the shared
core and documenting the edge it must build.

- Moved NappletProtocolJson (the wire codec) from amethyst to
  commons/jvmAndroid (package ...commons.napplet.protocol), next to the
  NappletRequest/Response types it marshals. It depends only on quartz +
  kotlinx.serialization + java.util.Base64 (Android 26+/JVM), so a future
  desktop host marshals through the identical object — request/result/push
  shapes can't drift between platforms. amethyst host/service/tests updated
  to import it; tests stay in amethyst and still exercise it.
- Added desktopApp/plans/2026-06-21-napplet-desktop-host.md: what's already
  shared (broker, protocol, codec, resolver, the shell.html/shim.js web
  contract), what desktop must build (KCEF/JCEF engine, custom-scheme
  serving, isolation, transport, gateways, UI), the decisions to make, a
  security-parity checklist, and recommended further extractions
  (NappletRequestRouter, shared web assets, the inert feed card).

commons:jvmTest and the amethyst napplet suite pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-21 22:12:28 +00:00
Claude
dfc237d27b refactor(napplet): audit fixes — sub lifecycle, broker/http caching, shim asset
Code-audit pass over the napplet subsystem (see
plans/2026-06-21-napplet-code-audit.md):

Correctness:
- Live subscriptions now store the exact INostrClient that opened them, so
  teardown unsubscribes from the right account even after an account
  switch (previously leaked on the original account).
- Multi-relay subscriptions emit a single relay.eose via an eoseSent
  latch, instead of one per relay (the SDK expects one).

Performance:
- The broker is cached per account (reference identity) instead of rebuilt
  on every request.
- The blob OkHttpClient is cached and reused (keyed by Tor port) for
  connection pooling, instead of a new client per fetch.

Refactor / docs:
- Moved the 105-line injected shim from a Kotlin string constant to
  assets/napplet/shim.js (loaded once like shell.html); corrected stale
  onChanged / subscribe comments.

Deferred (documented with rationale): cross-relay event dedup,
background pause/resume of live subs, request ordering, and the
recommended NappletProtocolJson -> commons/jvmAndroid move.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-21 22:01:57 +00:00
Claude
cd2bae081e feat(napplet): live subscription tail, multi-filters, resource.cancel
Implements the remaining tractable conformance follow-ups:

- Live subscription tail: relay.subscribe now opens a real
  client.subscribe whose SubscriptionListener streams relay.event
  (stored + live), relay.eose, and relay.closed pushes keyed by subId,
  instead of a one-shot snapshot. relay.close unsubscribes (tracked in
  liveSubs, torn down in onDestroy). The broker only authorizes the
  subscription (RELAY consent) and returns Subscribed; the host owns the
  live stream. Shim dispatches relay.closed too.
- Multi-filters: relay.query/subscribe honor every filter in filters[],
  not just the first (decodeFilterList; gateway query(List<Filter>);
  queryEvents unions across filters; max limit applied).
- resource.cancel: accepted at the host edge as a no-op Done.

Conformance tests extended (multi-filter decode, relay.closed push);
commons:jvmTest and the amethyst napplet suite pass.

Still open: identity getList/getZaps/getBadges + onChanged (shapes
underspecified), the keys.action push (needs a host trigger UI), the
resource nostr: scheme, inc + the niche domains, and on-device
verification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-21 21:43:36 +00:00
Claude
3df9f831b8 feat(quartz): complete KindNames registry for every supported kind
Fill the KindNames registry from 145 to 280 entries so every event kind
quartz defines a class for has a canonical English label + NIP. Adds the
whole NIP-90 DVM request/response set, NIP-29 relay groups, NIP-60/61
Cashu wallet + nutzaps, NIP-43 relay members, WebRTC calls (NIP-AC),
marketplace (NIP-15), git PRs/state (NIP-34), CLINK, NIP-51 curation
sets, NIP-85 assertions, Marmot MLS events, and more.

For the handful of kind numbers shared by multiple classes, the registry
keeps one canonical entry (e.g. CashuToken over the deprecated nip61
TokenEvent, ExternalIdentities over GalleryList, ReleaseArtifactSet over
SoftwareRelease). Kind 1 stays "Notes" rather than the bounty value-add
helper that reuses it.

Also point `amy nip`'s Nostr fallback at quartz's canonical NipText kind
30817 (NipTextEvent) alongside the wiki and long-form kinds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 21:41:52 +00:00
Claude
938f40d568 refactor(amethyst): fall back to quartz KindNames for unknown kinds
The relay kind chips, NIP-86 management screen, and app-recommendation
labels all map kinds to translated R.string labels via kindDisplayName(),
returning -1 for kinds without a localized name. Route that -1 case
through the shared quartz KindNames registry so any kind known to the
protocol layer renders its canonical English label instead of a bare
"k<kind>"/empty placeholder. Translations still win where they exist;
quartz is the canonical source for the rest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 21:10:31 +00:00
Claude
0f23051d54 feat(napplet): implement the four SDK conformance breakers
Closes the 🔴 items from the conformance audit so stock @napplet/shim
napplets interop:

1. Shell handshake: the host answers shell.ready with shell.init
   {capabilities:{domains,protocols},services} built from the declared
   domains (NappletProtocolJson.encodeShellInit), so supports() works.
2. Id-less messages: onShellMessage no longer drops messages without an
   id — shell.ready is answered locally and fire-and-forget messages get
   a synthetic id so they reach the broker.
3. keys: keys.registerAction/unregisterAction decode and the broker acks
   them (declared-gated, no consent) so registerAction() resolves; the
   shim dispatches the keys.action push. (Global-key binding is a
   follow-up — keys.action isn't emitted yet.)
4. upload: realigned to upload.upload{request:{data,mimeType,filename}} →
   rich UploadResult{ok,uploadId,status,url,sha256,size,mimeType};
   shell.html inlines the request Blob as base64 so it survives the
   bridge; the gateway uploads via the app's BlossomUploader to the
   user's kind:10063 server with a signed auth event.

NappletSdkConformanceTest's gap guards flip to conformance assertions for
shell.init/keys/upload; inc stays the one documented gap. commons:jvmTest
and the amethyst napplet suite pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-21 21:02:55 +00:00
Claude
b6c065d421 feat(quartz,cli): add KindNames registry + amy kind
Introduce a canonical, i18n-free event-kind registry in quartz:
`com.vitorpamplona.quartz.kinds.KindNames` maps each of the 145 known
kinds to an English label + the defining NIP. The data is ported from
Amethyst's relay-view `kindDisplayName` mapping (the NIP derived from
each event class's package), so the "what is kind N" knowledge now lives
once in quartz instead of only in the Android UI.

`amy kind <N|NAME>` looks a kind up by number (label + NIP) or searches
labels by name — a thin wrapper over KindNames, dispatched statelessly
(no account/network).

i18n split: quartz holds the canonical English (quartz is intentionally
translation-free); localized front ends overlay their own strings and can
fall back to KindNames.nameFor() for kinds they don't translate. amy
prints the English directly.

Verified: kind 1/0/30023/1059/24133 labels+NIPs, name search ("podcast"
→ 4), unknown → known:false, text + JSON output.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 21:00:54 +00:00
Claude
749a301469 feat(cli): cheap nak-parity wins — key nip49, nip lookup, blossom check/mirror
- `amy key encrypt|decrypt` (NIP-49): encrypt a secret key to ncryptsec1…
  and back. Bidirectionally interop-verified vs the real nak binary
  (amy-encrypt → nak-decrypt and nak-encrypt → amy-decrypt both match).
- `amy nip N` / `amy nip list`: look up a NIP — the nostr-protocol/nips
  git repo FIRST, then a Nostr fallback (NIP-50 search over wiki kind:30818
  + long-form kind:30023 on search-capable relays). Slug normalization
  handles 1→01 and hex-suffixed NIPs (7d→7D, 5a→5A); titles parsed from the
  setext headings NIP docs use.
- `amy blossom check|mirror`: HEAD-check blobs (exit 1 if any missing, like
  nak) and request BUD-04 mirroring of a blob from a source URL.

Also persists the full introspected nak comparison into ROADMAP. `kind`
stays deferred — it needs a kind→schema registry that doesn't exist in
quartz (would be data/logic that doesn't belong in cli).

Verified: key round-trip + nak cross-impl both ways; nip repo titles for
01/46/5A/7D + Nostr fallback on miss; blossom check 200/404 + exit codes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 20:50:02 +00:00
Vitor Pamplona
2187158c53 Merge pull request #3327 from vitorpamplona/claude/disappearing-scaffold-animation-zk9lql
Fix disappearing bar settle logic to prevent blank bands on partial reveals
2026-06-21 16:48:28 -04:00
Claude
d96d0220fb feat: gap-safe settle, snappier spring, and jitter dead-zone for disappearing bars
Three refinements to the disappearing top/bottom bar animations:

- Gap-safe settle: settleToNearestEdge could snap a partially-collapsed bar to
  fully hidden whenever it was past the halfway point, even when the content had
  only scrolled part of a bar height (e.g. a gentle flick from the top). Because
  the content padding is fixed and the bar is translated, hiding it further than
  the content scrolled reopens the same blank band the reveal-damping fix removed.
  Each bar now latches whether it has actually reached its hidden edge through
  scrolling; the settle only commits to fully hidden when that latch is set,
  otherwise it settles back into view. This keeps the deliberate-reveal behavior
  (a sub-halfway reveal after fully hiding still snaps back hidden) without the gap.

- Snappier settle spring: StiffnessMediumLow -> StiffnessMedium so the bars
  resolve to their edge with a quick native snap instead of a slow float. Still
  DampingRatioNoBouncy, and overshoot stays clamped by animateOne's bounds.

- Micro-scroll dead-zone: ignore sub-pixel scroll attempts so jitter doesn't
  nudge the bars or flip the binary status-bar toggle. Kept tiny and symmetric so
  the reveal never lags the content enough to open a gap.

Proportional top/bottom collapse was intentionally left out: both bars already
move at the same pixel rate (visual lock-step for their shared travel), and
forcing the shorter bar to finish at the same time as the taller one would push
it off the 1:1 content track and reintroduce a gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011jbLnoWks19ottXNrMZ6DH
2026-06-21 20:32:03 +00:00
Claude
ed3fbdd25e test(napplet): SDK wire-conformance audit + pinned conformance suite
Feature-by-feature audit of our edge layer against the canonical
@napplet/nap@0.15.0 / @napplet/core@0.15.0 message types, plus a test
suite that pins the codec to the SDK's exact wire so drift fails CI.

Audit (plans/2026-06-21-napplet-sdk-conformance-audit.md) catalogs every
domain with the verified wire shapes and ranks the inconsistencies found:

- 🔴 shell handshake missing: SDK uses shell.ready -> shell.init{capabilities,
  services} and answers supports() locally; we model a shell.supports request
  the SDK never sends, so a real napplet's capability env stays empty.
- 🔴 host drops id-less messages (shell.ready / inc.emit / keys.unregisterAction).
- 🔴 keys.* rejects at the boundary (only client-side stubs).
- 🔴 upload non-conformant (upload vs upload.upload; flat base64 vs
  request:{data:Blob}; and a Blob can't cross our JSON string bridge).
- ◐ relay query/subscribe use only the first of filters[]; identity
  getList/getZaps/getBadges + onChanged; resource nostr: + cancel;
  relay.closed + live tail.

Conformant and now pinned by NappletSdkConformanceTest: relay
publish/publishEncrypted/query/subscribe + event/eose pushes, identity
reads (method-specific result fields), storage get/set/remove/keys,
resource.bytes, and the error convention. Gap guards assert the current
(non-conformant) behavior so each flips intentionally when fixed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-21 20:25:20 +00:00
Claude
2c37ae642c feat(nip46): handle auth_url challenges in the remote-signer client
Adds NIP-46 `auth_url` (web-authorization) support to quartz's remote
signer, so amy (and Amethyst) can use auth-requiring bunkers like
nsec.app / nsecbunker:

- Both BunkerResponse deserializers (kotlinx + the JVM/Android Jackson
  one actually used by OptimizedJsonMapper) now special-case
  `{result:"auth_url", error:<url>}` BEFORE the generic error branch,
  which previously flattened it to a plain error and dropped the marker.
- RemoteSignerManager gained an `onAuthUrl` callback and a wait loop: on
  an auth_url response it surfaces the URL once and keeps waiting for the
  real response under the same request id (UNLIMITED channel so both are
  buffered). newResponse peeks the pending entry (get, not remove) so the
  follow-up isn't dropped; the waiter still removes it in finally.
- NostrSignerRemote forwards `onAuthUrl`; amy's Context prints the URL to
  stderr and the pending command keeps waiting.

Tests: a deserializer test (auth_url keeps result+error) and a manager
test (auth_url surfaced via callback, then the real response resumes the
request) — both green; existing duplicate/late-response manager tests
still pass after the get-vs-remove change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 20:19:01 +00:00
Claude
0d2ac83ebb feat(nsite): runtime hardening — blossom: fetch, sniffing, server fallback, blob cache
Closes the remaining nsite/napplet runtime gaps, all keeping content
integrity (every blob is sha256-verified) and the sandbox intact:

- resource.bytes blossom: scheme — blossom:<sha256> fetches from the
  user's kind:10063 Blossom servers and verifies the hash before
  returning. nostr: stays deferred (bytes semantics unspecified).
- Content-type byte-sniffing in the resolver: when a manifest path has
  no/unknown extension, sniff magic bytes (png/jpeg/webp/gif/pdf/wasm/...).
  Text/markup is never sniffed so HTML detection stays extension-driven.
  Unit-tested in quartz.
- kind:10063 server fallback: the launcher augments the manifest's servers
  with the author's published Blossom list (best-effort, when cached).
- Blob caching: the host OkHttp client caches blobs on disk with a forced
  immutable policy (content-addressed). The resolver re-verifies every
  served blob's sha256, so a stale/poisoned cache entry can't be served.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-21 20:11:15 +00:00
Claude
8b7caa5b01 feat(cli): NIP-46 nostrconnect:// reverse flow (both sides)
Add the client-initiated NostrConnect flow to complete NIP-46 parity:

- `amy login --nostrconnect [--relay …] [--name N]` (client) mints a
  transport keypair, prints a nostrconnect:// offer, subscribes, and
  waits for the signer's connect ACK (a kind:24133 whose decrypted
  result echoes our secret). The ACK's author is the signer; it persists
  a bunker account acting as that key.
- `amy bunker connect nostrconnect://…` (signer) parses a client offer,
  sends the secret-echo ACK, then services that client's requests on the
  offer's relays. Shares the serve loop with `amy bunker`.

NostrConnect.kt holds the offer parse/build (+percent-decode) and the
client handshake. BunkerCommand grew a `connect` sub-mode and factored
the request loop into serve().

Verified end-to-end:
- amy client ⇄ real `nak bunker connect`: amy learns nak's key and signs;
  event authored by nak, signature valid.
- amy client ⇄ amy `bunker connect`: same, authored by the host key.

Only `auth_url` challenges remain unimplemented in the NIP-46 surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 20:04:03 +00:00
Claude
695a5b4b25 fix(cli): nak-compatible bunker — percent-encoded URIs + get_relays
Close the NIP-46 interop gap against the real nak binary:

- `Identity.fromBunkerUri` now URL-decodes the relay/secret params. nak
  emits `bunker://<pk>?relay=wss%3A%2F%2F…&secret=…`; without decoding,
  `amy login` of a nak bunker URI produced a broken relay and never
  connected. (This was the one real break — found by testing vs nak.)
- `amy bunker` now percent-encodes the relay/secret in the URI it prints,
  matching nak's output format.
- `amy bunker` implements `get_relays` (returns its relay set), so nak
  clients that probe it get a proper reply instead of an error.

Verified end-to-end with `go install`-built nak over relay.damus.io,
both directions:
- `amy login bunker://` ⇄ `nak bunker`: amy signs, event authored by
  nak's key, signature valid.
- `nak event --sec bunker://<amy>` ⇄ `amy bunker`: nak signs through
  amy, event authored by amy's key; amy logs connect→ok, sign_event→ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 19:52:37 +00:00
Claude
54a6b44324 feat(cli): NIP-46 bunker — remote signer server + bunker login
Add both halves of NIP-46 remote signing so two amy processes interop:

- `amy bunker [--relay …] [--secret S] [--timeout SECS]` runs a remote
  signer for the active local-key account: prints a bunker:// URI, then
  subscribes to kind:24133, decrypts each BunkerRequest, dispatches to
  ctx.signer (connect/get_public_key/sign_event/nip04/nip44/ping), and
  publishes the encrypted BunkerResponse. Long-running like `subscribe`.
- `amy login bunker://PUBKEY?relay=…&secret=…` creates a remote-signer
  account: mints a local transport keypair, records the connection, and
  acts as PUBKEY. Context builds a NostrSignerRemote (vs the local
  NostrSignerInternal) and runs openSubscription()+connect() in prepare();
  every signing/encryption call is delegated to the bunker.

Storage: IdentityFile gains a `bunker` block; the transport key is kept
in the existing SecretStore `secret` field. Identity/DataDir load+save
handle the remote-signer account type; `canSign` reflects "writeable via
bunker".

Thin assembly over quartz nip46RemoteSigner (NostrSignerRemote,
BunkerRequest*/BunkerResponse*, NostrConnectEvent). Verified end-to-end
over relay.damus.io: alice hosts a bunker, bob logs in and `amy event`
signs remotely — the note is authored by alice's key and verifies
(id_ok + signature_ok); bunker logs connect→ok, sign_event→ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 19:38:56 +00:00
Claude
73d52401cd fix: remove reveal damping so the top bar stays glued to content
The 0.5 reveal-sensitivity damping in DisappearingBarNestedScroll made the
bars reveal at half the rate they hide. Because the content scrolls 1:1, the
bar offset would lag behind the content scroll: after hiding the chrome and
scrolling back up to the top, the list reaches its top while the bar is still
only half revealed, leaving a blank band between the bar and the first item.

The bar's translationY must mirror the content scroll offset exactly so its
bottom edge stays glued to the first item's top edge. Any persistent reveal
damping breaks that invariant and opens the gap, so revealing now tracks the
finger 1:1 like hiding does.

The original goal of the damping — keeping the chrome from popping back on the
tiny reverse drag a finger makes when it catches a fast scroll — is still met
without breaking the 1:1 invariant: a partial reveal that doesn't cross the
halfway point is snapped back to the hidden edge by settleToNearestEdge on
fling/lift, and the binary OS status bar is already debounced by the
show/hide hysteresis in the scaffold.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011jbLnoWks19ottXNrMZ6DH
2026-06-21 19:35:30 +00:00
Claude
76d9951d4f feat(napplet): SPA route fallback + external-link handoff in the host
Two nsite/napplet runtime improvements in the sandboxed WebView host,
both keeping the trust boundary intact:

- SPA fallback: a document navigation (Accept: text/html) to a route not
  in the manifest now serves the verified index.html instead of 404, so
  client-side-routed static sites survive deep links and refreshes.
  Missing sub-resources (js/css/images) still 404 — they don't accept
  html — so a broken asset never silently returns the page. The fallback
  only ever serves the manifest's own hash-verified index.html.

- External links: a link the user taps to an off-origin host is handed to
  the system browser via ACTION_VIEW instead of silently failing. Gated on
  a real user gesture (so a hostile site can't auto-redirect to spam-open
  the browser) and restricted to http/https (no arbitrary intent schemes).
  The sandbox WebView itself never navigates away from the internal origin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-21 19:29:43 +00:00
Claude
69d03bacca feat(cli): add nak-style git (NIP-34) + podcast (NIP-F4) commands
git (repository metadata + collaboration events; clone/push packfile
transport is out of scope):
- git announce  publish a kind:30617 repository announcement
- git list      list a user's repo announcements
- git show      print one announcement (naddr or kind:pubkey:id), cache-first
- git issue     publish a kind:1621 issue against a repo (fetches the repo
                announcement to build the EventHintBundle)

podcast (NIP-F4):
- podcast metadata  publish kind:10154 show metadata (replaceable)
- podcast publish   publish a kind:54 episode (--audio URL[,URL])
- podcast list      list a user's metadata + episodes

Thin assembly over quartz GitRepositoryEvent / GitIssueEvent /
PodcastMetadataEvent / PodcastEpisodeEvent. Verified offline: announce->
show cache round-trip (name/clone/hashtags), issue kind:1621 against the
repo, podcast metadata kind:10154 + episode kind:54.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 19:13:21 +00:00
Claude
fb35c85de4 feat(cli): add nak-style sync (NIP-77 Negentropy) primitive
`amy sync --relay URL [filter] [--down] [--up]` reconciles the local
event store with a relay using NIP-77 Negentropy:

- negotiate the symmetric difference under the filter (drives quartz
  NegentropySession over a raw WebSocket, mirroring geode's interop
  driver),
- --down (default) downloads the ids the relay has and we lack via
  Context.drain,
- --up uploads the events we have and the relay lacks via
  Context.publish.

Filter flags match fetch/subscribe; an empty filter reconciles the
whole store. Verified against relay.damus.io: cold store -> need=60,
downloaded=60; immediate re-sync -> need=0 (idempotent).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 19:09:18 +00:00
Claude
b3b23df675 Merge origin/main into claude/loving-hopper-rn553t
Integrate the `refactor(cli): simplify amy dispatch, Context lifecycle,
and store handling` change (Commands.kt removed, new route() sub-verb
helper, Context.open(dataDir).use { } lifecycle) with the nak-parity
Tier-1 commands added on this branch.

Re-wiring:
- Dropped Commands.kt; wired the 16 new verbs directly into Main.kt's
  dispatch (stateless: decode/encode/verify/key/filter + relay info
  interception; account-path: event/publish/fetch/subscribe/count/
  encrypt/decrypt/gift/outbox/blossom).
- Converted every new command from hand-rolled try/finally to
  Context.open(dataDir).use { ctx -> } and the multi-verb dispatchers
  (gift/blossom/key) to the shared route() helper.
- RelayCommands.dispatch now routes via route(); `relay info` stays a
  stateless Main interception and is also in the route map.

Verified post-merge: build green + smoke test across stateless,
account-path, and route() sub-verb paths (decode/key/filter, event->
verify round-trip, gift/blossom bad-verb errors, relay info on damus).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 18:47:18 +00:00
Vitor Pamplona
abd540e3c2 Merge pull request #3326 from vitorpamplona/claude/relaxed-meitner-3yr85y
Refactor command dispatch: extract Router helper, remove Commands.kt
2026-06-21 14:39:03 -04:00
Vitor Pamplona
1909f402ab Merge pull request #3325 from vitorpamplona/claude/feed-media-prefetch
feat(feeds): prefetch media + pre-parse text ahead of the viewport
2026-06-21 14:36:50 -04:00
Claude
8e1059a4ab feat(cli): add nak-style blossom blob commands (upload/download/list/delete)
Sixth batch of nak parity — Blossom (NIP-B7 / BUD-01/02/04):

- `amy blossom upload --server URL FILE [--mime-type M]` — authed upload,
  prints the blob URL + sha256.
- `amy blossom download URL|HASH [--out FILE]` — public download (accepts
  a full URL or a HASH + --server).
- `amy blossom list --server URL [USER]` — list a user's blobs (authed).
- `amy blossom delete HASH --server URL` — delete a blob you own.

Reuses commons BlossomClient + BlossomAuth and quartz
BlossomAuthorizationEvent / sha256; list/delete use OkHttp directly with
the quartz-built kind:24242 auth header. Verified a full upload->download
sha256 round-trip against blossom.primal.net and an authed list.

This completes the Tier-1 nak-parity surface (decode/encode/verify/key/
event/publish/fetch/subscribe/count/encrypt/decrypt/gift/filter/relay
info/outbox/blossom); only the kind/nip reference lookups remain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 17:46:27 +00:00
Vitor Pamplona
df1982a7f6 feat(feeds): prefetch media + pre-parse text ahead of the viewport
Warms the next/previous few feed notes off the main thread so media and
link previews are ready before the user scrolls to them, and pre-parses
rich-text bodies into the shared cache so scroll-time composition is a
cache hit instead of a UI-thread parse.

Per upcoming note (off Dispatchers.Default, deduped via a per-feed set,
cancelled on a new visible range):
- Pre-parses TextNote/Comment bodies through CachedRichTextParser using the
  renderer's exact key, so the composition reads a cached parse. Other kinds
  still get their media/links discovered, just without the render-cache warm.
- Prefetches images into Coil — inline content images, NIP-92 imeta blobs,
  NIP-94 file-header url tags, and video poster frames — and records each
  decoded aspect ratio in MediaAspectRatioCache so the box is reserved on
  first layout (no jump). Video poster ratios seed the video URL's box too.
- Warms OpenGraph/link previews via UrlCachedPreviewer.
Gated on showImages()/showUrlPreview() so it honors data-saver/Wi-Fi-only.
Video bytes are deliberately not prefetched (large, HLS, player pool already
starts fast).

Wired centrally at the two feed dispatchers (RenderFeedContentState,
RenderFeedState) so every list feed routed through them is covered without
per-screen wiring, plus direct hooks for the custom-render feeds (hashtag,
profile notes) and a LazyGridState variant for the grid feeds (gallery,
products, discover).

CachedRichTextParser is now content-addressed (memoized contentHash on
ImmutableListOfLists) so an off-thread pre-parse maps to the same entry the
renderer looks up, and its cache grows 50 -> 500 to hold the prefetch +
multi-feed working set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 13:44:39 -04:00
Claude
0fb1ca473e fix(profile): label the Apps tab "Apps & Sites" (it lists nsites too)
The tab already lists both NIP-5D napplets and NIP-5A nsites (the filter
and subscription include 15128/35128); rename the label so nsites aren't
hidden behind an "Apps"-only name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-21 17:44:22 +00:00
Claude
62d0b16740 feat(cli): add nak-style filter, outbox, relay info primitives
Fifth batch of nak parity:

- `amy filter [filter flags]` — stateless: assemble + print a NIP-01
  filter JSON from the same flags fetch/subscribe use (no query sent).
- `amy outbox USER` — show a user's NIP-65 read/write relays (outbox
  model), cache-first with a relay-drain fallback / --refresh.
- `amy relay info URL` — stateless NIP-11 info-document fetch over HTTP
  (Accept: application/nostr+json), parsed by quartz
  Nip11RelayInformation.

filter + relay info dispatch before account resolution (no ~/.amy
needed). Verified against relay.damus.io (NIP-11 doc) and fiatjaf's
kind:10002 (outbox read/write sets).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 17:42:28 +00:00
Claude
0ec3fc69e8 feat(cli): add nak-style count, encrypt/decrypt, gift primitives
Fourth batch of nak parity:

- `amy count [filter]` — NIP-45 COUNT, per-relay match counts (reuses
  quartz INostrClient.count). Reports per-relay + max as `total`.
- `amy encrypt --to USER [TEXT]` / `amy decrypt --from USER [CIPHER]` —
  raw NIP-44 (default) or NIP-04 (--nip04) with the active account key.
- `amy gift wrap --to USER [EVENT]` / `amy gift unwrap [WRAP]` — NIP-59
  seal+wrap and unwrap+unseal (reuses SealedRumorEvent/GiftWrapEvent).

Text/ciphertext/JSON all read from arg or stdin. Verified with two
local accounts: NIP-44 + NIP-04 round-trips, gift wrap(1059)->unwrap
recovering the inner note + author, and count=60 against relay.damus.io.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 17:38:01 +00:00
Claude
afc3c83baa feat(cli): add nak-style fetch + subscribe query primitives
Third batch of nak parity — the fetch-vs-subscribe split of nak's `req`:

- `amy fetch [filter] [--timeout SECS]` — one-shot query: open a
  subscription, collect until every relay sends EOSE (or timeout),
  dedupe by id, sort newest-first, cap at --limit (default 100), exit.
- `amy subscribe [filter] [--timeout SECS]` — live stream: print each
  matching event as it arrives (NDJSON under --json), until timeout or
  interrupt.

Shared filter flags (--kind/--author/--id/--tag/--since/--until/--limit
/--search) are assembled by RawEventSupport.buildFilter; --author/--id
accept npub/nevent/note/naddr or hex (local decode). fetch reuses
Context.drain; subscribe uses the client subscription directly and
verifies each event before printing. Verified against relay.damus.io
(kind:0 by author, kind:1 stream).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 17:34:24 +00:00
Claude
b539609dfe feat(cli): add nak-style event + publish primitives
Second batch of nak parity:

- `amy event --kind N [--content …] [--tags JSON] [--created-at TS]`
  builds and signs an arbitrary event with the active account. Prints
  the signed event by default; `--publish` / `--relay` broadcasts it.
- `amy publish [EVENT-JSON] [--relay …]` broadcasts a pre-made, signed
  event (verified before broadcast; reads stdin when no arg).

Both reuse quartz EventTemplate/NostrSigner.sign and the existing
Context.publish path. New RawEventSupport holds the shared arg/stdin +
relay-target helpers for the raw-event verbs. Verified offline via an
event -> verify round-trip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 17:30:47 +00:00
Claude
48b988d2f7 refactor(cli): simplify amy dispatch, Context lifecycle, and store handling
Four mechanical simplifications to amy with no change to the public
CLI/JSON contract:

1. Drop the Commands.kt pass-through layer. Main.kt now calls each
   command object directly; the file is repurposed into Router.kt,
   holding a single shared `route(name, tail, usage, routes)` helper.

2. Replace the `Context.open(dataDir)` + `try { } finally { ctx.close() }`
   boilerplate (~46 sites) with `Context.open(dataDir).use { ctx -> }`
   now that Context is AutoCloseable.

3. Remove the reflection-based `storeIsInitialized()` in Context; track
   the lazy event store via `Lazy.isInitialized()` instead.

4. Route every `*Commands.dispatch` through the `route` helper, dropping
   the repeated empty-check + unknown-verb `when` boilerplate.

Net -282 lines. Docs (cli/DEVELOPMENT.md, amy-expert skill + template)
updated to the new wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QjEvS812aPLZ6nM2XLobzF
2026-06-21 17:24:00 +00:00
Claude
b41160f1cb feat(cli): add nak-style stateless primitives (decode, encode, verify, key)
Add the first batch of nak parity commands to amy — the army-knife
primitives that operate purely on their arguments, with no account or
network. They dispatch before account resolution (like `use`), so they
run with zero `~/.amy/` state:

- `amy decode ENTITY`   NIP-19/21 entity -> JSON
- `amy encode <type> …` raw parts -> NIP-19 entity
- `amy verify [JSON]`   id-hash + signature check (reads stdin)
- `amy key generate|public`  mint a keypair / derive a pubkey

All four are thin wrappers over quartz (Nip19Parser, the NIP-19
entities, Event.verifyId/verifySignature, KeyPair) per the cli
thin-assembly rule. README + ROADMAP updated with a nak-parity matrix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 17:13:05 +00:00
Claude
434ef31f35 feat(profile): "Apps" tab listing a user's nsites & napplets
Adds a profile tab that lists the NIP-5A nsites (15128/35128) and NIP-5D
napplets (15129/35129) a user publishes, surfacing them per-author. Each
row is the inert feed card; opening one launches the sandboxed :napplet
process — the tab never executes applet code.

- UserProfileAppsFeedFilter + UserProfileAppsFeedViewModel (mirror the
  gallery feed; scans LocalCache.addressables for the four manifest kinds
  authored by the user, honoring mute/hidden filters).
- TabApps renders the feed via RefresheableFeedView -> NoteCompose (which
  now has the napplet/nsite cards).
- ProfileScreen: new ProfileTab.Apps wired through the pager.
- Profile relay subscription fetches the manifest kinds (UserProfileAppKinds)
  so the tab is populated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-21 16:18:04 +00:00
Claude
ad351c3127 feat(napplet): inline napplet feed card (sandbox-preserving)
Napplets (15129/35129) had no inline renderer — only the dedicated
browse screen. Add RenderRootNappletEvent/RenderNamedNappletEvent
mirroring the nsite card: an inert Compose card (Text + Button, no
WebView) that shows title/description/source/servers and the declared
capabilities, and launches into the sandboxed :napplet process via
NappletLauncher only on explicit tap. The card never executes applet
code, so feed exposure doesn't widen the trust boundary.

- StaticWebsite.kt: napplet renderers + RenderStaticWebsite gains a
  titleRes and a requires (permissions) row.
- NoteCompose: render branches for RootNappletEvent/NamedNappletEvent.
- LocalCache: index the napplet kinds so feeds can scan them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-21 16:10:14 +00:00
Vitor Pamplona
fa11a94613 Merge pull request #3323 from vitorpamplona/claude/powr-workout-interop-r1efh0
Add POWR / NIP-101e strength workout interop (read-only)
2026-06-21 11:52:55 -04:00
Vitor Pamplona
c503d2ee56 Merge pull request #3324 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-21 11:40:30 -04:00
Claude
35af6ca580 fix(fitness): store kind-33401 exercise templates in LocalCache
justConsumeInnerInner's when(event) has no generic addressable fallback —
its else branch logs "Event Not Supported" and drops the event. A kind-33401
ExerciseTemplateEvent arriving from a relay was therefore never stored as an
AddressableNote, so the fetched template never attached to its placeholder
note and the workout card's exercise title never resolved. Dispatch it to
consumeBaseReplaceable like the other addressable definitions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJwXz6CWez8r7trgHXT545
2026-06-21 15:39:55 +00:00
vitorpamplona
4aa713e2db chore: sync Crowdin translations and seed translator npub placeholders 2026-06-21 15:36:45 +00:00
Vitor Pamplona
d25f80bcb3 Merge pull request #3321 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-21 11:35:14 -04:00
Claude
56d836724f feat(napplet): structured-clone transport + relay.subscribe push channel
Stock napplets (built on @napplet/core) post structured-clone OBJECTS,
not JSON strings, and expect object replies — our shell only forwarded
strings, so their messages were dropped. Bridge the transport so real
napplets interop:

- shell.html bridges object<->string both directions: applet->native
  serializes object envelopes; native->applet parses and posts a
  structured-clone object (what the SDK reads via e.data.type).
- resource.bytes returns a real Blob: the shell rebuilds it from the
  host's base64 bytes+mime before delivering.
- relay.subscribe is push-based: relay.event (per match) then relay.eose,
  keyed by subId, no .result — matching @napplet/shim. New MSG_PUSH IPC
  frame carries unsolicited envelopes the host forwards verbatim;
  relay.close is a fire-and-forget no-op. Delivers the initial snapshot
  then EOSE (a live tail is a follow-up).

Injected shim updated to accept object messages, dispatch subscription
pushes by subId, and use the push-based subscribe. Codec gains
encodeRelayEvent/encodeRelayEose/readSubId with unit tests.

The shell/shim are JS and not covered by the JVM tests — this needs
on-device verification with a playground napplet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-21 15:34:36 +00:00
Claude
17b3252e76 fix(napplet): correct wire shapes to @napplet/nap message types
Verified the result field names and wire discriminants against the
canonical @napplet/nap 0.15.0 message types and value-types, and fixed
several mismatches:

- identity reads return method-specific fields, not a generic "result":
  getProfile->profile, getRelays->relays, getFollows/getMutes/getBlocked
  ->pubkeys, getList->entries, getZaps->zaps, getBadges->badges.
- getProfile now builds a ProfileData object ({name, displayName, about,
  picture, banner, nip05, lud16, website}) from parsed kind-0 metadata,
  mapping display_name -> displayName, instead of dumping raw content.
- storage wire types are storage.get/set/remove/keys (the SDK functions
  are getItem/setItem/removeItem/keys); storage.keys returns `keys`.
- relay.publish / publishEncrypted carry the unsigned template in the
  `event` field (per @napplet/shim), not `template`.

Shim updated to match; codec round-trip tests lock the shapes.

Still open and documented: the transport is structured-clone objects
(not JSON strings) with a real Blob for resource.bytes, and
relay.subscribe uses relay.event/relay.eose push — the shell relay needs
an object<->string bridge + push channel, with on-device verification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-21 14:42:28 +00:00
vitorpamplona
eb04e09d26 chore: sync Crowdin translations and seed translator npub placeholders 2026-06-21 14:32:14 +00:00
Vitor Pamplona
7b93bfb5b9 Merge pull request #3313 from vitorpamplona/claude/recommended-apps-search-3628xk
Add search and follow-list filtering to app recommendations
2026-06-21 10:30:39 -04:00
Vitor Pamplona
5f2e879d31 Merge pull request #3314 from vitorpamplona/claude/public-chat-notifications-qmn4gh
Notify on public chat replies without p-tags
2026-06-21 10:29:54 -04:00
Vitor Pamplona
d34453ec17 Merge pull request #3312 from vitorpamplona/claude/share-options-drawer-70nrq4
Extract share options to reusable bottom sheet
2026-06-21 10:27:36 -04:00
Claude
24638cc50c feat(napplet): implement the identity read API
Wire the upstream identity.* read methods (beyond getPublicKey) to the
active Account, returning JSON gated by the IDENTITY consent:

- getProfile  -> kind-0 metadata content
- getRelays   -> NIP-65 { "<url>": { read, write } } map
- getFollows  -> kind-3 followed author pubkeys
- getMutes    -> NIP-51 mute-list user pubkeys (decrypted)
- getBlocked  -> NIP-51 block-list user pubkeys (decrypted)

getList/getZaps/getBadges route through but degrade to Unsupported for
now; onChanged stays a client-side no-op until the live push channel
lands. Reads are public data only — never key material — and remote/
external signers still self-gate the consent.

Adds NappletRequest.IdentityRead, NappletResponse.Json, a
NappletIdentityGateway collaborator, codec round-trip for identity.*,
the shim methods, and unit tests. commons:jvmTest and the amethyst codec
test pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-21 14:26:27 +00:00
Claude
278dac7fea Merge remote-tracking branch 'origin/main' into claude/recommended-apps-search-3628xk
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt
#	amethyst/src/main/res/values/strings.xml
2026-06-21 14:11:37 +00:00
Vitor Pamplona
43f8919ce3 Merge pull request #3322 from vitorpamplona/claude/app-recommendations-search-h8bklq
feat: add a search box to the App Recommendations screen
2026-06-21 09:58:32 -04:00
David Kaspar
4a577485cf Merge pull request #3319 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-21 11:56:34 +01:00
Claude
eaa6c147e4 feat: add a search box to the App Recommendations screen
The Recommended Apps screen listed every discovered app definition in a
single sorted LazyColumn with no way to filter, so finding a specific app
to recommend meant scrolling the whole list. Users couldn't find a search
because there wasn't one.

Add an always-visible outlined search field below the description that
filters the list by app name (case-insensitive). Rows whose kind 31990
definition hasn't arrived yet have no name to match, so they only appear
when the box is empty. A dedicated empty state shows when a query matches
nothing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EjuK6g3KNkyEEnti6JtDEi
2026-06-21 02:40:00 +00:00
vitorpamplona
3dcc46de2b chore: sync Crowdin translations and seed translator npub placeholders 2026-06-21 02:25:11 +00:00
Vitor Pamplona
6227cfd220 Merge pull request #3320 from vitorpamplona/claude/modest-mayer-8mo1zd
feat: add Notification setting to show or hide Messages on the Notification tab
2026-06-20 22:22:58 -04:00
Vitor Pamplona
da5525c15d Merge pull request #3318 from vitorpamplona/claude/reply-reaction-android-tray-9bkgcx
fix: surface reply, not root, for multi-e-tag reactions in Android tray
2026-06-20 22:21:09 -04:00
Claude
966ba17455 fix: surface reply, not root, for multi-e-tag reactions in Android tray
A kind:7 reaction can carry several `e` tags (e.g. the thread root plus
the actually-reacted-to reply). Per NIP-25 the last `e` tag is the event
being reacted to, but the notification consumer resolved the reacted note
via `originalPost().firstOrNull()`, which picked the root. The tray then
showed the like as if it targeted the root note instead of the reply.

Switch to `lastOrNull()` to match NIP-25 and the in-app reaction card,
which already resolves the target with `note.replyTo?.lastOrNull()`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018EEQN8Sy7mQ81BEC3rUu5o
2026-06-21 00:17:46 +00:00
LubuSeb
4e6d8e106c Add URL-scoped comment feeds 2026-06-20 23:26:29 +02:00
Claude
bd8beb43e1 feat: add Notification setting to show or hide Messages on the Notification tab
Adds a "Show Messages" toggle to the Display section of Notification Settings.
When disabled, direct/group messages (NIP-17 chats, NIP-04 DMs, and Marmot
group messages) are filtered out of the Notification tab, keeping them only in
the Messages tab. Defaults to on, preserving existing behavior.

The setting is persisted per-account in AccountSettings/LocalPreferences and is
included in the notification feed key so the feed refreshes immediately when
toggled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018FwwcaBRyvnJFWaDLoQ16d
2026-06-20 20:27:32 +00:00
Claude
e3d3cebdd4 refactor(notifications): tighten public-chat reply walk and its docs
Audit follow-ups, no behaviour change:

- isNotifiablePublicChatReply bails before allocating the HashSet/ArrayDeque
  scratch when the channel message has no parents (top-level posts — the
  common case), and builds the deque straight from the parent list.
- Correct the docstring: a kind-42 replyTo holds only the immediate parent
  (the channel root is filtered out), so the chain is walked hop-by-hop
  through each cached ancestor — the previous wording implied replyTo already
  held the ancestors.
- Trim the over-long inline comment on the acceptableEvent gate.
- Make the multi-hop test prove what it claims: assert the immediate parent
  is not me, so the walk only passes by climbing to the grandparent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxDVCSWe1RwZ51vABBwqbG
2026-06-20 20:08:08 +00:00
Claude
45656e5476 docs: clarify observeNotes replacement caveat; hoist initial-value scan
Audit follow-up. The previous comment claimed an in-place addressable
replacement "not re-emitting here is fine" because AppRow watches each note;
that conflated per-row content (which does stay live) with list membership and
sort order (which do not re-evaluate on a kind-31990 replacement). Documents
the actual behavior and why it is acceptable. Also hoists the nested
remember{} initial-value cache scan out of the collectAsStateWithLifecycle
argument for readability; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8oLe5cMzSY8AzTiULJZiW
2026-06-20 20:00:33 +00:00
Claude
8c56bc64c8 feat(fitness): render kind-33401 exercise templates as a card
A standalone POWR exercise template (kind 33401) opened via naddr, a quote,
or in a thread previously fell through to RenderTextEvent, showing only its
bare instruction text with no title. Add ExerciseTemplateDisplay (equipment
icon, title, equipment/difficulty subtitle, instructions via the rich-text
pipeline) and dispatch to it from NoteCompose.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJwXz6CWez8r7trgHXT545
2026-06-20 19:56:49 +00:00
Claude
b43c1c9eda fix(napplet): align shell to verified @napplet/shim 0.15+ contract
Reverse-engineered the authoritative @napplet/shim (npm v0.16.0) and
corrected the napplet host to its real wire contract. The prior commit
carried guessed method names that would break real ecosystem napplets.

- Signing model: napplets never get a sign() (per upstream "signing and
  encryption are mediated by the shell"). Dropped keys.signEvent /
  keys.nip04* / keys.nip44*. relay.publish now takes an UNSIGNED template
  and the broker signs it as the user, returning the signed event;
  added relay.publishEncrypted (shell encrypts + signs + publishes).
- keys -> keyboard/command actions (registerAction/unregisterAction/
  onAction), client-side no-op stubs (not yet wired to the host keyboard).
- storage: get/set/remove -> getItem/setItem/removeItem; added storage.keys
  end-to-end (protocol, broker, DataStore, shim).
- resource.bytes returns a Blob (shim builds from {bytes, mime}).
- shell.supports gains optional protocol arg; added shell.ready/onReady/
  services stubs.
- relay.subscribe wired (initial matches; live tail still a follow-up).
- value.payInvoice and upload.blob kept as clearly-marked Amethyst-specific
  extensions (no upstream equivalent; real napplets never call them).

Broker now defers per-signature consent to remote/external signers via a
signsAsUser flag (publish/publishEncrypted) instead of an IDENTITY/KEYS
capability check. Tests updated; commons:jvmTest and the amethyst codec
round-trip test pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-20 19:39:36 +00:00
Claude
b835819f97 refactor: de-duplicate shareId and correct ShareActionRows docs
Hoist the AddressableNote-vs-id shareId computation out of the two image
share rows, and fix the KDoc rationale: "Share as Image" shares a local PNG
(no relay write); the rows are hidden on private rumors because they expose
the note's content publicly, not because they publish an e-tag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jc7PP3PLwT4spvjB2c72pk
2026-06-20 19:37:00 +00:00
Claude
a8feb88c2e refactor: derive app list from observeNotes, drop tick + rescan
The screen previously used observeNewEvents purely as an invalidation tick
to re-scan LocalCache.addressables on every app-definition insertion. Since
observeNotes already seeds with the cached kind-31990 notes and re-emits as
new ones arrive, the candidate list now derives straight from those notes:
the appDefinitionsTick counter and the repeated full-cache rescan are gone.
Initial value is seeded from the current cache snapshot to preserve the
first-frame content.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8oLe5cMzSY8AzTiULJZiW
2026-06-20 19:30:33 +00:00
Vitor Pamplona
cdfe007fb4 Merge pull request #3309 from vitorpamplona/claude/remove-alt-tags-events-786j5p
Remove unused NIP-31 alt tag surrogate code and clean up imports
2026-06-20 15:26:00 -04:00
Claude
e2f2c4654e feat(fitness): resolve POWR exercise templates (kind 33401)
Render real exercise names in POWR workout cards instead of slug-derived
labels by fetching the referenced kind-33401 exercise templates.

- ExerciseTemplateEvent (kind 33401, addressable) with title/format/
  format_units/equipment/difficulty accessors; registered in EventFactory.
- WorkoutRecordEvent now implements AddressHintProvider: linkedAddressIds
  (deduped 33401 + 33402 coordinates) seed the gatherer so the card
  re-renders when a template arrives, and addressHints feed the relay-hint
  index with the relay.powr.build hints so the fetch reaches where POWR
  published the templates.
- WorkoutDisplay resolves each exercise via LoadAddressableNote +
  observeNoteEvent<ExerciseTemplateEvent>, showing the template title once
  fetched and the slug until then.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJwXz6CWez8r7trgHXT545
2026-06-20 19:24:25 +00:00
Claude
2ec5c81fd3 perf(notifications): keep the public-chat reply gate inline so it short-circuits
Hoisting the p-tag/public-chat-reply check into a pre-computed val made it
eager: the tag scan (and, for channel messages, the reply-chain walk) ran on
every Note in the cache, even the overwhelming majority rejected by the cheap
`kind in NOTIFICATION_KINDS` check. Inline it back into the && chain in its
original 4th position so that kind check short-circuits ahead of it, and order
the OR so the cheaper tag scan runs before the reply-chain walk.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxDVCSWe1RwZ51vABBwqbG
2026-06-20 19:22:23 +00:00
Claude
445a20b2f3 fix: preserve user-provided media descriptions for accessibility
The blanket NIP-31 alt removal also dropped genuine accessibility
descriptions (image descriptions for the blind) on a few media paths where
the user's caption was only stored in the event-level alt tag. Restore them
via the proper, non-deprecated fields:

- NIP-94 FileHeaderEvent (kind 1063) and NIP-17 encrypted file headers
  (kind 15): write the `alt` tag only when the user actually provided a
  caption (the NIP-94 accessibility description), never the old boilerplate
  fallback.
- MIP-04 encrypted group media (kind 9): route the caption into the imeta
  `alt` field via buildMip04IMetaTag instead of an event-level alt tag.

Re-adds the narrowly-scoped TagArrayBuilder.alt() / AltTag.assemble() write
helpers (documented as accessibility-only, not for deprecated NIP-31
boilerplate). Boilerplate alt tags on all other event kinds remain removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014xAESAz1H1VNjmQpMVqBXj
2026-06-20 19:19:16 +00:00
Vitor Pamplona
6235c407c3 Merge pull request #3311 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-20 15:16:31 -04:00
Claude
21805f6857 refactor: use indexed observeNewEvents for app definition ticks
Replaces the global newEventBundles firehose (woke on every new event of
every kind, then filtered for AppDefinitionEvent client-side) with the
indexed LocalCache.observeNewEvents for kind 31990, so the cache delivers
only app-definition insertions to the recompute tick.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8oLe5cMzSY8AzTiULJZiW
2026-06-20 19:13:30 +00:00
vitorpamplona
64eeeb84bc chore: sync Crowdin translations and seed translator npub placeholders 2026-06-20 19:11:55 +00:00
Vitor Pamplona
1530cae4eb Merge pull request #3308 from vitorpamplona/claude/localization-unused-strings-3xsyhu
i18n: localize Marmot group chat strings and remove unused resources
2026-06-20 15:09:57 -04:00
Vitor Pamplona
e9bc3dcf0f Merge pull request #3310 from vitorpamplona/claude/android-tray-dismiss-vbdhve
Auto-dismiss notifications when events are read in-app
2026-06-20 15:05:09 -04:00
Claude
81a1c3b4c4 feat: auto-dismiss tray notifications when the event is read in-app
Tray notifications were only suppressed for new events while the app was
foregrounded (MainActivity.isResumed); notifications already posted while
backgrounded lingered even after the user opened the app and viewed them.

Per-event tray notifications are keyed by the triggering event's
id.hashCode(), so when a note bearing such an id is marked read in-app
(loadAndMarkAsRead's onIsNew branch), cancel the matching notification.
Childless group summaries are cleaned up so the tray doesn't keep an
empty summary around. Covers replies/mentions (Notification feed via
NoteCompose) and DMs (ChatMessageCompose); grouped reaction/zap cards
have no 1:1 event mapping and are left untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsnDifyUtaWUq4wGu4smZf
2026-06-20 17:51:54 +00:00
Claude
5ca44e277f feat(napplet): align with the upstream napplet SDK (envelope, namespaced API, domains)
Acts on the ecosystem audit so real napplets built against @napplet/web can run.

Wire: switch to the upstream envelope {type:"<domain>.<action>", id} →
{type:"…​.result", id, ok, …} across the JS shim, NappletProtocolJson, and the host
shuttle (host forwards the verbatim envelope, injects id on the reply).

API: rewrite the injected window.napplet.* to the namespaced SDK surface —
shell.supports, identity.getPublicKey (+onChanged stub), keys.{signEvent,nip04*,
nip44*}, relay.{publish,query,subscribe}, storage.{get,set,remove}, value.payInvoice,
resource.{bytes,bytesAsObjectURL}, upload.blob. subscribe currently returns the
initial matches via query (live tail is a follow-up).

Capabilities: split to the domain model — SHELL, IDENTITY, KEYS, RELAY, STORAGE,
VALUE, RESOURCE, UPLOAD (was IDENTITY/RELAY/WALLET/STORAGE/NET). shell.supports is
answered with no consent, reflecting declared+brokered domains; keys (signing) split
from identity (pubkey); signer-self-gating now covers both.

New ops: resource.bytes (https/data, broker-fetched, Tor-routed, consent-gated).
upload is wired end-to-end but its Android Blossom gateway is left unprovided
(Unsupported) pending the Uri + auth-event + server-selection integration.

Codec moved to java.util.Base64 (real on minSdk 26 and in JVM tests). Permissions
screen + capability labels updated to the 8 domains; new consent/label strings
localized. Broker + codec + capability tests updated/added.

:commons:jvmTest, :amethyst:testPlayDebugUnitTest (codec), and
:amethyst:compileFdroidDebugKotlin pass; spotless clean. See
plans/2026-06-20-napplet-ecosystem-audit.md (Update section) for the remaining gaps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-20 16:54:06 +00:00
Claude
c06d8eb7f7 refactor: make the Share drawer share-only; 3-dot menu keeps its copy rows
Per review, the shared element is now just the three true Share options
(browser link, image file, image URL) in ShareActionRows, surfaced by the
ShareOptionsBottomSheet drawer.

- The 3-dot menu keeps its four copy-to-clipboard rows inline, exactly as
  before; its share section is now a single "Share" row that opens the drawer
  instead of doing the share directly.
- NoteDropDownMenu owns the drawer state and renders the sheet in place of the
  menu dialog, so the existing direct callers (MultiSetCompose,
  MessageSetCompose) need no changes.
- The reaction-row Share button opens the same drawer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jc7PP3PLwT4spvjB2c72pk
2026-06-20 16:49:52 +00:00
Claude
b9ac370f4f feat(fitness): render POWR kind-1301 strength workouts
POWR and RUNSTR both publish kind 1301 but with incompatible tag schemas.
A POWR event previously rendered with the raw "33401:...:back-squat-bb"
coordinate as its activity label, no duration, and none of the set data.

Parse the POWR / NIP-101e dialect in quartz and render it in Amethyst:

- type tag for the activity (strength/circuit/emom/amrap), preferred over
  the RUNSTR exercise verb; coordinate-form exercise tags no longer leak as
  a verb.
- start/end session timestamps -> derived duration; completed flag.
- structured per-set exercise tags (kg weights, reps, rpe, set_type),
  grouped per exercise template with volume/top-weight aggregates.
- WorkoutDisplay now shows Exercises/Sets/Volume stats and a per-exercise
  breakdown (e.g. "Back Squat Bb -> 3 x 8 x 84 kg") in the viewer's unit.

Rendering interop only; Amethyst still publishes the RUNSTR-canonical form.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJwXz6CWez8r7trgHXT545
2026-06-20 16:47:39 +00:00
Claude
9a43e03748 feat: add top-nav feed filter to recommended apps screen
Wires the shared FeedFilterSpinner into the Recommended apps (NIP-89)
screen so it matches the other feed screens. The selection persists per
account via a new defaultAppRecommendationsFollowList setting and resolves
through the existing topNavFilterFlow machinery. App definitions only carry
an author dimension, so the Follows-style filters narrow the list to apps
made by those authors (matchAuthor); hashtag/relay/community variants are
no-ops on apps, as expected. Combines with the local text search and adds a
filter-empty message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8oLe5cMzSY8AzTiULJZiW
2026-06-20 16:47:23 +00:00
Claude
18f736abf7 refactor: stop writing deprecated NIP-31 alt tags on events we create
The NIP-31 event-level "alt" tag is deprecated, so Amethyst no longer
emits it on any event it builds. Removed all `alt(...)` builder calls and
`AltTag.assemble(...)` insertions across every event kind in quartz (and
the few app-side builders), along with the now-unused `ALT`/`ALT_DESCRIPTION`
companion constants and the `TagArrayBuilder.alt()` / `AltTag.assemble()`
write helpers.

Reading alt tags from incoming events is kept (AltTag.parse/match,
TagArray.alt(), Event.alt()) for interop with clients that still send them,
and the imeta media accessibility `alt` field (NIP-92/94) is untouched.

Updated/removed tests that asserted alt-tag presence and refreshed the
deterministic event-id/sig golden masters in UpdateMetadataTest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014xAESAz1H1VNjmQpMVqBXj
2026-06-20 16:46:25 +00:00
Vitor Pamplona
e20d7d2f0f Merge pull request #3307 from vitorpamplona/claude/custom-emojis-push-notifications-8n3h87
feat(notifications): render NIP-30 custom emoji reactions as avatar badge
2026-06-20 12:43:27 -04:00
Claude
9cafbf02cc feat(notifications): notify on public chat replies without a p-tag
Public chats (NIP-28, kind 42) routinely reply to a user without adding a
`p` tag, so the existing mention gate (Event.isTaggedUser) silently dropped
them from both the in-app Notifications feed and Android tray push.

Add NotificationFeedFilter.isNotifiablePublicChatReply: a cache-only check
that walks a channel message's reply chain for one of the user's own
messages — covering a direct reply to my message ("the previous message was
mine") and later messages in a thread I'm already part of ("an active
thread"). It is bounded (depth + visited-set) and reads only Note.replyTo,
so the push dispatcher and the feed can both consult it without loading the
account or decrypting anything.

Wire it as an OR alongside the p-tag gate in the three relevance sites that
share the rule — NotificationFeedFilter.acceptableEvent, the
NotificationDispatcher observer predicate, and EventNotificationConsumer —
while keeping tagsAnEventByUser as the scoping AND so unrelated channel
chatter never leaks through, even in Global mode.

Route ChannelMessageEvent to its own tray handler: reply-to-me renders as a
threaded reply (with inline reply action) grouped by channel so a busy room
collapses into one notification; a pure p-tag citation still renders as a
mention. Muting a thread suppresses it via the existing isAcceptable gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxDVCSWe1RwZ51vABBwqbG
2026-06-20 16:39:54 +00:00
Claude
62ca086302 feat: open a Share options bottom drawer from the reaction-row Share button
Tapping Share in a note's reaction row now opens a bottom drawer with the
same Copy & Share options as the 3-dot menu (Copy Text, Copy Author ID,
Copy Note ID, Copy raw JSON, Share link, Share as Image, Share as Image
URL) instead of jumping straight to the system share sheet.

The seven rows are extracted into a shared ShareCopyActionRows composable so
the 3-dot menu and the new ShareOptionsBottomSheet stay in sync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jc7PP3PLwT4spvjB2c72pk
2026-06-20 16:28:29 +00:00
Claude
2536a909fc i18n: localize Marmot group chat strings and remove unused resources
Extract hardcoded user-facing strings into string resources so they can be
translated, and delete string resources that are no longer referenced
anywhere in the codebase.

Localization (new string/plurals resources + stringRes/pluralStringResource
call sites):
- Marmot (MLS) group chat feature: list, chat, info, create, and edit
  screens, plus dialogs and toasts.
- OnchainSection (Copy/Send), DvmContentDiscoveryScreen (pay invoice),
  OtsSettingsSection (explorer API settings). Reused existing generic
  strings (back, cancel, save, remove, leave, description, members, send,
  clear) where available.

Cleanup:
- Removed 367 string/plurals resources with no remaining references, along
  with their translations across all locale files. References were resolved
  across Kotlin/Java (including multi-line R.string wraps), XML, gradle
  resValue, and intra-resource @string lookups to avoid removing live
  strings.

Verified with :amethyst:compilePlayDebugKotlin, compileFdroidDebugKotlin,
and processPlay/FdroidDebugResources (aapt resource linking) plus
spotlessCheck.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxvNWmNASbvna4CD5QJFo9
2026-06-20 16:25:34 +00:00
Claude
3cfc6f10e1 feat: add local search to recommended apps screen
Adds an in-screen text filter at the top of the Recommended apps
(NIP-89 kind 31990) screen that filters the loaded app list by name
and description as the user types, with a clear button and an
empty-results message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8oLe5cMzSY8AzTiULJZiW
2026-06-20 16:23:50 +00:00
Claude
0a663bc335 feat(notifications): render NIP-30 custom emoji reactions as avatar badge
Custom-emoji (NIP-30) reactions arrive with content ":shortcode:" backed by
an ["emoji", shortcode, url] tag. Previously the notification showed the raw
":shortcode:" text in the title. Since notification text strips ImageSpans,
the emoji image is now composited as a badge on the reactor's avatar
(largeIcon) and the title keeps just the author's name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvWKvm3YW6hS5gsJSqePaG
2026-06-20 16:18:02 +00:00
Claude
e54c78a6ea docs(napplet): ecosystem-compatibility audit vs upstream SDK / demo runtimes
Audits the implementation against napplet/naps (specs), napplet/web (@napplet/shim
SDK) and kehto/web (reference runtime + playground). Finding: the security core
(process isolation, verified blobs, consent, ledger, permissions UI) is solid and
ahead of what the demo runtimes specify, but the edge layer is not wire-compatible
with the ecosystem — upstream uses a namespaced window.napplet.* and a
{type:"domain.action", id} envelope, we use a flat API and {id, payload:{op}}, and
we lack the mandatory shell.supports() plus the resource/upload/keys domains. So no
real ecosystem napplet runs as-is. Doc includes a coverage scorecard and a
prioritized path to compatibility (envelope + namespaced shim first).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-20 16:13:12 +00:00
Claude
6432737424 i18n(napplet): localize all user-facing napplet strings
Moves every inline English literal in the napplet UI to string resources:

- Consent dialog: operation summaries (per op), capability label, button labels,
  and the title fallback now come from strings.xml. The payment amount is a proper
  <plurals> (sat/sats) via pluralStringRes.
- Permissions screen + Napplets list: capability names/descriptions, empty states,
  and the untitled-napplet fallback are localized.
- Host toasts (invalid napplet / WebView too old) localized.
- New NappletCapabilityLabels.kt maps each capability to shared label/description
  string resources, reused by both the consent dialog (getString) and the
  permissions screen (stringResource), so there's one source of truth.

Programmatic strings that cross the API boundary to the applet's JS (broker
Failed reasons) and log messages are intentionally left in English.

:amethyst:compileFdroidDebugKotlin passes; spotless clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-20 15:56:42 +00:00
Claude
c999d2ac73 feat(napplet): permissions management screen
A modern Material3 screen to review and revoke the permissions napplets hold.

Data layer (commons, tested): NappletPermissionStore gains all() (enumerate
persisted grants by coordinate) and remove(coordinate, capability); the ledger
gains allPersistedGrants() and revoke(identity, capability). DataStore actual
implements both (capability is the final space-delimited token of each key).

UI (amethyst): NappletPermissionsScreen renders one ElevatedCard per napplet —
resolved title + author, and a row per capability with an icon, label, and a
control: a Switch (Allowed/Blocked) for normal capabilities, or a "Blocked"
indicator for per-use ones (payments only ever persist a DENY). Each row has a
revoke action; each card a "Forget this napplet" (revokeAll). Empty state with a
shield. Reads/writes the same DataStore the broker uses, so changes take effect
immediately. Reached via a "Manage permissions" action on the Napplets top bar
(Route.NappletPermissions).

commons ledger tests added for allPersistedGrants + single-capability revoke.
:commons:jvmTest and :amethyst:compileFdroidDebugKotlin pass; spotless clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-20 15:28:28 +00:00
Claude
916a624ddb feat(napplet): capability-aware consent — per-payment, signer-aware identity, foreground-only
Refines the uniform consent model now that wallet + identity carry different risk.

Payments (WALLET): NappletCapability.requiresPerUseConsent — every payInvoice
re-prompts (with the decoded sats amount); the dialog drops "Always allow" and the
broker downgrades any always/session grant to one-shot, so a payment grant is
never persisted. No silent spend.

Identity: gated by us only when Amethyst holds the key (NostrSignerInternal). For
remote (NIP-46) / external (NIP-55) signers the broker defers to the signer's own
per-request consent instead of double-prompting — while still honoring a standing
per-napplet DENY and the requires declaration. The sign prompt shows a kind +
content preview.

Foreground-only execution: NappletHostActivity pauses the WebView's JS/timers in
onPause and resumes in onResume, so a backgrounded applet can't fire a
sign/decrypt/pay request whose prompt would be confused with Amethyst's own UI.
This is the precondition that makes deferring identity to an external signer safe.

commons broker tests added: wallet prompts every time and is never persisted;
external signer defers identity without prompting but still honors a standing DENY.

:commons:jvmTest and :amethyst:compileFdroidDebugKotlin pass; spotless clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-20 15:15:10 +00:00
Vitor Pamplona
b7aad6f61c Merge pull request #3306 from vitorpamplona/claude/gracious-bohr-2r07fo
NIP-22: Reply to Amethyst threads as kind 1111 Comments
2026-06-20 11:14:35 -04:00
Claude
96c5d9bcb6 feat: reply with kind 1111 to new Amethyst kind-1 thread roots
When replying to a note that is a kind 1 TextNoteEvent, is the root of a
new thread (no e-tags), and was itself posted from Amethyst (NIP-89
client tag), build a NIP-22 kind 1111 CommentEvent instead of a kind 1
reply. Forks keep using kind 1.

Applies across all kind-1 reply paths: the Android composer
(ShortNotePostViewModel), the notification quick-reply
(NotificationReplyReceiver), and the desktop composer (ComposeNoteDialog).

Adds Event.isClient / TagArray.isClient helpers (NIP-89, case-insensitive)
with unit coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V7RyevA6jL1NuY7uev2agS
2026-06-20 15:00:04 +00:00
Claude
075be5f8b7 feat(napplet): wallet (NWC) payment + live relay query; defer inter-applet
#1 WALLET — PayInvoice now pays via the user's connected NWC wallet
(account.sendZapPaymentRequestFor, wrapped suspend with a 60s timeout). The
gateway returns the preimage on success and throws (→ Failed) on no-wallet,
wallet error, or timeout, so an applet never wrongly believes a payment landed.
The consent dialog decodes the invoice and shows the amount in sats
(LnInvoiceUtil). Gated as before: must declare `value`/`wallet`, then consent.

#3 live relay query — QueryEvents now does a bounded live fetch
(INostrClient.fetchAll, EOSE/8s timeout) across the user's read relays, merged
with LocalCache, deduped, newest-first, limit-respected — instead of cache-only.

#2 inter-applet — deferred per design review. It needs new architecture
(multi-applet hosting + an archetype registry), not just a gateway, and would
risk forking the upstream NAP-INC/INTENT wire format. Surveyed upstream
napplet/naps and wrote the design + prerequisites in
amethyst/plans/2026-06-20-napplet-inter-applet.md.

:amethyst:compileFdroidDebugKotlin passes; spotless clean. Wallet + live query
need on-device verification (real NWC wallet / relays).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-20 14:40:46 +00:00
Vitor Pamplona
1223f5b832 fix(media): HTTP/2 keepalive ping to stop stale-connection image stalls
The media OkHttp client keeps 32 connections warm in a 5-minute pool but,
unlike the relay client, set no pingInterval. Hosts like blossom.primal.net
silently drop idle HTTP/2 connections between feed-scroll bursts. OkHttp then
pulls a dead connection from the pool and the request stalls until the read
timeout (30s wifi / 90s mobile), which users see as "the first image after a
pause takes forever".

Device MediaHttp logs showed the signature repeatedly:
  blossom.primal.net total=30000ms ttfb=-1ms conn=reused error=SocketTimeout

Add a 10s HTTP/2 keepalive ping so dead pooled connections are detected in
seconds and retryOnConnectionFailure re-issues on a fresh one, mirroring what
the relay client already does.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 10:24:50 -04:00
Vitor Pamplona
ee41ac7b7e Merge pull request #3305 from vitorpamplona/claude/sharp-dijkstra-j6do4j
Consolidate Crowdin sync and translator seed into single PR
2026-06-20 10:18:35 -04:00
Claude
86a19012b5 test(napplet): JVM unit tests for the protocol codec; move it off org.json
The codec is the one place the trust boundary parses untrusted applet input, so
it deserves tests — but Android stubs `org.json` in JVM unit tests
(returnDefaultValues), making the previous org.json-based codec untestable
off-device. Rewrites NappletProtocolJson on kotlinx.serialization (a real JVM
JSON impl the app already uses for @Serializable routes), which also drops a
runtime Android dependency from the parser.

Adds NappletProtocolJsonTest (20 cases): decode of every request op, unknown op
→ null, malformed/missing-field → throws (caught by the broker as Failed), encode
of every response variant, null→JsonNull for storage/paid, and a SignedEvent
round-trip back through Event.fromJson.

:amethyst:testPlayDebugUnitTest passes (20/0/0); spotless clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-20 14:13:11 +00:00
Claude
48a88f136f ci: combine Crowdin translation and translator-seed PRs into one
Previously the Crowdin workflow ran two independent jobs that each opened
their own pull request: the crowdin/github-action sync PR ("New Crowdin
Translations") and the peter-evans seed PR ("Seed translator npub
placeholders").

Collapse them into a single job: the Crowdin action now only downloads
translations into the working tree (push_translations/create_pull_request
disabled), the seed script edits translators.json in the same checkout, and
one create-pull-request step opens a single combined PR on
l10n_crowdin_translations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kxm4Pq3rm4doVLqJ2LaXtr
2026-06-20 14:10:35 +00:00
Vitor Pamplona
240d600091 Merge pull request #3302 from vitorpamplona/chore/seed-translators
Seed translator npub placeholders
2026-06-20 09:58:17 -04:00
Vitor Pamplona
abccc1ea30 Merge pull request #3303 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-20 09:58:01 -04:00
Claude
b422274140 feat(napplet): capability enforcement, read/storage capabilities, nsite host wiring
Closes the highest-leverage gaps from the completeness report (items 2–5).

Capability enforcement (#2):
- The broker now refuses any request whose capability is not in the manifest's
  `requires`. The host resolves `requires` to a declared capability set and sends
  it with every IPC request; the broker denies undeclared capabilities before any
  consent prompt. Per-operation consent summaries added for the new ops.

Read capability (#3):
- New QueryEvents request (RELAY capability) → NappletRelayGateway.query, answered
  from LocalCache (account.cache.filter). window.napplet.queryEvents(filter) added.

nsite host wiring (#4):
- NappletLauncher generalized to launch any NIP-5A site from paths+servers, so
  nsites (kinds 15128/35128) open in the sandbox too. The nsite card
  (StaticWebsite) gets an "Open" button; nsites declare no capabilities, so the
  broker refuses everything and they render as inert static content.

Storage + wallet (#5):
- STORAGE fully implemented: StorageGet/Set/Remove + DataStoreNappletStorage,
  namespaced per applet coordinate. window.napplet.storage.{get,set,remove}.
- WALLET modeled with PayInvoice + NappletWalletGateway, but kept Unsupported (no
  gateway provided) — no money path ships until verified end-to-end.
- Inter-applet messaging and live (non-cache) relay query remain v2.

commons broker tests cover declaration enforcement, query, and storage round-trip.
:commons:jvmTest and :amethyst:compileFdroidDebugKotlin pass; spotless clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-20 13:50:19 +00:00
Crowdin Bot
b4a60e281d New Crowdin translations by GitHub Action 2026-06-20 13:48:56 +00:00
vitorpamplona
67af8fc3bf chore: seed translator npub placeholders from Crowdin 2026-06-20 13:47:37 +00:00
Vitor Pamplona
4c7076753c Merge pull request #3301 from vitorpamplona/claude/apps-feed-version-updates-bofv60
Keep NIP-82 release versions live via LocalCache observer
2026-06-20 09:47:13 -04:00
Claude
2bccab797c feat(napplet): relay subscription to discover napplet manifests
NappletsScreen previously showed only what was already cached. Adds a lightweight
discovery subscription so the list actually populates:

- NappletsFilterAssembler / NappletsFilterSubAssembler (SingleSubEoseManager):
  one REQ per read relay for kinds 15129/35129, deduped to a single subscription
  per account. No follow-list/feed-state machinery — the screen reads LocalCache
  directly, so the query state carries only the account + scope.
- Registered as an app-lifetime singleton in RelaySubscriptionsCoordinator (the
  EOSE manager opens its relay sub at construction, so it can't be per-screen).
- NappletsScreen invokes NappletsFilterAssemblerSubscription, which subscribes
  on STARTED and tears down after the lifecycle grace window.

:amethyst:compileFdroidDebugKotlin passes; spotless clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-20 03:26:15 +00:00
Claude
9da1df57b9 fix: narrow app release observer to the app's i-tag
The version-chip observer filtered releases by author only, so an
author with many apps pulled every release they ever published into the
observer's working set. Narrow the filter on the release `i` tag (the
app id) so only this app's releases are loaded.

A blind limit is avoided on purpose: LocalCache.filter applies
take(limit) before sorting by created_at, so a limit could drop the
latest release the chip needs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbCTsBoGtBCJ1rTKCcar6w
2026-06-20 03:25:05 +00:00
Claude
cbbba279a7 refactor: use indexed LocalCache.observeNotes for app version chip
Replace the global newEventBundles + full-cache rescan with an
index-driven LocalCache.observeNotes(kind 30063 / author) observer, the
established idiom (NestLobbyScreen, OpenPollsState, DvmContentDiscovery).
The FilterIndex only wakes the observer when a matching release is
inserted, instead of re-scanning the whole addressables map on every
event app-wide.

Extract the NIP-82 release parsing (dTag-prefix + kind-30063 collision
handling) into shared helpers reused by findLatestNip82Release and
findAllNip82Releases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbCTsBoGtBCJ1rTKCcar6w
2026-06-20 03:12:02 +00:00
Claude
d4899c2afc feat(napplet): UI entry point, Tor-routed blob fetch, sandbox process isolation
UI entry point:
- NappletsScreen: a "Napplets" drawer item lists napplet manifests in the local
  cache (NIP-5D kinds 15129/35129) and opens the selected one in the sandboxed
  host. Wired as Route.Napplets with a NavBarItem + drawer entry.

Sandbox process isolation (security fix):
- Application.onCreate runs in every process, so the :napplet process was building
  AppModules and initiate() was loading the account + constructing the signer there
  — defeating the "no keys in the sandbox" guarantee. Amethyst.onCreate now detects
  the :napplet process and skips AppModules entirely, leaving `instance` unset so
  any accidental use fails fast.

Tor/proxy-aware blob fetch:
- The host's OkHttpClient now routes Blossom blob fetches through the user's Tor
  SOCKS proxy when active. The port is resolved in the main process by the launcher
  and passed via the Intent, so the sandbox process never needs the account-bound
  HTTP stack.

:amethyst:compileFdroidDebugKotlin passes; spotless clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-20 03:10:12 +00:00
Claude
ed23f81f3a fix: refresh NIP-82 app version chip when a new release arrives
The Apps feed version chip read the latest SoftwareReleaseEvent (kind
30063) once via a produceState keyed only on the app event id. NIP-82
releases point back to the app through an `i` tag rather than an `a`
tag, so they are never indexed as replies to the app note and never
ping its flows. As a result a newer release arriving while the card was
visible left the chip showing the old version.

Re-scan LocalCache on every new-event bundle (the same pattern used by
the calendar RSVP/calendar scans) so the chip updates without a manual
refresh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbCTsBoGtBCJ1rTKCcar6w
2026-06-20 00:45:43 +00:00
Vitor Pamplona
2f46293a69 Merge pull request #3300 from vitorpamplona/chore/seed-translators
Seed translator npub placeholders
2026-06-19 20:01:01 -04:00
vitorpamplona
fcebff50cb chore: seed translator npub placeholders from Crowdin 2026-06-19 23:59:48 +00:00
Vitor Pamplona
7657bda750 Merge pull request #3299 from vitorpamplona/claude/url-parser-punctuation-brvq35
Keep balanced closing delimiters in URLs
2026-06-19 19:59:25 -04:00
Claude
daae12b53d feat(napplet): Android sandbox host — isolated process, broker IPC, consent
Implements the Android side of the napplet/nsite trust boundary on top of the
commons core. The applet runs in a separate OS process holding no keys; every
dangerous operation is brokered to the main process and gated by user consent.

:napplet process (no secrets):
- NappletHostActivity: hardened WebView (no file/content access, no DOM storage,
  mixed-content blocked, SafeBrowsing on), applet served into an opaque-origin
  sandboxed iframe, manifest blobs served already-verified via
  shouldInterceptRequest with a default-deny CSP (connect-src 'none' = no direct
  network), and a window.napplet.* shim bridged over an origin-restricted
  WebMessageListener.

Main process (holds the signer):
- NappletBrokerService: bound Messenger service running the commons NappletBroker
  against the live account; exported=false + UID check. Builds the broker per
  request so account switches are honored; relay publish via the account's
  computed broadcast relays.
- NappletConsentActivity + NappletConsentCoordinator: capability-consent dialog
  with a suspend bridge; fails closed on dismissal.
- DataStoreNappletPermissionStore: persistent grant store.

Shared edge: NappletProtocolJson (JSON codec), NappletIpc (Messenger contract),
NappletLauncher (packs a verified manifest into the host Intent), shell.html.

Adds androidx.webkit (Apache-2.0) for the origin-restricted message bridge — a
plain @JavascriptInterface leaks into every frame and would break the boundary.
Manifest declares the :napplet activity, the consent activity, and the broker
service. :amethyst:compileFdroidDebugKotlin passes; on-device verification and a
UI entry point are the remaining steps (see the plan doc).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-19 23:50:58 +00:00
Claude
da336897c2 fix: keep balanced closing delimiters and inline commas in detected URLs
The URL detector stripped a single trailing punctuation char unconditionally,
which dropped the closing ")" from legitimate URLs such as
https://en.wikipedia.org/wiki/Bitcoin_(disambiguation).

Make the trailing strip balance-aware: a trailing ")", "}" or "]" is kept when
the URL contains its matching opener (balanced), and only stripped when it is
unbalanced wrapping/sentence punctuation (e.g. "(see example.com)" or
"http://test.com)"). Commas without surrounding spaces were already kept inside
paths; this also adds "]" to the begin/end punctuation sets so an unbalanced
bracket is handled symmetrically with parens and braces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AzZzVcMcuzjSdhD3xqCE87
2026-06-19 23:38:44 +00:00
Claude
119442293a feat(napplet): trust-boundary core for sandboxed nsite/napplet rendering
Adds the platform-agnostic core for hosting untrusted napplet (NIP-5D) /
nsite (NIP-5A) web content behind a hard trust boundary, so applet HTML/JS
can never reach the nsec, app storage, or LocalCache.

The Android host runs the WebView in a separate OS process (:napplet) that
holds no secrets and brokers every dangerous operation over IPC to the main
process. This commit lands the verifiable heart of that boundary in commons
commonMain (KMP-pure, fully unit-tested):

- NappletCapability + NAP-domain mapping (default-deny on unknown domains)
- NappletIdentity keyed by addressable coordinate (grants survive updates)
- NappletPermissionLedger / GrantState / store (persistent vs session vs once;
  standing DENY is authoritative)
- NappletRequest/NappletResponse wire protocol (no response carries key bytes)
- NappletBroker: the only holder of the signer; enforces consent, signs as the
  user only, refuses to publish foreign or unsigned events

Architecture, process model, IPC schema, WebView hardening, and consent UX are
documented in amethyst/plans/2026-06-19-napplet-sandbox-host.md. The Android
:napplet process, WebView host, AIDL broker, and consent UI are the next phase.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-19 23:22:45 +00:00
2700 changed files with 418917 additions and 28003 deletions

View File

@@ -13,7 +13,10 @@ a non-interactive JVM command-line client that drives the same `quartz` + `commo
humans, agents, and interop tests. `quic` is a from-scratch pure-Kotlin QUIC v1 + HTTP/3 +
WebTransport client (no JNI, no BouncyCastle), built because no Android-compatible Java QUIC library
exists. `geode` is a standalone JVM Nostr relay (Ktor) built on quartz's
relay-server code; smaller modules are `benchmark` (Android macrobenchmarks) and
relay-server code; smaller modules are `benchmark` (Android macrobenchmarks),
`relayBench` (head-to-head relay benchmark — boots geode, strfry and other
relay binaries, replays a shared deterministic corpus, measures ingest/query/
NIP-77 sync; `./relayBench/run.sh`, see `relayBench/README.md`) and
`quic-interop` (QUIC interop runner, lives at `quic/interop`). `nestsClient` runs
the audio-room protocol on top of `:quic` for the NIP-53 audio-rooms feature. It implements both IETF `draft-ietf-moq-transport-17` (under
`moq/`) and **moq-lite Lite-03** (kixelated's variant, under `moq/lite/`); the
@@ -76,12 +79,44 @@ amethyst/
- `nestsClient/` = MoQ + audio-rooms client; takes `:quic` as transport,
Quartz for crypto, `MediaCodec` / `AudioRecord` / `AudioTrack` for audio.
- `amethyst/` & `desktopApp/` = Platform-native layouts and navigation
- `cli/` = Thin assembly layer over `quartz/` + `commons/` (no new logic allowed)
- `cli/` = Thin assembly layer over `quartz/` + `commons/` (no new logic
allowed). May also depend on `:geode` (for `amy serve`, which embeds the
standalone relay); never on `:amethyst` or `:desktopApp`.
**Plans per module:** design docs for new subsystems live in the owning
module's `plans/YYYY-MM-DD-<slug>.md` (e.g. `cli/plans/`, `commons/plans/`).
The global `docs/plans/` folder is frozen — don't add new plans there.
## Android Runtime Processes (IMPORTANT — the app runs in TWO processes)
The Android app is **not single-process**. Android instantiates the one `Amethyst`
Application class (there is no per-process Application in the manifest) in **both**:
- **main** — the normal app: UI, account, signer, `LocalCache`, relay client.
`Amethyst.instance` (`AppModules`) is built here.
- **`:napplet`** — the sandboxed WebView host for NIP-5D napplets / NIP-5A nSites
(`NappletHostActivity`, declared `android:process=":napplet"`). It holds **no**
account or keys; `Amethyst.onCreate()` early-returns here so `Amethyst.instance`
is **left unset** (touching it throws `UninitializedPropertyAccessException`).
The sandbox runtime lives in its own module **`:nappletHost`** (depends only on
`:commons` + `:quartz`, **never** `:amethyst`) so it *cannot* import
`Amethyst`/`LocalCache`/`Account` — the broker-side (signer, gateways, registry)
stays in `:amethyst` and the two halves talk over Messenger IPC.
Consequences — don't get caught assuming one process:
- **Processes don't share memory.** Every `object`/companion/`static` is a
*separate copy per process*: `LocalCache` (an `object`), `NappletLaunchRegistry`,
etc. The populated `LocalCache` lives only in **main**; the sandbox neither
builds nor should reference it (a stray reference would lazily create a second,
empty cache there).
- **Don't assume `Amethyst.instance` exists.** Any code reachable from `:napplet`
(the host activity, content server, or an Application lifecycle callback like
`onTrimMemory`) must guard on the process and never reach for `instance`.
- **Cross-process state goes over Messenger IPC**, never a shared singleton — this
is why the broker (main) owns `NappletLaunchRegistry` and the sandbox only relays
an opaque token. See `amethyst/plans/2026-06-22-napplet-nsite-security.md`.
## Tech Stack
Exact versions live in `gradle/libs.versions.toml` (the source of truth — check
@@ -125,6 +160,17 @@ Summarize the survey in your plan: for each component, note whether it's
reused as-is, extracted from `amethyst/` to `commons/`, genuinely new
(platform-specific only), or a duplicate of an existing pattern to avoid.
**Relay client ops already exist — don't hand-roll subscribe/REQ/publish loops.**
One-shot and high-level relay operations (fetch a set, fetch one, page past the
relay cap, publish-and-confirm, NIP-45 count, NIP-77 sync/reconcile) are
`INostrClient` **extension functions** in
`quartz/…/nip01Core/relay/client/accessories/` (+ `…/reqs/` for the flow/subscribe
helpers). Because they're extensions, they don't surface under "usages of
`NostrClient`" or in completion — grep that package (or read its `README.md`, which
catalogs them) before writing a new subscription/collect loop. Reuse `fetchAll`,
`fetchFirst`, `fetchAllPages`, `publishAndConfirm`, `count`, `negentropyReconcile`,
etc. instead of re-implementing them.
**Share vs keep platform-native:**
- **Share** → `quartz/commonMain/` (business logic, data models, protocol) and
@@ -236,6 +282,41 @@ Do this before considering the task complete.
- The only acceptable inline fully-qualified names are: a genuine name
collision (prefer `import ... as Alias` instead), or where the language
requires it. Comments, KDoc, and string literals are exempt.
- **Prefer the `androidx.core` KTX extension over the raw platform Java call**
when one exists — this is what Android Lint's `UseKtx` flags. Common swaps:
`Bitmap.createBitmap(w, h, cfg)``createBitmap(w, h)`,
`Bitmap.createScaledBitmap(src, w, h, f)``src.scale(w, h, f)`,
`Uri.parse(s)``s.toUri()`, and `prefs.edit()…apply()``prefs.edit { }`.
Only adopt the KTX form when it's behaviour-preserving: keep any explicit
argument that differs from the extension's default (a non-`ARGB_8888`
`Bitmap.Config`, `scale(filter = false)`), and leave calls the KTX has no
equivalent for (e.g. the `createBitmap` pixels/matrix overloads, or a
conditional-`apply()` editor loop) untouched.
- **This "prefer the KTX sugar" rule does NOT extend to collection operators.**
The KTX preference is about platform wrappers (`Bitmap`/`Uri`/`SharedPreferences`),
which compile to the identical call. Collections are the opposite: in hot
event/parse paths Quartz deliberately uses raw JVM arrays (`TagArray =
Array<Array<String>>`) and the inline `fast*` operators (`fastForEach`,
`fastAny`, `fastFirstOrNull`, `fastFirstNotNullOfOrNull`, … in
`nip01Core/core/TagArray.kt`) instead of Kotlin `List` + stdlib
`forEach`/`map`/`filter`/`any` — the `fast*` variants allocate no iterator,
no intermediate list, and no lambda object. Don't "modernize" those into
stdlib collection calls; match the surrounding hot-path style.
- **Never put raw invisible/bidirectional Unicode characters in source files**
— write them as `\uXXXX` escapes instead (`'\u202E'`, `Regex("[\u200B-\u200D\uFEFF]")`).
This covers the bidi family Sonar's Trojan-Source rule (CVE-2021-42574)
flags — U+202AU+202E, the isolates U+2066U+2069, U+200E/U+200F, U+061C —
plus zero-width characters (U+200BU+200D, U+FEFF, U+2060). The escape
compiles to the identical codepoint, so behaviour is unchanged; the point is
that the file on disk stays visually unambiguous. Applies even when the
character is *intentional* (sanitizer strip-lists, adversarial test
payloads) — that's data, and escapes express it just as well. Exceptions:
U+200D as part of a real emoji ZWJ sequence in test data (👩‍👧 — functional,
not a bidi control), and LRM/RLM inside Crowdin-managed `strings.xml`
translations (legitimate RTL typography; don't touch those files by hand
anyway). Note the tooling trap: the Edit tool may normalise a typed
`\uXXXX` back into the raw character — if that happens, do the replacement
at byte level (`perl -CSD -pe 's/\x{202E}/\\u202E/g'`).
### Navigation Shell
- **Desktop**: Sidebar + main content area
@@ -245,3 +326,12 @@ Do this before considering the task complete.
- Commits: Conventional commits (`feat:`, `fix:`, etc.)
- Never use `--no-verify`
### Remotes & pull requests
A PR can be published two ways, via two **kinds** of remote — identify them by **URL** (`git remote -v`), because the names vary per clone and **a collaborator may have only one**:
- a **GitHub** remote (`github.com/vitorpamplona/amethyst`) — the **canonical** `main`; moves constantly. Standard `gh` PR flow.
- a **git-over-nostr** remote (`nostr://…/relay.ngit.dev/amethyst`, via `ngit`) — pushing fans out to GitHub **and** the GRASP git servers; **PRs here are nostr proposals** (the `pr/feat/*` branches), reviewed on **gitworkshop.dev***not* GitHub PRs. (In the maintainer's checkout these happen to be named `upstream` and `origin` respectively, but don't rely on that.)
Before opening, revising, or merging a PR by **either** path, use the **`ngit-pr`** skill. It covers when to use which, identifying your remotes by URL, the `gh` and `ngit` commands, and — critically for the nostr path — the three-mains alignment gate (GitHub main vs the lagging nostr `main` vs local `main`) that its create/revise/merge flows depend on. Skipping it leads to rejected pushes and PRs that don't show up as revisions.

View File

@@ -0,0 +1,91 @@
#!/bin/bash
# PreToolUse gate: make sure Kotlin is spotless-clean BEFORE it leaves the box.
#
# Fires on `git push` (Bash tool) and on the create_pull_request MCP tool. Runs
# `spotlessApply`; if that reformats any tracked .kt/.kts file, the push/PR is
# blocked (exit 2) so the agent commits the formatting fix first. This turns
# CI's `spotlessCheck` failure into an in-session block — no red PR, no round
# trip. `spotlessApply` runs the same formatters CI's `spotlessCheck` verifies,
# so a clean apply means a green check.
set -uo pipefail
cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0
# --- Parse the tool call off stdin; decide whether this call is a boundary. ---
payload="$(cat)"
should_gate="$(
printf '%s' "$payload" | python3 -c '
import json, shlex, sys
try:
data = json.load(sys.stdin)
except Exception:
print("no"); sys.exit(0)
tool = data.get("tool_name", "")
if tool.endswith("create_pull_request"):
print("yes"); sys.exit(0)
if tool != "Bash":
print("no"); sys.exit(0)
cmd = (data.get("tool_input") or {}).get("command", "")
# Tokenize like a shell so `push` inside a quoted commit message or heredoc
# stays one token and is NOT mistaken for the push subcommand.
try:
tokens = shlex.split(cmd, comments=True)
except ValueError:
tokens = cmd.split()
GLOBAL_WITH_ARG = {"-c", "-C", "--namespace", "--git-dir", "--work-tree", "--exec-path"}
for i, t in enumerate(tokens):
if t != "git" and not t.endswith("/git"):
continue
j = i + 1
while j < len(tokens): # skip git global options to reach the subcommand
tok = tokens[j]
if tok in GLOBAL_WITH_ARG:
j += 2; continue
if tok.startswith("-"):
j += 1; continue
break
if j < len(tokens) and tokens[j] == "push":
print("yes"); sys.exit(0)
print("no")
' 2>/dev/null
)"
[ "$should_gate" = "yes" ] || exit 0
# Nothing to format if no Kotlin is tracked/changed at all — cheap early out.
if ! git ls-files --error-unmatch '*.kt' '*.kts' >/dev/null 2>&1; then
exit 0
fi
# Snapshot Kotlin state (vs HEAD, so staged + unstaged both count) before/after
# formatting; any delta means the committed tree wasn't spotless.
before="$(git diff HEAD -- '*.kt' '*.kts' 2>/dev/null | sha1sum)"
log="$(mktemp /tmp/spotless-gate.XXXXXX.log)"
if ! ./gradlew spotlessApply >"$log" 2>&1; then
# 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
echo "----- gradle output (tail) -----" >&2
tail -n 40 "$log" >&2
rm -f "$log"
exit 2
fi
rm -f "$log"
after="$(git diff HEAD -- '*.kt' '*.kts' 2>/dev/null | sha1sum)"
if [ "$before" != "$after" ]; then
echo "BLOCKED: spotlessApply reformatted Kotlin files that were about to be pushed." >&2
echo "The changes below are now in your working tree. Commit them, then retry:" >&2
echo >&2
git diff --name-only HEAD -- '*.kt' '*.kts' >&2
echo >&2
echo " git add -A && git commit -m 'style: apply spotless' && <retry the push>" >&2
echo "(CI runs 'spotlessCheck'; pushing now would fail the lint job.)" >&2
exit 2
fi
exit 0

View File

@@ -211,6 +211,60 @@ install_konan_dep "llvm-19-x86_64-linux-essentials-109" \
install_konan_dep "libffi-3.2.1-2-linux-x86-64" \
"$KONAN_DEPS_URL/libffi-3.2.1-2-linux-x86-64.tar.gz"
# --- Gradle distribution: pre-seed the wrapper distribution ---
# The wrapper's distributionUrl (services.gradle.org) 307-redirects to
# github.com release assets, which the web sandbox's git-only GitHub proxy
# blocks (403) even at Full network access — so `./gradlew` can't bootstrap.
# Download the pinned distribution from a mirror instead, but verify it against
# Gradle's OFFICIAL sha256 (served from services.gradle.org, reachable here)
# so a tampered/wrong mirror file is rejected and never executed. Idempotent:
# skips entirely if the distribution is already installed.
seed_gradle_distribution() {
local props="$CLAUDE_PROJECT_DIR/gradle/wrapper/gradle-wrapper.properties"
[ -f "$props" ] || return 0
local url zip name hash dir ver official mirror ok=""
url=$(sed -n 's/^distributionUrl=//p' "$props" | sed 's/\\//g')
[ -n "$url" ] || return 0
zip=${url##*/}; name=${zip%.zip}
# Gradle stores the dist under base36(md5(distributionUrl)) — derive it so this
# keeps working across version bumps instead of hardcoding the hash dir.
hash=$(python3 - "$url" <<'PY'
import hashlib, sys
n = int.from_bytes(hashlib.md5(sys.argv[1].encode()).digest(), 'big')
d = "0123456789abcdefghijklmnopqrstuvwxyz"; s = ""
while n:
s = d[n % 36] + s; n //= 36
print(s or "0")
PY
)
dir="${GRADLE_USER_HOME:-$HOME/.gradle}/wrapper/dists/$name/$hash"
ver=${name%-bin}; ver=${ver%-all}
if [ -x "$dir/$ver/bin/gradle" ]; then return 0; fi # already installed
echo "Seeding Gradle distribution $ver (github release blocked; using verified mirror)..." >&2
mkdir -p "$dir"
official=$(curl -fsSL "https://services.gradle.org/distributions/${zip}.sha256") || {
echo "Could not fetch official Gradle checksum; leaving gradlew to fail as before." >&2
return 0
}
for mirror in \
"https://mirrors.cloud.tencent.com/gradle" \
"https://mirrors.huaweicloud.com/gradle"; do
if curl -fsSL -o "$dir/$zip" "$mirror/$zip" \
&& echo "${official} $dir/$zip" | sha256sum -c - >/dev/null 2>&1; then
ok=1; break
fi
echo "Mirror $mirror failed download/verify; trying next." >&2
rm -f "$dir/$zip"
done
if [ -z "$ok" ]; then
echo "Gradle seed failed against all mirrors; leaving gradlew to fail as before." >&2
return 0
fi
unzip -q "$dir/$zip" -d "$dir" && touch "$dir/$zip.ok"
echo "Gradle $ver seeded and verified against official sha256." >&2
}
seed_gradle_distribution
cd "$CLAUDE_PROJECT_DIR"
./gradlew --version > /dev/null 2>&1

View File

@@ -1,5 +1,17 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|mcp__github__create_pull_request",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-push-spotless.sh",
"timeout": 180
}
]
}
],
"SessionStart": [
{
"hooks": [

View File

@@ -129,10 +129,12 @@ via `Output.emit`. The template is in `references/command-template.md`;
copy it rather than re-deriving it.
Wire-up checklist:
1. New file in `cli/commands/` with the `object` pattern.
2. Add a branch in `Commands.kt`.
3. Add a branch in `Main.kt`'s `dispatch` (or under `marmotDispatch`
/ a new group dispatcher).
1. New file in `cli/commands/` with the `object` pattern. Sub-verb
`dispatch` functions use the shared `route(...)` helper in
`Router.kt` rather than a hand-rolled `when (tail[0])`.
2. Add a branch in `Main.kt`'s `dispatch` (top-level verbs call the
command object directly, e.g. `"relay" -> RelayCommands.dispatch(…)`;
`marmot` sub-verbs go through `marmotDispatch`'s `route` map).
4. Extend `printUsage()` in `Main.kt`.
5. Add the row to `cli/README.md`'s command table.
6. Update `cli/ROADMAP.md` — move the row from 🆕 / 📦 to ✅.
@@ -173,6 +175,7 @@ cli/
├── secrets/ # SecretStore backends (keychain / ncryptsec / plaintext)
└── commands/ # one file (or group) per top-level verb
├── UseCommand.kt # `amy use NAME`
├── Router.kt # `route(...)` shared sub-verb dispatcher
├── InitCommands.kt # init, whoami
├── CreateCommand.kt + LoginCommand.kt
├── RelayCommands.kt
@@ -186,15 +189,30 @@ cli/
├── MessageCommands.kt
├── MarmotResetCommand.kt
├── AwaitCommands.kt
── StoreCommands.kt
── StoreCommands.kt
├── AdminCommand.kt # `amy admin RELAY METHOD` (NIP-86)
├── ServeCommand.kt # `amy serve` (embeds :geode)
└── cashu/ # `amy cashu …` (NIP-60/61) — thin wrappers
├── CashuCommands.kt # over commons CashuWalletOps / CashuWalletReader
├── CashuWalletCommands.kt + CashuBalanceCommand.kt + CashuMintCommands.kt
└── CashuReceiveCommands.kt + CashuSendCommands.kt
+ CashuMaintenanceCommands.kt + CashuMintRecCommands.kt
```
Shared logic consumed by Amy lives in `commons/`:
- `commons/account/` — account bootstrap
- `commons/marmot/` — MLS / group state
- `commons/cashu/``ops/CashuWalletOps` (jvmAndroid) + `CashuWalletReader`
+ `CashuKeysetCounterStore`; the NIP-60/61 wallet, shared with Android.
- `commons/relayManagement/Nip86Retriever` — NIP-86 HTTP client, shared with
the Android relay-management screen.
- `commons/defaults/` — default relays, kinds
- Consult `commons/plans/` for cross-cutting design work in flight.
A few amy verbs lean on modules beyond `quartz`/`commons`: `amy serve`
depends on `:geode` (the standalone relay) — the one allowed extra module
dependency. `:amethyst` / `:desktopApp` remain forbidden (Rule 5).
## Common mistakes to refuse
- **Adding protocol logic to `cli/`.** Push back, offer to extract.

View File

@@ -18,8 +18,7 @@ object NotePublishCommand {
val args = Args(rest)
val text = args.positional(0, "text")
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val event = com.vitorpamplona.amethyst.commons.note
@@ -33,13 +32,15 @@ object NotePublishCommand {
"rejected_by" to ack.filterValues { !it }.keys.map { it.url },
))
return 0
} finally {
ctx.close()
}
}
}
```
`Context` is `AutoCloseable`; wrap it in `use { }` so it's closed
(RunState flushed, relays disconnected) on every exit path — never a
hand-rolled `try { } finally { ctx.close() }`.
`Output.emit(...)` handles the text-vs-JSON mode automatically. The
result map IS the `--json` shape; the human-readable text default is
derived from the same map by `Output.kt`'s renderer.
@@ -51,39 +52,34 @@ When a feature has several verbs (`note publish`, `note show`,
```kotlin
object NoteCommands {
suspend fun dispatch(dataDir: DataDir, tail: Array<String>): Int {
if (tail.isEmpty()) return Output.error("bad_args", "note <publish|show|react>")
val rest = tail.drop(1).toTypedArray()
return when (tail[0]) {
"publish" -> NotePublishCommand.run(dataDir, rest)
"show" -> NoteShowCommand.run(dataDir, rest)
"react" -> NoteReactCommand.run(dataDir, rest)
else -> Output.error("bad_args", "note ${tail[0]}")
}
}
suspend fun dispatch(dataDir: DataDir, tail: Array<String>): Int =
route("note", tail, "note <publish|show|react>", mapOf(
"publish" to { rest -> NotePublishCommand.run(dataDir, rest) },
"show" to { rest -> NoteShowCommand.run(dataDir, rest) },
"react" to { rest -> NoteReactCommand.run(dataDir, rest) },
))
}
```
Each verb gets its own file. Once a single file crosses ~200 lines,
split it — see `GroupCommands.kt` and its siblings as the reference.
The shared `route(name, tail, usage, routes)` helper (`Router.kt`)
handles the empty-input and unknown-verb `bad_args` branches, so the
`dispatch` body is just the verb→handler map. Each verb gets its own
file. Once a single file crosses ~200 lines, split it — see
`GroupCommands.kt` and its siblings as the reference.
## Wire-up checklist
For every new command:
1. File under `cli/commands/`.
2. Branch in `Commands.kt`:
2. Branch in `Main.kt`'s top-level `dispatch`, calling the command
object directly:
```kotlin
suspend fun note(dataDir: DataDir, tail: Array<String>): Int =
NoteCommands.dispatch(dataDir, tail)
"note" -> NoteCommands.dispatch(dataDir, tail)
```
3. Branch in `Main.kt`'s top-level `dispatch`:
```kotlin
"note" -> Commands.note(dataDir, tail)
```
4. Line in `printUsage()` explaining the verb.
5. Row in `cli/README.md`'s command table.
6. Status flip in `cli/ROADMAP.md` (🆕 / 📦 → ✅).
3. Line in `printUsage()` explaining the verb.
4. Row in `cli/README.md`'s command table.
5. Status flip in `cli/ROADMAP.md` (🆕 / 📦 → ✅).
## What not to do
@@ -95,7 +91,7 @@ For every new command:
them to `error: …` (text mode) / `{"error":…}` (JSON mode) plus the
right exit code.
- No holding a connection open across invocations — every run opens
a fresh `Context` and closes it in `finally`.
a fresh `Context` inside `use { }` so it closes on every exit path.
- No blocking reads for user input — take a flag.
- No global flags that collide with subcommand flags. `--name` is
reserved for subcommand use (group/profile name); the global

View File

@@ -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:
| Tree | Default file | Per-locale file |
|------|--------------|-----------------|
| **amethyst** (Android app) | `amethyst/src/main/res/values/strings.xml` | `amethyst/src/main/res/values-<locale>/strings.xml` |
| **commons** (KMP Compose resources, shared by Android + Desktop) | `commons/src/commonMain/composeResources/values/strings.xml` | `commons/src/commonMain/composeResources/values-<locale>/strings.xml` |
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:
```bash
cdef=commons/src/commonMain/composeResources/values/strings.xml
adef=amethyst/src/main/res/values/strings.xml
comm -12 \
<(grep '<string name=' "$cdef" | sed 's/.*name="\([^"]*\)".*/\1/' | sort -u) \
<(grep '<string name=' "$adef" | grep -v 'translatable="false"' | sed 's/.*name="\([^"]*\)".*/\1/' | sort -u) \
| while read -r k; do
cv=$(grep -m1 "name=\"$k\"" "$cdef" | sed 's/.*>\(.*\)<\/string>/\1/')
av=$(grep -m1 "name=\"$k\"" "$adef" | sed 's/.*>\(.*\)<\/string>/\1/')
[ "$cv" = "$av" ] && echo "SAFE-COPY $k" || echo "VALUE-DIFFERS $k commons=\"$cv\" amethyst=\"$av\""
done
```
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:
```bash
grep -nE '<string name="[^"]*">"' commons/src/commonMain/composeResources/values-*/strings.xml
# 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.
@@ -38,23 +90,45 @@ The default set of locales (unless the user specifies otherwise):
| Locale | Language | Directory |
|--------|----------|-----------|
| `cs-rCZ` | Czech | `values-cs-rCZ` |
| `cs` | Czech | `values-cs` |
| `pt-rBR` | Brazilian Portuguese | `values-pt-rBR` |
| `sv-rSE` | Swedish | `values-sv-rSE` |
| `de-rDE` | German | `values-de-rDE` |
> Czech was consolidated onto the base qualifier (PR #3461, 2026-07-03): a
> `cs: cs` `languages_mapping` entry in `crowdin.yml` makes Crowdin export to
> `values-cs`, and `values-cs-rCZ` no longer exists. The other locales still
> use Crowdin's default region-qualified `androidCode` until they are
> consolidated the same way — update this table as each one moves.
## Technique
### 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.
```
# amethyst tree
Default: amethyst/src/main/res/values/strings.xml
Target: amethyst/src/main/res/values-<locale>/strings.xml
# commons tree
Default: commons/src/commonMain/composeResources/values/strings.xml
Target: commons/src/commonMain/composeResources/values-<locale>/strings.xml
```
### 2. Find missing keys using cs-rCZ as reference
A convenient way to run the whole technique twice is to loop over the two base dirs:
Always diff against `cs-rCZ` first — it is the most complete locale and serves as the reference. Any keys missing in `cs-rCZ` will also be missing in the other target locales.
```bash
for base in amethyst/src/main/res commons/src/commonMain/composeResources; do
echo "########## tree: $base ##########"
# ... run the diff/count/value-extraction commands with $base/values[...] ...
done
```
### 2. Find missing keys using cs as reference
Always diff against `cs` first — it is the most complete locale and serves as the reference. Any keys missing in `cs` will also be missing in the other target locales.
You MUST diff **both** `<string name=` AND `<plurals name=` — these are independent resource types and a key that is a `<plurals>` in the source will never appear in a `<string>` diff. Forgetting `<plurals>` is the most common silent failure of this skill (it misses things like `music_playlist_track_count`, `notification_count_more`, etc.).
@@ -65,7 +139,7 @@ comm -23 \
<(grep '<string name=' amethyst/src/main/res/values/strings.xml \
| grep -v 'translatable="false"' \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<string name=' amethyst/src/main/res/values-cs-rCZ/strings.xml \
<(grep '<string name=' amethyst/src/main/res/values-cs/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort)
# Plurals: a separate resource type — MUST be diffed independently
@@ -73,16 +147,16 @@ echo "=== missing <plurals> ==="
comm -23 \
<(grep '<plurals name=' amethyst/src/main/res/values/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<plurals name=' amethyst/src/main/res/values-cs-rCZ/strings.xml \
<(grep '<plurals name=' amethyst/src/main/res/values-cs/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort)
```
This gives two lists of missing key names — keep them separate; `<plurals>` translations need the per-locale CLDR category set (see Step 5 → "Plurals: handle with care").
Crowdin can asymmetrically strip keys across locales (each translator independently chose source-identical for different keys), so **cs-rCZ is not a reliable upper bound**. Diff **every** target locale and union the results — don't assume the cs-rCZ set covers the others. A quick per-locale count is a useful sanity check against the Crowdin UI's "N untranslated":
Crowdin can asymmetrically strip keys across locales (each translator independently chose source-identical for different keys), so **cs is not a reliable upper bound**. Diff **every** target locale and union the results — don't assume the cs set covers the others. A quick per-locale count is a useful sanity check against the Crowdin UI's "N untranslated":
```bash
for locale in cs-rCZ de-rDE sv-rSE pt-rBR; do
for locale in cs de-rDE sv-rSE pt-rBR; do
ns=$(comm -23 \
<(grep '<string name=' amethyst/src/main/res/values/strings.xml \
| grep -v 'translatable="false"' | sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
@@ -111,7 +185,7 @@ done < <(comm -23 \
<(grep '<string name=' amethyst/src/main/res/values/strings.xml \
| grep -v 'translatable="false"' \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<string name=' amethyst/src/main/res/values-cs-rCZ/strings.xml \
<(grep '<string name=' amethyst/src/main/res/values-cs/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort))
# Missing <plurals>: extract the multi-line block (opening tag through </plurals>)
@@ -124,7 +198,7 @@ while IFS= read -r key; do
done < <(comm -23 \
<(grep '<plurals name=' amethyst/src/main/res/values/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<plurals name=' amethyst/src/main/res/values-cs-rCZ/strings.xml \
<(grep '<plurals name=' amethyst/src/main/res/values-cs/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort))
```
@@ -147,8 +221,10 @@ Flag and offer to fix:
```bash
# Scan every locale's strings.xml for <item quantity="one"> entries that
# hardcode "1" (or other literal digits) instead of using a placeholder.
# Looks at default + all values-* locales.
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml; do
# Looks at default + all values-* locales, in BOTH resource trees.
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml \
commons/src/commonMain/composeResources/values/strings.xml \
commons/src/commonMain/composeResources/values-*/strings.xml; do
awk -v file="$f" '
/<plurals/ { in_plurals = 1; name = $0; sub(/.*name="/, "", name); sub(/".*/, "", name) }
in_plurals && /quantity="one"/ {
@@ -167,7 +243,9 @@ done
Then scan for dead `quantity="zero"` entries. CLDR's `zero` category is integer-bearing only in **Arabic (`ar`)** and **Welsh (`cy`)**. In every other locale, count=0 falls through to `other`, so a `<item quantity="zero">` entry is dead and likely a translator/author bug (or it silently never fires):
```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 \
commons/src/commonMain/composeResources/values/strings.xml \
commons/src/commonMain/composeResources/values-*/strings.xml; do
# Skip Arabic and Welsh — they natively use the zero category.
case "$f" in
*values-ar*|*values-cy*) continue ;;
@@ -199,7 +277,7 @@ done < <(comm -23 \
<(grep '<string name=' amethyst/src/main/res/values/strings.xml \
| grep -v 'translatable="false"' \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<string name=' amethyst/src/main/res/values-cs-rCZ/strings.xml \
<(grep '<string name=' amethyst/src/main/res/values-cs/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort))
```
@@ -254,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.
for l in cs de-rDE sv-rSE pt-rBR; do
missing=$(comm -23 \
<(grep '<string name=' $base/values/strings.xml | grep -v 'translatable="false"' \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<string name=' $base/values-$l/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort))
# ... 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:
```bash
for f in <every edited strings.xml>; do
dups=$(grep -oE '<(string|plurals) name="[^"]*"' "$f" \
| sed 's/.*name="\([^"]*\)"/\1/' | sort | uniq -d)
[ -n "$dups" ] && echo "DUP in $f: $dups"
python3 -c "import xml.dom.minidom; xml.dom.minidom.parse('$f')" \
|| echo "MALFORMED $f"
done
# For a commons change, also run the build task that enforces this:
# ./gradlew :commons:convertXmlValueResourcesForCommonMain
```
## Common Mistakes
- **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 `<string name=`** — `<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-rCZ** — Crowdin can strip different keys in different locales (each translator's choice), so cs-rCZ is not a reliable upper bound. Diff each target locale and union the results.
- **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`)

View File

@@ -0,0 +1,183 @@
---
name: ngit-pr
description: How to create, review, revise, and merge pull requests in this repo, which can be published TWO ways — GitHub (the `gh` CLI) and git-over-nostr (the `ngit` CLI, where PRs are nostr **proposals** reviewed on gitworkshop.dev). Use whenever a task involves opening/updating/merging a PR by either mechanism, the `pr/feat/*` branches, the `ngit` or `gh` CLIs, gitworkshop.dev, or a `nostr://` remote. Remote names vary per clone (and a collaborator may have only one) — this skill identifies remotes by URL, and covers the three-mains alignment gate the nostr flow depends on.
---
# Pull requests: GitHub **and** git-over-nostr
This repo can be contributed to **two** ways, via **two kinds** of remote. Both end up in GitHub `main`.
| Remote kind | URL pattern | Role |
|--|--|--|
| **GitHub** | `github.com/vitorpamplona/amethyst` | **Canonical** `main`. Moves constantly (bots merge often) — a moving target. |
| **git-over-nostr** | `nostr://…/relay.ngit.dev/amethyst` | `ngit`. A push fans out to GitHub **and** the GRASP git servers and publishes nostr events. PRs are **proposals**, reviewed on **gitworkshop.dev**. |
## Step 0 — identify YOUR remotes (names are not universal)
Remote **names are per-clone**. In the maintainer's checkout the GitHub remote is `upstream` and the nostr remote is `origin`, but yours may differ, and **you may have only one of them** (e.g. cloned straight from `nostr://…`, so the nostr remote is your `origin` and there is no separate GitHub remote — pushing it still reaches GitHub via fan-out). Detect by **URL**, never assume a name:
```bash
git remote -v
GH_REMOTE=$(git remote -v | awk '/github\.com/ {print $1; exit}') # GitHub remote (may be empty)
NOSTR_REMOTE=$(git remote -v | awk '/nostr:\/\// {print $1; exit}') # git-over-nostr remote (may be empty)
echo "github=$GH_REMOTE nostr=$NOSTR_REMOTE"
```
The examples below use `$GH_REMOTE` / `$NOSTR_REMOTE` — substitute whichever you have.
## Which path?
| | **GitHub path** (`gh`) | **nostr path** (`ngit`) |
|--|--|--|
| Needs | a GitHub remote + `gh auth status` | a `nostr://` remote + `ngit` ≥ 2.5.0 |
| PR lives on | GitHub only | nostr + GitHub + GRASP (fans out) |
| Use when | Default; PR only needs to be on GitHub. Simplest, no alignment gate. | The PR must be visible/reviewable over nostr (gitworkshop), or you're revising/merging an existing **proposal** (a `pr/feat/*`). |
**Default to GitHub** unless the task is specifically about a nostr proposal (e.g. "the PRs on origin", a gitworkshop link, a `pr/feat/*` branch). If you only have one remote, that decides the path for you. Revise/merge a PR on **the same path it was created** — don't revise a GitHub PR via ngit or vice-versa.
---
# GitHub path (`gh`)
The normal flow most of this repo's history uses ("Merge pull request #NNNN …"). Requires a GitHub remote (`$GH_REMOTE`) and `gh auth status` OK.
```bash
# create — branch off main, push, open the PR
git checkout -b feat/<slug> main
git push -u "$GH_REMOTE" feat/<slug>
gh pr create --repo vitorpamplona/amethyst --base main --head feat/<slug> \
--title "feat: …" --body "…"
# review / list
gh pr list --repo vitorpamplona/amethyst
gh pr view <number> --repo vitorpamplona/amethyst # --comments for the thread
# revise — push more commits to the same branch
git push "$GH_REMOTE" feat/<slug>
# merge (maintainer)
gh pr merge <number> --repo vitorpamplona/amethyst --merge # or --squash
```
GitHub is the source of truth for this path — no three-mains gate. Standard Git Workflow rules from CLAUDE.md still apply (conventional commits, never `--no-verify`).
---
# nostr path (`ngit`)
Requires a `nostr://` remote (`$NOSTR_REMOTE`) and `ngit` ≥ 2.5.0 (`ngit --version`).
**Mental model:** an ngit PR ("proposal") is a *linear patch series off `main`*, published as nostr events. A "revision" is a new version of that proposal. Merging applies the series to `main` and publishes a merged-status event. There is **no** GitHub PR number; `ngit pr merge` makes a plain merge commit (amend it to a readable message).
## ⚠ The three-mains alignment gate (the thing that breaks everything)
Up to **three** `main` heads drift apart:
- GitHub main (`$GH_REMOTE/main` if you have it) — newest, moves every few minutes
- nostr main (`$NOSTR_REMOTE/main` tracking ref) — **lags**, often far behind
- local `main`
**Every create/revise/merge requires the proposal's base to equal the nostr `main`, and pushing `main` to the nostr remote requires GitHub's main to be an ancestor of what you push.** When misaligned, ngit **rejects pre-flight and publishes nothing** (safe — nothing half-breaks; realign and retry). Don't `--force` past it.
```bash
git fetch --all
echo "github=$([ -n "$GH_REMOTE" ] && git rev-parse "$GH_REMOTE/main") \
nostr=$(git ls-remote "$NOSTR_REMOTE" -h refs/heads/main | awk '{print $1}') \
local=$(git rev-parse main)"
# all present heads equal → proceed.
# local behind GitHub? git merge --ff-only "$GH_REMOTE/main" (or "$NOSTR_REMOTE/main" if that's all you have)
# nostr behind local? git push "$NOSTR_REMOTE" main (clean fast-forward only)
```
If you have **only** the nostr remote: align local `main` to `$NOSTR_REMOTE/main`; GitHub is handled by fan-out, and any GitHub/GRASP disagreement surfaces as an ngit rejection on push. If GitHub diverged from nostr (`out of sync with nostr` on push), that's a maintainer `ngit sync --ref-name refs/heads/main --force` situation — **stop and ask the human**, don't run a forced sync unprompted.
## Pushes are slow — run them in the background
`git push "$NOSTR_REMOTE" …`, `ngit send`, and `ngit pr merge` fan out to relays + GRASP servers and **routinely exceed 2 minutes**. Run with `run_in_background: true` and poll (e.g. `git ls-remote "$NOSTR_REMOTE"` for the expected ref). A foreground call hits the 2-minute tool timeout even while the push is actually succeeding.
## Identity
`ngit account whoami` shows the signing key; `ngit send`/`ngit pr merge` sign with **that** key regardless of original author. The maintainer (`VitorPamplona`, `_@vitorpamplona.com`) revising/merging a contributor's proposal with their own key is expected.
## List / view
```bash
ngit pr list # open + draft
ngit pr list --status open,draft,closed,merged,applied
ngit pr view <FULL-hex-event-id | nevent> # FULL id, not the short prefix
```
In `git branch -r`, proposals show as `<nostr-remote>/pr/feat/<slug>(<short-id>)` — the `(...)` is an ngit annotation; the real ref is `pr/feat/<slug>`. Status `applied` == merged.
## Create
Push a `pr/`-prefixed branch (linear, off current `main`):
```bash
git push -o 'title=My title' -o 'description=line1\n\nline2' -u "$NOSTR_REMOTE" pr/feat/<slug>
```
Advanced (cover letter, labels): `ngit send` — see `ngit send --help`.
## Revise (publish a new version)
The revision must be a **linear series off the current `main`** (a merge commit is the wrong shape).
```bash
# 1. align (gate above), then build the linear series:
git checkout -b <work> main
git cherry-pick <original-pr-tip> # existing PR commits
git cherry-pick <your-new-commits…> # yours on top (or: git rebase main)
# 2. verify it compiles + tests pass; tree == your intended change.
# 3. publish as a new version, linked to the proposal:
ngit send --in-reply-to <proposal-nevent> \
--subject "<keep or update title>" \
--description "<what changed>" \
-d main # SINCE_OR_RANGE "main" → commits in main..HEAD
```
`--in-reply-to` threads it under the same proposal on gitworkshop. **No `--force`** once the base equals the nostr `main`. `proposal builds on a commit N ahead of 'origin/main'` ⇒ gate unmet — realign and re-rebase, don't force. Verify: `git ls-remote "$NOSTR_REMOTE" | grep <short-id>` shows `refs/pr/<full-id>/head` at your new tip.
## Merge into main
The merge is **local**; the merged-status event publishes on the subsequent push.
```bash
# 1. ALIGN (mandatory) — all present mains equal.
git checkout main
ngit pr merge <FULL-hex-event-id> -d # local merge commit; marks proposal "applied"
# --squash for a squash merge
# 2. amend the generic merge message to something readable:
git commit --amend -F - <<'MSG'
Merge PR: <title>
Merges nostr proposal <short-id> into main:
- <commit summaries>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MSG
# 3. sanity-check, then publish (background — slow):
[ -n "$GH_REMOTE" ] && { git merge-base --is-ancestor "$GH_REMOTE/main" HEAD && echo "clean FF push" || echo "github moved; realign"; }
git push "$NOSTR_REMOTE" main
```
Confirm: `ngit pr list --status applied` shows it `applied`, and (if you have it) `git fetch "$GH_REMOTE"` fast-forwards GitHub's main to your merge.
## Cleanup
Delete throwaway branches pushed to the nostr remote (e.g. a `merge/*` used before switching to the proper flow): `git push "$NOSTR_REMOTE" --delete <branch>` (the per-GRASP "non-existent ref" warnings are idempotent fan-out). Delete the local merged branches ngit creates (`pr/feat/<slug>(...)`) and your work branch.
## Failure modes — quick reference
| Symptom | Cause | Fix |
|---------|-------|-----|
| `proposal builds on a commit N ahead of 'origin/main'` | base ≠ stale nostr `main` | realign `main`, rebase series, resend (no `--force`) |
| `! [remote rejected] main … out of sync with nostr` | GitHub main diverged from nostr | maintainer `ngit sync … --force`**ask the human** |
| push "succeeds" but nothing on gitworkshop | pushed a plain branch, not a proposal | use `pr/`-prefix or `ngit send --in-reply-to` |
| `ngit pr view`/`merge` "failed to parse event id" | used the short prefix | pass the **full** hex id or `nevent` |
| push hangs / times out at 2 min | normal GRASP fan-out latency | run in background; verify via `git ls-remote` |

View File

@@ -1,6 +1,6 @@
---
name: nostr-expert
description: Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (80+ NIP packages in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent). Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details.
description: Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (80+ NIP packages in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent), (8) Resolving user input (hex, npub, nprofile, or NIP-05 `name@domain` internet identifiers) to a pubkey. Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details.
---
# Nostr Protocol Expert (Quartz Implementation)
@@ -15,6 +15,7 @@ Practical patterns for working with Nostr in Quartz, AmethystMultiplatform's KMP
- Finding NIP implementations in quartz/ codebase
- Nostr cryptography (secp256k1 signing, NIP-44 encryption)
- Bech32 encoding/decoding (npub, nsec, note formats)
- Resolving user input (hex / npub / nprofile / NIP-05 `name@domain`) to a pubkey
- Event validation and verification
**For NIP specifications** → Use `nostr-protocol` agent
@@ -345,6 +346,55 @@ object Nip04 {
**Note**: Use NIP-44 (`Nip44`) for new implementations. NIP-04 has security issues.
## Hex Encoding (HexKey ↔ ByteArray)
Pubkeys, event ids and signatures are lower-case hex. Quartz uses the `HexKey`
typealias (`= String`) plus extensions in `nip01Core/core/HexKey.kt`, backed by
the `Hex` object in `utils/Hex.kt`. **Use these — never hand-roll a byte loop or
import a third-party hex codec.**
```kotlin
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.utils.Hex
val hex: HexKey = bytes.toHexKey() // ByteArray -> lower-case hex
val back: ByteArray = hex.hexToByteArray() // hex -> ByteArray (throws on odd length)
val safe: ByteArray? = input.hexToByteArrayOrNull() // null on invalid hex
Hex.isHex(input) // valid hex, any length
Hex.isHex64(input) // ~30% faster fast-path for a 32-byte key/id
hex.isValid() // 64 chars + valid hex (pubkey / event-id shape)
Hex.isEqual(hex, bytes) // compare hex to bytes without decoding
```
Constants `PUBKEY_LENGTH` / `EVENT_ID_LENGTH` (both 64) live in `nip01Core.core`.
## Core Utilities (time, random, event id)
Reuse these instead of hand-rolling — each avoids a common mistake:
```kotlin
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.sha256.sha256
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
TimeUtils.now() // Unix SECONDS for created_at — not currentTimeMillis()/1000
TimeUtils.oneHourAgo() // relative filter bounds (…Ago / …FromNow); all in seconds
RandomInstance.bytes(32) // secure random (SecureRandom) — for nonces/keys, not kotlin.random.Random
RandomInstance.randomChars() // 16-char subscription id
sha256(bytes) // raw hash primitive
EventHasher.hashId(pubKey, createdAt, kind, tags, content) // canonical event id
EventHasher.hashIdCheck(id, pubKey, createdAt, kind, tags, content) // verify untrusted events
```
`EventHasher` serializes `[0, pubkey, created_at, kind, tags, content]` in the
exact form NIP-01 requires — prefer it over calling `sha256` on your own JSON.
## Bech32 Encoding (NIP-19)
Encoding uses extension functions on `ByteArray` (`nip19Bech32/ByteArrayExt.kt`);
@@ -375,6 +425,25 @@ when (val entity = Nip19Parser.uriToRoute(input)?.entity) {
}
```
## Resolving User Input to a Pubkey (NIP-05 + NIP-19)
**Before writing any `if (isHex) … else if (npub) … else if ("@" in s) fetchWellKnown()` logic, stop — it already exists.** `resolveUserHexOrNull` in `quartz/nip05DnsIdentifiers/` accepts every identifier form a user might type and returns a 64-hex pubkey.
```kotlin
import com.vitorpamplona.quartz.nip05DnsIdentifiers.resolveUserHexOrNull
// hex | npub1… | nprofile1… | nsec1… | name@domain.tld → HexKey? (null if unrecognized/lookup fails)
val pubkey = resolveUserHexOrNull(userInput, nip05Client)
```
- Tries the **synchronous** hex/bech32 path first (`decodePublicKeyAsHexOrNull`) — only NIP-05-shaped input hits the network.
- `suspend`; re-throws only `CancellationException`. Pass `nip05Client = null` for offline contexts.
- Build the client with `Nip05Client(fetcher = OkHttpNip05Fetcher { _ -> okHttp })` (see `cli/Context.kt`). The OkHttp fetcher already runs on IO and disables redirects per the NIP-05 spec — don't re-implement the `.well-known/nostr.json` fetch or JSON parse.
- Need only hex/bech32 (no network)? Use `decodePublicKeyAsHexOrNull(input)` directly.
- Need to *verify* a claimed identifier maps back to a pubkey? `nip05Client.verify(Nip05Id.parse(id)!!, pubkey)`.
See `references/nip05-identifiers.md` for the full API surface (`Nip05Id`, `Nip05Client`, `Nip05Parser`, `KeyInfoSet`, Namecoin `.bit`) and the hand-rolled anti-pattern to avoid.
## Event Validation
```kotlin
@@ -503,6 +572,7 @@ Or see `references/nip-catalog.md` for complete catalog.
- **references/event-hierarchy.md** - Event class hierarchy, kind classifications, common types
- **references/tag-patterns.md** - Tag structure, TagArrayBuilder DSL, common tag types, parsing patterns
- **references/nip19-bech32.md** - `Nip19Parser`, `Bech32Util`, `TlvBuilder`, entity types (NPub, NSec, NEvent, NAddress, NProfile, NRelay, NEmbed)
- **references/nip05-identifiers.md** - Resolving any identifier (hex/npub/nprofile/nsec/`name@domain`) to a pubkey via `resolveUserHexOrNull`; `Nip05Client`, `Nip05Id`, `Nip05Parser`, Namecoin `.bit` — and the hand-rolled anti-pattern to avoid
- **references/event-factory.md** - `EventFactory` dispatch pattern and how to register a new kind
- **references/crypto-and-encryption.md** - Event signing/verification, secp256k1 abstraction, NIP-44 encryption, `SharedKeyCache`
- **references/large-cache.md** - `LargeCache<K,V>` expect/actual + `ICacheOperations` functional API
@@ -518,6 +588,9 @@ Or see `references/nip-catalog.md` for complete catalog.
| Verify signature | `event.verify()` | nip01Core/core/ |
| Encrypt (NIP-44) | `Nip44v2.encrypt(...)` | nip44Encryption/ |
| Bech32 encode | `Nip19.npubEncode(...)` | nip19Bech32/ |
| Resolve input → pubkey | `resolveUserHexOrNull(input, nip05Client)` | nip05DnsIdentifiers/ |
| Decode bech32 → pubkey (no net) | `decodePublicKeyAsHexOrNull(input)` | nip19Bech32/ |
| Verify NIP-05 identifier | `nip05Client.verify(Nip05Id.parse(id)!!, hex)` | nip05DnsIdentifiers/ |
| Find NIP | `scripts/nip-lookup.sh <number>` | - |
## Common Event Kinds

View File

@@ -13,7 +13,7 @@ under `experimental/`**. The categorized list below may lag behind —
| 02 | `nip02FollowList/` | ContactListEvent.kt | Follow/contact lists (kind 3) |
| 03 | `nip03Timestamp/` | OpenTimestampsAttestation.kt | Timestamps |
| 04 | `nip04Dm/` | EncryptedDmEvent.kt | Legacy encrypted DMs (deprecated for NIP-17) |
| 05 | `nip05DnsIdentifiers/` | Nip05Verifier.kt | DNS-based verification |
| 05 | `nip05DnsIdentifiers/` | UserHexResolver.kt, Nip05Client.kt | Internet identifiers; `resolveUserHexOrNull` resolves hex/npub/nprofile/`name@domain` → pubkey (see references/nip05-identifiers.md) |
| 06 | `nip06KeyDerivation/` | Mnemonic-related | BIP-39 key derivation |
| 09 | `nip09Deletions/` | DeletionEvent.kt | Event deletion requests (kind 5) |
| 11 | `nip11RelayInfo/` | RelayInformation.kt | Relay metadata |

View File

@@ -0,0 +1,98 @@
# NIP-05: Identifiers → Pubkey Resolution
How Quartz turns anything a human might type — a raw hex pubkey, an `npub`/`nprofile`/`nsec`, or a NIP-05 internet identifier (`alice@domain.tld`) — into a 64-hex Nostr pubkey. **Everything below already exists in `quartz/nip05DnsIdentifiers/`. Do not hand-roll it.**
## TL;DR — the one function you almost always want
```kotlin
import com.vitorpamplona.quartz.nip05DnsIdentifiers.resolveUserHexOrNull
// hex | npub1… | nprofile1… | nsec1… | name@domain.tld → 64-hex pubkey (or null)
val pubkey: HexKey? = resolveUserHexOrNull(userInput, nip05Client)
```
`resolveUserHexOrNull(input, nip05Client)` (in `UserHexResolver.kt`) is the canonical "accept any identifier form" resolver. It:
- trims input, tries the **synchronous** bech32/hex path first (`decodePublicKeyAsHexOrNull`), so hex/`npub`/`nprofile`/`nsec` never touch the network;
- only issues an HTTPS fetch for genuinely NIP-05-shaped input (`name@domain.tld`), gated by a cheap `looksLikeNip05()` precheck;
- returns `null` on anything unrecognizable or on a failed NIP-05 lookup (network error / no match);
- re-throws **only** `CancellationException`, so it's safe inside structured concurrency.
Pass `nip05Client = null` in pure-offline contexts — NIP-05-shaped inputs then fall through to `null` and no HTTP is attempted.
## ❌ Do not write this (the hand-rolled anti-pattern)
```kotlin
// DON'T. This re-implements resolveUserHexOrNull badly:
// - no nsec support
// - no input validation (accepts IP-literal / malformed domains → spurious fetches)
// - hand-parses JSON instead of using Nip05Parser
// - bespoke httpGet ignores the "MUST NOT follow redirects" rule
// - swallows CancellationException, breaking structured concurrency
fun resolveObserver(input: String): String? {
if (Hex.isHex64(input)) return input.lowercase()
if (input.startsWith("npub1") || input.startsWith("nprofile1")) { /* … */ }
if ("@" in input) return resolveNip05(input) // bespoke well-known fetch
return null
}
```
## ✅ Do this instead
```kotlin
// CLI already exposes it — commands call Context.requireUserHex(input):
val pubHex = resolveUserHexOrNull(input, nip05Client)
?: return Output.error("bad_args", "expected npub, nprofile, 64-hex, or name@domain.tld")
```
## Building an `Nip05Client`
`resolveUserHexOrNull` takes an `INip05Client`. On JVM/Android, wire the OkHttp fetcher (mirror what `cli/Context.kt` does):
```kotlin
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
import com.vitorpamplona.quartz.nip05DnsIdentifiers.OkHttpNip05Fetcher
val nip05Client = Nip05Client(fetcher = OkHttpNip05Fetcher { _ -> okHttpClient })
```
`OkHttpNip05Fetcher` already runs on `Dispatchers.IO` and disables redirects per the NIP-05 spec ("Fetchers MUST ignore any HTTP redirects"). Don't re-implement the fetch.
For tests / offline code, `EmptyNip05Client` is a no-op stub.
## The pieces (all in `quartz/…/nip05DnsIdentifiers/`)
| Type | File | Purpose |
|------|------|---------|
| `resolveUserHexOrNull(input, client?)` | `UserHexResolver.kt` | **Start here.** Any identifier form → 64-hex pubkey, or null. `suspend`. |
| `Nip05Id` | `Nip05Id.kt` | Parsed `name@domain`. `Nip05Id.parse(str)` validates (RFC 5321 local-part + hostname rules, rejects IP literals) and lowercases. `toUserUrl()` / `toDomainUrl()` build the `.well-known/nostr.json` URLs. `toDisplayValue()` collapses the `_` wildcard to just the domain. |
| `INip05Client` / `Nip05Client` | `INip05Client.kt`, `Nip05Client.kt` | Async resolver. `get(id): Nip05KeyInfo?` (pubkey + relays), `verify(id, hex): Boolean`, `load(id): KeyInfoSet?`, `list(domain): KeyInfoSet`, `loadClinkOffer(id): String?`. Auto-routes `.bit` domains to Namecoin. `EmptyNip05Client` = offline no-op. |
| `Nip05Fetcher` / `OkHttpNip05Fetcher` | `Nip05Fetcher.kt`, `OkHttpNip05Fetcher.kt` (jvmAndroid) | Transport SAM. OkHttp actual disables redirects + runs on IO. |
| `Nip05Parser` | `Nip05Parser.kt` | JSON `.well-known/nostr.json` codec: `parseHexKey`, `parseHexKeyAndRelays`, `parse``KeyInfoSet`, `parseClinkOffer`. |
| `Nip05KeyInfo` / `KeyInfoSet` | `Nip05KeyInfo.kt`, `KeyInfoSet.kt` | `Nip05KeyInfo(pubkey, relays)`; `KeyInfoSet(names: Map, relays: Map)` = the full domain listing. |
| `NamecoinNameResolver` | `namecoin/NamecoinNameResolver.kt` | `.bit` / `d/…` / `id/…` blockchain identifiers. `isNamecoinIdentifier(str)`, `resolve(str)`. Invoked automatically by `Nip05Client` — you rarely call it directly. |
## When you only need the pure (synchronous, no-network) part
If the input can only be hex/bech32 (no NIP-05), skip the client entirely:
```kotlin
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
// hex | npub1… | nprofile1… | nsec1… → 64-hex pubkey (or null). No suspend, no network.
val pubkey: HexKey? = decodePublicKeyAsHexOrNull(input)
```
See `references/nip19-bech32.md` for the full bech32 entity story. `resolveUserHexOrNull` is just this function plus the NIP-05 HTTP fallback.
## Verifying a claimed identifier
To confirm a profile's advertised `nip05` actually points back to its pubkey (NIP-05 verification), use `verify`, not `get`:
```kotlin
val ok: Boolean = nip05Client.verify(Nip05Id.parse("alice@domain.tld")!!, profilePubkeyHex)
```
## Tests
`quartz/src/commonTest/…/nip05DnsIdentifiers/Nip05Test.kt` covers parsing, URL construction, case-normalization, CLINK offers, and the validation rejects (IP literals, malformed domains).

View File

@@ -1,6 +1,6 @@
---
name: quartz-integration
description: Integration guide for using the Quartz Nostr KMP library in external projects. Use when: (1) adding Quartz as a Gradle dependency, (2) setting up NostrClient with WebSocket, (3) creating/signing/sending events, (4) building relay subscriptions with Filter, (5) handling keys with KeyPair/NostrSignerInternal, (6) using Bech32 encoding/decoding (NIP-19), (7) platform-specific setup (Android vs JVM/Desktop), (8) NIP-57 zaps, NIP-17 DMs, NIP-44 encryption in external projects.
description: Integration guide for using the Quartz Nostr KMP library in external projects. Use when: (1) adding Quartz as a Gradle dependency, (2) setting up NostrClient with WebSocket, (3) creating/signing/sending events, (4) building relay subscriptions with Filter, (5) handling keys with KeyPair/NostrSignerInternal, (6) using Bech32 encoding/decoding (NIP-19), (7) platform-specific setup (Android vs JVM/Desktop), (8) NIP-57 zaps, NIP-17 DMs, NIP-44 encryption in external projects, (9) running a relay on Quartz and serving/building its NIP-11 relay information document (application/nostr+json).
---
# Quartz Integration Guide
@@ -134,13 +134,14 @@ val privKeyHex: String? = keyPair.privKey?.toHexKey()
```kotlin
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
// ByteArray → hex
val hex = byteArray.toHexKey()
// hex → ByteArray
val bytes = HexKey.decodeHex(hex)
val bytes = hex.hexToByteArray()
// Bech32 import (npub, nsec)
val parsed = Nip19Parser.uriToRoute("npub1abc...")
@@ -148,6 +149,126 @@ val parsed = Nip19Parser.uriToRoute("npub1abc...")
val parsed = Nip19Parser.uriToRoute("nsec1abc...")
```
> Hex ↔ ByteArray is a first-class utility in Quartz — see **§3.1 Hex utilities** below.
---
### 3.1 Hex utilities (HexKey ↔ ByteArray)
Nostr keys, event ids and signatures travel as lower-case hex strings. Quartz
models this with the `HexKey` typealias (just a `String`) plus extension
functions — **do not** write your own byte loop or pull in a third-party codec.
**Packages:** `com.vitorpamplona.quartz.nip01Core.core` (the extensions) and
`com.vitorpamplona.quartz.utils` (the underlying `Hex` object).
```kotlin
import com.vitorpamplona.quartz.nip01Core.core.HexKey // typealias = String
import com.vitorpamplona.quartz.nip01Core.core.toHexKey // ByteArray → hex
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray // hex → ByteArray
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.utils.Hex
// Encode / decode
val hex: HexKey = pubKeyBytes.toHexKey() // lower-case, 2 chars per byte
val bytes: ByteArray = hex.hexToByteArray() // throws on odd length
// Untrusted input → decode safely
val maybe: ByteArray? = userInput.hexToByteArrayOrNull() // null if not valid hex
// Validate without decoding (no allocation)
Hex.isHex(userInput) // even-length, all hex digits (any length)
Hex.isHex64(userInput) // fast path for a 32-byte key/id (checks first 64 chars)
hex.isValid() // 64 chars AND valid hex (pubkey / event-id shape)
// Compare a hex string to raw bytes without decoding
Hex.isEqual(incomingHexId, myIdBytes)
```
| Need | Call | Notes |
|------|------|-------|
| ByteArray → hex | `bytes.toHexKey()` | lower-case output |
| hex → ByteArray (strict) | `hex.hexToByteArray()` | throws on odd length |
| hex → ByteArray (safe) | `hex.hexToByteArrayOrNull()` | `null` on invalid hex |
| is this valid hex? | `Hex.isHex(s)` / `Hex.isHex64(s)` | `isHex64` ~30% faster for keys/ids |
| is this a pubkey/id shape? | `hex.isValid()` | 64 chars + valid hex |
| hex == bytes? | `Hex.isEqual(hex, bytes)` | no decode allocation |
Constants `PUBKEY_LENGTH` and `EVENT_ID_LENGTH` (both `64`) live in the same
`nip01Core.core` package.
---
### 3.2 Everyday utilities (time, random, hashing, bech32, base64)
These small helpers exist so you don't reinvent them — and several have a
footgun the built-in avoids. **Prefer them over stdlib/hand-rolled equivalents.**
**Time — `TimeUtils` (`com.vitorpamplona.quartz.utils`).** Everything is in Unix
**seconds** (what `created_at` and filter `since`/`until` use), *not* millis.
```kotlin
import com.vitorpamplona.quartz.utils.TimeUtils
val createdAt = TimeUtils.now() // seconds — for created_at. NOT currentTimeMillis()/1000
val since = TimeUtils.oneDayAgo() // relative filter bounds: oneHourAgo(), fiveMinutesAgo()…
val fresh = TimeUtils.withinTenMinutes(event.createdAt) // NIP-42/NIP-98 freshness
// TimeUtils.nowMillis() is the only millisecond helper — non-protocol use only.
```
**Secure random — `RandomInstance` (`utils`).** Backed by `SecureRandom`; use it
for anything security-sensitive instead of `kotlin.random.Random`.
```kotlin
import com.vitorpamplona.quartz.utils.RandomInstance
val nonce = RandomInstance.bytes(32) // nonces, salts, keys
val subId = RandomInstance.randomChars() // 16-char [a-zA-Z0-9] subscription id
```
**Hashing — `sha256(...)` + `EventHasher`.** `sha256` is the raw primitive; to
compute/verify an **event id** use `EventHasher`, which canonically serializes
`[0, pubkey, created_at, kind, tags, content]` before hashing (getting this wrong
is what makes relays reject an event). Typed builders already do this for you.
```kotlin
import com.vitorpamplona.quartz.utils.sha256.sha256
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
val digest = sha256(bytes) // raw 32-byte hash
val id = EventHasher.hashId(pubKey, createdAt, kind, tags, content)
val valid = EventHasher.hashIdCheck(event.id, event.pubKey, event.createdAt, event.kind, event.tags, event.content)
```
**Bech32.** For `npub`/`nsec`/`note`/… prefer the NIP-19 layer (`ByteArray.toNpub()`,
`Nip19Parser.uriToRoute(...)` — see §10). Drop to the low-level
`Bech32` object (`nip19Bech32.bech32`) only for a custom prefix:
```kotlin
import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
val addr = Bech32.encodeBytes("npub", pubKeyBytes, Bech32.Encoding.Bech32)
val bytes = "npub1...".bechToBytes("npub") // decode + assert the prefix
```
**Base64.** Quartz has no wrapper — use the Kotlin stdlib `kotlin.io.encoding.Base64`
directly, and match the variant the spec wants: NIP-44/NIP-04 payloads use
`Base64.Default` (standard, padded); url-safe contexts use `Base64.UrlSafe`
(configure padding via `.withPadding(...)`).
| Need | Call |
|------|------|
| Now (event `created_at`) | `TimeUtils.now()` (seconds) |
| Relative filter bound | `TimeUtils.oneDayAgo()` / `oneHourAgo()` / … |
| Secure random bytes | `RandomInstance.bytes(n)` |
| Subscription id | `RandomInstance.randomChars()` |
| Raw hash | `sha256(bytes)` |
| Event id / verify | `EventHasher.hashId(...)` / `hashIdCheck(...)` |
| Bech32 custom prefix | `Bech32.encodeBytes(hrp, bytes, enc)` / `s.bechToBytes(hrp)` |
| Base64 | `kotlin.io.encoding.Base64` (`.Default` / `.UrlSafe`) |
---
## 4. Signing Events
@@ -634,7 +755,100 @@ val results = store.query<Event>(Filter(search = "bitcoin"))
---
## 15. Quick Reference
## 15. NIP-11 Relay Information Document
If you're standing up a relay on Quartz's relay-server code, serve your NIP-11
document with the **type-safe builder** — don't hand-write the JSON string.
**Package:** `com.vitorpamplona.quartz.nip11RelayInfo`
```kotlin
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip11RelayInfo.relayInformation
val info =
relayInformation {
name = "sot"
description = "NIP-50 profile search ranked by Nostr web-of-trust"
software = "https://github.com/vitorpamplona/sot"
version = "0.1"
supports(1, 11, 42, 50) // ints → spec-compliant [1,11,42,50] in the JSON
}
val json = info.toJson() // null/empty fields are omitted
```
Serve it at the relay root, branching on the `Accept` header (Ktor example):
```kotlin
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import io.ktor.http.ContentType
get("/") {
val accept = call.request.headers[HttpHeaders.Accept].orEmpty()
if (accept.contains(Nip11RelayInformation.CONTENT_TYPE)) { // "application/nostr+json"
call.respondText(json, ContentType.parse(Nip11RelayInformation.CONTENT_TYPE))
} else {
call.respondText("Open a WebSocket (NIP-01) or send Accept: ${Nip11RelayInformation.CONTENT_TYPE}")
}
}
```
### Nested objects, lists, and enforced limits
```kotlin
val info =
relayInformation {
name = "Paid Relay"
supports(1, 11, 42)
supportsExtensions("nip50-search") // supported_nip_extensions
countries("US", "CA") // relay_countries; also languages(...), tags(...)
nip50Features("profile_search") // the `nip50` field
// limitation { } — camelCase maps to NIP-11 snake_case fields
limitation {
maxSubscriptions = 20
maxFilters = 10
authRequired = true
}
// fees { } — each helper is repeatable
fees {
admission(amount = 1000, unit = "msats")
publication(amount = 100, unit = "msats", kinds = listOf(1, 30023))
}
// retention(...) — call once per policy entry
retention(kinds = listOf(0, 3), count = 1)
}
```
**Keep advertised limits in sync with enforced ones.** If you build a
`RelayLimits` for the server's policy chain, hand the *same* object to the
builder so what you publish can never drift from what you enforce:
```kotlin
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RelayLimits
val limits = RelayLimits(maxSubscriptions = 20, maxFilters = 10, maxLimit = 500, authRequired = true)
val info =
relayInformation {
name = "My Relay"
supports(1, 11, 42, 45)
limitation(limits) // == limits.toNip11Limitation()
}
```
To load an operator-supplied doc from disk or a string instead of building it,
use `Nip11RelayInformation.fromJson(json)`.
> `geode` (Quartz's standalone relay) builds its default document exactly this
> way — see `geode/.../RelayInfo.kt`.
---
## 16. Quick Reference
| Task | API | Package |
|------|-----|---------|
@@ -644,6 +858,13 @@ val results = store.query<Event>(Filter(search = "bitcoin"))
| Sign event | `signer.sign(template)` | `nip01Core.signers` |
| Serialize | `event.toJson()` | `nip01Core.core` |
| Parse | `Event.fromJson(json)` | `nip01Core.core` |
| ByteArray → hex | `bytes.toHexKey()` | `nip01Core.core` |
| hex → ByteArray | `hex.hexToByteArray()` / `hex.hexToByteArrayOrNull()` | `nip01Core.core` |
| Validate hex | `Hex.isHex(s)` / `Hex.isHex64(s)` / `hex.isValid()` | `utils`, `nip01Core.core` |
| Now (seconds) | `TimeUtils.now()` | `utils` |
| Relative time | `TimeUtils.oneDayAgo()` / `oneHourAgo()` | `utils` |
| Secure random | `RandomInstance.bytes(n)` / `randomChars()` | `utils` |
| Hash / event id | `sha256(bytes)` / `EventHasher.hashId(...)` | `utils.sha256`, `nip01Core.crypto` |
| Normalize relay URL | `RelayUrlNormalizer.normalize("wss://...")` | `nip01Core.relay.normalizer` |
| Setup relay client | `NostrClient(BasicOkHttpWebSocket.Builder { okhttp })` | `nip01Core.relay.client` |
| Subscribe | `client.openReqSubscription(subId, mapOf(relay to filters), listener)` | `nip01Core.relay.client` |
@@ -651,6 +872,8 @@ val results = store.query<Event>(Filter(search = "bitcoin"))
| NIP-44 encrypt | `signer.nip44Encrypt(text, recipientPubKey)` | `nip01Core.signers` |
| Bech32 decode | `Nip19Parser.uriToRoute("npub1...")` | `nip19Bech32` |
| Bech32 encode | `Nip19Bech32.createNPub(pubKeyHex)` | `nip19Bech32` |
| Build NIP-11 doc | `relayInformation { name = ...; supports(1, 11) }` | `nip11RelayInfo` |
| Serialize NIP-11 doc | `info.toJson()` (media type `Nip11RelayInformation.CONTENT_TYPE`) | `nip11RelayInfo` |
## Common Event Kinds

View File

@@ -123,6 +123,12 @@ Each subscription tracks "End of Stored Events" per relay. The eose manager in `
## Related
- **Headless / one-shot client ops** (CLI, geode, tests, non-compose code): don't go
through `Subscribable` — use the `INostrClient` extension functions in
`quartz/…/nip01Core/relay/client/accessories/` (`fetchAll`, `fetchFirst`,
`fetchAllPages`, `publishAndConfirm`, `count`, `negentropyReconcile`/`negentropySync`,
…). They're extensions, so they don't show up under "usages of `NostrClient`" — see
that package's `README.md` for the catalog before writing a raw subscribe/collect loop.
- `nostr-expert/references/tag-patterns.md` — how tags inform what a filter needs to look for.
- `kotlin-coroutines/references/relay-patterns.md` — relay pool internals (sibling layer beneath assemblers).
- `feed-patterns` skill — feeds compose several Subscribables (content + metadata + reactions).

4
.gitattributes vendored Normal file
View File

@@ -0,0 +1,4 @@
# Gzip-compressed test corpora (e.g. nostr_vitor_startup_data.json.gz): treat as
# binary so git never applies CRLF/text normalization or textual diff/merge, which
# would corrupt the compressed stream (important on Windows checkouts).
*.gz binary

View File

@@ -19,7 +19,7 @@ jobs:
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
@@ -66,7 +66,7 @@ jobs:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
@@ -122,7 +122,7 @@ jobs:
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
@@ -181,7 +181,7 @@ jobs:
timeout-minutes: 60
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5

View File

@@ -0,0 +1,157 @@
name: Bump Homebrew Formula (amy CLI)
# Sibling of bump-homebrew.yml, but for a DIFFERENT Homebrew artifact:
# - bump-homebrew.yml -> Cask `amethyst-nostr` (the desktop GUI app / DMG)
# - this workflow -> Formula `amy` (the headless CLI jar bundle)
#
# What it does today: after a stable release, download the published
# `amy-<version>-jvm.tar.gz` bundle, compute its sha256, and open a PR that
# syncs `cli/packaging/homebrew/amy.rb`'s url + sha256 to that release. That is
# exactly the manual step the formula header calls out ("replace the version in
# the url and the sha256 with the values for the actual published release
# asset"), so keeping the in-repo reference formula accurate makes the eventual
# homebrew-core submission a copy-paste.
#
# What it does NOT do yet: open a PR against Homebrew/homebrew-core. `brew
# bump-formula-pr` can only bump a formula that already EXISTS in homebrew-core,
# and `amy` has never been submitted there — that first submission is a manual,
# human-reviewed new-formula PR (the one-time bootstrap). Once it lands, wire the
# auto-bump here (symmetric to the cask action in bump-homebrew.yml) — see the
# "TODO(bootstrap)" note at the bottom of this file.
on:
release:
types: [released]
workflow_dispatch:
inputs:
tag:
description: 'Release tag to sync (for manual recovery)'
required: true
type: string
permissions:
contents: write
pull-requests: write
# The "Report failure" step opens a [release-ops] issue via
# github.rest.issues.create, which needs issues:write.
issues: write
concurrency:
# Serialize per tag; do not cancel in-progress runs.
group: bump-homebrew-formula-${{ github.event.release.tag_name || inputs.tag }}
cancel-in-progress: false
jobs:
sync-formula:
if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Re-assert stable release
uses: ./.github/actions/assert-stable-release
with:
tag: ${{ github.event.release.tag_name || inputs.tag }}
is_prerelease: ${{ github.event.release.prerelease || 'false' }}
is_draft: ${{ github.event.release.draft || 'false' }}
- name: Resolve version
id: ver
run: |
set -euo pipefail
TAG="${{ github.event.release.tag_name || inputs.tag }}"
VER="${TAG#v}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "ver=$VER" >> "$GITHUB_OUTPUT"
- name: Download jvm bundle and compute sha256
id: asset
run: |
set -euo pipefail
TAG="${{ steps.ver.outputs.tag }}"
VER="${{ steps.ver.outputs.ver }}"
URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/amy-${VER}-jvm.tar.gz"
echo "Fetching $URL"
# The `released` event can fire a hair before every matrix leg finishes
# uploading; retry with backoff (mirrors the repo's push/pull retry ethos).
ok=0
for i in 1 2 3 4 5; do
if curl -fsSL -o amy-jvm.tar.gz "$URL"; then ok=1; break; fi
wait=$(( 2 ** i ))
echo "attempt $i failed; retrying in ${wait}s"
sleep "$wait"
done
[[ "$ok" == 1 ]] || { echo "::error::could not download $URL"; exit 1; }
test -s amy-jvm.tar.gz
SHA=$(shasum -a 256 amy-jvm.tar.gz | awk '{print $1}')
echo "url=$URL" >> "$GITHUB_OUTPUT"
echo "sha256=$SHA" >> "$GITHUB_OUTPUT"
echo "amy-${VER}-jvm.tar.gz -> $SHA"
- name: Update reference formula
run: |
set -euo pipefail
FORMULA=cli/packaging/homebrew/amy.rb
URL="${{ steps.asset.outputs.url }}"
SHA="${{ steps.asset.outputs.sha256 }}"
# Rewrite the two indented lines in the formula block. Anchoring on the
# 2-space indent avoids touching the header comment's example curl url.
sed -i -E "s|^( url ).*|\1\"${URL}\"|" "$FORMULA"
sed -i -E "s|^( sha256 ).*|\1\"${SHA}\"|" "$FORMULA"
echo "----- $FORMULA -----"
grep -E "^ (url|sha256) " "$FORMULA"
- name: Open or update the formula-sync PR
# peter-evans/create-pull-request is MIT-licensed CI-only tooling (not
# linked into any shipped artifact). It no-ops when there is no diff.
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.GITHUB_TOKEN }}
base: main
branch: chore/bump-amy-formula-${{ steps.ver.outputs.tag }}
add-paths: cli/packaging/homebrew/amy.rb
commit-message: 'chore: sync amy Homebrew formula to ${{ steps.ver.outputs.tag }}'
title: 'chore: sync amy Homebrew formula to ${{ steps.ver.outputs.tag }}'
body: |
Auto-synced `cli/packaging/homebrew/amy.rb` to the
`${{ steps.ver.outputs.tag }}` release:
- `url` -> `${{ steps.asset.outputs.url }}`
- `sha256` -> `${{ steps.asset.outputs.sha256 }}`
Opened by `.github/workflows/bump-homebrew-formula.yml`. Merge to keep
the reference formula ready for the homebrew-core submission/bump.
# TODO(bootstrap): once `amy` is accepted into Homebrew/homebrew-core, add a
# step here that opens the homebrew-core bump PR automatically — symmetric to
# the cask bump in bump-homebrew.yml (a pinned macauley/action-homebrew-bump-
# formula, or `brew bump-formula-pr amy --url=<url> --sha256=<sha>` with a
# HOMEBREW_TOKEN). It is intentionally omitted until then because
# bump-formula-pr errors on a formula that is not yet in the tap.
- name: Report failure
if: failure()
uses: actions/github-script@v9
with:
script: |
const tag = context.payload.release?.tag_name || context.payload.inputs?.tag || 'unknown';
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `[release-ops] bump-homebrew-formula failed for ${tag}`,
body: [
`amy Homebrew formula sync failed for release \`${tag}\`.`,
``,
`- Run: ${runUrl}`,
`- Channel: Homebrew Formula (\`amy\` CLI)`,
``,
`Recovery options:`,
`1. Re-run the workflow once the underlying issue is fixed`,
`2. Manually update \`cli/packaging/homebrew/amy.rb\` (url + sha256) from the release asset`,
`3. Check the release actually published \`amy-${tag.replace(/^v/, '')}-jvm.tar.gz\``
].join('\n'),
labels: ['release-ops', 'bug']
});

View File

@@ -15,6 +15,10 @@ on:
permissions:
contents: read
# The "Report failure" step below opens a [release-ops] issue via
# github.rest.issues.create; that needs issues:write. Without it the failure
# reporter itself 403s and no alert is ever filed.
issues: write
concurrency:
# Serialize bumps per tag; do not cancel in-progress bumps.
@@ -28,7 +32,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Re-assert stable release
uses: ./.github/actions/assert-stable-release

View File

@@ -12,6 +12,10 @@ on:
permissions:
contents: read
# The "Report failure" step below opens a [release-ops] issue via
# github.rest.issues.create; that needs issues:write. Without it the failure
# reporter itself 403s and no alert is ever filed.
issues: write
concurrency:
group: bump-winget-${{ github.event.release.tag_name || inputs.tag }}
@@ -24,7 +28,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Re-assert stable release
uses: ./.github/actions/assert-stable-release

View File

@@ -44,13 +44,13 @@ jobs:
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" }
- { os: ubuntu-latest, arch: x64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" }
runs-on: ${{ matrix.os }}
timeout-minutes: 45
timeout-minutes: 60 # linux-portable leg also downloads the freedesktop runtime + builds the Flatpak bundle
defaults:
run:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
@@ -101,6 +101,25 @@ jobs:
fi
chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
# Flatpak tooling + the freedesktop runtime/sdk the manifest pins
# (runtime-version is greped from the manifest so this never drifts).
# Retried: the runtime download from Flathub is ~1 GB and flatpak
# install resumes cleanly on re-run.
- name: Install Flatpak tooling + runtimes (linux-portable only)
if: matrix.family == 'linux-portable'
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
with:
max_attempts: 3
timeout_minutes: 15
command: |
set -euo pipefail
sudo apt-get update && sudo apt-get install -y flatpak flatpak-builder
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
FDO_VER=$(grep -E "^runtime-version:" desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.yml | cut -d"'" -f2)
flatpak install --user --noninteractive flathub \
"org.freedesktop.Platform//${FDO_VER}" \
"org.freedesktop.Sdk//${FDO_VER}"
# macOS only: import the Developer ID Application cert into a throwaway
# keychain so jpackage's codesign pass can find it. Soft — if the
# MAC_CERTIFICATE_P12 secret isn't set (forks, or before Apple creds are
@@ -161,6 +180,36 @@ jobs:
( cd "$APP" && tar czf "../../../../portable/amethyst-desktop-${VER}-linux-x64.tar.gz" Amethyst/ )
fi
# Flatpak bundle: wraps the same createReleaseDistributable tree the
# AppImage uses. The manifest (desktopApp/packaging/flatpak/) copies the
# prebuilt jpackage tree into /app — no Gradle runs inside the sandbox.
# build-bundle emits a single-file .flatpak whose baked-in runtime-repo
# lets the user's flatpak fetch the freedesktop runtime from Flathub on
# install. --disable-rofiles-fuse: GH runners lack a usable rofiles-fuse.
- name: Build Flatpak bundle (linux-portable only)
if: matrix.family == 'linux-portable'
run: |
set -euo pipefail
VER="${{ steps.ver.outputs.version }}"
PKG="desktopApp/packaging/flatpak"
APP_ID="com.vitorpamplona.amethyst.Desktop"
OUT="desktopApp/build/flatpak"
# Inject the AppStream <release> entry for this build (the checked-in
# metainfo deliberately carries none — CI is the source of truth).
sed -i "s|<releases>|<releases>\n <release version=\"${VER}\" date=\"$(date -u +%F)\" />|" \
"${PKG}/${APP_ID}.metainfo.xml"
mkdir -p "$OUT"
flatpak-builder --user --force-clean --disable-rofiles-fuse \
--state-dir="${OUT}/.flatpak-builder" \
--repo="${OUT}/repo" \
"${OUT}/build-dir" \
"${PKG}/${APP_ID}.yml"
flatpak build-bundle "${OUT}/repo" \
"${OUT}/Amethyst-${VER}-x86_64.flatpak" \
"$APP_ID" \
--runtime-repo=https://dl.flathub.org/repo/flathub.flatpakrepo
ls -la "$OUT"
- name: Collect + rename assets
run: |
set -euo pipefail
@@ -200,7 +249,7 @@ jobs:
- name: Upload to GH Release (skip on dry-run)
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true'
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
files: dist/*
tag_name: ${{ steps.ver.outputs.tag }}
@@ -249,7 +298,7 @@ jobs:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
@@ -451,7 +500,7 @@ jobs:
- name: Upload to GH Release (skip on dry-run)
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true'
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
files: dist/*
tag_name: ${{ steps.ver.outputs.tag }}
@@ -479,7 +528,7 @@ jobs:
timeout-minutes: 60
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
@@ -488,7 +537,7 @@ jobs:
java-version: 21
- name: Cache gradle
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: |
~/.gradle/caches
@@ -572,6 +621,48 @@ jobs:
"dist/amethyst-fdroid-${TAG}.aab"
ls -la dist
# Accrescent does not accept AABs or monolithic APKs — it requires a signed
# APK set (.apks) of split APKs generated by bundletool from the AAB. We build
# it from the F-Droid flavor (no proprietary Google deps) and sign the splits
# with the same release keystore used above. Upload is still manual: drop this
# .apks into https://console.accrescent.app (no publish API/CLI exists yet).
- name: Build Accrescent APK set (F-Droid)
env:
SIGNING_KEY: ${{ secrets.SIGNING_KEY }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_STORE_PASSWORD: ${{ secrets.KEY_STORE_PASSWORD }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME}"
BUNDLETOOL_VERSION="1.18.3" # must be >= 1.11.4 per Accrescent requirements
curl -fsSL -o bundletool.jar \
"https://github.com/google/bundletool/releases/download/${BUNDLETOOL_VERSION}/bundletool-all-${BUNDLETOOL_VERSION}.jar"
# Same base64 keystore secret consumed by the r0adkll signing steps above.
echo "$SIGNING_KEY" | base64 -d > release.keystore
# --mode=default emits the split-APK set Accrescent wants (NOT --mode=universal,
# which produces a monolithic APK that Accrescent rejects).
java -jar bundletool.jar build-apks \
--bundle="dist/amethyst-fdroid-${TAG}.aab" \
--output="dist/amethyst-fdroid-${TAG}.apks" \
--ks=release.keystore \
--ks-key-alias="$KEY_ALIAS" \
--ks-pass="pass:$KEY_STORE_PASSWORD" \
--key-pass="pass:$KEY_PASSWORD" \
--mode=default
rm -f release.keystore bundletool.jar
# Accrescent's automated check rejects an APK set larger than 128 MiB.
SIZE_BYTES=$(stat -c%s "dist/amethyst-fdroid-${TAG}.apks")
echo "Accrescent APK set size: $((SIZE_BYTES / 1024 / 1024)) MiB"
if [ "$SIZE_BYTES" -gt $((128 * 1024 * 1024)) ]; then
echo "::warning::amethyst-fdroid-${TAG}.apks exceeds Accrescent's 128 MiB limit; the console will reject this upload."
fi
ls -la dist
- name: Classify release
id: classify
run: |
@@ -584,7 +675,7 @@ jobs:
fi
- name: Upload Android assets to GH Release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
files: dist/*
tag_name: ${{ github.ref_name }}

View File

@@ -10,12 +10,21 @@ permissions:
pull-requests: write
jobs:
# Single job so the Crowdin translation sync and the translator-placeholder seed
# land in ONE pull request instead of two. The Crowdin action only downloads into
# the working tree (push_translations/create_pull_request disabled); the seed
# script then edits translators.json; finally one create-pull-request step opens a
# single PR with both sets of changes (and no-ops when there is no diff).
synchronize-with-crowdin:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
# Need tags so scripts/translators.sh can resolve the last v* release tag
# for the "since last tag" window.
fetch-depth: 0
- name: crowdin action
uses: crowdin/github-action@v2
@@ -23,50 +32,52 @@ jobs:
upload_sources: true
upload_translations: true
download_translations: true
localization_branch_name: l10n_crowdin_translations
create_pull_request: true
pull_request_title: 'New Crowdin Translations'
pull_request_body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)'
pull_request_base_branch_name: 'main'
# Let the downloaded translations stay in the working tree; the single
# create-pull-request step below opens the combined PR.
push_translations: false
create_pull_request: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
# Keep docs/changelog/translators.json seeded with everyone who has translated
# recently, so the per-release `## Translations` credits (scripts/translators.sh)
# can resolve them to npubs. Runs independently of the sync job above and only
# opens/updates a PR when a genuinely new contributor appears.
seed-translators:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
# Need tags so the script can resolve the last v* release tag for the
# "since last tag" window.
fetch-depth: 0
# crowdin/github-action runs in a Docker container as root, so any file or
# directory it downloads (especially a brand-new locale folder like
# values-en-rGB/) ends up owned by root. The unprivileged runner user in
# the create-pull-request step below then can't unlink those files, which
# aborts its branch checkout with "unable to unlink ... Permission denied".
# Reclaim ownership of the whole working tree before touching git.
- name: Fix ownership after Crowdin Docker action
run: sudo chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE"
# Keep docs/changelog/translators.json seeded with everyone who has translated
# recently, so the per-release `## Translations` credits (scripts/translators.sh)
# can resolve them to npubs. Only adds rows when a genuinely new contributor
# appears.
- name: Seed translator placeholders from Crowdin
run: bash scripts/translators.sh --seed
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Open or update the seed PR
- name: Open or update the combined Crowdin PR
# peter-evans/create-pull-request is MIT-licensed CI-only tooling (not
# linked into any shipped artifact). It no-ops when there is no diff.
uses: peter-evans/create-pull-request@v7
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.GITHUB_TOKEN }}
base: main
branch: chore/seed-translators
add-paths: docs/changelog/translators.json
commit-message: 'chore: seed translator npub placeholders from Crowdin'
title: 'Seed translator npub placeholders'
branch: l10n_crowdin_translations
add-paths: |
amethyst/src/main/res/**/strings.xml
commons/src/commonMain/composeResources/**/strings.xml
docs/changelog/translators.json
commit-message: 'chore: sync Crowdin translations and seed translator npub placeholders'
title: 'New Crowdin Translations'
body: |
New Crowdin contributors were added to `docs/changelog/translators.json`
New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action).
Any new Crowdin contributors were added to `docs/changelog/translators.json`
with blank npubs. Fill in the npubs you have so the next release's
`## Translations` credits generate automatically via
`scripts/translators.sh --from <prev-tag> --to <this-tag>`.
`scripts/translators.sh --from <prev-tag> --to <this-tag>`.

View File

@@ -25,7 +25,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
@@ -55,7 +55,7 @@ jobs:
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5

5
.gitignore vendored
View File

@@ -180,6 +180,11 @@ desktopApp/src/jvmMain/appResources/*/ffmpeg/*
desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
desktopApp/packaging/appimage/squashfs-root/
# flatpak-builder state/cache from local builds (CI uses --state-dir under desktopApp/build/)
.flatpak-builder/
desktopApp/packaging/flatpak/**/build-dir/
desktopApp/packaging/flatpak/**/repo/
# Git worktrees
.worktrees/
.claude/worktrees/

View File

@@ -37,7 +37,9 @@ Platform-specific:
- **macOS**: Xcode Command Line Tools (`xcode-select --install`)
- **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset`
- **Linux (all)**: nothing extra for `.deb`; `rpm` + `fakeroot` for `.rpm`;
`appimagetool` + `desktop-file-utils` for AppImage
`appimagetool` + `desktop-file-utils` for AppImage; `flatpak` +
`flatpak-builder` for the Flatpak bundle (see
[`desktopApp/packaging/flatpak/README.md`](desktopApp/packaging/flatpak/README.md))
Install Linux RPM tooling:
@@ -109,6 +111,7 @@ are **not** required to build Amethyst from the committed sources.
| Linux `.deb` | `./gradlew :desktopApp:packageReleaseDeb` | `desktopApp/build/compose/binaries/main-release/deb/amethyst_*.deb` |
| Linux `.rpm` | `./gradlew :desktopApp:packageReleaseRpm` | `desktopApp/build/compose/binaries/main-release/rpm/amethyst-*.rpm` |
| Linux AppImage | `./gradlew :desktopApp:createReleaseAppImage` | `desktopApp/build/appimage/Amethyst-*-x86_64.AppImage` |
| Linux Flatpak | `flatpak-builder` over `createReleaseDistributable` output — see [`desktopApp/packaging/flatpak/README.md`](desktopApp/packaging/flatpak/README.md) | `desktopApp/build/flatpak/Amethyst-*-x86_64.flatpak` (CI) |
| Windows `.zip` portable | See below (inline `7z`) | — |
| Linux `.tar.gz` portable | See below (inline `tar`) | — |
@@ -148,7 +151,7 @@ Where:
| `<version>` | Tag stripped of leading `vX.YY.ZZ` |
| `<family>` | `macos`, `windows`, `linux` |
| `<arch>` | `x64`, `arm64` |
| `<ext>` | `dmg`, `msi`, `zip`, `deb`, `rpm`, `AppImage`, `tar.gz` |
| `<ext>` | `dmg`, `msi`, `zip`, `deb`, `rpm`, `AppImage`, `flatpak`, `tar.gz` |
Single source of truth: [`scripts/asset-name.sh`](scripts/asset-name.sh).
Package manager manifests (Homebrew cask, Winget) depend on this exact scheme —
@@ -160,6 +163,136 @@ Examples:
- `amethyst-desktop-1.12.1-macos-arm64.dmg`
- `amethyst-desktop-1.12.1-windows-x64.msi`
- `amethyst-desktop-1.12.1-linux-x64.AppImage`
- `amethyst-desktop-1.12.1-linux-x64.flatpak`
---
## Reproducible Android builds
The release APKs are **bit-for-bit reproducible**: anyone can rebuild the exact
bytes we ship (minus the signature) from the tagged source and confirm the
artifact on F-Droid / Zapstore / GitHub was built from this code and nothing
else. What makes that hold:
- **Pinned toolchain.** AGP, Kotlin, R8, and the Compose compiler are pinned in
`gradle/libs.versions.toml`; the build targets **JDK 21**. R8 is deterministic
for a fixed version + inputs, so the minified output is stable. Build with the
same JDK 21 you see in `BUILDING.md` / CI.
- **No build-time clock.** Nothing injects `System.currentTimeMillis()` /
build dates into `BuildConfig` (a Spotless rule bans the call in `quartz` and
`commons`), and AGP normalizes ZIP entry timestamps, so two builds an hour
apart are identical.
- **Deterministic version name.** `generateVersionName` only appends a branch
suffix off feature branches; a release tag builds in detached-`HEAD` (or from a
source tarball with no `.git`) resolve to the bare `app` version.
- **No dependency-metadata blob.** `dependenciesInfo { includeInApk = false;
includeInBundle = false }` in `amethyst/build.gradle.kts` stops AGP from
embedding the Google-encrypted dependency protobuf in the signing block — that
ciphertext is non-deterministic.
- **Reproducible native library.** The bundled Tor (Arti) `.so` is the one
binary we compile ourselves; it is built reproducibly from source (pinned Rust
toolchain, locked deps, canonical build path). See
[`tools/arti-build/README.md`](tools/arti-build/README.md) → "Reproducible
builds". All other native libs (`secp256k1`, `webrtc`) are version-pinned Maven
prebuilts and so are byte-identical by download.
### Verify a release APK reproduces
```bash
# 1. Check out the exact released tag and build the same variant unsigned.
git checkout v1.12.1
./gradlew clean :amethyst:assembleFdroidRelease
# 2. Diff your unsigned build against the published APK, ignoring only the
# signature (META-INF/*). apksigner + a zip-aware diff is the simplest check;
# diffoscope gives a human-readable breakdown of any remaining delta.
diffoscope \
amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-arm64-v8a-release-unsigned.apk \
amethyst-fdroid-arm64-v8a-1.12.1.apk
```
A clean run shows differences confined to `META-INF/` (the signing files). Any
diff in `classes*.dex`, `resources.arsc`, or native libs means something in the
toolchain drifted — file it before publishing.
---
## Local SonarQube analysis (opt-in)
The build supports running a [SonarQube](https://www.sonarsource.com/products/sonarqube/)
analysis against a locally hosted server. It is **off by default**: unless you
opt in, the scanner plugin is neither downloaded nor applied and the build is
unaffected.
### 1. Install and start a local SonarQube server
Either run the official Docker image:
```bash
docker run -d --name sonarqube -p 9000:9000 sonarqube:community
```
or download the [Community Build zip](https://www.sonarsource.com/products/sonarqube/downloads/),
unzip it, and start it (requires a JDK 17+ on `PATH`):
```bash
cd sonarqube-<version>
bin/macosx-universal-64/sonar.sh console # pick the folder matching your OS
```
Once it reports up, open <http://localhost:9000> (first login `admin`/`admin`,
you'll be asked to change it), create a **local project** named `Amethyst` with
project key `Amethyst`, and generate a **project analysis token** for it
(*Project Settings → Analysis Method → With Gradle*, or
*My Account → Security → Generate token*). The token looks like `sqp_…`.
### 2. Point the build at your server
Add the server and token to `local.properties` (gitignored — the token never
lands in the repo):
```properties
sonar.host.url=http://localhost:9000
sonar.token=sqp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
### 3. Run the analysis
```bash
./gradlew sonar
```
When it finishes, browse the results at
<http://localhost:9000/dashboard?id=Amethyst>.
### 4. Optional: include Android Lint results
The scanner auto-imports each Android module's lint report and shows the
findings as external issues alongside Sonar's own. It only *imports* — it never
runs lint itself — so without the reports on disk the analysis warns
`Unable to import Android Lint report file(s)`. Generate them first, then run
the scan as a **separate** invocation (chaining lint and `sonar` in one Gradle
call does not guarantee lint finishes first):
```bash
./gradlew :amethyst:lintPlayDebug :benchmark:lintBenchmark :nappletHost:lintDebug
./gradlew sonar
```
The reports persist under each module's `build/reports/`, so re-run lint only
when you want fresh lint data in the next scan.
Every `sonar.*` entry in `local.properties` is forwarded to the scanner, so any
[analysis parameter](https://docs.sonarsource.com/sonarqube-server/latest/analyzing-source-code/analysis-parameters/)
can be set there. `sonar.projectKey` / `sonar.projectName` default to the root
project name (`Amethyst`).
Even when opted in, the scanner plugin only loads on invocations that actually
request the `sonar` task — ordinary builds and IDE syncs are unaffected (which
is also why `./gradlew tasks` doesn't list it).
Note: the SonarQube Gradle scanner plugin is LGPL-3.0. It is a build-time-only
tool fetched after explicit opt-in; it is never linked into shipped artifacts.
---
@@ -496,12 +629,18 @@ State is shared across install channels (DMG, Homebrew, MSI, Winget, .deb,
expose downgrade migration risks — **prefer a single install channel per
machine**.
**Exception: Flatpak.** The sandbox redirects XDG dirs into
`~/.var/app/com.vitorpamplona.amethyst.Desktop/`, so a Flatpak install keeps
its own separate state and does not see (or risk downgrading) state written
by any other channel.
| OS | App location | State directories |
|---|---|---|
| macOS | `/Applications/Amethyst.app` | `~/Library/Application Support/Amethyst`<br>`~/Library/Preferences/com.vitorpamplona.amethyst.desktop.plist`<br>`~/Library/Caches/Amethyst` |
| Windows | `%LOCALAPPDATA%\Amethyst` or `C:\Program Files\Amethyst` | `%APPDATA%\Amethyst`<br>`%LOCALAPPDATA%\Amethyst` |
| Linux (deb/rpm) | `/opt/amethyst` | `~/.config/amethyst`<br>`~/.local/share/amethyst`<br>`~/.cache/amethyst` |
| Linux (AppImage/tar.gz) | user-chosen | Same as above |
| Linux (Flatpak) | `/var/lib/flatpak` or `~/.local/share/flatpak` | `~/.var/app/com.vitorpamplona.amethyst.Desktop/` |
Uninstall:
@@ -510,6 +649,8 @@ Uninstall:
- .deb: `sudo apt remove amethyst`
- .rpm: `sudo dnf remove amethyst`
- AppImage / tar.gz: delete the file / extracted directory
- Flatpak: `flatpak uninstall com.vitorpamplona.amethyst.Desktop` (add
`--delete-data` to also remove `~/.var/app/…`)
- macOS `.dmg`: drag from `/Applications` to Trash, then delete state dirs manually
---

83
PLANS.md Normal file
View File

@@ -0,0 +1,83 @@
# Amethyst plans index
_Cross-module roll-up of every `plans/` folder. Surveyed 2026-06-30._
Plans in this repo are **decentralized**: each module keeps its own design docs
in `<module>/plans/YYYY-MM-DD-<slug>.md` (see `.claude/CLAUDE.md`
"Plans per module"). There is no single plans directory — **this file is the
master index** that stitches the per-folder indexes together.
Each plan carries a `Status:` header (shipped | in-progress | queued |
abandoned) backed by codebase evidence. **Shipped** plans are moved into each
folder's `archive/`; live work stays at the top level. For the full per-folder
listing (including archived plans), open that folder's `README.md`.
> `docs/plans/` is the **frozen** legacy global folder — it is indexed here for
> completeness, but new plans must go in the owning module's `plans/` folder.
## Totals
**142 plans** across 10 folders:
| Status | Count |
| ------ | ----: |
| shipped (archived) | 122 |
| in-progress | 9 |
| queued | 8 |
| abandoned | 3 |
## By module
| Module | Plans | Shipped | In-prog | Queued | Aband. | Index |
| ------ | ----: | ------: | ------: | -----: | -----: | ----- |
| amethyst | 21 | 19 | 1 | 1 | 0 | [amethyst/plans](amethyst/plans/README.md) |
| nestsClient | 26 | 23 | 1 | 2 | 0 | [nestsClient/plans](nestsClient/plans/README.md) |
| desktopApp | 13 | 10 | 2 | 1 | 0 | [desktopApp/plans](desktopApp/plans/README.md) |
| quartz | 10 | 7 | 0 | 3 | 0 | [quartz/plans](quartz/plans/README.md) |
| commons | 6 | 2 | 2 | 2 | 0 | [commons/plans](commons/plans/README.md) |
| cli | 6 | 5 | 1 | 0 | 0 | [cli/plans](cli/plans/README.md) |
| quic | 4 | 3 | 0 | 0 | 1 | [quic/plans](quic/plans/README.md) |
| quic/interop | 1 | 1 | 0 | 0 | 0 | [quic/interop/plans](quic/interop/plans/README.md) |
| geode | 5 | 4 | 0 | 0 | 0 | [geode/plans](geode/plans/README.md) |
| docs (frozen) | 52 | 48 | 2 | 0 | 2 | [docs/plans](docs/plans/README.md) |
## Live work (not shipped)
Everything still open, across all modules. Shipped plans are omitted here — find
them under each folder's `archive/` via the per-module index above.
### In progress (9)
| Module | Plan | Summary |
| ------ | ---- | ------- |
| amethyst | [ios-support](amethyst/plans/2026-05-24-ios-support.md) | KMP-to-iOS port; quartz/commons iOS targets configured (Phase 1) but no `iosApp` module yet. |
| commons | [custom-feeds-plan](commons/plans/2026-05-04-custom-feeds-plan.md) | Custom feeds; model + builder + kind 31890 + desktop UI shipped, but relay-filter layer, DVM marketplace, kind 10090 sync, list resolution pending. |
| commons | [nest-subscription-manager-extraction](commons/plans/2026-05-06-nest-subscription-manager-extraction.md) | Split per-speaker subscription state machine out of `NestViewModel`; only the `ActiveSubscription` stepping-stone extracted. |
| desktopApp | [wallet-zapping-test-coverage](desktopApp/plans/2026-05-12-feat-desktop-wallet-zapping-test-coverage-plan.md) | NWC handler + RPC round-trip tests shipped; wallet-column-state and zap-dialog-logic tests still missing. |
| desktopApp | [napplet-desktop-host](desktopApp/plans/2026-06-21-napplet-desktop-host.md) | Desktop NIP-5A/5D host; shared core extractions done, desktop engine/scheme-handler/transport/UI edge not built. |
| cli | [cashu-cli](cli/plans/2026-05-28-cashu-cli.md) | NIP-60/61/87 Cashu wallet verbs in amy; full command surface ships, production-mint interop harness pending. |
| nestsClient | [t16-closure-roadmap](nestsClient/plans/2026-05-07-t16-closure-roadmap.md) | Priorities 1 & 2 closed and suite passes, but CI gating deferred and framesPerGroup rerun + two upstream items open. |
| docs | [viewport-aware-metadata-loading](docs/plans/2026-04-29-perf-viewport-aware-metadata-loading-plan.md) | Base preloader/rate-limiter infra exists but the LazyListState/snapshotFlow viewport selection isn't clearly wired. |
| docs | [macos-bunker-relogin](docs/plans/2026-06-18-fix-desktop-macos-bunker-relogin-plan.md) | PR 1 defense-in-depth shipped, but the cold-boot root cause is still open/unidentified. |
### Queued (8)
| Module | Plan | Summary |
| ------ | ---- | ------- |
| amethyst | [napplet-inter-applet](amethyst/plans/2026-06-20-napplet-inter-applet.md) | NAP-INC / NAP-INTENT inter-applet messaging; prerequisites (multi-applet hosting, archetype registry, `MESSAGING` capability) not built. |
| quartz | [local-headers-explorer](quartz/plans/2026-05-08-local-headers-explorer.md) | Headers-only Bitcoin P2P client to verify NIP-03 OTS attestations without a trusted block explorer. |
| quartz | [giftwrap-deletion-requests](quartz/plans/2026-06-12-giftwrap-deletion-requests.md) | Let a recipient-authored kind-5 delete/block a gift wrap (kind 1059) addressed to them. |
| quartz | [incremental-negentropy-storage](quartz/plans/2026-07-03-incremental-negentropy-storage.md) | Always-current (created_at, id) index so cold NEG-OPENs stop paying a full scan + seal. |
| commons | [event-renderer](commons/plans/2026-04-21-event-renderer.md) | Cross-platform UI-agnostic `RenderedEvent` subsystem shared by Amy, Desktop, Android; not started. |
| commons | [amethyst-to-commons-migration](commons/plans/2026-05-30-amethyst-to-commons-migration.md) | Roadmap to move shared `amethyst` Android code into `commons`; keystone `Account`/`LocalCache` extraction not begun. |
| desktopApp | [embedded-wallet-phase2-research](desktopApp/plans/2026-05-21-embedded-wallet-phase2-research.md) | Research for an embedded self-custodial Lightning wallet (Breez/ldk-node/lightning-kmp); parked, no code. |
| nestsClient | [cross-stack-interop-ci-gating](nestsClient/plans/2026-05-07-cross-stack-interop-ci-gating.md) | CI gating for the cross-stack interop suite; infra built then removed over wallclock cost, kept as a ready revisit target. |
| nestsClient | [framespergroup-production-rerun](nestsClient/plans/2026-05-07-framespergroup-production-rerun.md) | Re-run the two-phone field tests to settle the framesPerGroup test-pin (5) vs default (50); needs prod-rig access. |
### Abandoned (3)
| Module | Plan | Summary |
| ------ | ---- | ------- |
| quic | [congestion-control](quic/plans/2026-05-05-congestion-control.md) | NewReno congestion control parked indefinitely; the real concern was solved by the smaller `SendBuffer.bestEffort` fix instead. |
| docs | [desktop-relay-config-single-source](docs/plans/2026-04-23-feat-desktop-relay-config-single-source-plan.md) | Single `DesktopRelayConfig` class never built; relay state landed as `DesktopRelayCategories`/`LocalRelayCategories` instead. |
| docs | [macos-vlc-bundled-discovery](docs/plans/2026-05-18-fix-macos-vlc-bundled-discovery-plan.md) | macOS bundled-VLC `setenv` discovery fix; moot after VLC/VLCJ was removed entirely in the kdroidFilter migration. |

View File

@@ -158,13 +158,17 @@ Google Play Services infrastructure.
### Layer 4: AlarmManager Watchdog (5 minutes)
**What:** `ServiceWatchdogManager` fires an `ELAPSED_REALTIME_WAKEUP` alarm every 5
**What:** `ServiceWatchdogManager` fires an `ELAPSED_REALTIME` alarm every 5
minutes. The receiver checks if the service should be running and restarts it.
**Why needed:** This is the "belt and suspenders" layer. If all of the above layers fail
(sticky restart blocked, alarm from `onTaskRemoved` didn't fire, broadcast wasn't
delivered), the watchdog will catch it within 5 minutes. Uses `ELAPSED_REALTIME_WAKEUP` to
wake the device from sleep, ensuring the check happens even in Doze.
delivered), the watchdog will catch it within 5 minutes of the device being awake.
The alarm deliberately does NOT use the `_WAKEUP` variant: pulling the CPU out of
sleep every 5 minutes is a battery cost with no payoff, because a service restarted
on a sleeping device can't do useful network work until the device wakes anyway.
While the device sleeps, Layer 5 (WorkManager) and Layer 8 (FCM/UnifiedPush) cover
delivery; the moment the device wakes, the pending watchdog alarm fires.
### Layer 5: WorkManager Periodic Catch-Up (15 minutes)

View File

@@ -17,7 +17,7 @@ Join the social network you control.
[![Maven Central](https://img.shields.io/maven-central/v/com.vitorpamplona.quartz/quartz?label=Quartz%20%28Maven%20Central%29&labelColor=27303D&color=0877d2)](https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz)
[![JitPack snapshots](https://img.shields.io/badge/Quartz%20snapshots-JitPack-27303D?labelColor=27303D&color=0877d2)](https://jitpack.io/#vitorpamplona/amethyst)
[![CI](https://img.shields.io/github/actions/workflow/status/vitorpamplona/amethyst/build.yml?labelColor=27303D)](https://github.com/vitorpamplona/amethyst/actions/workflows/build.yml)
[![License: Apache-2.0](https://img.shields.io/github/license/vitorpamplona/amethyst?labelColor=27303D&color=0877d2)](/LICENSE)
[![License: MIT](https://img.shields.io/github/license/vitorpamplona/amethyst?labelColor=27303D&color=0877d2)](/LICENSE)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/vitorpamplona/amethyst)
## Download and Install
@@ -56,6 +56,37 @@ _Coming soon (separate PR): Scoop (Windows), AUR (Arch Linux)._
</div>
## Verifying the APK signature
If you sideload Amethyst (Obtainium, GitHub Releases, Zap Store), verify that your
APK is signed by the official release key before installing. All official Amethyst
APKs — both the `googleplay` and `fdroid` flavors — are signed with the same
certificate, whose SHA-256 fingerprint is:
```
C2:D0:AA:86:BC:B6:B6:20:90:56:1A:41:BB:E3:36:E9:8B:78:C2:D0:21:0A:49:8D:C8:85:F2:8E:13:48:CF:17
```
To check a downloaded APK yourself, run (`apksigner` ships with the Android SDK
build-tools):
```bash
apksigner verify --print-certs amethyst-*.apk
```
and confirm the reported `Signer #1 certificate SHA-256 digest` is
`c2d0aa86bcb6b62090561a41bbe336e98b78c2d0210a498dc885f28e1348cf17`.
Without the Android SDK, `keytool -printcert -jarfile amethyst-*.apk` (bundled
with any JDK) prints the same SHA-256 fingerprint.
With [AppVerifier](https://github.com/soupslurpr/appverifier), paste or share the
APK and compare against:
```
com.vitorpamplona.amethyst
C2:D0:AA:86:BC:B6:B6:20:90:56:1A:41:BB:E3:36:E9:8B:78:C2:D0:21:0A:49:8D:C8:85:F2:8E:13:48:CF:17
```
## Supported Features
<img align="right" src="./docs/screenshots/home.png" data-canonical-src="./docs/screenshots/home.png" width="350px">

View File

@@ -24,7 +24,9 @@ Build customized Amethyst Nostr clients for Android. Fork, rebrand, customize, a
2. **Android SDK**
- Command-line tools from https://developer.android.com/studio#command-line-tools-only
- Required components: build-tools, platform-tools, platforms;android-35
- Required components: build-tools, platform-tools, platforms;android-37
- The exact SDK level is `android-compileSdk` in `gradle/libs.versions.toml` —
check there if this number has drifted.
3. **Git** for cloning the repository
@@ -66,48 +68,54 @@ keyPassword=your-password
### 3. Configure Signing
Add to `amethyst/build.gradle` inside the `android {}` block:
Add to `amethyst/build.gradle.kts` inside the `android {}` block:
```gradle
def keystorePropertiesFile = rootProject.file("keystore.properties")
def keystoreProperties = new Properties()
```kotlin
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
}
signingConfigs {
release {
create("release") {
if (keystorePropertiesFile.exists()) {
storeFile rootProject.file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
}
}
}
```
This needs `import java.util.Properties` at the top of the file.
Update the release buildType to use the signing config:
```gradle
```kotlin
buildTypes {
release {
signingConfig signingConfigs.release
getByName("release") {
signingConfig = signingConfigs.getByName("release")
// ... existing config
}
}
```
Verify with `./gradlew :amethyst:signingReport` — the release variants should
report your keystore rather than `~/.android/debug.keystore`.
### 4. Disable Google Services (Required for F-Droid)
**⚠️ CRITICAL:** The Google Services plugin fails when you change the package name. For F-Droid builds, disable it.
Edit `amethyst/build.gradle`, comment out the plugin:
```gradle
Edit `amethyst/build.gradle.kts`, comment out the plugin:
```kotlin
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.jetbrainsKotlinAndroid)
// alias(libs.plugins.googleServices) // DISABLED for F-Droid
alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.serialization)
alias(libs.plugins.googleKsp)
}
```
@@ -141,8 +149,8 @@ Edit `amethyst/src/main/res/values/strings.xml`:
### Change Package ID
Edit `amethyst/build.gradle`:
```gradle
Edit `amethyst/build.gradle.kts`:
```kotlin
android {
defaultConfig {
applicationId = "com.yourcompany.yourapp"
@@ -152,8 +160,8 @@ android {
### Change Project Name
Edit `settings.gradle`:
```gradle
Edit `settings.gradle.kts`:
```kotlin
rootProject.name = "YourAppName"
```
@@ -167,36 +175,28 @@ Replace icon files in:
Make your app identify itself on posts with `["client", "YourAppName"]`.
**1. Create tag builder extension:**
You do **not** need to add the tag per event type. The client tag is applied
centrally by `NostrSignerWithClientTag`, a signer decorator that appends the tag
to everything it signs (and respects the user's "add client tag" privacy
setting). Changing the name is a one-constant edit:
Create `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/clientTag/TagArrayBuilderExt.kt`:
Edit `amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt`:
```kotlin
package com.vitorpamplona.quartz.nip01Core.tags.clientTag
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
fun <T : Event> TagArrayBuilder<T>.client(clientName: String) =
addUnique(arrayOf(ClientTag.TAG_NAME, clientName))
const val CLIENT_TAG_NAME = "YourAppName"
```
**2. Add to TextNoteEvent:**
That constant is passed to `NostrSignerWithClientTag` when the account's signer
is built, so every signed event carries your name.
Edit `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt`:
Add import:
```kotlin
import com.vitorpamplona.quartz.nip01Core.tags.clientTag.client
```
In both `build()` functions, add after `alt(...)`:
```kotlin
client("YourAppName")
```
The tag itself lives in
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/`
(`ClientTag`, `TagArrayBuilderExt`, `NostrSignerWithClientTag`) — you only need to
touch it if you want the optional NIP-89 handler address / relay hint variants.
### Modify Default Relays
Edit relay configuration in `quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/` or the UI settings files.
Edit `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/defaults/Constants.kt`
(see also `AmethystDefaults.kt` and `DefaultDmIndexerRelays.kt` in the same folder).
## Troubleshooting

View File

@@ -1,4 +1,7 @@
import org.gradle.api.services.BuildService
import org.gradle.api.services.BuildServiceParameters
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
alias(libs.plugins.androidApplication)
@@ -87,8 +90,10 @@ android {
vectorDrawables {
useSupportLibrary = true
}
@Suppress("UnstableApiUsage")
resourceConfigurations +=
}
androidResources {
localeFilters +=
listOf(
"ar",
"ar-rSA",
@@ -264,6 +269,23 @@ android {
resValues = true
}
// Reproducible builds: keep AGP from embedding the dependency-metadata blob
// in the APK/AAB. That blob is a protobuf of the resolved dependency tree
// encrypted with a Google public key; the ciphertext is non-deterministic,
// so its presence makes every release artifact impossible to reproduce
// bit-for-bit. Dropping it (F-Droid's documented recommendation) lets
// F-Droid / Zapstore independently rebuild and verify our developer-signed
// APKs.
//
// Play-channel trade-off: with includeInBundle = false the uploaded .aab no
// longer carries this metadata, so Play Console's app-dependency insights /
// known-vulnerability SDK alerts go unpopulated. Uploads still succeed; only
// that advisory feature is lost.
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
packaging {
resources {
excludes += listOf("/META-INF/{AL2.0,LGPL2.1}", "**/libscrypt.dylib")
@@ -323,6 +345,30 @@ kotlin {
}
}
// Gradle schedules Kotlin compilations of different variants of this module
// concurrently (e.g. playDebug + playBenchmark when CI runs unit tests, lint,
// and assembleBenchmark in one invocation), but they all share a single Kotlin
// daemon whose heap (kotlin.daemon.jvmargs) cannot fit two full :amethyst
// codegen passes — CI runs died with "GC overhead limit exceeded" inside the
// daemon. This no-op shared build service with maxParallelUsages = 1 tells the
// scheduler to run this module's Kotlin compile tasks one at a time; other
// projects' tasks (JVM tests, lint analysis, packaging) still run in parallel.
//
// CI-only: the OOM needs a cache-cold compile of several variants at once,
// which local builds (incremental, usually one variant) don't produce.
abstract class AmethystKotlinCompileLimiter : BuildService<BuildServiceParameters.None>
if (System.getenv("CI") != null) {
val kotlinCompileLimiter =
gradle.sharedServices.registerIfAbsent("amethystKotlinCompileLimiter", AmethystKotlinCompileLimiter::class) {
maxParallelUsages.set(1)
}
tasks.withType<KotlinCompile>().configureEach {
usesService(kotlinCompileLimiter)
}
}
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
metricsDestination = layout.buildDirectory.dir("compose_compiler")
@@ -331,12 +377,35 @@ composeCompiler {
dependencies {
implementation(platform(libs.androidx.compose.bom))
// Compose composition tracing — DEBUG ONLY, profiling aid (not shipped). Makes each
// recomposition show up as a NAMED slice in Perfetto system traces so we can see which
// composable recomposes (e.g. during the cold-start feed first-paint). All Apache-2.0.
// Usage: runtime-enable, then capture a Perfetto trace with the `track_event` data source:
// adb shell am broadcast -a androidx.tracing.perfetto.action.ENABLE_TRACING \
// -n com.vitorpamplona.amethyst.debug/androidx.tracing.perfetto.TracingReceiver
debugImplementation("androidx.compose.runtime:runtime-tracing")
debugImplementation("androidx.tracing:tracing-perfetto:1.0.0")
debugImplementation("androidx.tracing:tracing-perfetto-binary:1.0.0")
implementation(project(":quartz"))
implementation(project(":commons"))
implementation(project(":nestsClient"))
implementation(project(":nappletHost"))
// Compose Multiplatform resources runtime, so app-side screens that share a
// string with a commons renderer can read commons' generated `Res` directly
// instead of duplicating the key in the Android res tree.
implementation(libs.jetbrains.compose.components.resources)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
// Hardened WebView host for sandboxed napplet/nsite rendering (origin-restricted message bridge).
implementation(libs.androidx.webkit)
// Client side of the cross-process UI embedding: renders the sandboxed browser surface (hosted in
// the keyless `:napplet` process) inside a Compose component in the main app.
implementation(libs.androidx.privacysandbox.ui.core)
implementation(libs.androidx.privacysandbox.ui.client)
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
@@ -421,6 +490,9 @@ dependencies {
implementation(libs.markdown.ui.material3)
implementation(libs.markdown.commonmark)
// Syntax highlighting for the git repository code browser (Apache-2.0)
implementation(libs.highlights)
// LaTeX math rendering ($...$ and $$...$$ inline equations)
implementation(libs.jlatexmath.android)
implementation(libs.jlatexmath.font.greek)
@@ -495,6 +567,7 @@ dependencies {
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.secp256k1.kmp.jni.jvm)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.junit)

View File

@@ -1,5 +1,8 @@
# iOS Support for Amethyst
> **Status:** in-progress — `iosArm64`/`iosSimulatorArm64` targets are configured in `quartz` and `commons` (Phase 1), but no `iosApp` module exists yet — later phases not built.
> _Audited 2026-06-30._
**Date:** 2026-05-24
**Status:** Phase 1 complete; Phase 2 in flight
**Owner:** TBD

View File

@@ -0,0 +1,97 @@
# Napplet inter-applet communication (NAP-INC / NAP-INTENT) — design notes
> **Status:** queued — Explicitly deferred; no `MESSAGING` capability exists in `NappletCapability` and the prerequisites (multi-applet hosting, archetype registry) are unbuilt.
> _Audited 2026-06-30._
**Date:** 2026-06-20
**Status:** Deferred — design only. Prereqs not yet built (see below).
**Parent:** `amethyst/plans/2026-06-19-napplet-sandbox-host.md`
## Why this is deferred (not just "next")
Inter-applet messaging is the one napplet capability that needs **new
architecture**, not just a new broker op + gateway. Two hard prerequisites are
missing today:
1. **Multiple applets running at once.** `NappletHostActivity` is declared
`launchMode="singleTask"` and hosts exactly one applet. True live A↔B
messaging (the full NAP-INC request/result transport) requires either
multi-applet hosting (several iframes in one host, or several host processes)
plus a routing layer — none of which exists.
2. **An archetype / handler registry.** NAP-INTENT dispatches by *archetype*
(`note`, `feed`, `profile`, …) to a default-handler napplet. Our
`NappletManifest` has no `handles`/archetype declaration and there is no
"which napplet is the default handler for X" registry.
Both are sizeable subsystems. Shipping a half-version would also risk **forking
the wire format** from upstream while it is still being defined.
## Upstream model (napplet/naps survey, 2026-06-20)
Inter-applet is split into two shell-mediated specs (applets never reach each
other directly — every message crosses the shell/broker):
- **NAP-INC** (`inc`) — the transport. Messages are `{ type: "domain.action",
id, … }`, request/result correlated by `id`. Addressing is direct
(napplet→napplet) *or* archetype-mediated by the runtime.
- **NAP-INTENT** (`intent`) — invoke a napplet by **archetype** via
default-handler dispatch (`shell.supports("intent")`). The shell launches the
handler; napplets cannot invoke directly.
- **NAP-1…5** — concrete protocols on top of NAP-INC: `profile:*` (NAP-1),
`stream:*` (NAP-2), `chat:*` (NAP-3), `note:open` (NAP-4), `feed:*` (NAP-5).
Producer/consumer model.
Discovery is capability-probe based: `shell.supports("inc")`,
`shell.supports("inc", "NAP-N")`.
## How it would map onto our boundary
The broker model fits "shell-mediated" naturally — every message would cross
`NappletBrokerService` exactly like every other capability, gated by the ledger.
The pieces:
1. **Capability.** Add `NappletCapability.MESSAGING` mapped from NAP domains
`inc` / `intent` (default-deny like every other domain). Consent is a *link*
grant ("Applet A may message / open Applet B"), distinct from per-op consent.
2. **Protocol.** New `NappletRequest`/`NappletResponse` variants under
`MESSAGING`, shaped to mirror NAP-INC (`type = "domain.action"`, `id`
correlation) so we don't fork the wire format.
3. **Addressing.** Direct by napplet coordinate first; archetype dispatch only
after the registry (below) exists.
### Two viable implementation shapes (pick at build time)
- **NAP-INTENT, direct coordinate** — `napplet.intent({ target, payload })` →
consent → broker resolves `target` to a manifest in `LocalCache` → launches it
via a `NappletIntentLauncher` gateway, passing an initial payload the target
reads on startup (`napplet.intent` / `onIntent`). Fits the single-applet model
(you switch to the target). No simultaneous hosting needed. **Lowest lift; most
aligned with NAP-INTENT.** Result-return across the switch is awkward (fire-and-
forget, or a callback event).
- **NAP-INC brokered mailbox** — `napplet.sendTo(coordinate, msg)` /
`pollMessages()` with broker-persisted per-napplet inboxes, consent per link.
Works with no simultaneous hosting and is fully unit-testable, but it is async
fire-and-collect, not the request/result transport upstream describes.
Full live NAP-INC (simultaneous A↔B, request/result) needs the multi-applet
hosting prereq regardless.
## Prerequisites to build first
1. **Multi-applet hosting** — either N iframes in one `NappletHostActivity` with
per-iframe origin isolation + routing, or a host-per-applet process model and
a cross-process router in the broker. Decide the model before coding NAP-INC.
2. **Archetype registry** — a manifest `handles`/archetype tag (align with
upstream naps), an index over installed napplets, and a user-set default
handler per archetype (mirror NIP-89 handler selection, which Amethyst already
models for app recommendations).
3. **Link-consent UX** — distinct from capability consent: "Allow *Chess* to open
*Wallet*?", revocable per pair in a permissions screen.
## Recommendation
When picked up: start with **NAP-INTENT direct-coordinate** (smallest, aligned,
no new hosting), build the archetype registry next (unlocks default-handler
dispatch + reuses NIP-89 patterns), and only then tackle live NAP-INC once
multi-applet hosting lands. Keep the wire `type`/`id` shape identical to upstream
NAP-INC throughout to avoid a fork.

View File

@@ -0,0 +1,385 @@
# Contextual AUTH Permissions — Ask *why*, and trust follows
**Date:** 2026-07-01
**Module:** `amethyst` (+ shared bits in `commons`)
**Status:** Implemented — see "As-built" below for where the shipped design diverged from this proposal.
## As-built (final)
The implementation kept this doc's core ideas (purpose derivation, a prompt bus,
per-relay overrides, grant rationale) but the policy model was reshaped during
review:
- **Global mode is `RelayAuthPolicy { ALWAYS, NEVER, CUSTOM }`** — the earlier
`IF_IN_MY_LIST` / `TRUSTED_FOLLOWS` values were dropped. `CUSTOM` applies a
`RelayAuthCustomToggles` set of independent switches: **my relays & venues**,
**read posts from follows**, **message follows**, **message strangers**
(off by default). New-install default is `CUSTOM` with the first three on.
- **`AuthPurpose` is a `data class` (kind + counterparties + venues)** over an
`AuthPurposeKind` enum (SEND_DM, NOTIFY_INBOX, READ_OUTBOX, POST_VENUE,
READ_VENUE, MY_OWN_RELAY, OTHER) — not a sealed interface. Venues (NIP-28
public chats, NIP-72 communities, NIP-53 live activities) are first-class.
- **Settings screen** uses the app's settings design system (`SettingsSection`
card + `SettingsSwitchTile`) for the toggles and a grouped, lazily-rendered
per-relay list (NIP-11 icon, `displayUrl`, tap → relay info).
- **Give-up signal**: quartz's outbox surfaces `onEventGaveUp`, toasted by
`RelayPublishFailureToast`. `auth-required` NAKs never burn the retry budget
(they reset it) so a slow AUTH handshake can't drop the event.
- **Known limitation**: an event queued to a relay the user then *denies* stays
pending in the outbox (auth-required never gives up); evicting it would need a
quartz "give up on relay for this event" API — deferred.
## Context
Now that Amethyst answers NIP-42 relay AUTH challenges, we need to decide
*when* to reveal the user's identity to a relay — and, crucially, to tell the
user **why** an auth is being requested so they can make an informed choice.
The motivating cases:
- **NIP-17 DM send.** The recipient's DM inbox relays (kind 10050) may require
auth. If we silently refuse, the message never leaves the device and the user
has no idea why. We should ask: *"Relay X wants you to log in to deliver your
private message to Alice — allow?"*
- **Public inbox notifications.** Replying to / mentioning / reacting to someone
publishes to *their* NIP-65 inbox (kind 10002 read relays), which may require
auth.
- **Feed download from outboxes.** Reading a followed author's posts may require
auth to *their* write/outbox relays.
We also want an **automatic mode** for users who trust Amethyst's judgement:
auth (or not) based on a follow-graph heuristic — *if I follow the counterparty
(in any follow list), I trust them enough to reveal my identity to the relay
that serves them.* And regardless of mode, **explicit per-relay overrides** must
be able to force-allow or force-block a single relay. The blocked-relay list
(kind 10006) is a hard block.
## What already exists (reuse — do NOT rebuild)
The NIP-42 plumbing and a first-cut permission gate are already in place:
| Piece | Location |
|---|---|
| AUTH challenge receipt, kind-22242 signing, resend-on-OK | `quartz/.../nip01Core/relay/client/auth/RelayAuthenticator.kt`, `RelayAuthStatus.kt`, `nip42RelayAuth/RelayAuthEvent.kt` |
| Permission gate (per logged-in account) | `amethyst/.../service/relayClient/authCommand/model/AuthCoordinator.kt` |
| Decision engine (per-relay override → global policy) | `.../authCommand/model/RelayAuthPermissionLedger.kt` |
| Policy enum `ALWAYS`/`NEVER`/`IF_IN_MY_LIST`, decision enum `ALLOW`/`DENY` | `commons/.../relayauth/RelayAuthPolicy.kt` |
| Per-relay override persistence interface + DataStore impl | `commons/.../relayauth/RelayAuthPermissionStore.kt`, `amethyst/.../authCommand/model/DataStoreRelayAuthPermissionStore.kt` |
| Settings screen (global policy + per-relay list) | `amethyst/.../ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt` |
| Global policy setting, persisted local-only | `AccountSettings.defaultRelayAuthPolicy`, `LocalPreferences` key `DEFAULT_RELAY_AUTH_POLICY` |
| Blocked-relay list (kind 10006) | `amethyst/.../model/nip51Lists/blockedRelays/BlockedRelayListState.kt` (`.flow`) |
| Follow checks | `Account.isFollowing(...)`, `Account.allFollows.flow.value.authors`, `FollowListsState.isUserInFollowSets(...)` |
| DM / NIP-65 relay lookups | `DmRelayListState`, `Nip65RelayListState` (+ per-user via `LocalCache`) |
## The gap
`RelayAuthPermissionLedger.decide(relayUrl)` receives **only a relay URL**. It
resolves ALLOW/DENY **silently and immediately**. Three things are missing:
1. **No purpose/"why".** The decision point can't tell a DM-send from a
feed-read from a stranger's random challenge, so it can't explain itself or
attribute the relay to a counterparty.
2. **No interactive ASK.** `RelayAuthDecision` is binary. A DENY silently drops
the auth (and the send fails with no feedback).
3. **No follow-based trust.** `IF_IN_MY_LIST` only checks *my own* relays, never
"this relay belongs to someone I follow."
## Recommended architecture
Four changes, smallest surface first.
### 1. Carry the *purpose* to the decision point — `AuthPurpose` + an intent registry
New (in `commons/.../relayauth/`, KMP-safe, no Android deps):
```kotlin
sealed interface AuthPurpose {
data class SendDM(val recipients: Set<HexKey>) : AuthPurpose // recipient DM inboxes (10050)
data class NotifyInbox(val recipients: Set<HexKey>) : AuthPurpose // recipient NIP-65 read relays
data class ReadOutbox(val author: HexKey?) : AuthPurpose // author write/outbox relays
data object MyOwnRelay : AuthPurpose // relay in my own lists
data object Unknown : AuthPurpose // bare challenge, no attribution
}
```
The auth path is **reactive** (relay pushes the challenge; the lambda only knows
the URL). Most intent is already recoverable from quartz's per-relay pending
events + active filters (see "Where it lives" below), so the registry below is
**minimal** — only for hints quartz can't infer (e.g. the human recipient behind
an encrypted gift wrap). It lives with the coordinator, since `LocalCache`/
`Account` are main-process only:
```kotlin
// amethyst/.../service/relayClient/authCommand/model/RelayAuthIntentRegistry.kt
class RelayAuthIntentRegistry {
fun register(relay: NormalizedRelayUrl, purpose: AuthPurpose) // short TTL entry
fun purposesFor(relay: NormalizedRelayUrl): List<AuthPurpose> // read at decision time
}
```
Representative registration sites (each already computes its target relays):
- NIP-17 DM send → `SendDM(recipients)` on each recipient DM-inbox relay.
- Reply/mention/reaction broadcast → `NotifyInbox(recipients)`.
- Outbox feed subscriptions → `ReadOutbox(author)`.
*Race note:* keying by relay URL means concurrent purposes can collide; store a
small time-bounded **set** per relay and let the resolver consider all live
entries (the prompt can say "to send your DM to Alice and 2 others"). Acceptable
for a UX hint + trust check; the persisted decision is what actually gates.
### 1b. Persist *why* each relay was granted (grant rationale)
The decision stays **relay-based**, but each relay's stored record must also
remember **why** it was granted, so the settings screen can show, per relay,
purpose-grouped lines of counterparty users (with avatars):
> **wss://inbox.example.com** — Allowed
> · To send DMs to: (avatars) Alice, Bob, Carol
> · To download posts from: (avatars) Dave, Erin
Extend the persisted per-relay record from a bare `RelayAuthDecision` to
`decision + rationale`, where the rationale is an accumulated map keyed by
purpose kind:
```kotlin
// commons/.../relayauth/RelayAuthGrant.kt (new)
data class RelayAuthGrant(
val decision: RelayAuthDecision,
// purpose kind -> counterparty pubkeys seen for this relay under that purpose
val rationale: Map<AuthPurposeKind, Set<HexKey>> = emptyMap(),
val lastUsedAt: Long = 0L,
)
enum class AuthPurposeKind { SEND_DM, NOTIFY_INBOX, READ_OUTBOX, MY_OWN_RELAY }
```
The rationale is **updated every time** an auth is granted/re-used for that
relay: merge the current `AuthPurpose` counterparties into the matching kind's
set and refresh `lastUsedAt`. This keeps the "why" current as new
DMs/notifications/feeds route through the relay. Store only pubkeys — names and
avatars are resolved for display from `LocalCache` at render time, so the store
stays privacy-light and small.
### 2. Add an `ASK` outcome and a context-aware resolver
Extend the decision enum and generalize `decide()`:
```kotlin
enum class RelayAuthDecision { ALLOW, DENY, ASK } // ASK added
class RelayAuthContext(val relayUrl: String, val purposes: List<AuthPurpose>)
```
`RelayAuthPermissionLedger.decide(ctx)` precedence (highest → lowest):
1. **Blocked-relay list** (kind 10006) → `DENY`. Never reveal identity to a
blocked relay, whatever the policy.
2. **Explicit per-relay override** (`RelayAuthPermissionStore`) → return it.
3. **Global policy**:
- `NEVER``DENY`
- `ALWAYS``ALLOW`
- `IF_IN_MY_LIST``ALLOW` if relay ∈ my relay lists, else fall through
- `TRUSTED_FOLLOWS` *(new — see idea A below)*`ALLOW` if relay ∈ my lists
**or** any counterparty in `ctx.purposes` is followed (`Account.allFollows`
/ `FollowListsState.isUserInFollowSets`) and the purpose permits it; else
fall through.
4. **Fall-through**: `ASK` if the purpose is attributable (we can show a reason);
otherwise `DENY` silently (don't prompt for anonymous stranger challenges).
Keep the current relay-only `decide(url)` as a thin overload calling
`decide(RelayAuthContext(url, registry.purposesFor(url)))` so existing callers
compile.
Whenever the resolver yields `ALLOW` and an auth is actually sent — regardless
of *how* it was allowed (auto policy, stored override, or a just-approved ASK) —
call `store.recordUse(relayUrl, purpose)` for each attributed purpose so the
grant rationale (§1b) stays current.
### 3. Surface the ASK prompt to the UI and await the answer
The `signWithAllLoggedInUsers` lambda in `AuthCoordinator` is **already a
`suspend` context**, so the resolver can suspend and await a user decision — no
restructuring of the auth send path.
- Add an event stream on the coordinator (or account):
`SharedFlow<RelayAuthRequest>` where
`RelayAuthRequest(relay, purposes, reply: CompletableDeferred<UserAuthChoice>)`.
(Follows the repo's one-shot-event flow pattern — see `kotlin-flow-state-event-modeling`.)
- A composable observer (registered in the logged-in scaffold) collects the flow
and shows a dialog: *"{relay} requires you to log in to {reason}."* with
actions **Allow once / Always allow this relay / Block this relay**. The last
two write through `RelayAuthPermissionLedger.setDecision(...)`.
- The lambda `await`s the deferred (bounded by a timeout consistent with
`RelayAuthStatus`), then proceeds to sign or returns `emptyList()`.
Reason strings are derived from `AuthPurpose` via a small mapper (resolve
recipient pubkeys → display names through `LocalCache`).
### 4. New policy mode + settings
- Add `TRUSTED_FOLLOWS` to `RelayAuthPolicy` (recommended — idea A).
- `RelayAuthSettingsScreen`: add the new mode with an explanatory blurb; the
per-relay override list already supports force-allow/force-block (now
three-state incl. "ask"). No storage-format change if we keep decisions
per-relay (idea B, recommended default).
## Where it lives: quartz (generic mechanism) vs amethyst (policy + UI)
Goal (per the brief): if the auth+resend mechanism can be made **robust and
generic**, it belongs in **quartz**; only the *semantics* (why / follow-trust /
prompt copy / rationale UI) stay in **amethyst**.
### The resend queue already exists in quartz — and is the "intent registry"
`PoolEventOutbox` / `PoolEventOutboxState` already persist outgoing events
per-relay across reconnects, and `NostrClient.syncFilters(relay)` — called on
connect **and after an auth OK** (`RelayAuthenticator.checkAuthResults`) —
already re-sends pending EVENTs, not just REQ subscriptions. So the park-and-
flush half of idea C is largely built; we just need to make it correct.
It also means we mostly **don't need a separate `RelayAuthIntentRegistry`**:
quartz already knows, per relay, the *pending outgoing events*
(`PoolEventOutbox`) and the *active subscription filters* (`activeRequests`).
That set IS the intent. At AUTH time quartz can hand the injected decision
callback this context; amethyst derives purpose from it (a pending kind-1059
gift wrap → `SendDM`; a REQ whose `authors` are followed → `ReadOutbox`). Keep a
tiny registry only for hints quartz can't infer (e.g. the human recipient behind
a gift wrap, which is encrypted) — but drive the common cases off quartz state.
### Generic fixes to land in quartz (`nip01Core/relay/client/`)
1. **Treat `auth-required` as a first-class deferred state, not a burned retry.**
*(Landed — commit 2.)* The resend-after-auth path already works:
`syncFilters` on the auth `OK` re-sends every still-pending EVENT, so the
common single-round case (send → `auth-required` → auth → resend → accepted)
already delivered. The narrow bug: `PoolEventOutboxState.newResponse` sent
`auth-required` down the generic-failure path, so each NAK consumed the
per-relay retry budget (`isDone() = responses.size > 2 || tries.size > 3`).
Budget exhaustion isn't checked on the NAK itself but on the **next
`newTry`** — i.e. the resend `syncFilters` issues after the auth `OK`. So
across *repeated* rounds (slow external NIP-55 signer, reconnect churn, or a
relay that re-challenges) the saved event could be **evicted right as it was
about to be redelivered**. Fix: `auth-required` records no failure and leaves
`relaysRemaining` untouched (mirrors `StandaloneRelayClient`'s
`!msg.message.startsWith("auth-required")`), so the existing resend can
redeliver no matter how many auth rounds elapse first.
2. **Real retry policy instead of a hard count.** Replace the `>2 / >3` cliff
with bounded retries + backoff, and a **terminal "gave up" notification**
(via `RelayConnectionListener` / a publish-result callback) so events are
never *silently* dropped. `NostrClientPublishExt.publishAndConfirmDetailed`
and `pendingPublishRelaysFor` already give higher layers a confirmation
surface to build on.
3. **Enrich the injected auth-decision callback with pending context.** The
`signWithAllLoggedInUsers = (relayUrl, authTemplate) -> …` hook in
`RelayAuthenticator` currently gets only the URL. Pass a generic
`RelayAuthChallengeContext` carrying the relay's pending events + active
filters, and let it return not just "sign or not" but an outcome that can
**suspend for a host decision**. The `AuthPurpose`/`RelayAuthContext` types
move to a quartz-neutral shape (opaque to quartz); amethyst supplies the
resolver.
4. **Expose an "event is blocked on auth for relay X" signal** so a host UI can
show the prompt and reflect "queued, not lost." A `SharedFlow`/listener on the
client, host-agnostic.
### What stays in amethyst
The *policy and meaning*: blocked-list + follow-graph resolver, purpose/
counterparty derivation (needs `LocalCache`/`Account`, main-process only), the
`TRUSTED_FOLLOWS` mode, the ASK prompt UI, and the per-relay **grant rationale**
persistence + settings rows (§1b). These depend on identity/UI and cannot live
in quartz.
## A few ideas / open decisions
These are the knobs where more than one answer is defensible. Recommendation
first.
- **A. Follow-based trust shape.** *(Recommended: new `TRUSTED_FOLLOWS` policy
mode.)* Cleanest extension of the existing enum + settings radio group.
Alternatives: a separate independent "trust relays of people I follow" toggle
that layers on any base mode (more flexible, more UI); or never-automatic —
follow-status only pre-selects the "remember" button in the ASK dialog (most
conservative).
- **B. Decision memory granularity.** *(Decided: per-relay — the decision gate
is one ALLOW/DENY per relay.)* We keep the gate relay-based but enrich the
stored record with the grant rationale (§1b) so the settings screen can
explain each relay. Rejected alternative: making the *gate itself*
per-purpose × per-relay (allow relay X for DMs but keep asking for feed reads)
— richer but more confusing; the rationale display gives the transparency
without splitting the gate.
- **C. In-flight send when auth isn't yet granted.** *(Recommended: fix
quartz's existing outbox so park-and-flush is the default.)* The queue already
exists (`PoolEventOutbox` + `syncFilters`-after-auth); the work is making
`auth-required` a deferred state (not a burned retry) and adding backoff + a
terminal give-up signal — see the quartz section above. This is strictly
better than the amethyst-only best-effort/retry fallback and is generic, so it
belongs in quartz. Best-effort remains the trivial fallback only if we choose
not to touch quartz.
- **D. Which purposes auto-trust covers.** DMs and public inbox notifications are
clear yes. Outbox/feed reads ("maybe" in the brief) could be a sub-toggle
under `TRUSTED_FOLLOWS` so reading is treated more liberally than writing.
## Files to touch
- `commons/.../relayauth/RelayAuthPolicy.kt` — add `TRUSTED_FOLLOWS`, add `ASK`.
- `commons/.../relayauth/AuthPurpose.kt`**new** sealed hierarchy + `RelayAuthContext` + `AuthPurposeKind`.
- `commons/.../relayauth/RelayAuthGrant.kt`**new** per-relay record (decision + rationale, §1b).
- `commons/.../relayauth/RelayAuthPermissionStore.kt` + `amethyst/.../DataStoreRelayAuthPermissionStore.kt`
— store/load `RelayAuthGrant` (decision + rationale) instead of a bare decision; add a
`recordUse(relayUrl, purpose)` merge that updates the rationale + `lastUsedAt`.
- `amethyst/.../authCommand/model/RelayAuthPermissionLedger.kt` — context-aware
`decide(ctx)`, blocked-list + follow-trust inputs, `ASK` fall-through.
- `amethyst/.../authCommand/model/RelayAuthIntentRegistry.kt`**new, minimal**:
only for hints quartz can't infer (e.g. the recipient behind an encrypted gift
wrap). Common purposes are derived from quartz's pending events + active
filters instead.
**Quartz (generic mechanism — see the quartz section):**
- `quartz/.../nip01Core/relay/client/pool/PoolEventOutboxState.kt` +
`PoolEventOutbox.kt``auth-required` as a pending-auth state excluded from
`isDone()`; bounded retry + backoff; terminal give-up notification.
- `quartz/.../nip01Core/relay/client/auth/RelayAuthenticator.kt` — pass a
`RelayAuthChallengeContext` (pending events + active filters) to the injected
decision hook; allow the hook to suspend for a host decision.
- `quartz/.../nip01Core/relay/client/listeners/RelayConnectionListener.kt` (or a
new client `SharedFlow`) — "event blocked on auth for relay X" + "gave up"
signals. Fold the good `StandaloneRelayClient` auth-retry logic into the
production path.
- `amethyst/.../authCommand/model/AuthCoordinator.kt` — build `RelayAuthContext`
from the registry, emit `RelayAuthRequest` on `ASK`, await the reply.
- Wire the ledger's new inputs where it's constructed (blocked-list flow,
follow-check, relay-ownership lookups from `DmRelayListState`/`Nip65RelayListState`).
- Registration calls at the DM sender, reply/reaction broadcaster, and outbox
feed subscription.
- `amethyst/.../ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt` — new
mode; three-state per-relay overrides; **per-relay rationale rows** grouped by
purpose ("To send DMs to: …", "To download posts from: …") rendering
counterparty avatars + names resolved from `LocalCache`.
- New composable dialog + observer for `RelayAuthRequest`, hosted in the
logged-in scaffold.
## Verification
- **Unit (commons/amethyst JVM):** table-test `decide(ctx)` across the
precedence ladder — blocked beats override beats policy; `TRUSTED_FOLLOWS`
allows a followed-counterparty relay and falls to `ASK` for a stranger;
`Unknown` purpose → silent `DENY`.
- **Quartz outbox (JVM unit tests):** an `auth-required` NAK does **not** advance
`isDone()` and the event survives; after a simulated auth OK, `syncFilters`
re-sends it; a non-auth terminal error still discards; retries honor backoff
and emit a give-up signal instead of a silent drop. Include a race test:
repeated `auth-required` NAKs before auth completes must not drop the event.
- **Intent registry:** register/expire, multi-purpose merge on one relay.
- **Grant rationale:** `recordUse` merges new counterparties into the right
purpose kind, dedupes, refreshes `lastUsedAt`; `allDecisions()`/settings query
returns the grouped rationale for rendering.
- **`amy` interop:** drive a NIP-17 send to a recipient whose 10050 relay
requires auth against a local auth-required relay (`amy serve` / geode) and
confirm the AUTH round-trip + delivery once allowed. (Enforces the
verify-don't-guess rule.)
- **Manual:** send a DM to a followed vs non-followed npub on an auth-required
inbox under each policy mode; confirm the prompt copy names the right reason
and that Always/Block persist.
- `./gradlew :commons:test :amethyst:testDebugUnitTest` and `./gradlew spotlessApply`.

View File

@@ -0,0 +1,538 @@
# NIP-29 Relay-Based Groups — Deep Study of Armada + Amethyst Integration Plan
**Date:** 2026-07-07
**Status:** Research / design study (no code yet)
**Reference client studied:** [Armada](https://gitlab.com/soapbox-pub/armada) (Soapbox), commit at HEAD of `main`
**Spec:** [NIP-29](https://github.com/nostr-protocol/nips/blob/master/29.md)
This document is a deep study of how **Armada** — a Discord-style NIP-29 client
by Soapbox — creates events, manages chat, displays information, and handles
invites/joins/leaves, followed by a concrete plan for bringing NIP-29 into
Amethyst. It is written to answer "how does relay-based group chat actually work
in a shipping client, and where does it slot into Amethyst's existing chat
stack."
---
## 0. TL;DR for the impatient
- **NIP-29 groups live on ONE relay.** A group is addressed by the pair
`(relayUrl, groupId)`. The relay is the source of truth — it signs the group's
metadata/membership/roles and enforces who may write. This is fundamentally
different from Nostr's usual "publish everywhere" model.
- **Armada models it as Discord:** a **server = a relay**, a **channel = a
NIP-29 group**. The far-left rail is a list of relays; picking one shows that
relay's channels; picking a channel shows its kind-9 message timeline.
- **The user's home base is kind 10009** (NIP-51 "simple groups list"): it stores
both the relays they've added (`r` tags) and the groups they've joined
(`group` tags), privately (NIP-44 encrypted to self).
- **Amethyst is already ~70% wired for this at the protocol layer.** Quartz has a
`nip29RelayGroups` package (metadata/moderation/request events) **and** a
`nip43RelayMembers` package (the relay-level membership handshake Armada uses).
The main protocol gap is the **kind-9 group chat *message* event** itself.
- **Amethyst also already has the perfect UX analog:** the `ephemChat` feature
(NIP-C7, kind 23333) is a *relay-scoped* chat room identified by a
`RoomId(id, relayUrl)` — the exact shape of a NIP-29 group address — with a
full channel/screen/join/leave UI under
`amethyst/.../ui/screen/loggedIn/chats/publicChannels/ephemChat/`. NIP-29 is a
managed, moderated sibling of that feature.
---
## 1. NIP-29 protocol primer (the parts that matter)
### 1.1 The addressing model
A group is **not** a global object. It exists on a specific relay, and its id is
only unique *within that relay*. So every reference to a group is the pair:
```
(relay websocket URL, group id)
```
The group id is a short opaque string (Armada mints 8 random bytes → 16 hex
chars). Everything that happens "in" the group is tagged with `["h", "<groupId>"]`
and published **only** to that relay.
### 1.2 Event kinds
| Kind | Name | Signed by | Purpose |
|------|------|-----------|---------|
| **9** | group chat message | user | the actual chat line (has `["h", groupId]`) |
| 11 | group thread/forum post | user | long-form/threaded root |
| 1111 | NIP-22 comment | user | threaded reply to a chat message |
| 7 | reaction | user | emoji reaction (scoped by `h`) |
| 1068 | poll (NIP-88) | user | poll posted into the timeline |
| **9000** | put-user | user (admin) | add member / set roles |
| **9001** | remove-user | user (admin) | remove member |
| **9002** | edit-metadata | user (admin) | change name/about/picture/flags |
| 9005 | delete-event | user (admin) | moderator delete a message |
| **9007** | create-group | user (admin) | create a new group |
| 9008 | delete-group | user (admin) | delete a group |
| **9009** | create-invite | user (admin) | mint an invite code |
| **9021** | join-request | user | ask to join (optional `code`) |
| **9022** | leave-request | user | leave the group |
| **39000** | group metadata | **relay** | name, picture, about, flags (addressable, `d`=groupId) |
| **39001** | group admins | **relay** | `["p", pubkey, role…]` list |
| **39002** | group members | **relay** | `["p", pubkey]` list |
| **39003** | group roles | **relay** | `["role", name, desc]` definitions |
| 39004 | live AV participants | **relay** | LiveKit room presence (Armada extension use) |
The **9xxx** events are user-authored **requests/commands**; the relay validates
the author's role and, if accepted, updates the group state and re-emits the
**39xxx** relay-signed snapshots. **Clients read 39xxx, write 9xxx.**
### 1.3 Group metadata flags (kind 39000 tags)
`name`, `picture`, `about` carry display data. Boolean status is presence-based:
- `private` — only members can **read** (relay gates reads behind NIP-42 AUTH).
- `restricted`* — only members can **write**.
- `closed` — join requests are ignored; you need an invite code.
- `hidden` — metadata hidden from non-members.
- `livekit` — group has an AV room.
- `supported_kinds` — whitelist of accepted kinds (absent = all).
The antonyms are `public` / `open` (Armada's `edit-metadata` emits the antonym
tag to *clear* `private`/`closed`; see §2.4).
### 1.4 The `previous` tag (timeline references) — and why Armada drops it
NIP-29 defines an optional `["previous", <id-prefix>, …]` tag: each event should
reference the first-8-chars of several of the group's recent events so a relay
can reject events that were composed against a *forked* view of the timeline
(anti-context-spam). **Armada deliberately does not emit it** — see the
verbatim rationale in `ChatComposer.buildMessageTags`:
> relay29's `CheckPreviousTag` rejects any event whose first `previous` ref isn't
> in the group's in-memory last-50 ring. We can only pick refs from a local (and
> own-excluded) message snapshot, which routinely drifts out of that window —
> especially when replying to older messages — causing the relay to silently drop
> legitimate messages/replies. `previous` is optional in NIP-29 and only guards
> against relay-fork attacks, which don't apply to this single-host-per-group
> deployment.
**Takeaway for Amethyst:** Quartz *has* a `PreviousTag` class, but a
single-host-per-group deployment does not need it, and emitting it naively causes
silent drops. Start without it (like Armada); only add it if targeting a relay
that enforces it.
---
## 2. How Armada creates events
Armada's whole NIP-29 protocol layer is ~770 lines in
`client/src/lib/nip29.ts` (constants, parsers, tag builders), driven by a set of
TanStack-Query hooks in `client/src/hooks/`. Every write goes through a single
`useNostrPublish` mutation whose `relay` option **pins the event to the group's
host relay** — this is the linchpin of the whole design.
### 2.1 The publish chokepoint (`useNostrPublish.ts`)
```ts
// EventTemplate has an optional `relay?: string`.
// When set, publish ONLY to this relay (NIP-29 group traffic must stay on
// the group's host server). When omitted, the event goes to all app relays.
if (relay) {
await nostr.relay(relay).event(event, ); // single-relay send
} else {
await nostr.event(event, ); // fan out to app relays
}
```
It also: adds a NIP-89 `["client", APP_NAME]` tag, adds `published_at` for
replaceable kinds, stores the signed event locally *before* the network call (so
optimistic UI + offline retry work), and normalizes relay rejection strings for
toasts (`relayRejectionMessage`).
**Every NIP-29 write in Armada passes `relay: relayUrl`.** Group/channel events
*never* touch the general app relays. This is the single most important
invariant to replicate.
### 2.2 Create a group (`CreateGroupDialog.tsx` + `useGroupModeration.ts`)
A "create channel" is a **two-event sequence**, then a bookkeeping write:
```ts
// 1. kind 9007 create-group — just the h tag.
await createGroup({ groupId });
// → publishEvent({ kind: 9007, content: "", tags: [["h", groupId]], relay })
// 2. kind 9002 edit-metadata — name/about/visibility.
await editMetadata.mutateAsync({ name, about, isPrivate, isClosed });
// → tags: [["h", groupId], ["name", name], ["about", about],
// [isPrivate ? "private" : "public"], [isClosed ? "closed" : "open"]]
// 3. remember it in the user's kind 10009 list (best-effort).
updateList({ type: "add-group", ref: { id: groupId, relay: relayUrl } });
```
The group id is client-minted (`crypto.getRandomValues(8)` → hex). The creator
becomes admin automatically (the relay assigns the group-creator role). Then the
UI navigates to `/s/<relayParam>/<groupId>`.
> Server note: Armada's own relay (`server/group.go`) **restricts kind 9007 to
> configured admin pubkeys** — on that deployment only operators create channels.
> That's a relay-policy choice, not a NIP-29 requirement.
### 2.3 Send a chat message (`ChatComposer.tsx`)
A message is **kind 9** with `["h", groupId]` plus standard Nostr tags. The tag
builder (`buildMessageTags`) assembles:
- `["h", groupId]` — always first, required.
- `["t", hashtag]` — extracted hashtags.
- `["p", pubkey]` — NIP-27 mentions decoded from `nostr:npub…` in content.
- NIP-10 marked reply tags when replying:
`["e", rootId, relay, "root", rootAuthor]` + `["e", replyId, relay, "reply", replyAuthor]`.
- `["q", …]` — NIP-18 quotes for embedded nevent/naddr.
- NIP-30 custom emoji tags, NIP-92 `imeta` tags for uploads.
- **No `previous` tag** (see §1.4).
Sending is **optimistic**: the event is signed locally, inserted into the
timeline cache with a `pending` status, then sent. Because the id is computed at
sign time, the relay's echo of the same event dedupes automatically against the
optimistic copy (id match) and flips it to confirmed.
### 2.4 Edit metadata / moderation (`useGroupModeration.ts`)
All moderation is one small hook returning mutations, each a single pinned
publish. Exact tag shapes:
| Action | kind | tags |
|--------|------|------|
| putUser | 9000 | `[["h",g], ["p", pubkey, ...roles]]` |
| removeUser | 9001 | `[["h",g], ["p", pubkey]]` (content = reason) |
| deleteEvent | 9005 | `[["h",g], ["e", eventId]]` |
| editMetadata | 9002 | `[["h",g], ...metadataTags(patch)]` |
| deleteGroup | 9008 | `[["h",g]]` |
| createInvite | 9009 | `[["h",g], ["code", code]]` |
`metadataTags` emits antonym tags to clear flags (`public`/`open`) but only
*asserts* `restricted`/`hidden` (no documented antonyms). After a successful
moderation write, the relevant TanStack query keys are invalidated so the
39xxx-derived views refetch.
### 2.5 Invites (`InvitePeopleDialog.tsx`)
Opening the invite dialog **immediately mints an invite and builds a link** — the
"silly-easy part." Two-tier logic:
1. **Prefer a relay-level claim (NIP-43 / zooid).** `useRelayClaim` queries the
relay for a `kind 28935` invite it issues to authed members; if present, its
`claim` tag value is the invite code.
2. **Fall back to a per-group NIP-29 code (kind 9009).** If the relay issues no
claim, mint a random 6-byte code and publish `createInvite({ code })`.
The shareable URL is `…/s/<relayParam>/<groupId>?code=<code>`. Anyone opening it
hits `GroupPage`, which auto-joins using the `code` query param.
### 2.6 Join / leave / membership (`useGroupMembership.ts`, `useRelayMembership.ts`)
**Join (kind 9021)** is a two-step handshake, in this order:
```ts
// 1. Best-effort RELAY-level join first (zooid/Coracle relays gate ALL writes
// behind relay membership, rejecting non-members before group join is even
// considered). Ephemeral kind 28934 carrying the invite as a `claim` tag.
// NEVER throws — no-ops on relays that don't implement it (e.g. relay29).
await joinRelay({ relayUrl, claim: code });
// 2. The real NIP-29 group join. Same invite carried as a `code` tag.
await publishEvent({ kind: 9021, content: reason, tags: [["h",g], ["code",code]], relay });
// "already a member" rejection is treated as success.
```
**Leave (kind 9022):** `publishEvent({ kind: 9022, tags: [["h",g]], relay })`.
The relay auto-issues the corresponding 9001 remove-user.
**Membership state** is derived per NIP-29 by querying the *latest* of
`{kinds:[9000,9001], "#h":[g], "#p":[me]}` on the host relay — 9000 latest ⇒
member, 9001 latest ⇒ not. Polls every 30s; 15s stale time.
**Relay membership (NIP-43)** is its own layer, cleanly separated: `useRelayClaim`
(fetch a 28935 claim), `useJoinRelay` (publish 28934 with the claim),
`useLeaveRelay` (28936). All best-effort and non-throwing — the group join is the
source of truth for the actual outcome.
---
## 3. How Armada displays information
### 3.1 The three-pane Discord layout
```
┌────┬──────────────┬───────────────────────────┐
│rail│ channel list │ message timeline │
│ │ (this relay) │ + composer │
│ 🟦 │ # general │ ...kind 9 messages... │
│ 🟩 │ # random │ │
│ # dev │ [type a message] │
└────┴──────────────┴───────────────────────────┘
servers channels chat
=relays =NIP-29 groups =kind 9
```
- **`ServerRail.tsx`** — the far-left vertical rail. Each icon is a **relay**
(`{ kind: "server", url }`), fed by the pinned `PLATFORM_RELAYS` +
`config.addedRelays`. Supports Discord-style drag-to-reorder and **folders**.
(It also unifies in Concord E2EE communities, which are *not* NIP-29 — ignore
those.) Server order is synced to the kind-10009 `r` tags.
- **`ChannelSidebar`** — for the selected relay, lists its groups via
`useRelayGroups(relayUrl)`.
- **`GroupChat.tsx`** — the timeline + composer for the selected group.
Selecting a relay first, then a room, is *exactly* the "select the relay first
and then pick the rooms in each relay" UX in the ask. Folders are Armada's answer
to "organization to group rooms on" — but note they group **relays**, not rooms;
rooms are grouped implicitly by their host relay.
### 3.2 Fetching a relay's channels (`useRelayGroups.ts`)
Lists a relay's groups by querying `{kinds:[39000]}` on that relay. Key
subtleties:
- **Trust:** kind 39000 must be signed by the relay's own key. Armada reads the
relay's `self`/`pubkey` from its NIP-11 doc and adds `authors:[relaySelf]` so
**forged metadata from other publishers is never trusted**. Until NIP-11
resolves it races a direct fetch (2s) then refetches once when the key lands.
- **Hidden groups:** relays hide closed/private groups from open listings, so the
ids the user *remembers* (their kind-10009 `group` tags for this relay) are
queried explicitly by `#d` and merged in.
- **Provenance scoping:** several relays can share a signing key (zooid ships a
shared identity), so author-scoping alone bleeds channels across relays. Armada
records *which relay actually served* each cached event ("provenance") and
scopes the IndexedDB cache read by it. This is a real, painful edge case worth
remembering.
- **Cache-as-floor:** cached events are merged *under* live ones so a sparse/flaky
relay read can only add, never clear the list. Long stale time (1h), no polling
— channel metadata is the most stable thing in the app.
### 3.3 Fetching a group's roster (`useGroup.ts`)
One query pulls the newest of `{kinds:[39000,39001,39002,39003], "#d":[groupId]}`
(optionally author-scoped to the relay key) and composes
`{ group, admins, members, roles }` (newest event per kind wins). **Local-first:**
the plaintext 39xxx events are mirrored to IndexedDB, so a previously-opened
group renders its roster instantly, with a background relay refresh.
### 3.4 The message timeline (`useGroupMessages.ts`)
The most sophisticated hook. For `(relayUrl, groupId)`:
- **Timeline kinds:** kind 9 + kind 1068 (polls); live sub also watches kind 5
(deletions).
- **Local-first + snapshot-first paint:** seeds from a synchronous localStorage
"last screenful" snapshot → IndexedDB store → background relay page, merged
append-only so nothing already shown is dropped.
- **Scroll-up pagination:** `loadOlder()` walks an `until` cursor with a
gap-guard (Ditto's pattern) so a stale straggler doesn't leap the cursor past
real history.
- **Live subscription:** one `req` with a 5-minute `since` lookback (so a message
that arrived via push before the group was opened still replays); dedupes by id;
processes kind-5 deletions by dropping referenced ids.
- **Optimistic send status** map (`pending`/`failed`) reconciled by relay echo.
- **Resilience:** 60s backstop poll + refetch-on-focus/reconnect to heal
half-dead mobile sockets where the live socket silently died.
### 3.5 Group discovery / "home" (`useUserGroupList.ts`, kind 10009)
The **cross-device source of truth** for a user's memberships is a single kind
10009 event (NIP-51 "simple groups"):
- `group` tags `["group", id, relay]` — joined groups (with host relay).
- `r` tags `["r", relayUrl]` — servers/relays in use.
- Both stored as **NIP-44 private items** (encrypted to self in `.content`);
read-modify-write via `useUpdateUserGroupList`. Armada persists the *decrypted*
list to disk ("folded cache") so boot doesn't pay a signer round-trip, and
refuses to write if it couldn't decrypt the prior list (avoids wiping it).
There is also a `useGroupSearch` (NIP-50 `search` scoped by `#h`, merged with the
local timeline cache) for in-group message search.
### 3.6 What else rides on the `h` tag
Armada extends the group with several kinds, all scoped by `["h", groupId]` so
the relay routes/authorizes them: **pins** (kind 39041, an Armada extension,
addressable `d`=groupId, admin-only), **calendar events** (NIP-52
31922/31923/31925), **reactions** (kind 7), **threaded replies** (kind 1111
NIP-22 comments), and **webxdc mini-apps** (9450/24450). All follow the same
pattern: `["h", groupId]` + pin to host relay. Useful precedent that "anything
can be a group event if it carries `h` and the relay accepts the kind."
---
## 4. Design constraints & gotchas (the expensive lessons)
1. **Relay-scoping is absolute.** Group events go only to the host relay, and
group queries hit only the host relay. Amethyst's relay client fans out to
many relays by default — NIP-29 needs a *single-relay* send/subscribe path.
2. **39xxx is relay-signed; trust it by the relay's own key.** Filter directory
queries by `authors:[relayNip11Pubkey]`. Never trust group metadata otherwise.
3. **Shared relay identities bleed groups.** If you cache 39000 by author only,
two relays sharing a key cross-contaminate. Track per-relay provenance.
4. **`previous` tags cause silent drops** unless you can guarantee refs are in the
relay's last-50 ring. Omit them for single-host groups.
5. **Two membership layers.** NIP-29 group membership (9000/9001) is distinct from
relay-level membership (NIP-43, 28934/28935). Community relays (zooid) gate on
the latter *first*. Do the relay handshake best-effort, treat the group join as
authoritative.
6. **Cache-as-floor everywhere.** A flaky relay returning nothing must never blank
a channel list or roster. Merge cache under live, never overwrite.
7. **Optimistic UI needs local signing.** Sign locally, insert with pending
status, dedupe on relay echo by id.
---
## 5. Amethyst integration plan
### 5.1 What already exists (survey)
**Protocol (Quartz) — largely present.** `quartz/.../nip29RelayGroups/` already
has:
- `metadata/``GroupMetadataEvent` (kind **39000**), `GroupAdminsEvent`
(39001), `GroupMembersEvent` (39002), `SupportedRolesEvent` (39003).
- `moderation/``CreateGroupEvent` (9007), `EditMetadataEvent` (9002),
`PutUserEvent` (9000), `RemoveUserEvent` (9001), `DeleteEventEvent` (9005),
`DeleteGroupEvent` (9008), `CreateInviteEvent` (9009), plus tag helpers.
- `request/``JoinRequestEvent` (9021), `LeaveRequestEvent` (9022).
- `tags/``GroupIdTag` (the `h` tag), `CodeTag`, `GroupAdminTag`, `RoleTag`,
and even a `PreviousTag`.
**Relay membership (Quartz) — present.** `quartz/.../nip43RelayMembers/` has the
full NIP-43 handshake Armada calls "relay membership": `RelayJoinRequestEvent`,
`RelayInviteRequestEvent`, `RelayAddMemberEvent`, `RelayLeaveRequestEvent`,
`RelayMembershipListEvent`, `ClaimTag`, `MemberTag`. There's even an
`amethyst/.../ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt`.
**The UX analog (commons + amethyst) — present and close.** The `ephemChat`
feature (NIP-C7, kind **23333**) is a *relay-scoped* chat room:
- `quartz/.../experimental/ephemChat/chat/EphemeralChatEvent.kt` — kind 23333,
with `RoomId(room, relayUrl)` — **the same `(relay, id)` shape as a NIP-29
address.**
- `commons/.../model/emphChat/EphemeralChatChannel.kt``EphemeralChatChannel`
with `relays() = setOf(roomId.relayUrl)`, wired into `LocalCache`, `Account`,
`Note`, plus an `EphemeralChatListState`.
- `amethyst/.../ui/screen/loggedIn/chats/publicChannels/ephemChat/` — a full UI:
`EphemeralChatScreen`, `LoadEphemeralChatChannel`, `EphemeralChatChannelHeader`,
`JoinChatButton`/`LeaveChatButton`, a `NewEphemeralChatScreen`, and datasource
sub-assemblers (`FilterMessagesToEphemeralChat`, etc.).
So the Messages/Chats screen already hosts **four** conversation types:
NIP-04/17 DMs, NIP-28 public channels, and NIP-C7 ephemeral chats — all under
`chats/`. **NIP-29 groups become the fourth sibling.**
### 5.2 The main protocol gap
There is **no kind-9 group chat message event** in `nip29RelayGroups/`
(`CreateInviteEvent` at 9009 is the highest kind present; nothing for kind 9).
The 9xxx moderation, 39xxx metadata, and 9021/9022 request events exist, but the
actual message carrier does not. This is the first thing to build:
- `nip29RelayGroups/chat/GroupChatEvent.kt` — kind 9, `["h", groupId]` required,
NIP-10 reply markers, NIP-27 mentions, NIP-92 imeta, NIP-30 emoji — mirror
`ChannelMessageEvent` (NIP-28) which already does all of this, but swap the
channel `e`-root tag for the `h` group tag. Optionally kind 11 (thread) and
reuse NIP-22 `CommentEvent` for replies.
Also verify the existing 3900039003 parsers expose the flag tags
(`private`/`closed`/`restricted`/`hidden`/`livekit`/`supported_kinds`) and roles
per §1.3; extend if not.
### 5.3 Recommended architecture mapping
| Armada (React) | Amethyst target | Notes |
|----------------|-----------------|-------|
| `lib/nip29.ts` | `quartz/.../nip29RelayGroups/` | mostly exists; add kind-9 `GroupChatEvent` + any missing parsers |
| `useNostrPublish({relay})` | a single-relay send in `commons/.../relayClient/` | **critical new capability**: publish/subscribe pinned to one relay |
| `useRelayGroups` | a `RelayGroupsState` / filter assembler | query 39000 on one relay, author-scoped to its NIP-11 key |
| `useGroup` | `GroupChannel` model + roster state | compose newest 3900039003; mirror `EphemeralChatChannel` |
| `useGroupMessages` | a NIP-29 `FeedFilter` + `FeedContentState` | reuse `chats/publicChannels/datasource` sub-assembler pattern |
| `useUserGroupList` (10009) | an `Account` state object (like `ephemeralChatListState`) | NIP-44 private items; StateFlow of joined groups + servers |
| `useGroupMembership` | membership derivation from 9000/9001 | latest-wins per NIP-29 |
| `useRelayMembership` (NIP-43) | already in `nip43RelayMembers` + `RelayMembersScreen` | best-effort handshake before join |
| `ServerRail` + `ChannelSidebar` | Android: a relay picker → channel list inside the Chats tab | see §5.4 |
| `GroupChat` + `ChatComposer` | reuse the ephemChat/NIP-28 chat screen + composer | swap the datasource + send to kind 9 + `h` |
**Placement per CLAUDE.md:** protocol → `quartz/`; the group model, list state,
membership derivation, ViewModels/filters → `commons/` (so Desktop + CLI share);
screen composables + navigation → `amethyst/` (bottom-nav) and `desktopApp/`
(sidebar). The ephemChat feature is the template to copy for all three layers.
### 5.4 Recommended Amethyst UX
The ask floats three options; the study points to a clear answer:
- **Not** a flat list of rooms mixed into the DM inbox. NIP-29 rooms are
relay-scoped and there can be many per relay — mixing them into the DM room
list loses the relay grouping and doesn't scale.
- **Yes** to "select the relay first, then pick rooms in that relay." This is
Armada's model and it matches the protocol's addressing exactly. On Android
(bottom-nav, no room for a permanent Discord rail), the natural shape is:
- A **"Groups"/"Servers" entry inside the existing Chats tab** (alongside DMs,
Public Chats, Ephemeral Chats).
- Level 1: **your relays** (from kind-10009 `r` tags) — an "add relay" affordance
and each row shows unread rollup.
- Level 2: tap a relay → **its channels** (from `useRelayGroups`-equivalent),
with a create-channel action (subject to relay policy).
- Level 3: tap a channel → the **existing chat screen**, re-pointed at a NIP-29
kind-9 datasource.
- "Organization to group rooms on" = the **relay is the grouping**; add
Discord-style relay folders later if desired (Armada's `railLayout`).
- Desktop can render the true three-pane rail (it already uses a sidebar shell).
Deep-linking: adopt Armada's invite-link idea via Nostr-native addressing —
`naddr` to the kind-39000 (kind + relay-key author + `d`=groupId + relay hint),
plus an optional invite `code`. Amethyst already resolves `naddr`; a group `naddr`
should route into the channel and, if a code is present, fire a 9021 join.
### 5.5 Suggested build order
1. **Quartz:** add `GroupChatEvent` (kind 9) + builders; confirm 39000 flag/role
parsing. Unit-test against Armada-produced events (spin up `./start.sh` or use
`chat.soapbox.pub`).
2. **Relay client:** add a single-relay pinned publish + subscription path
(the `relay: relayUrl` equivalent). This unblocks everything else.
3. **commons:** `GroupChannel` model + `UserGroupListState` on `Account` (kind
10009, mirror `EphemeralChatListState`) + membership derivation.
4. **commons:** NIP-29 message `FeedFilter`/`FeedContentState` (copy the
ephemChat/NIP-28 sub-assembler; timeline kinds 9 + 1068 + 5).
5. **amethyst:** relay-picker → channel-list screens in the Chats tab; re-point
the chat screen/composer at the kind-9 datasource; join/leave/create/invite
dialogs (reuse `nip43RelayMembers` for the relay handshake).
6. **Later:** roles/moderation UI, pins, reactions, threads, calendar, LiveKit AV.
### 5.6 Explicitly out of scope
Armada's `concord-v1`/`concord-v2` directories are a **separate** end-to-end
encrypted community protocol (sealed envelopes, rekeying) — *not* NIP-29. They
share the chat *components* via a `ChatTransport` abstraction but nothing else.
Ignore them for NIP-29. (The `ChatTransport` pattern — a presentational chat UI
fed by a capability interface — is itself a nice idea worth borrowing so DMs,
NIP-28, ephemeral, and NIP-29 all render through one component.)
---
## 6. Key file references
**Armada (studied):**
- `client/src/lib/nip29.ts` — constants, parsers, tag builders
- `client/src/hooks/useNostrPublish.ts` — the `relay`-pinned publish chokepoint
- `client/src/hooks/useGroupModeration.ts` — 9000/9001/9002/9005/9007/9008/9009
- `client/src/hooks/useGroupMembership.ts` + `useRelayMembership.ts` — 9021/9022 + NIP-43
- `client/src/hooks/useRelayGroups.ts` / `useGroup.ts` / `useGroupMessages.ts` — display
- `client/src/hooks/useUserGroupList.ts` — kind 10009 home base
- `client/src/components/layout/ServerRail.tsx` — Discord rail (relay = server)
- `client/src/components/chat/ChatComposer.tsx` — kind-9 send + the `previous`-tag rationale
- `client/src/components/dialogs/{CreateGroup,InvitePeople}Dialog.tsx`
- `server/group.go`, `server/invites.go`, `server/unmanaged.go` — relay29 policy
**Amethyst (integration surface):**
- `quartz/.../nip29RelayGroups/**` — existing protocol events (add kind-9 chat)
- `quartz/.../nip43RelayMembers/**` — relay membership handshake
- `quartz/.../experimental/ephemChat/**` + `commons/.../model/emphChat/**` — the relay-scoped chat analog
- `amethyst/.../ui/screen/loggedIn/chats/publicChannels/ephemChat/**` — UI template
- `amethyst/.../ui/screen/loggedIn/relays/nip43/RelayMembersScreen.kt`
- `quartz/.../nip28PublicChat/message/ChannelMessageEvent.kt` — the kind-9 builder to mirror

View File

@@ -0,0 +1,208 @@
# Concord — Mobile Integration Plan (mirroring NIP-29 Relay Groups)
## Context
The Concord protocol engine is complete in `quartz/…/concord/` (CORD-01…07,
~65 tests) and driven end-to-end by the `amy concord` CLI over a commons
`ConcordActions` layer. This plan covers the **Android app integration**, and it
deliberately **mirrors the just-merged NIP-29 relay-groups feature** — that work
used Soapbox's Armada as a study base and established the exact Amethyst touch
points a group-chat protocol should plug into. Wherever possible we clone the
NIP-29 file structure with Concord equivalents rather than inventing parallels.
Naming: user-facing = **"Concord Channels"** (Amethyst reserves "community" for
NIP-72). Protocol-internal code keeps the spec term `community`.
## The one structural difference from NIP-29
NIP-29 group metadata (kind 39000) is **relay-signed and public**, so groups are
browsable. Concord communities are **end-to-end encrypted**: the only public
artifact is the addressable kind-33301 invite **bundle**, whose content is
token-gated. Consequences for the mirror:
- **Addressing** is by *derived stream pubkey* (`group_key.pk` per plane/epoch),
not `(hostRelay, groupId)`. A Concord channel lives at its plane address and
may be mirrored on several relays (the community's relay set), not pinned to
one host. So `ConcordChannel.relays()` = the community relay set.
- **Discovery** cannot preview E2EE content. The discovery feed surfaces **public
invite links** (kind-33301 bundles + links shared in notes), filtered by
author/hashtag — the entry action is *redeem a link*, not *browse contents*.
This is a genuinely thinner surface than NIP-29; documented, not a bug.
- **Membership = key possession**, verified locally from the folded Control Plane
+ banlist (already implemented), not from relay-signed 39001/39002.
## Per-account persistence & subscription model (Concord is between NIP-17 and NIP-28/29)
Separate **addressing** from **encryption/membership** and Concord's place is clear:
| Concern | NIP-28 | NIP-29 | NIP-17 | **Concord** |
|---|---|---|---|---|
| Find messages by | channel id | `(relay, h)` | `#p = me` | **`authors=[derived plane pk]`** |
| Content | public | public | E2EE to you | **E2EE to a shared key** |
| Decrypt with | — | — | your key | **per-channel derived conv key** |
| Membership | open | relay roster | key possession | **key possession** |
| "My rooms" home | follow list | kind-10009 | chatroom set | **kind-13302 (carries secrets)** |
The decisive point: a Concord wrap's `p` tag is **ephemeral**, so you can never
find messages with `#p = me` (the NIP-17 model). You subscribe **by author = the
derived plane pubkey** (NIP-28/29 addressing), a query only a secret-holder can
form, and decrypt with the shared plane key (NIP-17 E2EE).
**Home base = kind-13302 `ConcordCommunityList`** (built in quartz): NIP-44
self-encrypted, replaceable, relay-synced. Unlike NIP-17 (only secret is your
identity key) or NIP-29 (public group tags), **each entry carries the community
secrets** (`community_root`, salt, epoch, private-channel keys). Same trust model
as NIP-17's recoverable giftwrapped history: a leaked nsec exposes them, nothing
worse. `ConcordChannelListState` wraps 13302 exactly like `RelayGroupListState`
wraps 10009 / `EphemeralChatListState` wraps its list — **same wiring, entries
hold keys.**
**In-memory projection (LocalCache):** `ConcordChannel` keyed by
`(communityId, channelId)`, holding the folded Control-Plane state + decrypted
messages — recomputed from events, never persisted as identity (the NIP-28/29
half).
**Subscription = per-plane author REQ, fanned out from the joined list** — not a
single `#p=me` catch-all. `ConcordMyChannelsFilterAssembler` (mirrors NIP-29's
`RelayGroupMyJoinedGroupsFilterAssembler`) walks `account.concordChannelList`,
derives each community's control-plane + channel-plane addresses, and issues
`{kinds:[1059], authors:[planePk]}` per plane across the community's relays.
**Secrets at rest:** relay copy is self-NIP-44-encrypted (13302); the on-device
mirror can be wrapped with `commons/keystorage`.
## Layering (same as NIP-29)
- `quartz/…/concord/` — protocol (done)
- `commons/…/model/concord/``ConcordChannel`, `ConcordChannelListState`,
membership/view-mode enums, discovery constraint (platform-agnostic)
- `amethyst/…/chats/publicChannels/concord/` — screens, feed filters, datasource
subassemblers, navigation
- `commons/…/actions/ConcordActions.kt` — builders/filters/folding (done)
- `cli/…/commands/Concord*Commands.kt` — verbs (done; already matches the
`RelayGroupCommands` route+verb-map pattern)
## Mirror map (NIP-29 file → Concord equivalent)
### commons state
- `model/nip29RelayGroups/RelayGroupChannel.kt`**`model/concord/ConcordChannel.kt`**
— a `Channel` subclass keyed by a `ConcordChannelId(communityId, channelId)`,
holding the folded `ConcordCommunityState` + this channel's messages StateFlow,
`relays()` = community relay set, `membershipOf()` from the authority resolver,
`placeholderNote()`.
- `RelayGroupListState.kt`**`model/concord/ConcordChannelListState.kt`** —
backed by the **kind-13302** joined-communities list (already in quartz:
`ConcordCommunityList`). Exposes `liveCommunities: StateFlow<List<Entry>>` and
`liveServers: StateFlow<Set<communityId>>`. `join(community)`/`leave` do
read-modify-write of the 13302 event. Mirrors `EphemeralChatListState`.
- `RelayGroupMembership.kt`**`ConcordMembership.kt`** (OWNER/ADMIN/MEMBER/BANNED/
NONE) derived from `AuthorityResolver` (rank + banlist).
- `RelayGroupViewMode.kt`**`ConcordViewMode.kt`** (INLINE/GROUPED).
- `model/nip29RelayGroups/GroupDiscoveryConstraint.kt`**`ConcordDiscoveryConstraint.kt`**
(AllPublic / ByPeople / ByHashtags) matching against a public invite bundle.
### Account wiring (`amethyst/…/model/Account.kt`)
Add right after the `relayGroupList` lines (~382): a
`ConcordChannelListState(signer, cache, decryptionCache, scope, settings)` field
+ its decryption cache. Action methods next to `joinRelayGroup` (~1472):
`createConcordCommunity`, `joinConcordFromLink`, `postConcordMessage`,
`createConcordInvite`, `banConcordMember`, `follow/unfollow(ConcordChannel)`
delegate to `ConcordChannelListState`. Writes go through the community relay set.
Add `concordViewMode` to `AccountSettings.kt`.
### LocalCache (`amethyst/…/model/LocalCache.kt`)
Add a `LargeCache<ConcordChannelId, ConcordChannel>` index + `getOrCreateConcordChannel`,
and route inbound kind-1059 wraps on known plane addresses into the fold (decrypt
→ edition/message). Mirrors `getOrCreateRelayGroupChannel`.
### Messages inbox integration (THE key mirror)
- `chats/rooms/dal/ChatroomListKnownFeedFilter.kt` + `ChatroomListNewFeedFilter.kt`
— extend the 5-way `feed()` concatenation to **6-way**: add a `concordChannels`
block reading `account.concordChannelList.liveCommunities`, branching on
`concordViewMode` (INLINE = one row per channel via
`LocalCache.getOrCreateConcordChannel(...).newestChatNote() ?: placeholderNote()`;
GROUPED = one synthetic `ConcordServerRoomNote(communityId, newest)` per
community). Update `applyFilter`/`updateListWith` with a
`filterRelevantConcordMessages(...)` keyed by `concordRowKey()`.
- `chats/rooms/dal/RelayGroupServerRoomNote.kt`**`ConcordServerRoomNote.kt`** —
synthetic event-less Note collapsing a community's channels into one inbox row.
- `chats/rooms/ChatroomHeaderCompose.kt` — add `rendersWithoutEvent` branches for
`ConcordServerRoomNote` and channel placeholders; `ConcordServerRoomCompose`
`Route.ConcordServer(communityId)`; `ConcordRoomCompose` (chip = community name)
`routeFor(channel)`. **This is where the "chip opens the Concord Channel"
requirement lands.**
### Screens (`amethyst/…/chats/publicChannels/concord/`, mirror `relayGroup/`)
- `ConcordServerList.kt` (community rows) · `ConcordChannelListScreen.kt(communityId)`
(a community's channels, from the folded Control Plane) ·
`ConcordChatScreen.kt(communityId, channelId, …)` (top-level route target) ·
`ConcordChannelView.kt` (reuse the NIP-28 `ChannelFeedViewModel`/`ChannelView`
stack via the `ConcordChannel: Channel` subclass) · `ConcordMembersScreen.kt` ·
`ConcordMetadataScreen.kt`/`ViewModel.kt` (create/edit) · `ConcordTopBar.kt`
(name + role badge + Members/Edit/Invite/Ban/Leave menu) · `LoadConcordChannel.kt`.
- Compose composer gated on `membershipOf(me).isMember()`; else a "redeem an
invite to post" notice.
### Discovery feed (GitRepositories-style triad; thinner than NIP-29)
- `concord/dal/ConcordDiscoveryFeedFilter.kt` (`AdditiveFeedFilter<Note>` over
public kind-33301 bundles; "My Communities" branch = the 13302 list) +
`concord/dal/ConcordDiscoveryConstraint.kt` bridge +
`concord/datasource/subassemblies/FilterConcordBundlesBy{Authors,Follows,Hashtag}.kt`.
`ConcordDiscoveryScreen.kt` = `DisappearingScaffold` + `FeedFilterSpinner` +
`RenderFeedContentState` with `ConcordDiscoveryCard` (name + Join button). FAB →
`ConcordBrowse`/redeem-link.
### Navigation (`ui/navigation/routes/Routes.kt` + `AppNavigation.kt`)
`@Serializable` routes: `Concord`(communityId, channelId, +draftId?/inviteToken?),
`ConcordServer`(communityId), `ConcordMembers`, `ConcordCreate`, `ConcordEdit`,
`Concords`(object, bottom-nav → discovery), `ConcordBrowse`. `RouteMaker.routeFor(ConcordChannel)`
+ deep-link: an invite URL/`nostr:`-embedded link → `Route.Concord(..., inviteToken=…)`,
auto-redeeming on open (mirror NIP-29's inviteCode auto-join). Wire through
`BouncingIntentNav.kt`.
### Invite/redeem UI + linkification
- `InviteConcordDialog.kt` (moderator: mint + share link via `ConcordActions.mintInviteLink`)
· `JoinConcordDialog.kt` (paste a link → redeem) · `ui/components/ConcordInviteCard.kt`
(render a link as a preview card; tap → `Route.Concord(inviteToken)`) ·
`ui/components/ClickableConcordInviteLink.kt` (inline linkify shared invite URLs).
### Notifications (your explicit ask)
Route a Concord message notification click to the **channel chat**, not the feed:
in the notification builder + `BouncingIntentNav`, map a Concord message
notification to `Route.Concord(communityId, channelId)`. Mirror how NIP-29
group notifications resolve via `routeFor`.
### Zaps & likes
Because `ConcordChannel` extends `Channel` and messages render through the shared
`ChannelView`, reactions (kind 7) and zaps attach through the existing chat
reaction/zap path — but they must be **wrapped on the channel plane** (kind-7/9735
rumors sealed like messages, bound to channel+epoch), not published in the clear.
Add `ConcordActions.buildReaction`/`buildZapRequest` that wrap on the plane, and
point the shared reaction/zap affordances at them for Concord notes.
## Build order (each a tested, shippable slice)
1. **commons foundation**`ConcordChannel`, `ConcordChannelListState` (13302),
membership/view-mode enums; unit tests. Wire into `Account.kt` + `AccountSettings`.
2. **LocalCache index** + inbound wrap folding.
3. **Messages inbox** 6-way concat + `ConcordServerRoomNote` + header render/nav
(delivers the chip-opens-channel behavior).
4. **Chat screens** (reuse NIP-28 `ChannelView`) + nav routes + create/invite/join.
5. **Discovery feed** triad (public invite bundles).
6. **Notifications routing + zaps/likes on-plane.**
## Verification
- commons: `:commons:jvmTest` unit tests for `ConcordChannelListState` (13302
round-trip/merge) and `ConcordChannel` folding, mirroring
`RelayGroupListDecryptionTest`/`RelayGroupChannelTest`.
- Android: `:amethyst:installDebug`; create a community, see it in Messages with a
chip, tap → channel opens, send/receive between two emulators, redeem an invite
link deep-link, verify a notification click opens the chat. Cross-check against
`amy concord` (same relay) for wire interop, and against Armada for protocol
interop (`Nip29ArmadaInteropTest` is the precedent).
## Gotchas carried from the NIP-29 study
- Membership has two independent layers (Concord authority vs NIP-43 relay
membership); we only implement Concord authority.
- Cache-as-floor + optimistic local signing for snappy UX.
- E2EE means no server-side moderation and no metadata preview — surface state
from the local fold only.

View File

@@ -0,0 +1,113 @@
# Dual-mode replies: inline + "minichat" threads across all chats
## Goal
Give every Amethyst chat two ways to reply, chosen at send time:
- **Inline reply** — a normal chat message that references its parent and stays in
the main timeline (today's behavior). On the wire this is the chat protocol's
native reply: NIP-C7 kind-9 with a `q` quote (Concord), kind-42 reply (NIP-28),
kind-9 `+h` reply (NIP-29), kind-14 reply (NIP-17 DM).
- **Minichat reply** — a **kind-1111 NIP-22 `CommentEvent`** rooted at the parent
message. It is pulled *out* of the main timeline and shown in a separate
**minichat** ("chat within a chat") opened from the parent. This matches Soapbox
Armada exactly (kind-9 `q` = inline quote, kind-1111 = thread).
The rule is uniform and protocol-agnostic: **any kind-1111 whose root is a chat
message opens as that message's minichat.** So the same treatment automatically
covers Concord kind-9, NIP-28 kind-42, NIP-29 kind-9, and (later) NIP-17 kind-14 —
wherever a 1111 lands on a chat message.
## Reuse survey (what already exists — do NOT rebuild)
| Need | Reuse |
|---|---|
| kind-1111 reply builder (NIP-22 `K/E/P`+`k/e/p`) | `quartz/.../nip22Comments/CommentEvent.replyBuilder`; Concord's `ChannelChat.reply` already uses it |
| 1111 → parent wiring | `LocalCache.computeReplyTo` (CommentEvent branch) → `parentNote.replies`; minichat content = `note.replies.filter { it.event is CommentEvent }` |
| "N replies" chip | `observeNoteReplyCount(note, avm)` (EventObservers.kt) — already used by `RelayGroupThreadsScreen` |
| Shared per-row action strip | `ChatMessageCompose.NormalChatNote` `detailRow` `Row` — one place, every chat type |
| Thread rendering | `threadview/ThreadFeedView` + `ThreadAssembler.findThreadFor`; NIP-29 `RelayGroupThreadsScreen` as the chat-adjacent precedent |
| Per-message 1111 REQ (public chats) | `FilterRepliesAndReactionsToNotes` (kinds incl 1111, `#e`) via `EventFinder`; `RelayGroupThreadFeedFilterAssembler` (compose-scoped `#h`+1111 sub) |
| Composer reply state + "replying-to" preview | `*NewMessageViewModel.replyTo` + `chats/utils/DisplayReplyingToNote` |
| NIP-22 comment composer | `note/nip22Comments/CommentPostViewModel` (full-featured) |
Concord already delivers kind-1111 replies through the existing channel-plane
subscription (they're wrapped like every other rumor), so **no new subscription is
needed for Concord** — only the timeline split, the chip, the minichat screen, and
the composer picker.
## Design
### 1. Wire model (settled — matches Armada)
- Inline reply → native chat reply event, native reply tags, stays in timeline.
- Minichat reply → kind-1111 `CommentEvent`: uppercase `K/E/P` at the immutable
thread root (the chat message), lowercase `k/e/p` at the immediate parent, plus
whatever binding the plane requires (Concord: `channel`/`epoch`). One level:
replying inside a minichat roots the new 1111 at the **same** root message
(parent = the message being answered, root = the minichat root), rendered flat —
so minichat messages don't spawn sub-threads. (The wire still permits nesting;
we render flat.)
### 2. Timeline vs minichat split (rendering)
- **Main feed** excludes kind-1111 comments whose root is a chat message — they
live in the minichat, not as flat siblings. Implemented in the shared
`ChannelFeedFilter` / `ChatroomFeedFilter` by dropping `CommentEvent`s that root
onto a message already in the feed (keep everything else).
- Each root message row shows an **"N replies" chip** (from `observeNoteReplyCount`
restricted to CommentEvent replies) in the `detailRow` strip; tap → minichat route.
### 3. Minichat screen
- A thread screen keyed by the **root message id** (+ the channel/room key needed to
re-derive the plane / re-subscribe). Renders the root message pinned at top, then
its kind-1111 replies as a flat mini-timeline (reuse `ChatroomMessageCompose`), with
its own composer that always sends kind-1111 rooted at this message.
- Back it with `ThreadFeedView`/`ThreadAssembler` where possible; for Concord, feed
it from `rootNote.replies` (already populated) + a lifecycle sub that keeps the
plane live.
### 4. Composer mode picker
- Add `replyMode: ReplyMode {INLINE, MINICHAT}` next to `replyTo` in each
`*NewMessageViewModel` (Concord `ConcordNewMessageViewModel`, DM
`ChatNewMessageViewModel`, channels `ChannelNewMessageViewModel`).
- Render a small toggle beside `DisplayReplyingToNote` ("Reply in chat" ⇄ "Reply in
thread"). Default = INLINE (least surprise; user opts into pulling it aside).
- Send branch: `MINICHAT` routes to the kind-1111 builder
(`CommentEvent.replyBuilder` / Concord `buildChannelReply`), `INLINE` keeps the
native reply builder.
### 5. Subscriptions
- **Concord**: none new (1111 arrives via the channel plane). Just ensure the
timeline filter and minichat read `rootNote.replies`.
- **NIP-28 / NIP-29 (phase 2)**: add a compose-scoped assembler (clone
`RelayGroupThreadFeedFilterAssembler`) that REQs `{kinds:[1111], "#e":[<visible
message ids>]}` (and `#E`) off the feed's current message-id set (from
`FeedContentState`). Reuse the same minichat screen/row.
- **NIP-17 DM (phase 3, later)**: kind-1111 replies must be gift-wrapped like the
kind-14s; deferred — needs an encrypted-comment path, more design.
## Phasing
1. **Phase 1 — Concord, full UX + all shared pieces.** ReplyMode enum + composer
toggle; timeline split (drop chat-rooted 1111s); "N replies" chip in the shared
`detailRow`; minichat route + screen; Concord send branch. Delivers the complete
dual-mode experience for Concord and builds every shared component.
2. **Phase 2 — public chats.** Per-message 1111 subscription for NIP-28 + NIP-29;
reuse the Phase-1 chip/screen/composer. NIP-29 already has a thread screen to
reconcile with.
3. **Phase 3 — DMs.** Gift-wrapped kind-1111 minichat for NIP-17. Deferred.
## Decisions (settled)
- **Default mode** when tapping reply: **INLINE**. User opts into MINICHAT via the toggle.
- **Minichat depth**: **flat, one level**. Replying inside a minichat roots at the
same message; no sub-threads.
- **Scope now**: **Phase 1 + 2 together** — Concord AND public chats (NIP-28/NIP-29).
DMs (phase 3) still deferred.
- **Screen styling**: **chat-styled bubbles** (reuse `ChatroomMessageCompose`) so the
minichat reads as "a chat within a chat".
## Verification
- quartz/commons unit tests for the reply-mode builders + the timeline-filter split
(a chat-rooted 1111 is excluded from the feed but present in `rootNote.replies`).
- On-device: in Concord, reply inline (stays in timeline) and reply-in-thread (opens
minichat); confirm Armada shows our minichat replies as a thread and its threads
open as our minichat; confirm the "N replies" chip count.

View File

@@ -0,0 +1,245 @@
# WebSocket Ping Interval Study — 122 Production Relays
**Date:** 2026-07-12
**Question:** Would relays drop Amethyst's connections if the client WebSocket
ping interval were raised (e.g. 120s → 240s on mobile data to save battery)?
Was the long-standing 120s value ever load-bearing, and what is the best
middle ground?
**Answer (TL;DR):** Keep a single **120s** ping interval on every network.
Raising it to 240s saves almost no battery — 90% of surveyed relays send
their *own* pings every 3070s, which OkHttp must answer, so the radio's
wake cadence is set by the relays, not by our interval — and it starts
dropping real relay tiers: 240s pings lose `relay.ditto.pub` (~240s idle
timeout) and every `nostr1.com`-hosted relay (~300s tier); 300s pings even
lose `relay.snort.social` (~600s tier). Lowering below 120s would only
rescue a ~120s tier of 6/122 relays that already cycle today, at 2× the
ping traffic on every other connection. 120s is, by measurement, the sweet
spot it was presumably never designed to be.
---
## 1. Motivation
`OkHttpClientFactoryForRelays` sets `pingInterval(120s)` on every relay
WebSocket. During battery work the interval was tentatively doubled on
mobile data on the theory that each client ping on an otherwise-idle
cellular connection wakes the radio and pays the multi-second tail-energy
cost. The maintainer asked the right question: *do we actually know how
production relays react to different ping intervals?* Nobody had tested
the 120s value. This study answers it empirically.
Two distinct drop mechanisms are in play:
1. **Relay/reverse-proxy idle timeouts** — testable from any vantage.
2. **Carrier NAT idle timeouts** — only testable from a real cellular
network (not from this environment; see §7).
## 2. Relay population
Production relays were harvested by fetching **600 kind:10002 (NIP-65)
relay-list events** from indexer relays (`indexer.coracle.social`,
`user.kindpag.es`) and counting `r`-tag references: **1,468 distinct
relays**, ranked by how many users actually list them. The **top 140**
(plus all Amethyst default relays) formed the test population.
- **122 relays accepted a WebSocket** from the test vantage.
- 18 were unreachable *from a datacenter IP* (Cloudflare 403 challenges:
`nostr.wine`, `relay.0xchat.com`; TCP resets: `relay.nostr.band`,
`nostr.bitcoiner.social`, `relayable.org`, `nostr.fmt.wiz.biz`; plus
ordinary 5xx/410s). These blocks are IP-reputation-based, not
ping-related, and don't affect the conclusions — but they mean the
study cannot speak for those relays.
## 3. Method
Three experiments, all through the same stack (Python `websocket-client`,
TLS, one REQ per connection whose filter matches nothing, so the relay
answers EOSE and the connection then carries zero application traffic).
Server pings were always answered with pongs automatically (as OkHttp
does) and logged.
- **Phase A — idle survival.** 140 relays, **zero client pings**, hold
for **780s (13 min)**. Records: drop time, close code, server-ping
timestamps. A relay surviving 780s of total client-ping silence proves
*any* client interval ≤ 780s is safe for it.
- **Phase B — ping efficacy.** Every Phase A dropper re-tested with
client pings at **55 / 110 / 120 / 180 / 240 / 300s** (one connection
per interval, window = observed idle timeout + 2 ping cycles + margin,
capped at 780s). This distinguishes "pings reset the relay's idle
timer" from "only data frames count".
- **Case study —** `relay.ditto.pub` with 60s pings for 420s (it had
dropped an idle connection at 257s while *its own* ping got our pong at
123s — proving pongs don't reset its timer but client pings do).
## 4. Phase A results — idle survival with zero client pings
**99 of 122 relays (81%) survived 13 minutes of complete client-ping
silence.** For four out of five relays, the client ping interval is
irrelevant to connection survival at any plausible value.
The 23 droppers cluster into clean idle-timeout tiers:
| Tier | Count | Relays |
|---|---|---|
| < 30s (probe rejected / non-idle close) | 2 | `nostr.petrkr.net/strfry`, `next.nsite.run` |
| **~60s** | 8 | `nostr.pareto.space` (47s), `nostr.vps.satsnode.xyz` (×2), `relay.mostro.network`, `nostr.bond/alpha`, `nostr.sgiath.dev`, `nostr.bitcoinplebs.de`, `nostr.schneimi.de` |
| **~120s** | 8 | `cfrelay.snowcait.workers.dev` (118s), `nostr-verified.wellorder.net` (120.7s), `nostr-pub.wellorder.net` (120.8s), `git.shakespeare.diy` (125.7s), `nostr-relay.irgenius.org` (126.0s), `nostr-verif.slothy.win` (126.1s), `nostr.bit4use.com` (126.6s), `sendit.nosflare.com` (131.4s) |
| **~240s** | 1 | `relay.ditto.pub` (240.9s; 257.1s in an earlier run) |
| **~300s** | 2 | `david.nostr1.com` (300.5s), `dkkc.nostr1.com` (300.7s) i.e. the **nostr1.com / relay.tools hosting tier** |
| **~600s** | 2 | `nos.lol/<haven path>` (600.8s), `relay.snort.social` (601.0s) |
### Server-ping cadence (the finding that reframes the question)
Among the 99 relays that held an idle connection for the full window:
| Serverclient ping cadence | Relays |
|---|---|
| 35s | 45 |
| 3670s | 37 |
| ~300s | 8 |
| no server pings at all | 9 |
**90 of 99 relays ping the client; 82 of them every ≤ 70s.** OkHttp
answers every server ping with a pong regardless of the client-side
`pingInterval`. So on a connected cellular device the radio is being
woken every 3070s *per connection* by the relays themselves. Changing
the client interval from 120s to 240s does not change that cadence at
all the client ping is a rounding error in the connection's keepalive
traffic. **The claimed battery saving of a longer client ping interval
does not exist in practice.** (Corollary: the real mobile-battery lever
is connected time and connection count in the background which the
app already minimizes by disconnecting 30s after backgrounding not
the ping schedule.)
## 5. Phase B results — which client intervals keep the droppers alive
For every idle-dropper, one connection per candidate interval
(`x@T` = dropped at T seconds despite pinging at that interval;
`skip` = interval observed idle timeout, unsafe by construction):
| relay | idle-drop | 55s | 110s | 120s | 180s | 240s | 300s | max safe |
|---|---|---|---|---|---|---|---|---|
| nostr.pareto.space | 46.9s | skip | skip | skip | skip | skip | skip | none |
| nostr.vps.satsnode.xyz | 51.1s | skip | skip | skip | skip | skip | skip | none |
| nostr.vps.satsnode.xyz/… | 51.2s | skip | skip | skip | skip | skip | skip | none |
| relay.mostro.network | 60.5s | x@60.8 | skip | skip | skip | skip | skip | none |
| nostr.bond/alpha | 60.8s | x@60.9 | skip | skip | skip | skip | skip | none |
| nostr.sgiath.dev | 60.8s | x@60.9 | skip | skip | skip | skip | skip | none |
| nostr.bitcoinplebs.de | 60.9s | x@61.1 | skip | skip | skip | skip | skip | none |
| nostr.schneimi.de | 62.2s | x@61.1 | skip | skip | skip | skip | skip | none |
| cfrelay.snowcait.workers.dev | 118.2s | x@85.0 | x@74.5 | skip | skip | skip | skip | none |
| nostr-verified.wellorder.net | 120.7s | **OK** | x@120.7 | x@120.8 | skip | skip | skip | 55s |
| nostr-pub.wellorder.net | 120.8s | **OK** | x@120.8 | x@120.8 | skip | skip | skip | 55s |
| git.shakespeare.diy/… | 125.7s | **OK** | x@125.9 | x@126.1 | skip | skip | skip | 55s |
| nostr-relay.irgenius.org | 126.0s | **OK** | x@125.8 | x@125.9 | skip | skip | skip | 55s |
| nostr-verif.slothy.win | 126.1s | **OK** | x@126.1 | x@126.3 | skip | skip | skip | 55s |
| nostr.bit4use.com | 126.6s | **OK** | x@126.4 | x@126.5 | skip | skip | skip | 55s |
| sendit.nosflare.com | 131.4s | x@81.7 | x@41.9 | x@7.0 | skip | skip | skip | none |
| **relay.ditto.pub** | 240.9s | OK | OK | **OK** | x@673.5 | **x@609.8** | skip | **120s** |
| **david.nostr1.com** | 300.5s | OK | OK | **OK** | x@300.6 | **x@300.7** | x@300.7 | **120s** |
| **dkkc.nostr1.com/…** | 300.7s | OK | OK | **OK** | x@300.7 | **x@301.1** | x@300.6 | **120s** |
| nos.lol/<haven path> | 600.8s | OK | OK | OK | OK | OK | x@602.1 | 240s |
| **relay.snort.social** | 601.0s | OK | OK | OK | OK | OK | **x@607.7** | 240s |
Key observations:
1. **A client ping interval numerically below the idle timeout is NOT
sufficient.** 180s and 240s pings failed against the ~300s
`nostr1.com` tier, and 300s pings failed against the ~600s
`snort.social` tier, even though each ping "should" have arrived in
time. The empirical rule across every tier: **pings only reliably
reset a relay's idle timer when the interval is at most roughly half
the timeout.** (Likely cause: these stacks check activity in coarse
windows rather than resetting a precise per-frame deadline, so an
interval near the window size loses boundary races.)
2. The **~60s tier is unsalvageable** — even 55s pings didn't help
(their timers count only data frames). These 8 relays drop idle
Amethyst connections *today* under the 120s setting and would under
any setting; the existing reconnect-on-demand path is the correct
handling for them.
3. The **~120s tier is only rescued by ≤55s pings** — meaning
**today's 120s interval never kept them alive either** (110s and
120s pings both failed). They cycle today; they'd cycle at 240s.
No candidate change affects them.
4. The tiers that DO depend on our ping interval are exactly
**ditto (~240s), nostr1.com (~300s), and snort/nos.lol-haven
(~600s)** — and 120s holds all of them, while 240s loses the first
two and 300s loses all three.
Connections kept alive (of 122 reachable), by candidate interval:
**55s → 110 · 120s → 104 · 240s → 101 · 300s → 99.**
The `relay.ditto.pub` case study confirms the mechanism: with an idle
connection its *own* ping at t=123s received our pong and it still
closed at 257s (pongs don't count as activity), but with 60s client
pings it stayed up indefinitely (client pings do count).
## 6. Why not go lower than 120s?
55s pings would rescue the ~120s tier (6 relays). But:
- those relays already cycle today, so the status quo loses nothing;
- 55s pings double the client-ping traffic on all ~100+ connections to
rescue 5% of relays whose operators chose aggressive timeouts;
- the radio is already woken every ≤70s on 82/122 connections by server
pings, so the *incremental* battery cost is modest — but so is the
benefit, and drop/reconnect for those 6 relays is already handled
gracefully by `BasicRelayClient`'s backoff + the keep-alive sweep.
A per-relay adaptive interval (shorten pings only for relays observed to
drop idle connections) is possible future work, but OkHttp's
`pingInterval` is per-client, not per-socket, so it would require
per-relay client instances — not worth the complexity for 6 relays.
## 7. Carrier NAT — the part this study cannot measure
The other purpose of client pings is keeping carrier NAT/firewall
mappings alive on cellular. That is untestable from a datacenter vantage.
Published measurements and platform folklore put aggressive carrier TCP
idle timeouts around 45 minutes (most are 1530 min; FCM survives on
~28 min heartbeats *with OS cooperation Amethyst doesn't get*). 120s
sits comfortably inside even the aggressive bound, so relay-side and
NAT-side constraints agree on the same answer. Anyone wanting to raise
the interval later must first re-run Phase B *and* validate on real
cellular networks — the relay data alone already rules out 240s.
## 8. Decision
- **`WEBSOCKET_PING_INTERVAL_SECS = 120`, one value for wifi and mobile.**
The tentative 240s mobile value was reverted in this same branch after
these measurements: it saved ~nothing (server pings dominate radio
wakes) and dropped the ditto and nostr1.com tiers.
- OkHttp's `pingInterval` doubles as the dead-connection detector (a
missed pong fails the socket within one interval), so 120s also keeps
failure detection twice as fast as 240s would — relevant after silent
network path changes.
## 9. Reproduction
Vantage caveats: datacenter egress IP (18 relays refused it), all
traffic via an HTTP CONNECT proxy. A control connection with 100s pings
survived every window, ruling out proxy-imposed idle limits ≤ 780s.
Sketch (Python `websocket-client`): open `wss://` to each relay, send
one REQ whose filter matches nothing (`{"kinds":[1],"authors":["00…01"],
"limit":1}`), auto-pong server pings, and either never ping (Phase A,
780s window) or ping at the candidate interval (Phase B). Log connect /
EOSE / server-ping / close timestamps. Population: top-N relays by
`r`-tag frequency across kind:10002 events fetched from indexer relays.
## Appendix — Phase A survivor cadences (99 relays)
Server-ping cadence measured over the 13-minute window. `none` means the
relay sent no pings at all and still held the idle connection.
| cadence | relays |
|---|---|
| ~2535s | `articles.layer3.news`, `aegis.relayted.de`, `assistantrelay.rodbishop.nz`, `bots.utxo.one`, `custom.fiatjaf.com`, `dev.calendar-relay.edufeed.org`, `greensoul.space` (×2), `groups.0xchat.com`, `groups.satsdisco.com`, `h.codingarena.top/inbox`, `haven.calva.dev/inbox`, `haven.nostrfreedom.net`, `haven.relayted.de`, `hist.nostr.land`, `lang.relays.land` (×3), `nexus.libernet.app`, `nip17.com`, `nostr-01.uid.ovh`, `nostr-relay.derekross.me` (×2), `nostr.damupi.com/inbox`, `nostr.easydns.ca`, `nostr.kfx.fr` (×2), `nostr.land`, `nostr.nothing.is-lost.org/haven`, and 17 more at ~30s; `nostrelites.org`, `purplepag.es`, `relay.noswhere.com` at ~30s |
| ~5570s | `nostr.thalheim.io`, `nostr.xmr.rocks`, `offchain.pub`, `relay.mostr.pub`, `relay.nostr.net`, `relay.primal.net`, `relay.damus.io`, `indexer.coracle.social`, `directory.yabu.me`, `user.kindpag.es`, `profiles.nostr1.com`, `nostr.oxtr.dev`, and ~25 more |
| ~300s | `nostr-relay.corb.net`, `nostr.001.j5s9.dev`, `nostr.8777.ch`, `nostr.einundzwanzig.space`, `nostr.mikoshi.de`, `nostr.pbfs.io`, `nostr.sectiontwo.org`, `nostr.wild-vibes.ts.net` |
| none | `nos.lol`, `nostr.mom`, `relay.divine.video`, `relay.fountain.fm`, `koru.bitcointxoko.org`, `nostr-pr02.redscrypt.org`, 2 × Cloudflare-Workers relays, 1 other |
Raw JSON for both phases (per-relay timestamps, close codes, server-ping
series) was captured during the study session; the tables above are the
complete decision-relevant summary.

View File

@@ -0,0 +1,178 @@
# Resource Usage Ledger — battery/data accounting, user-visible + NIP-17 reportable
**Date:** 2026-07-12
**Goal:** Let users (and developers) see how much network, connection time, and
background activity the app consumes, per subsystem — and let a user send that
data to the developers over NIP-17, reusing the crash-report consent pattern.
When consumption crosses "something is wrong" thresholds, proactively ask the
user (rate-limited, opt-out-able) whether they'd like to send a report.
Background: the 2026-07-12 ping-interval study (see
`2026-07-12-relay-ping-interval-study.md`) showed the dominant energy proxy is
connection-time (relays server-ping every 3070s while connected) and that
battery bugs are production-only phenomena — so the ledger ships in release,
collects passively, and never transmits anything without an explicit user
action.
## Survey (existing components reused)
- **Send path** — the crash-report pipeline: `DisplayCrashMessages` prefills
the NIP-17 DM composer via `routeToMessage(user = <dev pubkey>, draftMessage,
expiresDays = 30)`; the user taps Send; `Account.sendNip17PrivateMessage`
gift-wraps to the recipient's kind-10050 DM relays. Reused as-is — the
ledger only builds a different draft string.
- **Persistence idiom** — `ScheduledPostStore` (Jackson + Mutex + tmp-rename +
version envelope + StateFlow). Cloned as `ResourceUsageStore`.
- **Relay traffic** — counted by a new `RelayConnectionListener`
(same hook `RelayStats` uses), NOT by modifying quartz.
- **Connection time** — integrated from `INostrClient.connectedRelaysFlow()`
(exact between emissions; no timers).
- **Network class** — `ConnectivityManager.isMobileOrFalse` StateFlow.
- **Foreground** — new tiny `ForegroundTracker` (ActivityLifecycleCallbacks →
StateFlow<Boolean>), registered next to `AppForegroundRecycleHook`;
`MainActivity.isResumed` is not observable and slightly stricter than
process-foreground.
- **HTTP subsystems** — `RoleBasedHttpClientBuilder` already funnels every
role (image/video/uploads/money/nip05/preview/push) through two shared
clients; a cached per-role `newBuilder().addInterceptor(counting)` wrapper
gives per-subsystem byte attribution without touching the shared clients.
- **UI idioms** — `NotificationSettingsScreen` structure (`Scaffold` +
`TopBarWithBackButton` + `SettingsSection` cards), route in `Routes.kt`,
`composableFromEnd` registration, catalog entry via
`SettingsCatalogBuilder.symEntry` (icon: existing `MaterialSymbols.Bolt`
no font regen).
- **App-open dialog** — `DisplayCrashMessages` pattern, mounted in the same
`AppNavigation` block.
## Design
### Counters
Flat `Map<String, Long>` per UTC epoch-day, retained ~30 days. Key grammar:
`<area>...<mobile|wifi>.<fg|bg>[.<rx|tx>]`, e.g.:
- `net.image.mobile.bg.rx` — bytes downloaded by the image subsystem on
cellular while backgrounded (same for video/uploads/money/nip05/preview/push)
- `relay.msg.wifi.fg.rx|tx` — approx relay websocket payload bytes
- `relay.connms.mobile.bg` — relay-connection-milliseconds (Σ relays × time)
- `wakelock.notif.ms` / `wakelock.notif.count`
- `worker.scheduledPost.runs` / `worker.calendarReminder.runs` /
`worker.notificationCatchUp.runs`
- `app.starts` — process starts (detects WorkManager cold-start churn)
- `relay.connects.<net>.<vis>` / `relay.connfails.<net>.<vis>` — completed
(re)connections and failed dials; each connect paid a TCP+TLS handshake,
so high daily counts are the reconnect-churn signature
- `cpu.ms` — whole-process CPU time deltas ([android.os.Process
.getElapsedCpuTime] sampled at flush): the honest aggregate of parsing,
crypto, coroutines, and UI without per-subsystem guesswork
- `app.fgms` — time with UI visible; display power is proportional to it and
it's the denominator for every per-day comparison
- `crypto.verify.count` / `crypto.verify.us` — event signature verifications
(LocalCache.justVerify hook), settling "does Schnorr verify cost matter"
with data
- `net.<role>.<net>.<vis>.reqs` / `.activems` — HTTP request counts and
active-transfer time per subsystem; counting lives on the shared base
client (OkHttpClientFactory) with tag-based role attribution, so untagged
callers land in `other` instead of escaping the ledger
- `net.bursts.<net>.<vis>` — estimated radio wake-ups from HTTP burst
patterns (new activity after >10s of HTTP silence): the battery-relevant
measure that bytes alone can't capture, since scattered small requests
each pay the radio ramp+tail
- `media.playms` — actual media playback time (ExoPlayer isPlaying
segments): decoder + screen + streaming at once, the denominator for
video bytes
- `pow.ms` / `pow.sessions` — NIP-13 mining time (any job mining in the
PoW queue): full-core CPU, the largest attributable CPU consumer
- `tor.ms` / `tor.starts` — in-app (Arti) Tor uptime and bootstraps, from the
raw TorService status (NOT TorManager.status, whose WhileSubscribed
upstream calls service.start() when collected). External Tor (Orbot) is
deliberately untracked — its battery belongs to Orbot
- `service.alwayson.ms` — NotificationRelayService uptime: the mode context
that explains a device's relay connection-time
- `call.ms`/`call.sessions`, `nests.ms`/`nests.sessions` — calls and NIP-53
audio rooms (mic + Opus + live media connection), from the foreground
services' lifecycles
- `location.ms` — time actively listening for GPS updates (geohash tagging);
mostly a tripwire for a leaked location subscription
- `crypto.decrypt.count/us`, `crypto.encrypt.count/us` — NIP-04/44 work via a
MeteringNostrSigner decorator wrapped inside NostrSignerWithClientTag at
account load; durations metered only for local-key signers (external/
remote waits are IPC/network, not CPU)
- `sign.local|nip46|nip55.count` — signatures by signer kind: NIP-46 is a
relay round-trip and NIP-55 an Amber IPC wake, so the kind is the
battery-relevant dimension (this supersedes "signing is negligible", which
is only true for local keys)
- `battery.drain.fg|bg` — measured battery percent while discharging, sampled
at flush from BatteryManager: NOT app-isolated, but the ground truth that
report corpora can correlate the other counters against
- `screen.<Name>.ms` — foreground time per screen, added after the original
privacy review: only the route's base NAME is recorded (screenNameOf strips
every navigation argument before the value leaves the nav layer), so the
ledger can say "Profile" but never whose profile
Deliberately not tracked (v1): per-coroutine or per-dispatcher CPU (needs a
thread registry; `cpu.ms` answers whether CPU matters at all first).
(Two earlier v1 exclusions were later revisited: per-screen time ships with
names-only privacy as above, and signing is now counted per signer kind
because NIP-46/NIP-55 signatures are network/IPC round-trips, not local CPU.)
Flat keys keep the store schema-free: new counters need no migration.
### Components (`amethyst/.../service/resourceusage/`)
- `UsageKeys` — key constants/builders + dimension helpers.
- `ResourceUsageStore` — daily buckets on disk (`resource_usage.json`),
`mergeInto(day, deltas)`, `allDays()`, prune, plus alert state
(lastAlertAtSec, optOut).
- `ResourceUsageAccountant` — in-memory `ConcurrentHashMap<String, LongAdder>`
hot path (`add()` is called per relay frame), debounced flush (30s) into the
store, day-rollover handling, merged read API for UI/report.
- `ForegroundTracker` — startedActivities>0 as StateFlow.
- `RelayUsageListener``RelayConnectionListener` counting sent/received
frame sizes with current network/visibility dims.
- `RelayConnectionTimeIntegrator` — combines connectedRelays × isMobile ×
isForeground; closes an accounting segment on every change and on
`closeOpenSegment()` (called from accountant flush and reads, so multi-hour
stable background sessions still account without any timer).
- `UsageCountingInterceptor` + counting response body — per-role HTTP bytes;
wrapped clients cached per (role, base client identity).
- `ResourceUsageReportAssembler` — Markdown: device/app header (crash-report
style), human summary (today + 7 days), fenced per-day counter dump.
- `ResourceUsageAlerts` — pure threshold logic (see below) + rate limiting.
- `DisplayResourceUsageAlert` — consent dialog (view details / send / not
now / don't ask again).
- UI: `ResourceUsageScreen` under `ui/screen/loggedIn/settings/`.
### Wiring (AppModules / Amethyst / hooks)
- store + accountant + integrator constructed in `AppModules`; listener added
via `client.addConnectionListener`.
- `ForegroundTracker` registered in `Amethyst.onCreate` (main process only).
- `RoleBasedHttpClientBuilder` gains an optional usage meter.
- `EventNotificationConsumer.withWakeLock` gains an optional held-duration
callback (threaded through `NotificationDispatcher`).
- Workers increment their run counters via `Amethyst.instance` (guarded).
- `AppModules.trim()` flushes the accountant (backgrounding = natural flush).
### Alert thresholds (v1, deliberately conservative — tune with real reports)
Evaluated on the last *complete* day, OR today once exceeded:
- background cellular traffic > 50 MB/day
- relay connection time > 12 relay-hours/day while backgrounded on cellular
- notification wakelock held > 30 min/day
- process starts > 75/day
Rate limit: at most one prompt per 7 days; "don't ask again" persisted.
Never auto-sends: every path goes through the DM composer where the user sees
exactly what will be sent and must tap Send.
### Privacy
Counters are sizes, durations, and counts — no URLs, no relay names, no event
content. The report includes device model fields identical to the crash
report. Everything stays on-device until the user explicitly sends the DM
(NIP-40 30-day expiration, same as crash reports).
### Explicitly out of scope (v1)
- Layer 1 (Perfetto/ODPM macrobenchmarks) and Layer 2 (`TrafficStats` socket
tags) — add only if the ledger proves blind somewhere (e.g. WS bytes are
payload-approximate; TrafficStats would give exact on-wire bytes).
- Per-relay attribution in the ledger (RelayStats screens already exist).
- Desktop: accountant/store are Android-module for now; extraction to commons
is mechanical if desktop wants it.

View File

@@ -0,0 +1,124 @@
# Chat feed scroll performance — fast fling through thousands of messages
Status: plan. Owner: chat feed (`amethyst/.../ui/screen/loggedIn/chats/feed/`).
## Problem
A fling through a long chat history composes hundreds of `ChatroomMessageCompose`
rows per second. Each newly composed row currently pays for work that is either
(a) derivable off the composition path, (b) only needed for *recent* messages, or
(c) side-effectful (coroutines, relay-filter updates) and therefore multiplies
into churn under velocity. The redesign added per-row observers (group position,
reaction chips, delivery ticks) that are individually cheap but sum up at 1000+
rows.
## What is already fine (verified, don't re-litigate)
- **Rich text parse is cached**: `TranslatableRichTextViewer`
`CachedRichTextParser.parseText` behind `remember(content, tags)`; re-scrolling
past a message doesn't re-parse.
- **Engagement flows are sampled**: `observeNoteReactions/Zaps/Replies` sample at
200500ms, and the EventFinder assembler folds all subscribed note ids into a
small number of shared REQs (`SingleSubEoseManager`), not one REQ per note.
- **LazyColumn hygiene**: stable `key = idHex`, `contentType = kind`,
`animateItem()` gated by performance mode.
- **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 |
| 2 | 3× `collectAsStateWithLifecycle` on metadata flows | `watchChatGroupPosition` | collector churn |
| 3 | `LaunchedEffect``loadAndMarkAsRead(route, createdAt)` coroutine | `NormalChatNote` | 1 coroutine/row; read-marker lock churn |
| 4 | `LaunchedEffect``accountViewModel.decrypt(note)` for the jumbo flag | `NormalChatNote` | 1 coroutine/row, even for plaintext kinds |
| 5 | Reaction observer + zap observer subscriptions (ids added to shared relay filters; filter update per batch) | `ChatReactionChips`, `ObserveZapAmountText` | relay REQ re-issue churn under velocity |
| 6 | 2× flow collectors for delivery ticks on own messages, forever, even for long-settled history | `ChatDeliveryTicks` | collector churn |
| 7 | Per-row animation/gesture state: `animateColorAsState`, press `InteractionSource`, swipe `pointerInput`, highlight `LaunchedEffect` | `ChatBubbleLayout` | allocation, mostly unavoidable |
| 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` |
| 10 | `onScrollToNote` recreated when `ChatFeedLoaded` recomposes → unstable param defeats item skipping | `ChatFeedLoaded` |
| 11 | Feed invalidation rebuilds the sorted list; every reaction arriving during scroll can reorder/emit | `FeedContentState` (existing infra, sampled) |
## Plan
### Phase 0 — Measure first (do not skip)
1. **Macrobenchmark**: add a `benchmark` scenario that seeds `LocalCache` with
510k synthetic `ChannelMessageEvent`s (mixed: text, links, emoji-only, a few
with reactions/zaps) and flings the public-chat screen. Metrics:
`frameDurationCpuMs` P50/P90/P99, jank %, `frameOverrunMs`.
2. **Composition tracing / recomposition counts** on a seeded room; compose
compiler reports (`composables.txt`) for `ChatroomMessageCompose`,
`NormalChatNote`, `InnerChatBubble`, `ChatReactionChips` — verify skippability
and find unstable params (expect #10).
3. Record baseline numbers in this file before changing anything.
### Phase 1 — Kill per-row CPU and coroutines (expected biggest wins)
4. **Day-stamp grouping (#1)**: replace `dateFormatter` equality in `groupsWith`
with an epoch-local-day integer comparison (`(createdAt + zoneOffsetSeconds) / 86400`),
and give `NewDateOrSubjectDivisor` the same predicate so the break condition
stays mirrored (only its *display* string needs formatting, and only when a
divider actually renders). Zero `SimpleDateFormat` on the scroll path.
5. **Hoist read-marking (#3)**: replace the per-row `LaunchedEffect` with one
feed-level `snapshotFlow { listState.firstVisibleItem createdAt }`-driven
marker update (only the newest visible timestamp matters; the marker is
monotonic). One coroutine per scroll session instead of one per row.
6. **Jumbo without a coroutine (#4)**: only launch the decrypt effect for
encrypted kinds (`PrivateDmEvent`, sealed rumors not yet in the decrypt
cache). Plaintext kinds (public chats, NIP-17 rumors already unwrapped —
the vast majority) take the synchronous path only.
7. **Retire settled delivery ticks (#6)**: once a message is fully accepted (or
untracked and older than the tracker window), render the tick from a
`remember`ed terminal value and drop both collectors. Only in-flight sends
keep live flows.
8. **Group position with fewer collectors (#2)**: collect the three metadata
flows only while any of the three notes is missing `event`/`author`
(the common case for history is all-loaded → pure `remember`, no collectors).
### Phase 2 — Tame side-effect churn under velocity
9. **Defer new relay-filter membership while flinging (#5)**: gate
`EventFinderFilterAssemblerSubscription` enrollment on
`!listState.isScrollInProgress` (or debounce enrollment ~300ms): rows that
fly past never join the reaction/zap filters; rows you settle on subscribe
as today. Needs a small `LocalScrollSettled` composition local (provided by
`ChatFeedLoaded`) so `ChatReactionChips`/`ChatDeliveryTicks` can wait without
threading params. Verify with #1 measurements that filter re-issues drop.
10. **Defer media loads (#8)**: same settled-gate for the image/video preview
composables inside chat bubbles (placeholder immediately, Coil request on
settle). Coil cancels in-flight requests on dispose already, but not
starting them is cheaper than cancel.
11. **Stabilize item lambdas (#10)**: `remember(items.list, listState)` around
`onScrollToNote`; pass `shouldHighlight` via `derivedStateOf` keyed per item
(#9) so only the highlighted row recomposes.
### Phase 3 — Structural (only if Phase 1/2 measurements demand more)
12. **Precomputed row model**: build a lightweight `ChatFeedRow(note, groupPos,
dayStamp, isJumbo, isSystem)` list inside `FeedContentState` (background
thread, once per feed update) so item composition becomes pure rendering.
This subsumes #4/#8 above but is a bigger refactor of shared feed infra —
justify with numbers first.
13. **Finer contentType**: distinguish `bubble / jumbo / system / zap-card`
contentTypes so Lazy slot reuse doesn't rebuild structurally different rows.
14. **Prefetch tuning**: evaluate `LazyListPrefetchStrategy(nestedPrefetchItemCount)`
for the fling case on Compose ≥1.7.
### Phase 4 — Regression guardrails
15. Wire the Phase-0 macrobenchmark into CI (or at least a documented manual
run before releases touching the chat feed), and re-record numbers here.
## Non-goals
- Virtualizing bubble internals (Compose already skips off-screen work).
- Caching parsed rich text more aggressively (already LRU-cached).
- Changing feed sorting/invalidation infra (shared with all feeds; separate plan
if measurements point there).

View File

@@ -0,0 +1,83 @@
# CORD-06 Refounding — real member removal for Concord
## Problem
Concord membership is key possession: a banned member (CORD-04 banlist) still
holds the community's `community_root`, so every client just *declines to show*
their posts — they can still decrypt everything. That is a soft removal. CORD-06
adds the hard removal: rotate the key so a removed member's key stops working for
anything sent afterwards.
The quartz crypto for the kind-3303 rekey blob (`ConcordRekey`, `RekeyBlob`)
already existed and was tested, but nothing in the app called it. This wires the
whole path — build, publish, receive, persist, UI — around a **Refounding**
(whole-community rotation), the removal that matters while Amethyst supports only
public channels (a per-channel rekey needs private channels, not built yet).
## What a Refounding does (CORD-06 §3)
1. Ban the removed members on the current Control Plane (so the compacted snapshot
carries the ban).
2. Roll `community_root` to a fresh random 32 bytes at `rootEpoch + 1`. Public
channels + the Control/Guestbook planes all derive from the root, so rolling it
rotates every plane at once.
3. Republish the **compacted** Control Plane under the new root — keep only each
entity's head edition and re-wrap its *original plaintext seal*, so the original
authors' signatures survive re-encryption (a fresh joiner verifies the slim
state exactly as it verified the full chain).
4. Mint per-recipient kind-3303 rekey blobs delivering the new root to every
retained member, sealed + addressed under the **prior** root on the
`base-rekey-pseudonym(prior_root, community_id, new_epoch)` address — which every
current member precomputes, so they receive it live. A removed member gets no
blob and can never derive the new root.
## Layers
- **quartz** `concord/cord06Rekey/`
- `ConcordKeyDerivation`: `baseRekeyAddress` / `channelRekeyAddress` (the rekey
stream addresses), `epochKeyCommitment` (`prevcommit`, CORD-02 §A.5).
- `ConcordRekey`: signer-based `blobForSigner` / `findNewKeyWithSigner` (bunker
accounts open a blob with one `nip44Decrypt`, no raw key).
- `ConcordRefounding`: `compactControlPlane`, `buildBaseRekeyWraps`, `build`
(whole refounding), `findNewRoot` (receive: verify scope/epoch/continuity, find
my blob). `OpenedStreamEvent` now also carries the inner `seal` so compaction
can re-wrap it. Tests in `ConcordRefoundingTest`.
- **commons**
- `ConcordActions`: `guestbookPlane` / `nextBaseRekeyPlane`, `buildGuestbookJoin`
/ `guestbookMembers`, `buildRefounding`, `openBaseRekey`.
- `ConcordCommunitySession`: folds the Guestbook plane into `members`
(the recipient set), buffers inbound base-rekey wraps (`pendingBaseRekeyWraps`),
exposes `controlPlaneWraps` for compaction, and AUTHs to + subscribes the
Guestbook and next-epoch base-rekey planes (`streamKeys`, `subscribeAddresses`).
- `ConcordSessionRegistry.sync`: rebuilds a session when its entry's root/epoch
changed — the session is a pure function of its entry, so adopting a new root is
just a persisted entry swap.
- `ConcordSubscriptionPlanner.auxiliaryPlaneSubs`: REQs the Guestbook + next
base-rekey planes for every joined community.
- **amethyst**
- `Account`: announces a Guestbook JOIN on create/join (`announceConcordGuestbookJoin`)
so members are visible to a future rotator; `refoundConcordCommunity` (owner /
BAN-holder) bans + rolls + publishes + persists; `drainConcordRekeys` (revision
tick) adopts an inbound rotation from an authorized rotator; `adoptConcordRoot`
persists the new root (prior root kept as a `HeldRoot`) and re-seeds the new
epoch's Guestbook, guarded against double-adopt.
- `AccountViewModel.removeConcordMember`; `ConcordMembersScreen` "Remove from
community" action + confirm dialog, gated exactly like Ban.
## Recipient set
The rotator re-keys **Guestbook membership the privileged roster self**, minus
the removed and the already-banned. The Guestbook is best-effort/off-consensus, so
a member who joined but whose Guestbook JOIN hasn't propagated to the rotator would
be missed and locked out — the accepted trade for a serverless, key-possession
membership model. Adopting a new root re-announces the Guestbook JOIN at the new
epoch so cascading removals keep a live membership.
## Known limitations / follow-ups
- No explicit "you were removed" detection: a removed member simply stops receiving
new content (their old-epoch keys still read history). CORD-06's "held all n
chunks, none is mine ⇒ removed" self-eviction is not implemented.
- Per-channel rekey (single private channel) is not wired — needs private channels.
- Race convergence (two rotators, same epoch, lexicographically-lowest-key wins) is
not implemented; single-rotator (owner/admin) refounding is the supported path.

View File

@@ -0,0 +1,71 @@
# Bitchat geohash chat interop
Status: **Phase 1 (public geohash channels) shipped.** Phase 2 (encrypted DMs)
is designed but not implemented.
## What Bitchat does (verified against `permissionlesstech/bitchat`
iOS + `bitchat-android`)
Bitchat's Nostr side has two chat features. Amethyst is a pure-Nostr client
(no BLE mesh / Noise identity), so only the Nostr halves are in scope.
### Public geohash channels ("location channels") — SHIPPED
- Message = **kind 20000** (ephemeral), content = plain UTF-8 text.
Tags: `["g", geohash]` (required), `["n", nickname]` (optional),
`["t","teleport"]` (optional). Optional NIP-13 `["nonce", …]` PoW, default 8
bits, used to relax per-sender relay rate limits.
- Presence = **kind 20001**, only the `g` tag, empty content.
- Subscribe: `kinds:[20000,20001]`, `#g:[geohash]` (exact cell).
- Precision levels (geohash chars): building 8, block 7, neighborhood 6,
city 5, province 4, region 2.
- Identity = a per-geohash throwaway key `HMAC-SHA256(deviceSeed, geohash)`,
deterministic per (device, cell), unlinkable to the user's npub.
- **Relay routing is geographic and load-bearing:** a cell's traffic goes to the
5 relays nearest the cell center, chosen from the public MIT-licensed
`permissionlesstech/georelays` CSV both clients load. If Amethyst used any
other relay set its messages would not rendezvous with Bitchat clients.
### Private DMs — NOT YET IMPLEMENTED (Phase 2)
- Standard NIP-17/59: rumor kind 14, seal kind 13, gift wrap kind 1059, wrap
under a throwaway key, NIP-44 v2.
- **The kind-14 rumor content is NOT plain text.** It is
`"bitchat1:" + base64url(<binary bitchat packet>)` — a `BitchatPacket`
(TLV + a `NoisePayloadType` byte) carrying the private message, delivery ACKs,
and read receipts. Full DM interop therefore requires porting that binary
framing (`NostrEmbeddedBitChat.swift` / `NostrEmbeddedBitChat.kt`).
- Two DM flavors: geohash DMs (gift-wrapped to a participant's per-geohash
pubkey) and stable-identity DMs (to an npub learned via Bitchat's mesh
`[FAVORITED]:<npub>` handshake — mesh-specific, mostly N/A for a Nostr client).
## What shipped (Phase 1)
- **quartz** `experimental/bitchat/`: `GeohashChatEvent` (20000),
`GeohashPresenceEvent` (20001), `GeohashKeyDerivation` (per-geohash key),
registered in `EventFactory`. PoW reuses the existing `nip13Pow` `PoWTag`.
- **commons** `service/georelay/`: `GeoRelayDirectory` (closest-N by haversine,
host tie-break, `:443` dedup), `GeoRelayCsvLoader` (runtime CSV fetch + fallback).
- **amethyst**: `GeohashChatScreen` + `GeohashChatViewModel` (live subscription +
send), `GeohashChatDeviceSeed` (global encrypted seed store),
`Account.signWithAndSendPrivately`, `Route.GeohashChat`, and a chat action on
the geohash feed screen.
- **cli**: `amy geochat listen|send|keys` — the interop harness. Verified with a
live send→relay→listen round-trip (kind 20000, PoW, `g`/`n` tags intact).
## Follow-ups
1. **Encrypted DMs (Phase 2).** Port the `bitchat1:` binary packet
(`BitchatPacket` TLV + `NoisePayloadType`) into quartz, wrap/unwrap it in the
existing NIP-17 stack (`GiftWrapEvent`/`SealedRumorEvent`/`ChatMessageEvent`),
handle geohash DMs (to a per-geohash pubkey) and delivery/read receipts.
Add `amy geochat dm` for interop testing.
2. **Desktop UI.** The shared pieces (quartz events, `GeoRelayDirectory`) are
already cross-platform; add a desktop `GeohashChatScreen` equivalent.
3. **LocalCache integration (optional).** The current Android screen manages its
own subscription/state rather than routing through `LocalCache`/the chatroom
list. Integrating would give unread badges and a unified chat list, at the
cost of a `Channel`/feed-filter/datasource fork.
4. **Location-driven channel picker.** Use `LocationState` to offer the
region/province/city/neighborhood/block/building cells for the user's current
position, plus a manual/teleport entry.
5. **Presence heartbeats + i18n.** Periodically emit kind 20001 while a channel
is open; extract the hardcoded screen strings into `strings.xml`.

View File

@@ -0,0 +1,171 @@
# Making location (geohash) chats first-class in Amethyst
Builds on `2026-07-15-bitchat-geohash-interop.md` (Phase 1 shipped: protocol,
geo-relay routing, a self-contained chat screen, `amy geochat`). This plan takes
it from a bolt-on screen to a native feature woven into Home, Messages, and the
map.
## The one constraint that shapes everything
Geohash **chat** (kind 20000) is signed with anonymous per-cell throwaway keys,
unlinkable to npubs. So "which of my follows are chatting here" is **not
derivable from the chat stream**. Any "follows are active near you" signal must
come from a *linkable* source:
- **kind-1 geohash notes** (`GeoHashFeedFilter` scans `LocalCache.notes` for
`isTaggedGeoHash`; authors are real npubs) → intersect with
`account.kind3FollowList` = genuine "follows near this place." This is the
template `HomeLiveFilter.followsThatParticipateOn` already uses, just sourced
from notes instead of chat events.
- **kind-10081 geohash follow lists** (`GeohashListEvent`) — the user's own, and
optionally follows' public lists.
- Anonymous **liveliness** (kind-20000/20001 presence counts) — "N people here",
no identities.
The Home bubble is therefore: *anonymous liveliness + follows' geo-note activity
→ tap into the cell's chat.* We will not imply the chat reveals who's there.
## Key architectural decision: reuse what exists
1. **Joined channels = the geohash follow list (kind 10081).** `account.geohashList`
(`model/nip51Lists/geohashLists/GeohashListState.kt`, `flow: StateFlow<Set<String>>`,
`follow`/`unfollow`, NIP-44-private capable) already models "geohashes I care
about." Treat *following a geohash* as *joining its location channel*. No new
list type, and it's private-capable. (Trade-off: today it also drives the
kind-1 notes feed; we're overloading one list for both "notes near here" and
"chat here." Acceptable — it's the same user intent. Alternative if we want
separation: a local, unpublished joined-set matching Bitchat's ephemerality.)
2. **Make geohash chat LocalCache-backed** so it can flow through the same feed
machinery as every other room. This is the crux of "first-class": the Messages
list, Home bubbles, unread counts, and pins all read from `LocalCache`
channels. Model it on the ephemeral-chat feature end to end.
## Phase A — Data model + LocalCache integration (foundation)
- `commons/.../model/geohashChat/GeohashChatChannel.kt` — a `Channel` subtype
keyed by geohash (mirror `model/emphChat/EphemeralChatChannel.kt`).
`toBestDisplayName()` returns the reverse-geocoded place (or `#geohash`).
- `LocalCache`: add a `geohashChannels` map + a consumer that routes
`GeohashChatEvent`/`GeohashPresenceEvent` into the channel (mirror
`ephemeralChannels`/`liveChatChannels` + `getOrCreateEphemeralChannel`).
- A rooms-list **subassembler** that subscribes to the joined geohashes
(`account.geohashList.flow``GeoRelayDirectory.closestRelays(g)`, kinds
20000/20001, `#g`) and feeds LocalCache — mirror
`chats/rooms/datasource/FollowingEphemeralChatSubAssembler.kt` +
`FilterFollowingEphemeralChats.kt`.
- Migrate `GeohashChatViewModel` to read the channel's notes from LocalCache
(via `LocalCache.observeNotes` like `NestLobbyScreen`) instead of its private
subscription — unifies the live view with the cached one and lets the screen
reuse the full `ChatroomMessageCompose` (reactions, replies) later. Keep the
send path (per-geohash signer + PoW + geo relays) as-is.
- Ephemeral caveat: relays don't store kind 20000, so joined-cell rooms only show
messages seen while subscribed. Scope background subscriptions to *joined* cells
(+ the current-location cell) to bound battery/relay load; document the cap.
## Phase B — Messages tab
- `chats/rooms/dal/ChatroomListKnownFeedFilter.kt` — add a 7th family
(`geohashChannels` from `account.geohashList`) to `feed()` + `applyFilter`
(newest message per cell), mirroring `filterRelevantEphemeralChats`.
- `chats/rooms/ChatroomHeaderCompose.kt` — add a `GeohashRoomCompose` branch in
`ChatroomEntry``nav.nav(Route.GeohashChat(geohash))`, with a location-pin
`HeaderPill`, the cell name (`LoadCityName`), and a live participant count (no
avatars — anonymous).
- `chats/rooms/NewConversationScreen.kt` — append one `ConversationType`
("Location chat", geohash icon/accent, pros/cons, `route = Route.NewGeohashChat`)
to the *Relay* section of `conversationSections` (single source of truth).
## Phase C — The builder ("+" → create/join)
- `Route.NewGeohashChat` + `NewGeohashChatScreen.kt` (mirror
`ephemChat/metadata/NewEphemeralChatScreen.kt`). Three ways to pick a cell:
1. **Current location levels.** New `GeohashChannelLevel` mapper
(region=2, province=4, city=5, neighborhood=6, block=7, building=8 chars —
the Bitchat levels; `GeohashPrecision` has the char counts but not the
names). From `LocationState.geohashStateFlow` (raise its hardcoded 5-char
precision to 8 so we can truncate to each level), list the six cells with
`LoadCityName` + a Join/Open button.
2. **Manual geohash** text field (validate against the base32 alphabet).
3. **Teleport** → the map picker (Phase E).
- Join = `account.geohashList.follow(geohash)` then `nav.nav(Route.GeohashChat)`.
- Reuse `LocationAsHash`/`ILocationGrabber` for the permission flow.
### Geohash-list management (the kind-10081 add/remove UI)
Today the **only** way to add to the kind-10081 list is the Follow toggle on the
kind-1 `GeoHashScreen` — you must already be viewing that cell. Followed cells
then appear as read-only feed chips in the Home top-nav (`TopNavFilterState`).
There is **no** screen to view the list, remove entries, or add an *arbitrary*
geohash. This builder is that missing "add" UI (it writes via
`account.geohashList.follow`, the same path). Round it out with a small manage
screen:
- `NewGeohashChatScreen` doubles as the **add** surface (current-location levels /
manual / map).
- Add a lightweight **"My location channels"** list (its own route, or a section
in the builder): render `account.geohashList.flow` with `LoadCityName` per cell,
a remove (`unfollowGeohash`) swipe/menu, and an "add" button into the builder.
This is also what Phase B's Messages rows and Phase D's Home bubble read from,
so it's the one management surface for the whole feature.
## Phase D — Home "live near you" bubble
- New feed state `homeGeohashLive` in `AccountFeedContentStates.kt` (parallel to
`homeLive`; different signal source, so not folded into `HomeLiveFilter`).
Sources: joined cells with recent activity (LocalCache, post Phase A) + the
current-location cell.
- `home/live/RenderGeohashBubble.kt` — a bubble showing the cell name, a
liveliness dot (reuse `LiveStatusIndicator` pattern; "online" = recent presence),
and social proof from **geo-notes**: "N follows posted near · M chatting"
(follows-near count = `GeoHashFeedFilter` authors ∩ `kind3FollowList`).
- `HomeScreen.kt` `DisplayLiveBubbles` — add the `GeohashChatChannel` (or a
synthetic geohash item) case to the type dispatch; click → `Route.GeohashChat`.
## Phase E — Teleport + map
- `LocationPickerMap.kt` — extend the display-only osmdroid `LocationPreviewMap`
with a `MapEventsOverlay`/`MapEventsReceiver` so long-press/tap drops a pin and
yields a coordinate → `GeoHash.encode(lat, lon, level)`. osmdroid (Apache-2.0)
already supports this; only the wiring is new.
- Teleport screen (or a mode in `NewGeohashChatScreen`): pick a point on the map,
show the resulting cell name + level selector, Open → `Route.GeohashChat`.
- **Auto-teleport flag:** in `GeohashChatViewModel`, compare the channel's geohash
to the current-location cell (`LocationState`); when they differ (or no
permission), pass `teleported = true` to the already-plumbed
`sendMessage(..., teleported)`. Add a manual override toggle in the composer.
- Optional: forward geocoding (place-name search) via
`Geocoder.getFromLocationName` — none exists today; small addition for a
"search a place" box.
## Phase F — Privacy, presence, polish
- **"Post as my real account" opt-in** (global or per-channel, with a
location-exposure warning). This is also the *only* way a follow becomes
visible in the chat itself — relevant to the Home social-proof story.
- **Presence heartbeats:** emit kind 20001 periodically while a channel is open
(`GeohashChatViewModel.announcePresence` already exists; schedule it).
- **Privacy note:** subscribing to a cell reveals interest in that location to
its relays (via the `#g` REQ from your IP); Tor mitigates. The kind-10081 list
can stay NIP-44-private.
- Extract hardcoded screen strings to `strings.xml` (`<plurals>` for the counts —
see `res/CLAUDE.md`); desktop `GeohashChatScreen` equivalent.
## Open decisions (need a call)
1. **Joined list:** reuse kind-10081 geohash follow list (recommended, private,
already wired) vs. a separate local ephemeral joined-set (closer to Bitchat).
2. **LocalCache integration depth:** full (Phase A — enables Messages/Home/unread,
bigger) vs. keep the self-contained screen and only add the builder + a
Home bubble fed by geo-notes (smaller, but not truly "in the Messages list").
3. **Default identity in these rooms:** stays anonymous per-cell (recommended);
the real-account opt-in is Phase F.
## Verification
- Unit: `GeohashChannelLevel` bucketing, the joined-list ↔ subscription wiring,
the geo-note follow-intersection count.
- `amy geochat` remains the wire-level interop check against a real Bitchat cell.
- Drive the app: join a cell from the "+" chooser, confirm it appears in Messages,
post/receive, teleport via the map and confirm the `["t","teleport"]` tag, and
confirm the Home bubble reflects a follow's kind-1 geo-note near you.

View File

@@ -0,0 +1,184 @@
# NIP-46 Signer — device verification checklist
Everything below is behavior that JVM unit tests **cannot** exercise: interactive
consent dialogs, the foreground service, real relay traffic, deep links, and
cross-app interop. The protocol/authorization logic underneath is covered by
`quartz` (`NostrConnectSignerServiceTest`) and `commons`
(`Nip46PermissionAuthorizerTest`, `Nip46ConsentIntegrationTest`) unit tests; this
list is the manual pass that earns "first-class" on a real device.
Run as the signer on one device/account ("bunker"); use a second app/account as
the client.
## Pairing
- [ ] **Bunker flow**: Settings → Nostr Signer → turn on → scan/copy the
`bunker://` QR into a client (nsec.app, Coracle, Nostrudel, or a second
Amethyst via `amy login bunker://…`). Client resolves your npub via
`get_public_key`.
- [ ] **NostrConnect flow**: client shows a `nostrconnect://` code → "Scan a
code" on the signer screen pairs it and the signer turns on.
- [ ] **NostrConnect informed consent**: pairing a `nostrconnect://` offer that
carries `perms=` shows a connect sheet listing the app's requested
permissions (e.g. "Sign notes (kind 1)", "Decrypt messages") + a trust
picker BEFORE anything is granted. Approving pre-grants exactly those ops
(unless Paranoid); Cancel/Block declines and nothing is registered. A
re-pair of a known app skips the sheet and keeps prior decisions.
Repro: `amy login --nostrconnect --perms sign_event:1,nip44_encrypt`
prints an offer that carries exactly those perms (see CLI interop driver).
- [ ] **Global scanner**: scan a `nostrconnect://` from the profile/search
camera → lands on the signer screen and pairs.
- [ ] **Deep link**: tap a `nostrconnect://` link (web/other app) → Amethyst
opens the signer screen and pairs (cold start AND already-running).
## Note preview in the sign dialog (device-only)
- [ ] A `sign_event`/publish request renders the unsigned event as a **NoteCompose
preview** (text + media + mentions, authored by the signing account), with
the "Show event" JSON toggle still available below it.
- [ ] Works for both a NIP-46 remote app and a napplet Publish/SignEvent.
- [ ] When the main Activity is gone (app fully backgrounded, only the signer
foreground service alive → `CallSessionBridge.accountViewModel` is null),
the dialog falls back to the plain content quote + JSON without crashing.
- [ ] **Risk to watch:** NoteCompose is feed UI rendered inside a standalone
dialog Activity; if it reads a CompositionLocal only provided by the main
scaffold it could crash at runtime (compiles fine). Verify on device; if it
misbehaves, the JSON fallback path is one boolean away.
## Entry point + connected-apps management (2026-07-16)
- [ ] **Drawer entry**: the signer opens from the left drawer's "You" section, directly
under Wallet (moved out of Settings). It's also available as a bottom-bar favorite.
- [ ] **Dedicated apps screen**: "Manage connected apps" on the signer screen opens a
NIP-46-only list (name, npub, relay count, last-used, trust chip), separate from the
napplet/nsite/browser Connected Apps screen. NIP-46 apps no longer appear there.
- [ ] **Idle auto-forget**: an app left unused for 7 days is dropped on the next signer
start (its background relay subscription goes with it); an app still signing is kept.
## Comes-to-front on a request (2026-07-16)
- [ ] **Backgrounded surfacing**: with Amethyst fully backgrounded (Android 12+), a client
signing/connect request pops the consent dialog — via a full-screen-intent notification
on the high-importance "Signing requests" channel (the `startActivity` fast path is
BAL-blocked when backgrounded). On a locked screen it launches straight to the dialog;
while actively on another app it shows a heads-up prompt to tap.
- [ ] **Foreground**: with Amethyst in the foreground the dialog opens directly (no extra
notification — `SignerConsentNotifier` no-ops when `foregroundTracker.isForeground`).
- [ ] **Android 14+ caveat**: `USE_FULL_SCREEN_INTENT` is restricted for non-calling apps,
so the FSI may degrade to a heads-up rather than auto-launch — verify the prompt still
arrives and is tappable. Requires notification permission (already needed for the
always-on service).
## Consent (Tier 1)
- [ ] **First-connect trust picker**: a bunker-flow connect with a valid secret
shows the trust-level dialog (Full trust / Reasonable / Paranoid) BEFORE any
signing; choosing a level records it in Connected Apps.
- [ ] **Cancel/Block**: dismissing the connect dialog rejects the connection (no
silent grant).
- [ ] **Per-op ASK**: with a REASONABLE app, ask the client to sign a
**kind 0 / kind 3 / delete (5)** or **decrypt a DM** → the per-op dialog
appears (these are excluded from the auto-allowed set).
- [ ] **Remember variants**: "allow for this op" stops re-prompting; "session"
stops until the signer restarts; "24h/30d" expire; "deny for op" sticks.
- [ ] **PARANOID app** prompts on every request; **FULL_TRUST** never prompts.
- [ ] **Timeout**: ignore a per-op dialog for 2 minutes → the request fails
closed (deny) and the signer keeps serving later requests (not wedged).
## Anti-spam rotation (already shipped)
- [ ] "New address" → confirm dialog → old `bunker://` goes dark, connected apps
drop, QR updates; re-pairing a legit app keeps its trust level.
## Visibility (Tier 2)
- [ ] Signer screen shows "Signing as npub1…", a live "Recent activity" feed
(signed kind N / encrypted / decrypted / shared pubkey, green/red dot,
relative time), and per-app history on the Connected-App detail screen.
- [ ] The Connected-App detail screen for a remote client shows its name/url,
not a raw `nip46:` coordinate.
## Reliability (Tier 3)
- [ ] **Relay health**: kill connectivity → status shows "X of N relays
connected"; restore → "all connected".
- [ ] **Boot restart**: enable the signer, reboot the device → the foreground
service comes back and the signer answers a request without reopening the
app. (Same for an app update via `MY_PACKAGE_REPLACED`.)
- [ ] **Doze/background**: after ~30 min idle in Doze, a request still gets
serviced (may lag by a relay reconnect).
## Interop matrix
Pair + sign + nip44 encrypt/decrypt + logout against each:
- [ ] nsec.app
- [ ] Coracle
- [ ] Nostrudel
- [ ] snort / other NIP-46 client
## CLI interop driver (`amy`) — added 2026-07-17
The `cli` module (`amy`) drives the same quartz/commons code, so it plays either
side of every NIP-46 flow for reproducible interop tests without a second phone.
All of it reuses `Nip46PermissionAuthorizer.parsePerms` / `toSignerOp` — no
protocol logic in `cli`. See `amy --help` (the `Remote signing (NIP-46)` block).
Amy as the **client** (Amethyst is the signer):
- `amy login bunker://…` — pair against Amethyst's advertised `bunker://`, then
every `amy` signing verb routes through it. Surfaces `auth_url` challenges to
stderr, so it completes even when Amethyst defers consent.
- `amy login --nostrconnect [--perms sign_event:1,nip44_encrypt,…]` — mint an
offer for Amethyst to scan. `--perms` is what exercises the app's
**informed-consent** sheet (the offer carries the declared ops).
Amy as the **signer** (Amethyst, or any client, is the client):
- `amy bunker` — headless auto-approve signer for the operator's own key.
- `amy bunker --perms sign_event:1,nip44_encrypt` — restricted signer: allows
only the listed ops, **rejects** the rest. Use to test how the app-as-client
handles a signer that says no.
- `amy bunker --interactive` — keeps listening and prompts `y/N` per request on
the terminal (TTY-only, default-deny, prompts serialized). Composes with
`--perms` (auto-allow the listed ops, prompt for the rest = the "Reasonable"
policy on the CLI).
## Audit findings — known limitations (2026-07-16)
An adversarial review of the signer logic surfaced these. The head-of-line
issues below share one root cause: `authorize()`/`onConnect()` run **inline** in
`NostrConnectSignerService`'s single-consumer loop, and relay-set changes restart
that loop via `collectLatest`.
- **FIXED — unbounded first-connect prompt.** `Nip46ConsentBridge.requestConnect`
now has the same 120s `withTimeoutOrNull` as `requestOp`, so an ignored
first-connect dialog can no longer wedge the loop forever.
- **FIXED — consent no longer blocks other clients (needs on-device
validation).** The service now fans each request out into a child coroutine
under a `Semaphore(maxConcurrentHandles=16)`; dedup/staleness/rate-limit stay on
the single consumer, only `handle()` runs concurrently. So a request awaiting a
prompt no longer stalls auto-allowed traffic, and several prompts can be pending
at once. Two guards keep this safe: (1) the identity signer's crypto is
serialized by `BunkerRequestProcessor.cryptoLock` — authorization (the prompt)
runs UNLOCKED, only the sign/encrypt/decrypt holds the lock — so an external
NIP-55 app never sees concurrent IPC ops; (2) first-connect consent is
serialized by `Nip46PermissionAuthorizer.connectLock` so two connects can't stack
dialogs. Per-op prompts batch: the shared `SignerConsentCoordinator.pending`
flow drives one dialog (1 pending) or a checkbox list (>1). Covered by
`BunkerRequestProcessorConcurrencyTest`, but the on-device paths below still need
a real run:
- [ ] **Burst batching:** a client fires several dangerous-kind requests at once
→ one batched sheet with checkboxes + select-all, Allow/Deny selected,
"Remember" toggle. Approving a subset leaves the rest pending.
- [ ] **Auto-allowed keeps flowing:** while a prompt sits open, a REASONABLE
auto-allowed request from another app still gets signed and answered.
- [ ] **No concurrent external-signer ops:** with a NIP-55 external signer, two
approved requests do not drive overlapping IPC (they serialize).
- [ ] **Fail-closed on dismiss:** backing out of the batched sheet denies every
still-open request (not just the selected ones).
- **Relay-set change cancels in-flight work.** A `logout` (or a new nostrconnect
pairing) mutates the listen set → `collectLatest` restarts the service →
cancels the in-flight `handle()`. Practical impact is low (a logout ACK is lost
but the client is leaving; a pairing-time cancel makes other clients retry).
Proper fix: manage subscriptions incrementally (diff add/remove) instead of a
full restart. Deferred (same reason).
- **Low-severity, left as-is:** activity-log records an O(capacity) list copy per
serviced request (negligible under rate-limiting); the per-author rate limiter
evicts by insertion order rather than LRU (the 3-arg `accessOrder`
`LinkedHashMap` isn't in KMP commonMain); first-time transport-key/secret mint
is unsynchronized (practically serialized on the UI thread).
## Deliberately NOT changed
The always-on foreground **notification** was left as-is: it is shared with the
relay/DM always-on service, so retitling it "Signing for N apps" or deep-linking
it to the signer screen would be wrong when the service is up for another reason.
Interactive consent uses its own dedicated dialog Activity, so it needs no
notification actions.

View File

@@ -0,0 +1,201 @@
# NIP-29 group-chat subscriptions: split *state* (always-on) from *content* (paginated)
**Status:** proposed · **Date:** 2026-07-18 · **Module:** `amethyst` (+ `commons` model, reuses `commons`/`quartz` paging)
## Problem
NIP-29 relay-group ("RelayGroup") chat is served today by **six** overlapping
REQ assemblers, each keyed differently and each re-deriving the same two queries:
| Query shape | Emitted by (today) |
|---|---|
| Metadata `#d` (3900039005 + pins) | Warmup, ChannelPublic (open), MyJoinedGroups (roster subset), OnRelay (directory) |
| Content `#h` (kind 9 + poll) | MyJoinedGroups (limit 50), Warmup (limit 50), ChannelPublic-open (limit 200) |
| 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
duplication:
- **State** (metadata / roster / roles / pins) — small replaceable events →
**one always-on account subscription**, gated on the NIP-29 settings toggle.
The cache is always current; no per-screen metadata re-fetch.
- **Content** (kind 9 chat + polls) — high volume → **live tail + backward history
pager**, exactly like NIP-04 DMs and Concord channels. Gap-proof (`RelayLoadingCursors`
handles cache-prune rewind), demand-driven by the visible feed, reconnect-safe.
No message path that delivers a group message today may be dropped.
## Reused framework (do not reimplement)
Mapped end-to-end from the NIP-04 DM stack and the **Concord channel** stack, which
is the closest existing template (a public group channel already paged this way):
| Piece | Location | Role |
|---|---|---|
| `BackwardRelayPager(name, pageLimit, liveTailSeconds)` | `commons/.../relayClient/paging/` | single-active per-relay backward orchestrator |
| `RelayLoadingCursors` | `quartz/.../relay/client/paging/` | per-relay `until`/`reached`/`done` cursors + `rewindTo` (prune realign) — **one instance per group scope** |
| `WindowLoadTracker` + `trackingListener` | `commons/.../relayClient/paging/` | live-tail "all relays settled" indicator |
| `PagingStatus`, `RelayPagingProgress` | paging pkg / quartz | atomic display snapshot |
| `RelayReachCursor` / `RelayReachSentinels` / `RelayReachMarkers` | `commons/.../ui/feeds/RelayReachMarker.kt` | viewport-driven "load older" markers |
| `DmHistoryLoadingCard`, `RefreshingChatroomFeedView(olderBoundary, markersInGap, sentinels)` | `amethyst/.../chats/feed/ChatFeedView.kt` | shared feed hooks |
| `DmHistoryTuning.recentBoundary()` | `commons/.../model/privateChats/` | shared live-tail floor (7 days) |
**Direct templates to copy:**
`ConcordChannelHistorySubAssembler` + `ConcordChannelHistoryFilterAssembler` +
`ConcordChannelHistorySubscription` + `ConcordChannelScreen`'s
`ConcordBackfillHistoryToWindow`; and `ChatroomNip04SubAssembler` (live tail) /
`ConcordChannelFilterAssembler` (batched always-on live).
## Target architecture
Four concerns, mirroring the DM stack (rooms-list tail + per-conversation tail +
per-conversation history) plus a groups-only always-on state sub.
1. **`RelayGroupStateSubAssembler`** — *always-on*, account-keyed.
Roster `#d` (39000/39001/39002/39003/39005) batched one filter per host relay
across the joined set. Keeps `since` (tiny replaceable events; reconnect just
re-confirms). Mounted at `LoggedInPage` (like `AccountFilterAssemblerSubscription`),
gated on `ChatFeedType.NIP29`. **This is today's `RelayGroupMyJoinedGroups` roster
path, promoted to always-on and stripped of content.**
2. **`RelayGroupPreviewTailSubAssembler`** — *always-on*, account-keyed, batched.
Content `#h` (kind 9 + poll) across **all** `liveRelayGroupList` group ids,
`since = recentBoundary()`, **no per-group limit** (a time floor bounds it, so it
batches into one filter per relay). `WindowLoadTracker`. Drives Messages-list
previews and keeps joined groups' recent chat live app-wide. **Replaces
`RelayGroupMyJoinedGroups` content path (A).** Batching + time-floor `since`
eliminates both the per-group-`since` bug and the reconnect re-download.
3. **`RelayGroupChatTailSubAssembler`** — per-open-`GroupId`, live tail for the
*currently open* group: content `#h` (9 + poll), `since = recentBoundary()`, host
relay. Covers recent + live updates for **any** open group, **including non-joined**
groups opened by link (which the batched preview tail — joined-only — doesn't cover).
Mirrors the DM per-conversation live tail.
4. **`RelayGroupChatHistorySubAssembler`** — per-open-`GroupId`, `BackwardRelayPager`
(`liveTailSeconds` = 7d floor; the tails cover above it), cursors on
`RelayGroupChannel.history`, content `#h` (9 + poll, **all authors**) `until`+`limit`
on the host relay. Demand-driven by the feed markers; eager `advanceAll()` backfill
to a window target on open. **Replaces ChannelPublic-open content (C) and
ChannelFromUser (D).** All-authors, so it re-materializes my own history too.
`ChannelFeedFilter` is unchanged — it reads `channel.notes`, so every path that fills
the cache surfaces. (It has **no `limit()`**, so it already renders whatever is cached.)
## Message-coverage proof (can't-miss-messages checklist)
Every current content-delivery path and what covers it after:
| Path (today) | Kinds / scope | After |
|---|---|---|
| **A** MyJoined content (50) | 9,poll `#h` joined | **Preview tail (batched `#h`, since=window)** for previews + **chat tail** when open |
| **B** Warmup content (50) | 9,poll,11,1111 `#h` card | **KEEP** — non-joined cards/discovery aren't in the joined tail (screen-dependent, per design) |
| **C** ChannelPublic-open content (200) | 9,poll `#h` open | **Chat tail (recent) + history pager (older, gap-proof)** |
| **D** ChannelFromUser (`authors=me`) | 9,poll `#h` me | **History pager (all-authors) + tail + optimistic-send attach + host echo** → redundant |
| **E** ThreadFeed | 11,1111 `#h` | **KEEP** (Threads screen; separate `threadNotes` feed). Pager adoption is a follow-up. |
| **F** Notifications | 7,9,1111,1068,… `#h`+`#p=me` | **KEEP** — always-on, p-tags-me; unchanged bonus |
| §3 by-id (`filterMissingEvents`) | ids | **KEEP** — quotes/replies/mentions; **+ serving-relay fix below** |
| §3 pinned by-id backfill | ids `filterMetadataToRelayGroup` | **KEEP** (host relay; older-than-window pins) |
| §3 replies/reactions `#e/#q` | 1111 etc. | **KEEP** — comments never attach to timeline (by design) |
**Serving-relay keying hazard (real, pre-existing — fix as part of "can't miss").**
`attachToRelayGroupIfScoped` keys the channel by `GroupId(groupId, servingRelay)`.
All subscriptions here are host-pinned, so they're safe. But `filterMissingEvents`
can deliver a referenced group message from a **non-host** relay, filing it under a
different channel object than the host-keyed one the UI reads → cached but invisible.
Fix: when attaching a group-scoped content event, if exactly one existing
`RelayGroupChannel` carries that `groupId` (the joined/host one), attach there
instead of minting a `(groupId, servingRelay)` channel — reusing the existing
`singleOrNull`-by-groupId resolution already used for the `relay == null` optimistic
branch. Ambiguous ids (the relay-wide `_` group joined on several relays) keep
serving-relay keying.
## File-by-file changes
**New (`commons` model):**
- `RelayGroupChannel`: add `val history = RelayLoadingCursors()` (mirror `ConcordChannel.history`).
**New (`amethyst` datasource, per templates):**
- `RelayGroupStateFilterAssembler` (+ SubAssembler) — always-on roster.
- `RelayGroupPreviewTailFilterAssembler` (+ SubAssembler) — batched preview tail.
- `RelayGroupChatTailFilterAssembler` (+ SubAssembler) — per-open live tail.
- `RelayGroupChatHistoryFilterAssembler` (+ SubAssembler) — per-open history pager.
- Subscription composables for each (`*Subscription`), copying the Concord ones.
**Modified:**
- `RelaySubscriptionsCoordinator`: register the four new assemblers; drop the retired ones (see below).
- `LoggedInPage`: mount `RelayGroupStateSubscription` + `RelayGroupPreviewTailSubscription` (always-on, gated).
- `RelayGroupChannelView`: mount chat-tail + history subscriptions; wire
`RefreshingChatroomFeedView(olderBoundary, markersInGap, sentinels)` + a
`BackfillHistoryToWindow` (copy `ConcordBackfillHistoryToWindow`).
- `MessagesSinglePane`/`MessagesTwoPane`: drop `RelayGroupMyJoinedGroupsSubscription`
(its roster role moves to the always-on state sub; previews come from the tail).
- `LocalCache.attachToRelayGroupIfScoped`: host-relay normalization (serving-relay fix).
- `AccountViewModel.dataSources()`: expose the four new assemblers; remove retired handles.
**Retired:**
- `RelayGroupMyJoinedGroupsFilterAssembler` **content path** → deleted; the file's
roster role becomes `RelayGroupStateFilterAssembler` (rename/replace).
- `ChannelPublicFilterSubAssembler` **`RelayGroupChannel` branch** (`filterMessagesToRelayGroup`
+ `filterMetadataToRelayGroup`) → removed; metadata now always-on, content now tail+pager.
(Keep `filterMetadataToRelayGroup`'s **pinned-id backfill** — re-home it on the chat-tail or a
small pin sub so older-than-window pins still resolve.)
- `ChannelFromUserFilterSubAssembler` **`RelayGroupChannel` branch** (`filterMyMessagesToRelayGroup`) → removed.
- Keep `RelayGroupWarmup*` (non-joined cards), `RelayGroupsOnRelay*` (directory),
`RelayGroupsDiscovery*` (discover feed), `RelayGroupThreadFeed*` (threads), and the
notifications path unchanged.
## Rollout order (additive first, retire last — never a window where messages drop)
1. **Additive, no removals:** add `RelayGroupChannel.history`; add the four new
assemblers + subscriptions + coordinator/dataSources handles + `LoggedInPage`
and `RelayGroupChannelView` wiring. New content now flows through tail+pager
**alongside** the old A/C/D (harmless dedup by id). Compile + smoke.
2. **Serving-relay normalization** in `LocalCache` (independent correctness fix).
3. **Retire** A-content, C-relay-group-branch, D-relay-group-branch; move roster to
the always-on state sub; drop the Messages-pane `MyJoinedGroups` mount. Compile.
4. Re-home the pinned-id backfill; delete now-dead code; `spotlessApply`; full suite.
## Edge cases
- **Quiet group** (newest message older than the 7-day tail): won't appear in the
preview tail; its Messages row falls back to cached / placeholder (same as NIP-04).
Opening it → the history pager's eager backfill loads it. Optional: a one-shot
newest-1 per quiet joined group in the state sub's initial snapshot.
- **Non-joined open group:** covered by the per-open chat tail + history pager
(both per-`GroupId`, no joined-list dependency).
- **Reconnect:** tails carry `since=recentBoundary()` (time floor, shared-safe,
incremental); history is `until`-based (position, not reconnect-sensitive);
`FiltersChanged` already ignores `since`-only changes → no full replay.
- **Threads:** unchanged this pass; a follow-up can point `RelayGroupThreadFeed` at
a second `BackwardRelayPager` on `RelayGroupChannel` for kind 11/1111.
## Testing
- Unit: preview-tail filter batches one `#h` filter per relay with
`since=recentBoundary()` and no per-group limit; history filter emits only for
armed relays at their `requestedUntil`; state filter emits roster `#d` per relay.
- Cursor behavior is already covered by `RelayLoadingCursors` tests (reused).
- Manual (amy / device): join a group after session start → open → history backfills;
scroll up past the window → older pages load; reconnect → no full re-download;
quote a group message from a non-host relay → it appears in the group.

View File

@@ -0,0 +1,177 @@
# NIP-29 group-chat loading — test plan (per screen × per assembler)
**For:** an AI validating branch `claude/nip29-group-load-perf-wz4yca` before trusting the
state-vs-content refactor (see `2026-07-18-nip29-group-chat-subscriptions.md`).
**Question this answers:** *does the correct data load on every screen, and can we ever miss a message?*
**Implemented on this branch (all headless-runnable tiers):**
- **Tier B — filter shapes, every assembler.**
- Assemblers 16 + card-warmup joined-skip + reconnect stability (`needsToResendRequest`) + directory: `amethyst/src/test/.../relayGroup/datasource/RelayGroupFilterBuildersTest.kt` (tests the pure `RelayGroupFilterBuilders.kt` the assemblers now delegate to).
- #9 ChannelPublic relay-group branch (state+pins, no message window): `.../publicChannels/datasource/subassemblies/FilterRelayGroupStateTest.kt`.
- #10 group notifications (`#p`+`#h`): `.../service/relayClient/reqCommand/account/nip01Notifications/FilterGroupNotificationsToPubkeyTest.kt`.
- #8 discovery `#p` roster augmentation: `.../relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthorsTest.kt`.
- **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 |
| **Screen loading** (mount → subscribe → feed renders); UI marker/sentinel→`advance`; always-on mounting; backfill-to-window loop | **Android emulator/device** | the amethyst wiring end-to-end |
The amethyst *assemblers* are Android-module, so `amy` cannot invoke them directly — it validates the
**framework + filter shapes + ingest** they depend on. The **screen** rows below therefore have a
headless part (unit + amy) and a device part; do both, and mark any device row you couldn't run.
## The assemblers under test (all of them)
| # | Assembler | Mounts on | Must load |
|---|---|---|---|
| 1 | `RelayGroupJoinedState` (always-on) | LoggedInPage → every screen | joined groups' 39000/1/2/3/5 → name, roster, roles, pins, my membership |
| 2 | `RelayGroupJoinedChatTail` (always-on) | LoggedInPage → Messages | joined groups' recent chat (`#h` since=window) → true newest-message previews |
| 3 | `RelayGroupOpenChatTail` | open group chat screen | the open group's recent chat + live (incl. **non-joined**) |
| 4 | `RelayGroupOpenChatHistory` | open group chat screen | older chat on demand (`#h` until+limit), gap-proof |
| 5 | `RelayGroupOpenThreads` | Threads tab | kind-11/1111 threads |
| 6 | `RelayGroupCardWarmup` | discovery cards, relay channel-list, members/metadata/parent screens | a **non-joined** card's metadata + preview; **skips joined** groups |
| 7 | `RelayGroupsOnRelay` | relay channel-list, subgroups bar, parent picker | a host relay's whole group directory |
| 8 | `RelayGroupsDiscovery` | Discovery screen | cross-relay discovery feed (by follows / global) |
| 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".
# Seed a group G on the relay:
amy relaygroup create --relay $RELAY --gid G --name "Test" ... # 39000/39001/39002
# Seed chat spanning the live-tail boundary (7d): messages older AND newer than now-7d.
for t in <timestamps old→new>; do amy publish --relay $RELAY --kind 9 --tag h=G --created-at $t "msg $t"; done
# Seed: kind-11 thread + kind-1111 reply (h=G); a pinned kind-9 older than 7d + 39005 pin list;
# one kind-9 that p-tags "me"; a SECOND group G2 on the same relay (batching); a group on a
# second relay R2 (multi-relay); a group with <LIMIT total messages (small-group path).
```
Discover exact flags with `amy <verb> --help` (`fetch`/`subscribe`/`publish`/`relaygroup`).
`amy fetch --json` gives machine-checkable output for assertions.
---
## Tier A — baseline (must stay green)
```bash
./gradlew :amethyst:compilePlayDebugKotlin
./gradlew :quartz:jvmTest :commons:jvmTest :amethyst:testPlayDebugUnitTest :cli:test
./gradlew spotlessCheck
```
## 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`).
- **5 OpenThreads:** `#h`, kinds [11,1111], host relay.
- **6 CardWarmup:** a **joined** group ⇒ `emptyList()`; a **non-joined** group ⇒ metadata (unless contentOnly) + `#h` content (9,poll,11,1111) `limit`.
- **7 OnRelay:** directory `#`-less filter, kinds 39000-39003, `limit 500`, that relay.
- **8 Discovery:** by-follows + host-relay `#p` roster augmentation (see `RelayGroupsDiscoverySubAssembler`); global variant.
- **9 ChannelPublic relay-group branch:** returns **only** `filterRelayGroupState` (metadata + pin ids) — **no** message-window filter.
- **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.
## Tier C — `amy` + relay integration (framework, ingest, can't-miss)
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)
- **C7 Notifications:** `--kind 9 --tag h=G --tag p=<me>` returns the me-tagged message. (screen-10)
- **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`).
Watch logcat: `adb logcat | grep -E "DMPagination|relayGroup"`.
For **each screen**, the pass criteria:
- **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.
- **D7 Members / Metadata screens (6/1):** roster + roles render.
- **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**.
- **short page** (returns < `limit` though more exist) NOT exhaustion (only an *empty* page ends a relay).
- **echoes the same newest events every page** `RelayLoadingCursors.onEose` "not strictly older done" fires.
- out-of-order / duplicate events cursor takes `min(createdAt)`; dedup by id.
- EOSE before any event, or **no EOSE at all** `WindowLoadTracker` idle/abs-cap; pager silence watchdog `stalled`.
- **AUTH-required CLOSED("auth-required")** relay `stalled`-but-kept; re-`advance` retries; **not silently dropped**.
- mid-stream socket drop resubscribe with `since`/`until`, **no full replay, no gap** (`rewindTo` on prune).
- relay result cap below `limit` treated like a short page.
`UntilLimitPagingRelayTest` + `BackwardRelayPagerTest` already cover the empty-page / until-limit-walk /
echo-newest cases for the **generic** pager. E1 is to (a) re-run them against the NIP-29 `#h` filter
shapes and (b) add the not-yet-covered faults (ignore-since, out-of-order, AUTH-CLOSE, silence, reorder).
### E2 — conformance against REAL NIP-29 relay implementations (surfaces THEIR bugs)
geode / strfry / nostr-rs-relay are **generic** (store+serve by tag; no NIP-29 semantics), so they can't
surface a real NIP-29 relay's bugs. Point the harness (reuse relayBench's `RelayUnderTest` adapter) at an
actual NIP-29 relay (e.g. relay29, chorus, a self-hosted groups relay) in a **container**, seed a group
via `amy relaygroup`, and run C1C9 + E1's corpus. A failure is a **relay** bug or a client/relay
mismatch a NIP-29 conformance report the operator can act on. Cover the behaviors only a real NIP-29
relay has:
- **AUTH gating** on closed/private groups (39002 membership): does the client AUTH and then receive the `#h` timeline?
- **relay-signed 39xxx** (the relay's own key) the `isRelaySignedGroupEvent` gate.
- **`previous`-tag fork rejection** on send (a relay rejecting an event whose `previous` refs it doesn't recognise).
- the relay's actual **`since`/`until` inclusivity** and **result caps / rate limits** on the `#h` timeline.
Public relays are non-deterministic (live data): use a containerized instance for CI, a public one only
for exploratory runs. **E1 hardens our client against buggy relays; E2 tells us which third-party relay
is buggy** both are needed before trusting "loads correctly" in the wild.
## Cross-cutting invariants (assert throughout)
- **No missed messages:** the union of tail + history + pins + notifications = the full timeline; C3 is the decisive test. Also exercise `RelayLoadingCursors.rewindTo` trim the cache below the window, page again, confirm the pruned band re-loads.
- **No double-download on reconnect** (C9/D8).
- **Retirement left no gap:** with `RelayGroupMyJoinedGroups` deleted and `ChannelPublic`/`ChannelFromUser` relay-group content removed, D1/D2/D3 still load proving the tail+pager replaced them.
- **CardWarmup joined-skip** (Tier B #6 / D5): a joined card issues no warmup REQ.
- **Serving-relay hazard (FIXED):** a group message fetched from a **non-host** relay (e.g. `filterMissingEvents` quote resolution) used to be filed under `GroupId(G, otherR)` and lost. `LocalCache.attachToRelayGroupIfScoped`/`attachThreadToRelayGroupIfScoped` now redirect a stray (no channel for the serving relay) to the group's single confirmed **host** channel via `redirectStrayRelayGroupContent`, keyed off `RelayGroupChannel.hasRelaySignedState()` (a phantom never has relay-signed state, so the redirect only ever lands on a real host strictly safe, the common host-pinned path is an untouched O(1) fast path). Covered by `RelayGroupContentRoutingTest`. **Device-untested:** the pure router + channel signal are unit-tested; the LocalCache wiring is a guarded fast-path/slow-path swap that still needs a device pass (Tier D) to confirm end-to-end.
## Exit criteria
- Tier A green; Tier B all assertions pass; Tier C1C9 pass (esp. **C3**).
- Tier D1D9 pass on device, or each unrun row is explicitly flagged for a human.
- Known-failing by design until follow-ups: the serving-relay hazard row and (if unadopted) a threads pager.

View File

@@ -0,0 +1,190 @@
# v1.13.0 release QA — coverage, open findings, and recipes
One extended testing session against v1.13.0 (~2011 commits since v1.12.6). Fixes landed on
`fix/napplet-account-isolation-and-consent` (30 commits); each commit message carries its own
root-cause reasoning and is the better reference for *why* a given change looks the way it does.
This document records what that session **could not** capture in commit messages: what was actually
exercised, what was not, what we chose to leave broken, and how to reproduce the setups.
**Device under test:** Samsung SM-T220 tablet, Android 14, `sw600dp`, `play`/`benchmark`, arm64.
Everything below is that one configuration unless stated.
---
## 1. Coverage
### Exercised on device
| Area | Notes |
|---|---|
| Upgrade path | install over a month-old build; migrations survived, ~880 ms cold start |
| Napplet / web app per-account isolation | leak found and fixed; each account now has its own jar |
| Embedded tab rebuild on account switch | blank-tab bug found and fixed; verified both directions + a forced-failure A/B |
| Launch-account signing binding | desync reproduced end to end, then verified fixed |
| NIP-46 remote signer | 13 checks, all passing (pairing gate, 22242 prompt, decrypt counterparty/plaintext/narrow grant/scoping) |
| Concord | invite consent gate, private/voice rename, role rank gate, revoke gate |
| NIP-29 relay groups | directory browse (crash found), naddr deep-link join, membership resolution |
| Breadth sweep | Messages, NIP-29, Git, Podcasts, Blossom list, Location, Theming |
| Location | map picker + teleport, before/after on 4 symptoms, composer path |
| Notifications | tab showed ~3 items; root-caused to a `since` deadlock and fixed — feed now scrolls back 16 months |
| Concord role grants | picker built and device-verified (rank gating, preselection, survives the fold) |
### Fixed but **only unit-verified** — never run on a device
Concord rollback floor · stranded recovery · member-set gap · invite expiry · moderation head ·
**chain-poisoning fix** · community-list unknown-key preservation · V4V fee clamp · Cashu SSRF
validator · Blossom 402 (cap / re-prompt / double-spend / `X-Reason`) · amountless-invoice display ·
**napplet consent diff dialog** (never seen rendered) · connect-dialog capability disclosure ·
`identity.watch` DENY · DM ciphertext previews and the `User` metadata race · chat date separators ·
podcast duplicate description · Concord leave affordance · the three revocation wirings.
### Never opened at all
Nests / audio rooms (`quic` + MoQ) · Marmot / MLS · **the entire Desktop app** (where Privacy Lock
actually ships) · Blossom "Sync all" (skipped deliberately — uploads to real servers) · podcast
chapters / transcripts / credits · Git branch switching · Messages live-typing and per-type toggles ·
real payments (zaps, V4V streaming, Cashu redeem) · push notifications · search · Calendar, Chess,
Polls, Marketplace, Workouts, Badges, Follow Packs, Emojis, HLS Upload, App Store, Live Streams.
NIP-29 admin: the menu is reachable and renders, but Edit metadata, invite creation, subgroups and
pinned messages were never exercised.
### Platform gaps
- **Android 14 only.** `targetSdk` is 37; Android 15+ forces edge-to-edge and that path is untested.
An emulator makes this cheap and it is the highest-value remaining gap.
- **Tablet only** — no phone layout. **`play` only** — no fdroid. **`benchmark` only** — the real
`release` (full R8) has never been built or run. **arm64 only.**
- **Amber / NIP-55** external signer never tested, including a known decrypt double-prompt risk.
- **Tor-on paths** — Tor was disabled for untrusted relays mid-session and not restored.
---
## 2. Open findings (known, deliberately not fixed)
**Release mechanics**
- `appCode` still `454` and `app` still `1.12.6` — Play hard-rejects a duplicate versionCode.
- Firebase `TransportRuntime` cannot schedule (`JobInfoSchedulerService` missing from the merged
manifest). If this reproduces in `release`, **Crashlytics delivery is broken** and the release
ships blind.
**Correctness / UX**
- Tor settings do not take effect until app restart, with no indication.
- An unreachable relay is reported as "No groups on this relay yet" — indistinguishable from empty.
- NIP-29: a stale "Requested" join state is never reconciled against an arriving 39002 roster.
- Concord: leaving does not unpin from the bottom bar, leaving a dead tab.
- Read-only accounts render nothing for a kind:4 chatroom body (better than ciphertext, still wrong).
- Modal geohash picker header is overdrawn by the MapView (pre-existing).
- `amy relaygroup create` reports success on relays that silently reject it — always verify with `info`.
- `amy login bunker://…` hangs and never delivers a `connect`.
**Security / protocol**
- NIP-46 "Generate a new address" claims to disconnect every app; it rotates the transport key and
revokes nothing.
- WebView storage profiles are never deleted on logout — a removed account's cookies persist.
Requires a broker message so `:napplet` can call `ProfileStore.deleteProfile`.
- Control-plane *edit* paths (`editConcordMetadata`, `grant`, channel edits) still drop unknown JSON
keys; only the community list was fixed.
- **CORD-05: `community_id` does not commit to `community_root`**, so a crafted invite can carry a
real community's identity with an attacker's root. **Armada has the identical gap** — this needs a
spec conversation, not a unilateral fix.
- **CORD-04: the BANLIST is not rank-gated, so any BAN holder can ban anyone — including the owner.**
Role/grant editions are rank-gated (`canActOn`), but a banlist edition is a single *whole-list*
entity, so no client rank-checks its contents; the gate is the author's BAN bit alone. A rank-5
moderator's ban of a rank-1 admin is therefore **accepted** by the fold, and the admin then loses
every permission (`hasPermission` is `!isBanned && …`). **Armada has the identical gap** — its
`banlistGate` calls the rank-blind `isAuthorized(.., Permissions.BAN)` while its role path uses
the rank-aware `canActOnPosition`.
**This is a conformance bug, NOT a spec gap** — an earlier note here said the opposite and was
wrong. CORD-04 §3 is explicit and normative: "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 step 3 of §5. Only §4, the section that defines the
Banlist, omits the rank half — and both independent implementations read §4 in isolation and made
the same mistake. Spec: <https://github.com/concord-protocol/concord> (`04.md`).
**FIXED and shipping**`AuthorityResolver` now enforces §3 as a *delta rule* (an edition may only
add/remove npubs its signer strictly outranks; the owner is never a valid target; unpermitted
entries are ignored rather than rejecting the edition, so a bulk-ban survives). The UI and the
ban/unban write path route through it too — `ConcordModeration.currentBanned` now reads the
*honored* banlist via the resolver instead of decoding the raw head, which also closes a
laundering path where our own next ban would re-publish an unauthorized entry under our signature.
**Known consequence: Armada has not shipped this, so banlists can differ between clients**
we ignore a ban Armada honors when the signer did not outrank the target. Deliberate.
Write-up to send upstream: `docs/concord-banlist-rank-conformance.md`.
Still open, both covered in the write-up: a banned member holding BAN can lift their own ban (the
gate reads role-derived permissions, so bans do not stick against any BAN holder — this one is a
genuine fixpoint-ordering question and needs a spec ruling), and a forked ban survives an unban
that does not chain onto it.
- Notification cards whose target note isn't in `LocalCache` render "Event is loading or can't be
found in your relay list" (seen on old zaps). `tagsAnEventByUser` needs the reacted-to note
loaded, so deep history stays partially unresolved. Cosmetic, pre-existing.
---
## 3. Setup recipes
**`amy` with an isolated identity** (never touch the maintainer's real one):
```
AMY=$(pwd)/cli/build/install/amy/bin/amy
H=/tmp/qa-home; mkdir -p $H
HOME=$H $AMY --account qa login <nsec> --secret-backend plaintext
```
`amy` scopes by `$HOME`, not a flag. `init` prompts for a passphrase and hangs without a TTY — use
`--secret-backend plaintext` for throwaway identities.
**NIP-29 test group.** `relaygroup create` on `communities.nos.social` and `relay.groups.nip29.com`
returned success but published nothing; `groups.0xchat.com` worked. Always confirm with
`relaygroup info`. To reach a group in a 1000+ entry directory, skip the UI and deep-link:
`adb shell am start -a android.intent.action.VIEW -d "nostr:<naddr>"`, encoded via
`amy encode naddr --pubkey <relay-nip11-pubkey> --kind 39000 --identifier <groupId> --relay <url>`.
To make a device account an admin, have it join first, read its pubkey from `relaygroup info`, then
`put-user … --role admin`.
**Proving "no network before consent."** Run a local relay (`amy serve`), expose it with
`adb reverse tcp:7777`, and make it the *only* relay in the artefact under test. Count events before
and after the user action — that turns "I didn't see traffic" into an actual measurement.
---
## 4. Patterns worth acting on
These recurred often enough to be process problems rather than individual bugs.
**Tests that assert the bug.** At least five encoded the buggy behaviour as intended — a NIP-46 test
named `getPublicKeyReturnsUserPubKeyWithoutAuthorization`, a V4V invariant only ever run on
well-formed input, a napplet session test that never crossed accounts. *Always verify a new
regression test fails without the fix* — and beware that **Gradle will serve a stale up-to-date
`jvmTest` and report BUILD SUCCESSFUL**, which makes that check silently lie. Use `--rerun-tasks`.
**Implemented-but-unreachable capabilities.** Five found: `leaveConcordCommunity`,
`ConcordInviteBundle.isExpired`, `NappletPermissionLedger.endSession`, `grantConcordRole`, and
`NappletBroker.revokeSessionGrants`. Each made a feature look complete to anyone reading the model
while being unreachable to users, and the first one actually invoked turned out to be **broken as
written**. A lint for "public capability with no caller outside its declaring file" would catch the
whole class cheaply.
**A narrow query window can deadlock against its own paging.** The Notifications tab asked relays
for 7 days, and its backward-paging fallback only armed once the feed held a *full page* — so a
quiet inbox could never fill a page, and therefore never widened the window. The EOSE `since` map
is in-memory, so every cold start re-pinned it. Look for this shape wherever a "load more" boundary
is gated on a full page: the empty state is self-sustaining. Note also that the relay-side `limit`
already bounds these queries, which is what makes dropping the time floor safe.
**Hypotheses need measurement, not plausibility.** Four confident diagnoses were wrong: the "npub in
title" bug was a `User` lazy-init data race, not a display bug; chat date separators were a
`reverseLayout` misconception, not bubble grouping; the map picker had no tile problem at all; and
NIP-29 membership was a *relay rejecting the REQ* (`blocked: it's not allowed to mix metadata kinds
with others`), not membership modelling. Instrument first.
**Check the reference implementation.** Reading Armada changed the answer three times out of three —
it corrected an owner-rotation rule that would have stranded owners, stopped an invite-binding "fix"
that was both interop-breaking and ineffective, and supplied the chain-poisoning design (gate *after*
folding, not before). Armada is **AGPLv3** and Amethyst is MIT: read for semantics, copy nothing.
**Comments encoding constraints are load-bearing.** The synchronous SharedPreferences read looks like
an obvious StrictMode fix; its comment records that an async hydrate reopens a settings-clobber race.
Removing it would have been a confident, review-passing regression.
**Beware concurrent agents and `git add -A`.** Two commits were contaminated, and one silently
committed another worker's temporary revert. Stage explicit paths, always.

36
amethyst/plans/README.md Normal file
View File

@@ -0,0 +1,36 @@
# amethyst plans
_Audited 2026-06-30. 21 plans: 19 shipped (archived), 1 in-progress, 1 queued, 0 abandoned._
## In progress
| Plan | Summary |
| ---- | ------- |
| [2026-05-24-ios-support.md](2026-05-24-ios-support.md) | Incremental KMP-to-iOS port; quartz/commons iOS targets are configured (Phase 1) but no `iosApp` module exists yet. |
## Queued
| Plan | Summary |
| ---- | ------- |
| [2026-06-20-napplet-inter-applet.md](2026-06-20-napplet-inter-applet.md) | NAP-INC / NAP-INTENT inter-applet messaging — deferred; prerequisites (multi-applet hosting, archetype registry, `MESSAGING` capability) not yet built. |
## Archived (shipped)
| Plan | Summary |
| ---- | ------- |
| [archive/2026-05-14-onchain-zaps.md](archive/2026-05-14-onchain-zaps.md) | NIP-BC (kind 8333) onchain Bitcoin zaps — hand-rolled `quartz/nipBCOnchainZaps/` consensus layer plus Android send/receive/display. |
| [archive/2026-05-25-appfunctions-signer-prompts.md](archive/2026-05-25-appfunctions-signer-prompts.md) | How AppFunctions write verbs acquire signatures across the three signer types; write verbs now ship. |
| [archive/2026-05-26-appfunctions-gemini-discovery.md](archive/2026-05-26-appfunctions-gemini-discovery.md) | Verifying Gemini-side discovery of Amethyst's AppFunctions; description-based (`isDescribedByKDoc`) discovery shipped. |
| [archive/2026-05-26-appfunctions-screens-as-verbs.md](archive/2026-05-26-appfunctions-screens-as-verbs.md) | Map every Amethyst screen's feed filter to an AppFunction/MCP verb; 46 verbs now ship. |
| [archive/2026-05-26-avif-implementation-plan.md](archive/2026-05-26-avif-implementation-plan.md) | Task-by-task plan for AVIF support via a `MediaMimeTypes` helper across the upload pipeline. |
| [archive/2026-05-26-avif-support.md](archive/2026-05-26-avif-support.md) | Comprehensive design making AVIF a first-class image format on every Amethyst surface. |
| [archive/2026-05-27-avif-instrumented-tests-design.md](archive/2026-05-27-avif-instrumented-tests-design.md) | Design for on-device AVIF upload-pipeline regression tests with committed fixtures. |
| [archive/2026-05-27-avif-instrumented-tests-plan.md](archive/2026-05-27-avif-instrumented-tests-plan.md) | Implementation plan for the AVIF instrumented + JVM unit tests and their fixtures. |
| [archive/2026-06-01-dm-live-tail-and-history-slices.md](archive/2026-06-01-dm-live-tail-and-history-slices.md) | DM loading split into a fixed live tail plus per-relay backward history paging (`WindowLoadTracker` / `RelayLoadingCursors`). |
| [archive/2026-06-19-napplet-sandbox-host.md](archive/2026-06-19-napplet-sandbox-host.md) | Keyless `:napplet`-process WebView host with brokered, consent-gated capabilities for NIP-5A/5D content. |
| [archive/2026-06-20-napplet-ecosystem-audit.md](archive/2026-06-20-napplet-ecosystem-audit.md) | Audit of our napplet shell against the upstream `@napplet` SDK; wire-compat gaps subsequently closed. |
| [archive/2026-06-21-napplet-code-audit.md](archive/2026-06-21-napplet-code-audit.md) | Code audit of the napplet subsystem recording correctness/perf/refactor fixes and deferred items. |
| [archive/2026-06-21-napplet-sdk-conformance-audit.md](archive/2026-06-21-napplet-sdk-conformance-audit.md) | Feature-by-feature SDK conformance audit; the four conformance breakers were fixed and pinned by tests. |
| [archive/2026-06-22-napplet-nsite-security.md](archive/2026-06-22-napplet-nsite-security.md) | Security review of the nsite/napplet attack surface; launch-token identity and per-applet origins landed. |
| [archive/2026-06-23-napplet-nap-theme-notify-inc.md](archive/2026-06-23-napplet-nap-theme-notify-inc.md) | Add the `theme`, `notify`, and `inc` NAP domains so demo napplets boot; capabilities + `NappletIncBus` shipped. |
| [archive/2026-06-24-napplet-embedded-tabs.md](archive/2026-06-24-napplet-embedded-tabs.md) | Embedded warm bottom-bar napplet/nsite/browser tabs via `SurfaceControlViewHost`, in the new `:nappletHost` module. |
| [archive/2026-06-25-embed-text-selection-native-parity.md](archive/2026-06-25-embed-text-selection-native-parity.md) | Host-drawn text selection (handles, magnifier, toolbar, IME proxy) for embedded sandboxed surfaces. |
| [archive/2026-06-25-web-app-naming.md](archive/2026-06-25-web-app-naming.md) | Naming overhaul for web-app / nApplet / nSite / favorite surfaces (route, screen, controller renames). |
| [archive/2026-06-26-nsite-napplet-favorite-icons.md](archive/2026-06-26-nsite-napplet-favorite-icons.md) | Derive favorited nSite/nApplet bottom-nav icons from verified manifest blobs (`NappletIconPath`). |

View File

@@ -1,5 +1,8 @@
# Onchain Zaps in Amethyst
> **Status:** shipped — Full `quartz/nipBCOnchainZaps/` consensus + builder + verify layer ships with Android model/UI wiring (`OnchainZapResolver`, `OnchainZapEvent` view, `ReusableZapButton`).
> _Audited 2026-06-30._
**Date:** 2026-05-14
**Status:** Active

View File

@@ -1,5 +1,8 @@
# AppFunctions signer prompts — design
> **Status:** shipped — `AmethystAppFunctions.kt` ships 46 `@AppFunction` verbs including write verbs that acquire signatures across signer types.
> _Audited 2026-06-30._
**Date:** 2026-05-25
**Status:** Draft — no code yet

View File

@@ -1,5 +1,8 @@
# Verifying Gemini-side AppFunctions discovery
> **Status:** shipped — Verification doc; verbs ship with `@AppFunction(isDescribedByKDoc = true)` description-based discovery as the doc concludes.
> _Audited 2026-06-30._
**Date:** 2026-05-26
**Status:** Active — answers the open question from
`2026-05-25-appfunctions-signer-prompts.md`

View File

@@ -1,5 +1,8 @@
# All Amethyst screens as AppFunctions / MCP endpoints
> **Status:** shipped — 46 `@AppFunction` verbs ship in `AmethystAppFunctions.kt`, exceeding the screen-to-verb surface this plan proposed.
> _Audited 2026-06-30._
**Date:** 2026-05-26
**Status:** Active — informs the v1 read-verb surface and guides
future MCP work

View File

@@ -1,5 +1,8 @@
# AVIF Support Implementation Plan
> **Status:** shipped — `service/uploads/MediaMimeTypes.kt` and the rest of the AVIF upload pipeline are present in `amethyst/`.
> _Audited 2026-06-30._
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make AVIF (still and animated) work as a first-class image format in Amethyst across every user-visible surface — uploads (Blossom + NIP-96), feeds, profiles, DMs, emoji packs, reactions, gallery, and caching — with graceful no-crash fallback on Android API < 31.

View File

@@ -1,5 +1,8 @@
# AVIF support — comprehensive design
> **Status:** shipped — AVIF upload/display support landed — `MediaMimeTypes.kt` plus the compressor/stripper/preview changes are in tree.
> _Audited 2026-06-30._
**Issue:** [vitorpamplona/amethyst#837](https://github.com/vitorpamplona/amethyst/issues/837)
**Date:** 2026-05-26
**Branch:** `feat/avif-support` (based on `d1610bf97`, origin/main = upstream/main)

View File

@@ -1,5 +1,8 @@
# Design — AVIF instrumented tests
> **Status:** shipped — The designed test files and AVIF fixtures exist under `amethyst/src/androidTest/` and `src/test/`.
> _Audited 2026-06-30._
**Date:** 2026-05-27
**Status:** Design (pre-implementation)
**Owning module:** `amethyst/`

View File

@@ -1,5 +1,8 @@
# AVIF Instrumented Tests — Implementation Plan
> **Status:** shipped — `AvifUploadPipelineInstrumentedTest.kt`, `MediaMimeTypesTest.kt`, and the three `.avif` fixtures are committed.
> _Audited 2026-06-30._
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add an Android on-device test layer that catches regressions in the AVIF upload pipeline and the Phase E bugs fixed manually (`C2``C8` plus follow-ups in commits `df84475d4`, `5a760c046`, `b9112550b`, `1db110cdf`).

View File

@@ -1,5 +1,8 @@
# DM loading: live tail + per-relay history paging
> **Status:** shipped — Live-tail + per-relay history layers exist (`WindowLoadTracker`, `RelayLoadingCursors`, `AccountGiftWrapsHistoryEoseManager`); doc marks itself authoritative-as-of-code.
> _Audited 2026-06-30._
> **Status:** authoritative as of 2026-06-05. The "Current architecture"
> section below describes the code as it actually stands. The original
> time-slice design and the round-model history are kept at the bottom under

View File

@@ -0,0 +1,274 @@
# Napplet / nsite sandbox host — design
> **Status:** shipped — Keyless sandbox host shipped: `:nappletHost` module, `NappletBrokerService`, `NappletLaunchRegistry` all present (rendering half superseded by embedded-tabs).
> _Audited 2026-06-30._
**Date:** 2026-06-19
**Status:** Core (commons) implemented + tested; Android host (`:napplet` process, WebView, broker IPC, consent, DataStore) implemented and compiling — needs on-device verification
**Companion:** `quartz/plans/2026-06-19-napplet-nip5a-resolver.md` (the bottom half — manifest parsing + verified Blossom resolution — already landed in `quartz`).
> **Update (2026-06-24):** the *rendering* model below (full-screen-only
> `NappletHostActivity`, host living in `amethyst/androidMain/.../napplet/`) has
> moved on. The sandbox runtime now lives in its own **`:nappletHost`** module and
> renders three ways — full-screen, embedded warm bottom-bar tabs, and an arbitrary-URL
> browser — with a cross-process `SurfaceControlViewHost`, a soft-keyboard IME proxy,
> and per-site Tor routing. See **`amethyst/plans/2026-06-24-napplet-embedded-tabs.md`**
> for the current architecture. The **trust model** in this doc (keyless `:napplet`
> process, brokered consent-gated capabilities, verified-blob serving) is unchanged.
## Goal
Render NIP-5A static sites (nsites) and NIP-5D napplets *inside* Amethyst, with
the applet's HTML/CSS/JS running behind a **hard trust boundary**: it must never
be able to read the user's `nsec` or any other secret, read app storage,
`LocalCache`, or other accounts' data, or sign / encrypt / publish / zap without
explicit per-applet user consent. A napplet is untrusted third-party code served
from an untrusted Blossom CDN; we treat it accordingly.
## Threat model
**Adversary:** the applet bundle (HTML/CSS/JS), authored by an untrusted party,
delivered from an untrusted CDN.
It must NOT be able to:
1. Read the `nsec` / any `NostrSigner`-held secret, or decrypted key material.
2. Read app private storage, `LocalCache`, DataStore, other accounts, or another
applet's sandboxed storage.
3. Sign, encrypt/decrypt, publish, subscribe, or zap without explicit consent.
4. Escalate to native code, other installed apps, or the file system.
5. Reach the network directly to exfiltrate or fingerprint (network is a *brokered
capability*, default-deny).
6. Forge content past the signed manifest (already handled by `StaticSiteResolver`:
manifest is authority, CDN is untrusted, every blob is sha256-verified).
**Trusted:** the main Amethyst process, the broker, the consent UI, quartz
verification. **Assumption:** the Android System WebView (Chromium) renderer
sandbox is sound — and we add an OS-process boundary on top so that even a full
WebView/renderer escape lands in a process that holds no secrets.
## Why a separate OS process is the load-bearing decision
Android processes have isolated address spaces. The decrypted `privKey`, the
`KeyPair`, and the `NostrSigner` instance live **only in the main process heap**.
The applet host runs in a separate process (`android:process=":napplet"`) that:
- never constructs a `NostrSigner`, never touches `SecureKeyStorage`/Keystore,
never holds an `Account` or `LocalCache`;
- only holds an IPC handle to *request operations*, whose **results carry no key
material** (a signed event, a ciphertext, a pubkey — never the private key).
So even arbitrary code execution inside the WebView renderer (already its own
sandboxed process) or inside the `:napplet` app process cannot read the main
process's memory where the secret lives. This is the guarantee the same-process
approach cannot make.
## Process & component model
```
┌─────────────────────────────────────────┐ ┌───────────────────────────────────┐
│ Main process (com.vitorpamplona.amethyst)│ │ Applet process (…:napplet) │
│ │ │ │
│ Account · NostrSigner · SecureKeyStorage │ │ NappletHostActivity │
│ LocalCache · NostrClient · Blossom · NWC │ │ └─ WebView │
│ │ │ ├─ shell page (trusted, │
│ NappletBrokerService (bound, not exported)│◀──AIDL─▶│ │ app-asset origin) │
│ └─ commons NappletBroker │ Binder │ │ exposes bridge → broker │
│ └─ NappletPermissionLedger │ (UID │ └─ <iframe sandbox= │
│ │ checked)│ "allow-scripts"> │
│ NappletConsentActivity (consent UI) │ │ = applet, opaque origin│
└─────────────────────────────────────────┘ └───────────────────────────────────┘
holds secrets holds NO secrets
```
### Three transport hops (each a trust step-down)
1. **applet iframe ↔ shell page**`postMessage` with strict origin checks. The
shell injects a tiny `window.napplet.*` client shim into the applet that wraps
postMessage calls into promises (request-id correlation). This is the NIP-5D
capability surface the applet codes against.
2. **shell page ↔ `:napplet` native** — exactly one audited bridge,
`WebView.addWebMessageListener` restricted to the **shell origin only** (the
applet's opaque-origin iframe cannot reach it). The shell forwards validated
requests.
3. **`:napplet` process ↔ main-process broker** — AIDL/`Messenger`. The broker
checks `Binder.getCallingUid() == Process.myUid()` (reject anything not from
our own app), consults the permission ledger, runs the operation with the real
signer/client, returns only the result.
### WebView hardening (`:napplet`)
- Shell document served from app assets via `WebViewAssetLoader` at a fixed
internal origin (`https://napplet.localhost/`). The applet lives in a child
`<iframe sandbox="allow-scripts">` **without `allow-same-origin`** → unique
opaque origin: no access to the shell DOM, cookies, `localStorage`,
`IndexedDB`, or the bridge.
- Applet resources are served by `shouldInterceptRequest` from an **in-memory map
of already-verified blob bytes** (resolved + sha256-checked by
`StaticSiteResolver` *before* the WebView loads). Only manifest-declared paths
resolve; everything else → 404. No `file://`, no `content://`.
- `WebSettings`: `allowFileAccess=false`, `allowContentAccess=false`,
`allowFileAccessFromFileURLs=false`, `allowUniversalAccessFromFileURLs=false`,
`setGeolocationEnabled(false)`, `mediaPlaybackRequiresUserGesture=true`,
`safeBrowsingEnabled=true`, no insecure mixed content. `domStorageEnabled`
only for a partitioned per-applet store (or off in v1).
- **CSP**: the shell injects `connect-src 'none'` for the applet by default — the
applet has *no direct network*. Relay/Blossom/identity all go through the
broker. Direct network is itself a capability (`net`) that widens `connect-src`
to user-approved origins only.
- External navigation blocked in `shouldOverrideUrlLoading`; links open in the
system browser only after consent.
## Capability / NAP-domain model
`NappletManifest.requires()` already yields the bare NAP domains
(`identity`, `relay`, `storage`, …; see `quartz/.../tags/RequiresTag.kt`). Map
each to a `NappletCapability` with the concrete broker operations it unlocks:
| NAP domain | Capability | Broker operations |
|---|---|---|
| `identity` | `IDENTITY` | `getPublicKey`, `signEvent`, `nip04/44 encrypt/decrypt` |
| `relay` | `RELAY` | `publish`, scoped `subscribe` (read) |
| `value` / `wallet` | `WALLET` | NIP-57 zap request / invoice; NWC pay (stricter, separate grant) |
| `storage` | `STORAGE` | per-applet sandboxed KV store, namespaced by applet identity — **never** app storage |
| `net` | `NET` | widen CSP `connect-src` to approved origins |
| *(unknown)* | — | **denied by default**, surfaced to the user |
**Applet identity for the ledger** = author pubkey + `d` identifier (the
addressable coordinate), *not* the blob hash, so grants survive updates. We still
record the manifest aggregate hash (`computeAggregateHash()`) so a user who picks
"ask again on code change" is re-prompted when the bundle changes.
## Permission ledger
`NappletPermissionLedger` — per-applet-identity grants, each
`NappletCapability → GrantState` (`ASK` / `ALLOW_ONCE` / `ALLOW_SESSION` /
`ALLOW_ALWAYS` / `DENY`), persisted via a `NappletPermissionStore` interface
(DataStore actual on Android). The broker consults it on every request:
- `DENY` → immediate `Denied` response.
- `ALLOW_*` → execute.
- `ASK` → suspend, launch `NappletConsentActivity` (main process), await the
user's decision, optionally persist, then execute or deny.
Consent UI reuses the signer-prompt design
(`amethyst/plans/2026-05-25-appfunctions-signer-prompts.md`) and
`commons/.../ui/signing`.
### Capability-aware consent policy
The uniform "once / session / always / deny" is refined per capability:
- **Payments (`WALLET`)** — `requiresPerUseConsent = true`: every `payInvoice`
re-prompts with the decoded sats amount; the dialog never offers "Always allow"
and a stray always/session grant is downgraded to one-shot so nothing persists.
- **Identity (`IDENTITY`)** — gated by us **only when Amethyst holds the key**
(`NostrSignerInternal`). For remote (NIP-46) / external (NIP-55) signers the
broker defers to the signer's own per-request consent (no double-prompt), while
still honoring a standing per-napplet `DENY` and the `requires` declaration. The
sign prompt shows a kind + content preview.
- **Foreground-only execution** — the napplet WebView's JS/timers are paused when
the host isn't resumed (`onPause`/`onResume`), so a backgrounded applet cannot
fire a sign/decrypt/pay request whose prompt would surface over — and be
confused with — Amethyst's own UI. This is the precondition that makes deferring
identity to an external signer safe.
## Module placement
- **`quartz`** — protocol done. (Optional later: a canonical `NapDomain` constant
set once the upstream NAP list stabilizes — an open question in the resolver
plan.)
- **`commons/commonMain`** — new CLI-safe `napplet/` feature package:
- `napplet/NappletCapability.kt` — NAP-domain ↔ capability mapping.
- `napplet/protocol/``NappletRequest` / `NappletResponse` sealed families + JSON codec.
- `napplet/permissions/``NappletPermissionLedger`, `GrantState`, `NappletPermissionStore`.
- `napplet/NappletBroker.kt` — platform-agnostic broker: `(NostrSigner +
relay/blossom/zap handles + ledger) → (NappletRequest → NappletResponse)`.
The heart of the boundary; **fully unit-testable on the JVM**.
- `napplet/ui/` — shared consent composables.
- **`amethyst/androidMain`** —
- `NappletHostActivity` (`:napplet`) + WebView + `WebViewAssetLoader` + the
shell HTML/JS shim asset.
- `NappletBrokerService` (main process, bound, `exported=false`) wrapping the
commons broker; `Binder` UID check.
- `NappletConsentActivity` (main process) using the commons consent UI.
- AIDL/`Messenger` plumbing; OkHttp `BlobFetcher` wiring (the resolver plan's
named follow-up).
- AndroidManifest: `:napplet` process declaration, the bound service, the
consent activity.
- **`desktopApp`** — out of scope for v1 (the `:napplet` process model is
Android-specific; desktop needs a separate child-process/WebView strategy).
## Inter-applet communication (v2)
Napplets' differentiator is talking to each other. Model: applets address each
other by napplet coordinate; `napplet.send(target, msg)` is **routed through the
broker** (shell→broker→shell) so two opaque-origin iframes never share an origin
or memory, and the user (or a manifest-declared allowlist) consents to the link.
Deferred to v2; v1 nails the single-applet boundary first.
## Testing & verification
- **commons (now, JVM):** broker decision logic per capability
(granted/denied/ask), ledger state transitions + persistence semantics,
protocol JSON round-trips, and security cases — unknown NAP domain denied,
request for an undeclared path 404s without fetching, signer responses never
contain key bytes.
- **Android (needs emulator):** instrumented tests for the WebView host, the
opaque-origin iframe isolation, and the process boundary — flagged as on-device
verification.
## Phasing (within the "full napplet" milestone)
1. **Core (commons, tested)** — protocol + capability model + ledger + broker. ✅ done.
2. **Android host** — `:napplet` process + WebView + verified-blob serving via
`shouldInterceptRequest` + CSP. ✅ implemented (`NappletHostActivity`).
3. **Broker IPC + `identity` + `relay`** — Messenger broker
(`NappletBrokerService`), `window.napplet.*` shim, consent UI
(`NappletConsentActivity`), DataStore ledger. ✅ implemented.
4. **Capabilities beyond identity/publish:**
- **`relay` read** — `QueryEvents`: bounded live relay fetch (`fetchAll`,
EOSE/timeout) merged with `LocalCache`, newest-first. ✅
- **`storage`** — per-applet sandboxed KV store (`DataStoreNappletStorage`). ✅
- **`value`/`wallet`** — `PayInvoice` wired to the user's NWC wallet
(`sendZapPaymentRequestFor`); consent shows the decoded sats amount; throws
(→ `Failed`) on no-wallet/error/timeout. ✅ (needs on-device verification)
- **`net`** — CSP widening to approved origins. ⏳
5. **Capability enforcement** — the broker refuses any request whose capability is
not in the manifest's `requires` (passed host→broker as `declared`), before any
consent prompt. ✅
6. **Inter-applet (NAP-INC / NAP-INTENT)** — deferred; design + prerequisites in
`2026-06-20-napplet-inter-applet.md`. **`net`** capability and an install-style
up-front capability grant UI also remain. ⏳
### Implemented Android components (amethyst `…/napplet/`)
| File | Process | Role |
|---|---|---|
| `NappletHostActivity` | `:napplet` | WebView host: hardened settings, opaque-origin iframe, verified-blob `shouldInterceptRequest`, CSP, `window.napplet` shim, Messenger client |
| `NappletBrokerService` | main | Bound Messenger service; runs the commons `NappletBroker` against the live account; `exported=false` + UID check |
| `NappletConsentActivity` / `NappletConsentCoordinator` | main | Capability-consent dialog + suspend bridge to the broker |
| `DataStoreNappletPermissionStore` | main | Persistent grant store (`NappletPermissionStore` actual) |
| `NappletProtocolJson` / `NappletIpc` | both | JSON codec + Messenger wire contract |
| `NappletLauncher` | caller | Packs a verified manifest into the host Intent |
| `assets/napplet/shell.html` | `:napplet` | Trusted shell page that sandboxes the applet iframe and relays messages |
### Remaining before user-facing ship
- **On-device verification (needs emulator/device):** opaque-origin iframe really
excludes the bridge; CSP `connect-src 'none'` blocks fetch/XHR/WebSocket; a real
napplet renders and round-trips a `getPublicKey` / `signEvent` through consent.
- ✅ **UI entry point:** a "Napplets" drawer item → `NappletsScreen` that lists
cached napplet manifests (kinds 15129/35129) and launches the host.
- ✅ **Process isolation:** `Amethyst.onCreate` now skips `AppModules` entirely in
the `:napplet` process, so the account/signer are never loaded there. (Previously
`initiate()` loaded the account in every process — a real hole, now closed.)
- ✅ **Privacy:** the host routes blob fetches through the user's Tor SOCKS proxy
when active; the port is passed in by the launcher (main process) so the sandbox
process never touches the account-bound HTTP stack.
- ✅ **Relay discovery:** `NappletsFilterAssembler` (registered in
`RelaySubscriptionsCoordinator`, invoked by `NappletsScreen`) REQs kinds
15129/35129 from the user's read relays while the screen is open, so manifests
flow into `LocalCache` for the list to render.
- **Consent UX:** reuse `commons/.../ui/signing` styling; show the manifest title
and a per-capability rationale; batch-grant on first run.

View File

@@ -0,0 +1,277 @@
# Napplet implementation audit vs the upstream SDK / demo runtimes
> **Status:** shipped — Audit doc; the wire-compatibility gaps it identified were subsequently closed (shell handshake, keys, upload now in the broker/codec).
> _Audited 2026-06-30._
**Date:** 2026-06-20
**Sources:** `github.com/napplet/naps` (NAP specs), `github.com/napplet/web`
(`@napplet/shim` SDK), `github.com/kehto/web` + `kehto.github.io/web/playground`
(reference runtime). Audited against our branch `claude/awesome-pasteur-xwiwad`.
## TL;DR
Our shell is a **correct and security-hardened NIP-5A/5D renderer + capability
broker** — process isolation, verified-blob serving, default-deny CSP,
capability-aware + signer-aware consent, foreground-only, permissions UI. Those
are *shell-quality* properties the demo runtimes don't even specify, and we're
ahead there.
**But it is not wire-compatible with the napplet ecosystem.** Real napplets are
built against `@napplet/shim`, which exposes a **namespaced** `window.napplet.*`
and a **`{type:"domain.action", id}`** postMessage envelope. We inject a **flat**
`window.napplet.*` and use a **`{id, payload:{op}}`** envelope. A napplet from the
kehto playground calling `window.napplet.relay.publish(...)` or
`window.napplet.shell.supports("relay")` hits `undefined` on our shell. So today:
**0 real ecosystem napplets run as-is.**
## Reference: the upstream surface
`window.napplet` namespaces (from `@napplet/web`):
| Namespace | Methods (upstream) |
|---|---|
| `shell` | `supports(domain)`**required of every runtime** |
| `identity` | `getPublicKey()`, `onChanged(handler)` |
| `keys` | signing / NIP-04 / NIP-44 (separate domain from `identity`) |
| `relay` | `subscribe(filters)` (live), `publish(event)`, `query(filters)`, `publishEncrypted` |
| `storage` | `get(key)`, `set(key, value)`, `remove(key)` |
| `inc` | `emit(type, payload)`, `on(type, handler)` (inter-napplet) |
| `resource` | `bytes(url)`, `bytesAsObjectURL(url)` — fetch https/blossom/nostr/data |
| `value` | shell-mediated value transfer + zaps (depends on `relay`) |
| `upload` | shell-mediated file/blob upload (Blossom; depends on `relay`) |
| `intent` | invoke a napplet by archetype |
| `theme`, `notify`, `media`, `config`, `outbox`, `ifc`, `cvm` | further domains |
**Envelope:** `{ type: "<domain>.<action>", id, ...payload }`
result `{ type: "<domain>.<action>.result", id, ok, ... }`.
**Sandbox:** `iframe sandbox="allow-scripts"` only (no `allow-same-origin`) — **we
match this exactly.**
## Coverage scorecard (our broker vs upstream domains)
| Domain | Status | Notes |
|---|---|---|
| `shell` | ✗ **missing** | `supports()` is foundational; without it well-behaved napplets bail early |
| `identity` | ◐ | `getPublicKey` ✓; `onChanged` ✗ (no push) |
| `keys` (sign/nip04/nip44) | ◐ | implemented, but lumped under our `IDENTITY` capability, not a `keys` domain |
| `relay` | ◐ | `publish` ✓, `query` ✓ (now live); `subscribe` (live) ✗, `publishEncrypted` ✗ |
| `storage` | ✓ | get/set/remove — shape matches (wire differs) |
| `value` | ◐ | we do `payInvoice` via NWC; upstream `value` is zaps/value-transfer (depends on relay) — different method shape |
| `resource` | ✗ | no `bytes(url)`; we only serve manifest subresources |
| `upload` | ✗ | no Blossom upload |
| `inc` | ✗ | deferred (see inter-applet plan) |
| `intent` | ✗ | deferred |
| `theme` `notify` `media` `config` `outbox` `ifc` `cvm` | ✗ | not modeled |
Our extra `NET` domain has **no upstream equivalent** — upstream fetching is
`resource`. Our `fromNapDomain` recognizes `identity/relay/value/storage/net`;
everything else (`shell`, `resource`, `upload`, `inc`, `intent`, `keys`, …) maps
to *unknown → denied*. So a manifest `requires: ["relay","shell","resource"]`
would have two of three flagged unknown — and `shell` is mandatory.
## The two interop blockers
1. **Envelope.** Real napplets send `{type:"relay.publish", id, event}` and await
`{type:"relay.publish.result", id, ok}`. We expect `{id, payload:{op:"publish"}}`
and reply `{id, response:{type:"published"}}`. Incompatible.
2. **API surface.** Upstream is namespaced (`relay.publish`, `identity.getPublicKey`,
`keys.signEvent`, `shell.supports`, `resource.bytes`). Ours is flat
(`getPublicKey`, `signEvent`, `publish`, `queryEvents`, `payInvoice`) + a
namespaced `storage`. Only `storage` lines up.
Both live in our **edge layer** (the injected JS shim + `NappletProtocolJson` +
`NappletIpc`) and the capability enum — *not* in the security core (broker,
ledger, process model), which is dialect-agnostic. So aligning is an edge rewrite,
not an architecture change.
## What we have that the demos don't
- Separate-OS-process sandbox (`:napplet`), not just an iframe.
- Verified-blob serving (manifest-authoritative, per-blob sha256) with default-deny
CSP (`connect-src 'none'`) and Tor-routed fetch.
- Capability **declaration enforcement** (manifest `requires` gate), **per-use**
payment consent, **signer-aware** identity deferral, **foreground-only** execution.
- A persisted permission ledger + a permissions-management UI.
These are real-shell concerns the reference runtimes leave to the implementer; we
should keep them.
## Recommended path to ecosystem compatibility (priority order)
1. **Adopt the upstream envelope** `{type:"domain.action", id}` / `…​.result` in the
shim + `NappletProtocolJson` + `NappletIpc`. (Unblocks everything.)
2. **Re-shape the injected shim** to the namespaced `window.napplet.*` and add
**`shell.supports(domain)`** (cheap; derive from the granted capability set).
3. **Split capabilities** to match domains: `keys` (sign/nip04/nip44) distinct from
`identity` (getPublicKey/onChanged); rename `NET``resource`; add `SHELL`,
`UPLOAD`, `VALUE` semantics (zap-by-target, not raw invoice).
4. **Add the push channel**: `relay.subscribe` (live events), `identity.onChanged`,
later `inc.on` — the host already holds a `JavaScriptReplyProxy` we can push
unsolicited `…event` messages through.
5. **`resource.bytes`** (consented fetch of https/blossom/nostr/data) and
**`upload`** (Blossom), both brokered + consent-gated.
6. Lower priority / app-specific: `theme`, `notify`, `media`, `config`, `outbox`,
`intent`, `inc`, `ifc`, `cvm`.
## Update (2026-06-20): ecosystem alignment landed
Acted on #1#5. The dialect mismatch is resolved:
- **Envelope** is now `{type:"<domain>.<action>", id}``{type:"…​.result", id, ok, …}`,
matching upstream (codec + host shuttle + shim rewritten; round-trip unit-tested).
- **Namespaced `window.napplet.*`** shim: `shell.supports`, `identity.getPublicKey`
(+`onChanged` stub), `keys.{signEvent,nip04*,nip44*}`, `relay.{publish,query,subscribe}`,
`storage.{get,set,remove}`, `value.payInvoice`, `resource.{bytes,bytesAsObjectURL}`,
`upload.blob`. The applet's own SDK-targeted code now runs unchanged.
- **`shell.supports(domain)`** implemented (no consent; reflects declared+brokered domains).
- **Capabilities split** to the domain model: `SHELL`, `IDENTITY`, `KEYS`, `RELAY`,
`STORAGE`, `VALUE`, `RESOURCE`, `UPLOAD` (was `IDENTITY/RELAY/WALLET/STORAGE/NET`).
- **`resource.bytes`** implemented for `https`/`data` (broker-fetched, Tor-routed,
consent-gated); `blossom:`/`nostr:` are a follow-up.
## Update (2026-06-21): return shapes verified against `@napplet/nap@0.15.0`
Pulled the canonical message types (`@napplet/nap` `*/types.d.ts` + `value-types`) and corrected
the result field names — several were wrong guesses. The authoritative wire:
| Method | Request `type` | Result field(s) |
|---|---|---|
| `identity.getPublicKey` | `identity.getPublicKey` | `pubkey: string` |
| `identity.getProfile` | `identity.getProfile` | `profile: ProfileData \| null` |
| `identity.getRelays` | `identity.getRelays` | `relays: Record<url, {read,write}>` |
| `identity.getFollows`/`getMutes`/`getBlocked` | same | `pubkeys: string[]` |
| `identity.getList` | `identity.getList` | `entries: string[]` |
| `identity.getZaps` / `getBadges` | same | `zaps[]` / `badges[]` |
| `storage.getItem/setItem/removeItem/keys` | **`storage.get`/`set`/`remove`/`keys`** | `value` / — / — / `keys: string[]` |
| `relay.publish` / `publishEncrypted` | same | template in the **`event`** field; result `{ok, event, eventId}` |
| `relay.query` | `relay.query` | `events: NostrEvent[]` |
| `resource.bytes` | `resource.bytes` | `blob: Blob`, `mime: string` |
`ProfileData` is `{ name?, displayName?, about?, picture?, banner?, nip05?, lud16?, website? }`
note **`displayName`** (camelCase), so the shell maps kind-0 `display_name``displayName` rather
than dumping raw content. Corrected in code: identity reads now emit method-specific fields;
`storage.*` wire types fixed (the *function* is `getItem`, the *envelope* is `storage.get`);
`storage.keys` returns `keys`; `relay.publish`/`publishEncrypted` read the template from `event`;
`getProfile` builds a `ProfileData` object. Locked by `NappletProtocolJsonTest`.
**Transport: structured-clone objects + subscription push — landed.** `@napplet/core` posts
**structured-clone objects** (not JSON strings) via `target.postMessage(obj)` and validates
cloneability — that is how `resource.bytes` returns a real `Blob`, and why a stock napplet's
object messages were dropped before (our shell only forwarded strings). Fixed:
- **`shell.html` bridges object↔string both ways.** applet→native serializes object envelopes to
the string the native bridge carries (requests carry no Blobs); native→applet parses the reply
to an object and posts a **structured-clone object** (what the SDK reads via `e.data.type`), not
a string. The injected shim accepts either form.
- **`resource.bytes` Blob.** The shell rebuilds a real `Blob` from the host's base64 `bytes`+`mime`
before delivering, so both the SDK and our shim resolve to a `Blob`.
- **`relay.subscribe` push channel.** Subscriptions are answered with `relay.event` (one per match)
then `relay.eose`, keyed by `subId` — no `.result`, matching the SDK. A new `MSG_PUSH` IPC frame
lets the broker push unsolicited envelopes the host forwards verbatim; `relay.close` is a
fire-and-forget no-op. Today this delivers the **initial snapshot then EOSE**.
Still open: a **live subscription tail** (push as events arrive, not just the snapshot) plus
`identity.onChanged`/`inc.on`; multi-`filters` queries (we use the first filter); the Blossom
`upload` gateway; and **on-device verification** — the shell/shim changes are JS and not exercised
by the JVM unit tests.
## Update (2026-06-21, even later): feed surfacing + nsite runtime hardening
- **Feed surfacing (sandbox-preserving).** Napplets gained an inline feed card (nsites already had
one), both render via `NoteCompose`, are indexed in `LocalCache`, and a profile **"Apps & Sites"**
tab lists a user's manifests. The cards are inert (`Text`+`Button`, no WebView); execution begins
only on explicit tap, in the `:napplet` process.
- **SPA route fallback** — a document navigation (Accept: text/html) to a route not in the manifest
serves the verified `index.html`; missing sub-resources still 404.
- **External-link handoff** — a user-tapped off-origin http(s) link opens in the system browser
(gesture-gated so a hostile site can't auto-redirect); the sandbox WebView never navigates away.
- **`resource.bytes` `blossom:` scheme** — `blossom:<sha256>` fetches from the user's kind:10063
Blossom servers and verifies the hash before returning. `nostr:` stays deferred (unspecified).
- **Content-type byte-sniffing** — when a manifest path has no/unknown extension, the resolver
sniffs magic bytes; text/markup is never sniffed, so HTML detection stays extension-driven.
Unit-tested in quartz.
- **kind:10063 fallback** — the launcher augments the manifest's `servers` with the author's
published Blossom list (best-effort); every blob is still sha256-verified.
- **Blob caching** — the host OkHttp client caches blobs on disk (forced-immutable, since they're
content-addressed); the resolver re-verifies every served blob, so a stale entry can't be served.
Remaining: live subscription tail + `identity.onChanged`/`inc.on`, the Blossom `upload` gateway,
`getList`/`getZaps`/`getBadges`, the `nostr:` resource scheme, multi-`filters` queries — and
**on-device verification** of all the WebView-host behavior.
## Update (2026-06-20, later): verified against `@napplet/shim@0.16.0` and corrected
Pulled the authoritative SDK (`@napplet/shim` v0.16.0, npm/unpkg) and corrected the
implementation to its real contract. Commit `5ca44e27` had carried several wrong guesses; the
verified surface is:
| Namespace | Verified methods (v0.16.0) |
|---|---|
| `shell` | `supports(domain, protocol?)` (sync), `ready()`, `onReady(cb)`, `services` |
| `identity` | `getPublicKey()`, `onChanged(h)`, + read API (`getRelays/getProfile/getFollows/getList/getZaps/getMutes/getBlocked/getBadges`) |
| `keys` | **keyboard/command actions**`registerAction/unregisterAction/onAction` (NOT signing) |
| `relay` | `publish(template, options?)` → signed `NostrEvent`, `publishEncrypted(template, recipient, encryption?)`, `query(filters)`, `subscribe(filters, onEvent, onEose, options?)` |
| `storage` | `getItem/setItem/removeItem/keys` (512 KB quota; `instance.*` variant) |
| `resource` | `bytes(url)``Blob`, `bytesAsObjectURL(url)` |
| `inc` | `emit(topic, extraTags?, content?)`, `on(topic, cb)` |
Crucial design fact, quoted: **"signing and encryption are mediated by the shell via
`relay.publish()` and `relay.publishEncrypted()`"** and *"no cryptographic dependencies — the
shim sends JSON envelope messages and the shell handles identity"*. **There is no `sign()` and no
raw nip04/44 in the napplet surface.** There is **no `value` or `upload` domain** in v0.16.0.
Corrections landed (this commit):
- **Signing model fixed (the big one).** Dropped the bogus `keys.signEvent` / `keys.nip04*` /
`keys.nip44*` napplet ops. `relay.publish` now takes an **unsigned template** (`kind/tags/content`)
and the broker signs it as the user and returns the signed event — exactly the upstream contract.
Added `relay.publishEncrypted` (broker encrypts to recipient with nip44/nip04, then signs +
publishes). The broker still defers the per-signature prompt to remote/external signers
(`signsAsUser` + non-internal signer) and honors standing DENY.
- **`keys` re-pointed to keyboard actions** (`registerAction/unregisterAction/onAction`),
implemented as client-side no-op stubs (not yet wired to the host keyboard) so action-using
napplets don't crash. They never cross the broker boundary.
- **`storage` renamed** to `getItem/setItem/removeItem` and **`storage.keys`** added end-to-end
(protocol + broker + DataStore + shim), matching upstream.
- **`resource.bytes` now returns a `Blob`** (shim builds it from `{bytes, mime}`); wire field
renamed `contentType``mime`.
- **`shell.supports(domain, protocol?)`** gained the optional protocol arg; added `shell.ready()`,
`onReady`, `services` stubs.
- **`relay.subscribe`** wired (initial matches; live tail still a follow-up).
- `value.payInvoice` and `upload.blob` are **kept as clearly-marked Amethyst-specific extensions**
(no upstream equivalent in v0.16.0) — a real `@napplet/shim` napplet never calls them, so they
can't conflict.
Verified off-device: `commons:jvmTest` (broker + capability + ledger) and the amethyst codec
round-trip test (`NappletProtocolJsonTest`) both green.
Still open (documented, not blocking basic napplets):
- **`upload`** — wired end-to-end (protocol/shim/capability) but the Android Blossom
gateway is unprovided (`Unsupported`): a correct upload needs a content Uri + signed
auth event + server selection, which needs on-device verification.
- **Live push** — `relay.subscribe` returns initial matches via `query`; a live tail and
`identity.onChanged`/`inc.on` need a push channel over the existing reply proxy.
- **Underspecified domains** — `inc`, `intent`, `theme`, `notify`, `media`, `config`,
`outbox`, `ifc`, `cvm` remain unknown→denied (no method spec available to build to).
- **Method-name fidelity** — ✅ resolved. All standard method names/shapes are now confirmed
against `@napplet/shim@0.16.0` (see the later update above), not guessed.
- **Identity read API** — ✅ partly landed. `identity.getProfile` (kind-0 content), `getRelays`
(NIP-65 read/write map), `getFollows` (kind-3 authors), `getMutes` and `getBlocked` (NIP-51
decrypted user tags) now read from the active `Account` and return JSON, gated by the IDENTITY
consent (and deferred to remote/external signers). `getList`/`getZaps`/`getBadges` route through
but degrade to `Unsupported` for now; `identity.onChanged` is still a client-side no-op (needs
the live push channel). Return shapes still want on-device verification against a real napplet.
- **On-device verification** of the whole round-trip with a real playground napplet.
Revised ecosystem-compatibility estimate: **~80%** — real request/response napplets using
identity(getPublicKey)/relay(publish/publishEncrypted/query/subscribe)/storage/resource +
`shell.supports` now run against the *verified* contract; remaining gaps are the identity read
API, keyboard-action wiring, live subscription tails, the niche domains, and device verification.
## Verdict (original assessment, pre-update)
- **As a secure NIP-5A/5D renderer + broker:** ~85% — the hard, security-critical
parts are done and tested; gaps are on-device verification and breadth of ops.
- **As an ecosystem-compatible napplet host (runs real napplets):** ~25% — blocked
by the envelope + namespaced-API mismatch and missing `shell`/`resource`. Until
#1#2 land, existing napplets won't run regardless of how solid the core is.

View File

@@ -0,0 +1,65 @@
# Napplet subsystem code audit — bugs, performance, refactor, placement
> **Status:** shipped — Audit recording completed fixes (LiveSub teardown, broker caching, shim extraction); referenced files exist.
> _Audited 2026-06-30._
**Date:** 2026-06-21. Scope: the napplet/nsite subsystem (`amethyst/.../napplet/`,
`commons/.../napplet/`, `quartz/.../nip5aStaticWebsites` + `nip5dNapplets`).
## Fixed in this pass
| # | Category | Issue | Fix |
|---|---|---|---|
| 1 | 🐛 correctness | **Live subscription unsubscribed from the wrong client after an account switch**`closeLiveSubscription`/`onDestroy` used the *current* account's client, leaking the sub on the original. | `LiveSub` holder stores the exact `INostrClient` that opened the sub; teardown uses it. |
| 2 | 🐛 correctness | **Multi-relay subscriptions emitted N `relay.eose`** (one per relay) — the SDK expects one. | An `eoseSent` latch (`compareAndSet`) emits a single `relay.eose`. |
| 3 | ⚡ perf | **Broker rebuilt on every request** (new gateways/prompt each call). | `broker()` caches per account (reference identity), rebuilt only on switch. |
| 4 | ⚡ perf | **A fresh `OkHttpClient` per blob fetch** (no connection pooling). | `blobHttpClient()` caches the client keyed by Tor port (`@Synchronized`). |
| 5 | ♻️ refactor | **105-line `SHIM_JS` string constant** in `NappletHostActivity.kt` (no highlighting, hard to edit). | Moved to `assets/napplet/shim.js`, loaded once like `shell.html`. |
| 6 | 📝 docs | Stale shim comments (`onChanged` "follow-up", `subscribe` "snapshot…follow-up"). | Rewritten to match reality (no-op onChanged; live tail). |
All compile; `commons:jvmTest` + the amethyst napplet suite stay green.
## Deferred — with rationale (not silently dropped)
- **Duplicate events across relays** — the same event id can arrive from multiple relays, so
`relay.event` is pushed more than once. Deduping needs a per-subscription seen-id set (unbounded
memory for long subs); napplets already dedupe by id. Left as-is; documented.
- **Background teardown of live subscriptions** — a backgrounded-but-alive napplet keeps its relay
subscription open (the WebView is paused, but the service keeps streaming). It is *not* a
permanent leak: the service is bind-only, so closing the napplet → `unbindService` → service
`onDestroy` → all subs torn down. A proper pause/resume (unsubscribe on background, re-`REQ` on
foreground) is a real optimization but needs an IPC pause/resume signal + device verification.
- **Request ordering** — requests are handled concurrently (`scope.launch` per message), so
`storage.set` then `storage.get` aren't guaranteed in-order. Matches the SDK's async model;
serializing would hurt throughput. Documented, not changed.
- **`runBlocking` in `shouldInterceptRequest`** — this runs on a WebView *background* worker thread
(not the UI thread), so blocking there during a blob fetch is acceptable; WebView fans out
resource loads across workers. Left as-is.
- **Over-flags from the sweep that aren't real:** `pendingRequests` / `bridgeReplyProxy` "races" —
both the `WebMessageListener` callback and the reply `Handler` run on the **main looper**, so
there is no cross-thread access. A null `bridgeReplyProxy` only drops a reply to an
already-gone WebView (the applet is gone too) — harmless.
## Recommended moves to commons / quartz
- **quartz (protocol-only): correct as-is.** NIP-5A/5D events, `NappletManifest`,
`StaticSiteResolver` + `StaticSitePathLookup` (`sniffContentType`), `SiteAggregateHash` are all in
`commonMain`. No app policy leaked in.
- **commons (shared logic): mostly correct.** Broker, capability, identity, request/response,
permissions ledger/store, and gateway interfaces are in `commonMain` — right home.
- **DONE: `NappletProtocolJson``commons/jvmAndroid`.** Moved to
`commons/.../napplet/protocol/` (next to the types it marshals) so the future **desktop** host
reuses the exact wire codec. Its tests stay in `amethyst` (JUnit4) for now and still exercise it
via the commons dependency; converting them to `kotlin.test` and moving to commons `jvmTest` is a
small follow-up. See `desktopApp/plans/2026-06-21-napplet-desktop-host.md`.
- **amethyst (Android-only): correctly platform-bound.** `NappletHostActivity` (WebView/process),
`NappletBrokerService` (Service/Messenger/account), the gateway *implementations* (account,
`BlossomUploader`, DataStore, NWC), `NappletLauncher`, consent UI, `NappletIpc`, and the screens
all belong here.
## Lower-priority refactors (not done)
- Extract a shared Tor-aware OkHttp builder (host `buildHttpClient` vs service `blobHttpClient`
duplicate the proxy logic) into a small util.
- `summaryFor`'s long `when` could become a per-request-type method, and `encodeResponse`'s big
`when` is repetitive — both are readability, not correctness.

View File

@@ -0,0 +1,131 @@
# Napplet SDK conformance audit — feature by feature
> **Status:** shipped — Conformance audit; the four breakers are marked fixed and pinned by `NappletSdkConformanceTest`.
> _Audited 2026-06-30._
**Date:** 2026-06-21
**Authoritative sources (verified, not from memory):**
`@napplet/nap@0.15.0` (`dist/<domain>/types.d.ts` — the canonical wire message types),
`@napplet/shim@0.16.0` (the SDK napplets bundle), `@napplet/core@0.15.0` (base envelope +
shell handshake). Audited against branch `claude/awesome-pasteur-xwiwad`.
Our edge layer: `NappletProtocolJson` (codec), `NappletRequest`/`NappletResponse` (commons),
`NappletHostActivity` (`SHIM_JS` + `shell.html` relay), `NappletBroker`, `NappletBrokerService`.
## Update — the four 🔴 breakers are now implemented
All four conformance breakers below are fixed (codec pinned by `NappletSdkConformanceTest`):
1. **Shell handshake** — the host answers `shell.ready` with `shell.init { capabilities:{domains,
protocols}, services }` built from the declared domains (`NappletProtocolJson.encodeShellInit`),
so a stock napplet's cached environment is populated and `supports()` works.
2. **Id-less messages** — `onShellMessage` no longer drops messages without an `id`: `shell.ready`
is answered locally, and other fire-and-forget messages get a synthetic id so they reach the broker.
3. **keys** — `keys.registerAction`/`keys.unregisterAction` decode and the broker acknowledges them
(declared-gated, no consent) so `registerAction()` resolves; the shim dispatches the `keys.action`
push. (The actual global-key binding is still a follow-up — `keys.action` isn't emitted yet.)
4. **upload** — realigned to `upload.upload { request:{ data, mimeType, filename } }` → rich
`UploadResult { ok, uploadId, status, url, sha256, size, mimeType }`; `shell.html` inlines the
request `Blob` as base64 so it survives the bridge; the gateway uploads via the app's
`BlossomUploader` to the user's kind:10063 server with a signed auth event.
Also now implemented (the ◐ follow-ups):
- **Live subscription tail** — `relay.subscribe` opens a real `client.subscribe` whose listener
streams `relay.event` (stored + live), `relay.eose`, and `relay.closed` pushes by `subId`;
`relay.close` unsubscribes (tracked in `liveSubs`, torn down in `onDestroy`).
- **Multi-`filters`** — `relay.query`/`subscribe` honor every filter in the `filters[]` array,
not just the first (`decodeFilterList`, gateway `query(List<Filter>)`).
- **`resource.cancel`** — accepted at the host edge as a no-op `Done`.
Still open: identity `getList`/`getZaps`/`getBadges` + `onChanged` (object shapes / list-type
semantics underspecified), the `keys.action` push (needs a host command-palette UI to *trigger*
actions — registration already conforms), the `resource` `nostr:` scheme (unspecified bytes),
`inc`/`intent`/the niche domains, and **on-device verification** of the host/shell behavior.
## Base envelope & error convention (verified)
- `NappletMessage` carries only **`type`** (`"domain.action"`). **There is no universal `id`** —
request/response pairs add `id`; fire-and-forget and handshake/push messages have **no `id`**.
- **No universal `ok`.** Each domain picks its own: `relay.publish`/`publishEncrypted`,
`outbox.publish`, `upload`, `intent` use `ok: boolean`; identity/storage/query results omit `ok`
and signal success by the data field's presence + an optional `error?: string`. The SDK shim
rejects when `error` is present and otherwise reads the domain's data field.
- **Ours:** we set `ok` on *every* result. Harmless (the SDK reads the data field and ignores the
extra `ok`), and our own injected shim relies on `ok`. ✅ compatible, ⚠️ non-canonical.
## Transport (verified) — two architectural gaps
1. **Structured-clone objects, not strings.** `@napplet/core` posts cloneable **objects**. Our
`shell.html` now bridges object↔string both ways (done earlier). ✅
2. **🔴 The host drops id-less messages.** `NappletHostActivity.onShellMessage` does
`id = optString("id").ifEmpty { return }` — so every message **without an `id`** is dropped:
`shell.ready`, `inc.emit`, `keys.unregisterAction`. This silently breaks the shell handshake and
all fire-and-forget messages. **Must fix** to forward/handle id-less messages.
3. **🔴 Blob-carrying requests can't cross.** `upload.upload`'s request payload contains a `Blob`
(`data: Blob | ArrayBuffer`). Our applet→native bridge does `JSON.stringify`, which turns a Blob
into `{}`. Real-napplet uploads lose their bytes. Needs a Blob-aware request path.
## Shell handshake (verified) — 🔴 not implemented
The SDK does **not** send a `shell.supports` message. Instead:
- napplet posts **`shell.ready`** (no payload), and
- the shell replies **once** with **`shell.init`** = `{ capabilities: { domains: string[],
protocols: Record<string,string[]> }, services: string[] }`.
- `shell.supports(capability, protocol?)` is then answered **synchronously and locally** from that
cached environment.
**Ours:** we implement a `shell.supports` *request* (`ShellSupports` → `Supported`) and our injected
shim calls it async. A real napplet never sends `shell.supports`; it sends `shell.ready` — which our
host **drops** (no id) — so its cached environment stays empty and `supports()` returns `false` for
everything, likely making well-behaved napplets bail early. **Highest-impact gap.**
Fix: host answers `shell.ready` with a `shell.init` carrying the declared domains.
## Per-domain conformance matrix
Legend: ✅ conformant · ◐ partial · 🔴 mismatch/missing · not modeled.
| Domain | SDK surface (wire) | Ours | Verdict |
|---|---|---|---|
| **shell** | `shell.ready`→`shell.init{capabilities,services}`; `supports()` local | `shell.supports` request→`{supported}` | 🔴 wrong model (no handshake) |
| **identity** | `getPublicKey`→`{pubkey}`; `getProfile`→`{profile}`; `getRelays`→`{relays}`; `getFollows`/`getMutes`/`getBlocked`→`{pubkeys}`; `getList`→`{entries}`; `getZaps`→`{zaps}`; `getBadges`→`{badges}`; `onChanged` push | getPublicKey ✅; profile/relays/follows/mutes/blocked ✅ (exact fields); getList/getZaps/getBadges → Unsupported; onChanged no-op | ◐ reads ✅, push/3 methods missing |
| **relay** | `publish{event}`→`{ok,event,eventId}`; `publishEncrypted{event,recipient,encryption}`; `query{filters[]}`→`{events}`; `subscribe{subId,filters,relay?}`; `close{subId}`; pushes `relay.event{subId,event,resources?}`, `relay.eose{subId}`, `relay.closed{subId,reason?}` | publish/publishEncrypted ✅ (read `event`); query ◐ (first filter only); subscribe ✅ + event/eose push ✅ (snapshot); close ✅ (no-op); `relay.closed` not emitted | ◐ strong; multi-filter + live tail + `relay.closed` open |
| **storage** | `get`→`{value}`; `set`; `remove`; `keys`→`{keys}` (512 KB quota) | ✅ exact (`get/set/remove/keys`, `value`/`keys` fields) | ✅ (quota not enforced) |
| **resource** | `bytes{url}`→`{blob,mime}`; `cancel`; https/blossom/nostr/data | bytes ✅ (shell builds Blob); https/data/blossom ✅; nostr ; cancel | ◐ nostr + cancel missing |
| **keys** | `registerAction{action}`→`{actionId,binding?}`; `unregisterAction{actionId}`; pushes `keys.action{actionId}`, `keys.bindings{bindings[]}` | client-side no-op stubs only; **host rejects** `keys.*` (decodes to null) | 🔴 real napplets' `registerAction` rejects |
| **upload** | `upload.upload{request:{data:Blob,mimeType?,...}}`→`{ok,uploadId,status,url?,sha256?,...}`; `upload.status` | type `upload` + `{bytes(base64),contentType}`→`{url}`; gateway null→Unsupported | 🔴 wrong type + shape + Blob transport |
| **inc** | `inc.emit`; `inc.subscribe`/`.result`; `inc.unsubscribe`; `inc.event` push; channel mode (`inc.channel.*`) | (deferred; maps to null→denied) | not modeled |
| **intent** | invoke a napplet by archetype | | |
| **theme/notify/media/config/outbox/ifc/cvm** | further domains | | |
## Inconsistencies, ranked
1. **🔴 Shell handshake missing** (`shell.ready`→`shell.init`). Real napplets get an empty
capability environment → `supports()` false → likely bail. *Fix: host emits `shell.init`.*
2. **🔴 Id-less messages dropped** by the host. Breaks the handshake + every fire-and-forget
(`inc.emit`, `keys.unregisterAction`). *Fix: forward/handle id-less messages.*
3. **🔴 `keys.*` rejects** for real napplets (we only stub client-side). *Fix: decode
`keys.registerAction`/`unregisterAction`, answer a stub `{actionId}`; later wire `keys.action`.*
4. **🔴 `upload` non-conformant** (`upload` vs `upload.upload`, flat base64 vs `request:{data:Blob}}`,
`{url}` vs rich `UploadResult`) AND the Blob can't cross our string bridge. *Fix: realign the
wire + a Blob-aware request path when the gateway lands.*
5. **◐ `relay.query`/`subscribe` use only the first of `filters[]`.** Multi-filter napplets get
partial results. *Fix: honor all filters.*
6. **◐ Identity `getList`/`getZaps`/`getBadges` + `onChanged`** unimplemented.
7. **◐ `resource` `nostr:` scheme + `resource.cancel`** unimplemented.
8. **◐ `relay.closed` push** not emitted; **live subscription tail** absent (snapshot only).
9. **⚠️ Non-canonical `ok` on every result** (harmless, but not how the SDK signals success for
identity/storage/query).
## What the conformance tests assert
`NappletSdkConformanceTest` (amethyst, JVM) pins the codec to the **exact SDK wire** for the
methods we support, so a regression that drifts from `@napplet/nap` fails CI:
- **Requests:** the SDK's exact envelope (`relay.publish{event}`, `storage.get`, `identity.*`,
`resource.bytes`, multi-`filters`) decodes to the right `NappletRequest`.
- **Results:** `encodeResponse` emits the SDK's exact field names (`pubkey`, `profile`, `relays`,
`pubkeys`, `entries`, `value`, `keys`, `events`, `event`/`eventId`, `bytes`/`mime`).
- **Pushes:** `relay.event{subId,event}` / `relay.eose{subId}` match the SDK push shapes.
- **Gap guards:** tests that *document current behavior* for the known gaps (`shell.ready`,
`keys.registerAction`, `upload.upload`, `inc.emit` currently decode to `null`), each annotated
with the audit item so the day we fix them the guard flips intentionally.

View File

@@ -0,0 +1,114 @@
# Napplet / nsite security review (2026-06-22)
> **Status:** shipped — Security review recording completed hardening; `NappletLaunchRegistry` token model and per-applet origins are in tree.
> _Audited 2026-06-30._
A review of the attack surface for NIP-5A static sites (nsites) and NIP-5D napplets,
the protections in place, and the residual risks — with what was fixed in this pass
and what remains as future work.
## Trust model (what holds)
- **Keys never enter the sandbox.** The `:napplet` process holds no account/signer.
Signing happens only in the main process (`signer` fixes `pubkey`, host clock
prevents backdating). *"Even a full WebView/renderer escape into this process yields
no secret."*
- **Content integrity.** Every blob is sha256-verified against the **signed** manifest
before serving (`StaticSiteResolver`); cache is content-addressed + re-verified, so a
poisoned/stale cache can't be served. nsites launch with **zero** capabilities.
- **Network containment.** App CSP `connect-src 'none'`; egress only via the brokered,
consent-gated `resource.bytes`, Tor-routed. Bridge is origin-restricted + main-frame.
- **IPC binding** is `exported=false` + same-UID (`onBind` UID check). Payments always
prompt per-use with the amount.
## Fixed in this pass
1. **Cross-napplet identity/storage spoofing (was: per-message identity).** The broker
used to trust `author`/`identifier`/`declared` sent on **every** IPC message from the
`:napplet` process. A WebView→native escape could forge another napplet's coordinate
and read/act as it. Now the **main process** mints a random launch token
(`NappletLaunchRegistry`), hands only that token to the sandbox, and the broker
resolves it back to the trusted identity + declared set. A compromised sandbox can act
as nothing but the napplet it was launched as (it holds only its own token).
2. **Private mute/block leak via `identity.getMutes`/`getBlocked`.** These read
`muteList.flow` / `blockPeopleList.flow`, which contain **decrypted private** entries.
Now they read the events' **public** tags only (`MuteListEvent.publicMutes()`,
`PeopleListEvent.publicUsersIdSet()`).
3. **Silent "allow-always" actions + UI-redress.** Added persistent **trusted chrome**
(a sandbox bar the applet can't draw over: shield + name + tap-to-see "what it can
access") and a **live toast** when a granted RELAY/UPLOAD/VALUE op runs — so an
allow-always grant can't act completely silently. Also fixes the host drawing under
the status/navigation bars (edge-to-edge insets).
4. **Real per-applet storage origin (was: opaque sandbox → broken apps).** The applet
ran in an `allow-scripts`-only iframe served under `napplet.local/app/`, i.e. an
**opaque ("null") origin**. That has no `localStorage`/`IndexedDB`/service worker
(reads throw `SecurityError`), and module scripts / asset fetches are CORS-blocked, so
essentially every bundled SPA rendered **blank** or **crash-looped** ("cache version
0 → reset → reload" forever, because IndexedDB never persisted the version). Each
applet now loads on its **own** internal origin `https://<id>.napplet.local`
`id = sha256(author + ":" + identifier)`, truncated + letter-prefixed to a DNS label —
with `allow-scripts allow-same-origin`, served at the **origin root** (bundlers emit
absolute `/assets/...` URLs). A real origin restores DOM storage / IndexedDB / SW and
makes the applet's own assets same-origin (no CORS shim needed). **Isolation is
preserved precisely because the applet origin is distinct from the shell's:** the
bridge stays origin-restricted to the shell (`napplet.local`), so the cross-origin
applet still cannot reach it or read the shell DOM — it talks only via `postMessage`,
which the shell relays. Per-applet subdomains keep applets' storage isolated from one
another; CSP `connect-src 'none'` is unchanged; the app CSP was **tightened** to
`'self'` (it no longer grants the shell origin).
## Residual risks / future work
- **`allow-same-origin` is safe only while the applet origin ≠ the shell/bridge origin.**
A frame carrying *both* `allow-scripts` and `allow-same-origin` can strip its own
sandbox **iff it is same-origin with its embedder**; here it never is (applet on a
per-applet subdomain, shell on `napplet.local`), so it cannot. **Load-bearing
invariant — do not break:** never serve the applet on the shell origin, and never add
an applet origin (`*.napplet.local`) to the bridge's `addWebMessageListener` allowlist
(kept to `setOf(ORIGIN)`). Collapsing the two origins would hand the applet the bridge.
- **Persistent client storage is now a persistence/exfil surface.** The applet keeps
`localStorage`/`IndexedDB` across launches (origin-scoped, per applet, key-free and
isolated from other applets). A consented applet can build durable local state and,
combined with allow-always RESOURCE, a durable profile. Tie to the session-scoped-grant
proposal above; consider a "clear this applet's data" affordance.
- **Per-applet origin id.** `sha256(author:identifier)` is stable per applet and
collision-resistant; root/replaceable applets (kinds 15128/15129, no `d` tag) collapse
to one origin per author — fine, since there's one root per author. Changing the id
scheme later resets an applet's storage (new origin); acceptable.
- **Service workers are reachable but unwired.** With a real origin the applet *can* now
register a service worker; the host does not yet route SW fetches
(`ServiceWorkerControllerCompat`), so registration currently fails gracefully (a
warning, not a crash). If SW support is wired later, SW-originated fetches MUST go
through the same verified content server, never the network.
- **Coarse, persistent grants.** Non-payment capabilities persist as ALLOW_ALWAYS. A
consented napplet can still, thereafter, publish as you (RELAY), read your social graph
(IDENTITY), and make arbitrary network calls (RESOURCE) without re-prompting. The new
chrome + toasts make this *visible*, but the model is still allow-forever. **Proposed:**
a session-scoped grant ("Allow while open", cleared on close) in `GrantState` +
consent dialog, defaulted for RESOURCE/RELAY; and per-origin consent for cross-origin
`resource.bytes` https fetches.
- **`resource.bytes` as exfil channel.** Once RESOURCE is allow-always, the applet can
encode data into arbitrary https URLs through the Tor proxy. Tor hides the IP, not the
payload. Tie to the per-origin/session proposal above.
- **`'unsafe-inline'` in app `script-src`.** Required to inject the shim; the applet is
the author's own (content sha256-pinned), so it's not an escalation. `connect-src
'none'` remains the real boundary. Residual, accepted.
- **Trust pivots on the author key.** A compromised author key lets an attacker push a
new *signed* manifest and own the app — expected Nostr trust model. Aggregate `x` hash
is enforced when present (only *recommended* by the spec); per-path hashes always
protect.
- **Sandbox isolation is now structural.** The `:napplet` runtime
(`NappletHostActivity`, content server, IPC, key actions) lives in its own
`:nappletHost` module that depends only on `:commons` + `:quartz` — so it is
*compile-time incapable* of importing `Amethyst`/`LocalCache`/`Account`. The
broker-side (signer, gateways, `NappletLaunchRegistry`) stays in `:amethyst`;
the activity binds the broker service by class-name string and the two halves
communicate only over Messenger IPC.
- **Launch-token lifecycle.** Tokens are capped (LRU, 128) rather than explicitly
unregistered on sandbox close (the sandbox is a separate process and can't reach the
main-process registry). A long-backgrounded napplet whose token was evicted would need
relaunch. Acceptable; revisit if it bites.

View File

@@ -0,0 +1,55 @@
# Napplet NAP domains: theme, notify, inc
> **Status:** shipped — `NappletCapability` has `THEME`/`NOTIFY`/`INC` and `NappletIncBus` is wired into `NappletBrokerService`.
> _Audited 2026-06-30._
Date: 2026-06-23
Status: in progress
## Problem
Real-world demo napplets (e.g. kehto/web's `apps/playground/napplets/*`) hard-gate
their own boot: each reads `window.napplet.shell.supports(<domain>)` for every
domain in its manifest `requires` and aborts ("unavailable") if any is missing.
**Every** kehto demo `requires: theme`; most also need `inc`; toaster needs
`notify`. Amethyst's `fromNapDomain` returns `null` for `theme/notify/inc/cvm`,
so `shell.supports()` is false for them and all demos fail at boot.
These are real NAP service domains (kehto ships reference handlers). We add the
three the demos need (theme, notify, inc); `cvm` is deferred (its own design).
## Wire contracts (verified against kehto reference services + demos)
- **theme** — `theme.get``theme.get.result { theme: { colors: { background, text, primary } } }`.
Optional host push `theme.changed { theme }` (we skip the push for v1; the
app theme rarely changes while a napplet is foreground). Read-only, **no consent**.
- **notify** — `notify.create { title, body }``notify.created { id }` (past-tense,
**not** the generic `.result`), `notify.list``notify.listed { notifications }`,
`notify.dismiss { notificationId }` fire-and-forget. Consent-gated (ask once).
Host shows a system notification + tracks a per-coordinate store for list/dismiss.
- **inc** — a topic pub/sub bus. `inc.emit { topic, args, payload }` (fire-and-forget)
delivers `inc.event { topic, payload }` to **other** subscribed napplet sessions
(no echo to the sender). `inc.subscribe`/`inc.unsubscribe` register interest.
Gated on the INC declaration at the router edge (like `identity.watch`), no
per-call consent. NOTE: Amethyst runs napplets **foreground-only, one at a time**,
so cross-napplet delivery is usually a no-op in practice — but the bus is correct
if/when multiple sessions overlap, and it lets the demos boot + emit without error.
## Capability mapping & consent
`NappletCapability` gains `THEME`, `NOTIFY`, `INC`; `fromNapDomain` maps the bare
domains. `requiresConsent` is false for `SHELL` and `THEME` (negotiation/cosmetic),
true otherwise. INC is authorized at the router (declared-only) and never reaches
the broker consent path.
## Touch points
- commons: `NappletCapability`, `NappletRequest`, `NappletResponse`,
`NappletBrokerCollaborators` (new gateways), `NappletBroker`,
`protocol/NappletProtocolJson` (decode + custom reply types + inc/theme pushes),
`NappletRequestRouter` (inc edge ops).
- amethyst: `gateways/AccountNappletGateways` (+ theme/notify gateways), an
app-wide `NappletIncBus`, and `NappletBrokerService` wiring (notify store + inc
push transport, like `NappletLiveSubscriptions`/`NappletIdentityWatch`).
Staged commits: (1) theme, (2) notify, (3) inc.

View File

@@ -0,0 +1,183 @@
# Embedded napplet / nsite / browser tabs — final architecture
> **Status:** shipped — `EmbeddedTabLayer` and the `:nappletHost` module exist; embedded warm-tab + browser render paths are implemented (PR #3348).
> _Audited 2026-06-30._
**Date:** 2026-06-24
**Status:** Implemented on `claude/webview-menu-custom-url-ikrgbz` (PR #3348); needs on-device verification.
**Supersedes the rendering half of** `amethyst/plans/2026-06-19-napplet-sandbox-host.md`
(full-screen-only `NappletHostActivity` in `amethyst/androidMain`). The trust
model is unchanged and still governed by
`amethyst/plans/2026-06-22-napplet-nsite-security.md`.
## What changed since the sandbox-host plan
The 2026-06-19 design rendered a napplet/nsite **only** full-screen, in its own
`:napplet` Activity/task. This branch adds:
1. A second, **embedded** render path: warm bottom-bar "favorite" tabs that live
inside the main Amethyst window, swapped in place with no relaunch — built on
a cross-process `SurfaceControlViewHost` surface.
2. A **browser** host: the same keyless `:napplet` sandbox now also renders an
arbitrary user-typed URL (not just a verified-blob nsite/napplet), as both a
full-screen activity and an embedded tab.
3. A working **soft keyboard** inside the embedded surface (the cross-process
surface window cannot itself be an IME target).
4. The sandbox runtime extracted into its **own `:nappletHost` module** (depends
only on `:commons` + `:quartz`; cannot import `Amethyst`/`LocalCache`/`Account`).
5. **Per-site Tor / open-web** routing memory, and a startup **preloader** that
warms every bottom-bar favorite so the first tap is instant.
The keyless-sandbox guarantee is preserved throughout: keys live only in the main
process, every NIP-07 / `window.napplet` call is brokered + consent-gated, and the
embedded surface is z-ordered **below** the client window.
## Two render paths, one sandbox
| | Full-screen | Embedded tab |
|---|---|---|
| Container | `NappletHostActivity` / `NappletBrowserActivity` (`:napplet` task) | `SandboxedSdkView` in `EmbeddedTabLayer` (main window) |
| Surface | the Activity's own window | `SurfaceControlViewHost` shipped to the host `SandboxedSdkView` |
| Chrome (pull-down) | `NappletControlSheet` (native Android `View`s) | `TopControlSheet` (Compose) |
| Soft keyboard | the Activity's own window (native) | `RemoteImeView` proxy in the **main** window |
| Lifecycle | one session per task | many warm sessions, parked off-screen |
Both paths run the **same** `NappletHostService` / `NappletBrowserService` and the
same `shell.html` + `shim.js`; only the host/embedding differs.
## Persistent warm-surface layer (embedded)
`EmbeddedTabLayer` is mounted once in the app shell, below the drawer/dialogs. It
renders **every** warm session's `SandboxedSdkView` and keeps it attached:
- `EmbeddedTabHost` (object) owns the set of warm `sessions`, the active id, and
the reserved `contentBounds`. `setActive(id)` returns a token; `clearActiveIfOwner(token)`
only clears if the caller still owns it (so a fast tab A→B→A swap can't have B's
teardown clear A's just-set active id). `retainOnly(ids)` warm-keeps the
bottom-bar favorites (+ the momentarily-active tab) and evicts the rest.
- The active session is offset over the current tab's bounds; **inactive warm tabs
keep the same size and are merely shifted ~10 000 dp off-screen** (never resized
to 1 dp). Parking by translation rather than resize is what avoids the ~1 s black
flash on every tab switch (a resize forces a surface re-render).
- The surface is z-below, so Compose draws over it — that's how `TopControlSheet`
sits on top. Each surface is wrapped in `EmbeddedSurfaceTouchHolder` so a scroll
gesture isn't stolen by a host-side ancestor (the cross-process WebView can't
`requestDisallowInterceptTouchEvent` for itself).
`EmbeddedTabFactory` builds/acquires the per-app controller (`EmbeddedBrowserController`
/ `EmbeddedNappletController`, both `EmbeddedSurfaceController` + `EmbeddedImeBridge`)
and gates preloading on Tor (won't warm a Tor-routed site over clearnet while Tor is
still connecting).
## Per-session services (the multi-tab refactor)
`NappletHostService` / `NappletBrowserService` are **single shared instances** (same
bind `Intent`); per-tab state lives in a `NappletTab` / `BrowserTab` map keyed by the
client-stamped `KEY_SESSION_ID` (`NappletEmbedContract` / `NappletBrowserContract`).
Each tab carries its **own** reply `Messenger`, so broker responses + pushes route to
the right tab (the broker echoes `replyTo`). Critical correctness rules baked in:
- `contentServer` is `@Volatile` (read on the WebView worker thread in
`shouldInterceptRequest`, written on main).
- broker-reply delivery drops a reply whose tab was replaced (`tabs[id] !== tab`)
and wraps `postMessage` in `runCatching` (a torn-down WebView can't crash the relay).
- session close / `onDestroy` tears down **that tab's** content server + WebView only
(an earlier bug destroyed sibling tabs' WebViews → "open A, switch to B, back to A =
black").
## Embedded soft keyboard (IME proxy)
The embedded surface is a `SurfaceControlViewHost` window: in z-below mode it forwards
touch but **cannot be an IME target**, so a focused field would get no keyboard. The fix
mirrors Flutter's `TextInputPlugin`:
- `RemoteImeView` — an invisible, focusable `EditText` in the **main** app window takes
the keyboard. A real local `Editable` is the source of truth (the platform handles
composing regions, suggestions, autofill); edits are **coalesced across batch edits**
and the whole **editing state** (text + selection + composing) is shipped — not
individual ops. It flushes **synchronously** at the outermost `endBatchEdit` so a
compose-then-commit in one frame preserves the composing region.
- `shim.js` IME agent (gated on `window.__nappletImeProxy`) applies that state to the
focused field and synthesizes the matching DOM `input`/composition events so web
frameworks react as if typed natively. It is surrogate-pair-safe (emoji / CJK-supplement
never split into a lone surrogate) and supports `contenteditable` via Range-mapped char
offsets (in-place replacement, not a `textContent` overwrite), with selection-echo dedup.
- `EmbeddedTabLayer` shrinks the active surface to clear the keyboard using the **snapped**
`WindowInsets.imeAnimationTarget` (not the per-frame animated `ime`), so the expensive
cross-process surface resize happens once, not every animation frame.
State + ops cross over `MSG_IME_EVENT` / `MSG_IME_OP` (`EmbeddedImeBridge`). Full-screen
hosts set neither IME flag (they have a native keyboard).
## Per-site network routing
`WebUrlNetworkRegistry` (keyed by site host) and `NappletNetworkRegistry` (keyed by
`author:identifier`) remember whether a site routes through **Tor** (default) or the
**open web** — some servers reject Tor exits, so a user can opt one out and it must
survive relaunch. Both hydrate from DataStore asynchronously and now expose
`awaitReady()`; the preloader awaits hydration **before** its first routing decision so a
cold start can't route an open-web-pinned site through Tor. Live in the **main** process
only; the keyless sandbox never reads them (the launcher stamps the choice into the
launch intent).
## The two control-sheet twins
`TopControlSheet` (Compose, `:amethyst`, drawn in the main process over the z-below
surface) and `NappletControlSheet` (hand-built Android `View`s, `:nappletHost`, in the
keyless sandbox process) are deliberate twins: the sandbox module is Compose-free and
can't depend on `:amethyst`, so the composable can't be shared. They are kept visually
identical by hand — same 10 dp row rhythm, same muted-icon + framework `Switch` Tor row.
Both are a top-center pull-down (collapsed = a small grabber out of the corner where a
site puts its own avatar/menu): route over Tor, reload, "what it can access" (sandboxed
apps), open full screen.
## Bottom bar
Built-ins and favorites are one ordered list (`BottomBarEntry`, polymorphic with
`@SerialName("builtIn")` / `@SerialName("favorite")`). `UISharedPreferences.decodeBottomBarItems`
migrates the old persisted discriminator (the kotlinx default fully-qualified class name)
to the short names, falling back to `DefaultBottomBarEntries` — fixing the one-time bottom
bar reset the `@SerialName` change would otherwise have caused.
The **Browser** launcher (`BrowserScreen`) is an omnibox + the shared `FavoriteAppsGrid`;
each opened URL lands in its own full-screen `NappletBrowserActivity`. When the Browser is
reached from the drawer (pushed) rather than as a bottom-bar tab, its omnibox shows a back
arrow — the standard `nav.canPop()` rule (mirrors `NappletsTopBar`).
## File map (final)
**`:nappletHost`** (`com.vitorpamplona.amethyst.napplethost`, `:napplet` process):
`NappletHostService` / `NappletBrowserService` (per-session WebView hosts),
`NappletHostActivity` / `NappletBrowserActivity` (full-screen),
`NappletHostUiAdapter` / `NappletBrowserUiAdapter` (`SandboxedUiAdapter` for the embedded
surface), `NappletControlSheet` (native pull-down), `NappletContentServer` +
`NappletBlobHttp` + `NappletBlobCache` + `NappletBlobPrefetcher` (verified-blob serving,
Tor-routed, content-addressed), `NappletEmbedContract` / `NappletBrowserContract` /
`NappletHostContract` (Messenger wire keys), `NappletIpc`, `NappletKeyActions`,
`NappletWebViewInsets`.
**`:amethyst` embed** (`ui/screen/loggedIn/embed`): `EmbeddedTabLayer`, `EmbeddedTabHost`,
`EmbeddedTabFactory`, `EmbeddedTabChrome`, `EmbeddedSurfaceController`,
`EmbeddedSurfaceTouchHolder`, `EmbeddedImeBridge`, `RemoteImeView`, `TopControlSheet`,
`EmbeddedTabPreloader`, `TorToggleButton`.
**`:amethyst` main-process napplet** (`napplet/`): `NappletBrokerService`,
`NappletLauncher`, `NappletLaunchRegistry`, `WebUrlNetworkRegistry`,
`NappletNetworkRegistry`, `SandboxForegroundHold`, consent (`NappletConsent*`), gateways,
DataStore stores.
**`:amethyst` launchers/screens**: `BrowserScreen`, `FavoriteWebAppScreen`,
`FavoriteNappletScreen`, `FavoriteAppsScreen` + `FavoriteAppsGrid`.
**`:commons`**: `shell.html`, `shim.js` (`composeResources/files/napplet/`),
`FavoriteApp`/`FavoriteAppIcon`, `BottomBarEntry`.
## Residual / on-device verification
- All embedded-surface behavior (IME, scroll-gesture claiming, warm-keep across swaps, the
Tor shrink) needs emulator/device verification — `SurfaceControlViewHost` + the IME proxy
can't be unit-tested.
- The IME proxy runs a host-window `EditText`; it ships only editing **state** to the page,
never to any key material — the keyless-sandbox boundary is unaffected.
- Launch-token lifecycle, coarse persistent grants, and the `resource.bytes` exfil channel
remain as tracked in the 2026-06-22 security review.

View File

@@ -0,0 +1,259 @@
# Embedded text selection — native-Android parity
> **Status:** shipped — Doc states core feature-complete; host-drawn selection (handles, magnifier, IME proxy) landed in `EmbeddedTabLayer`/`RemoteImeView`.
> _Audited 2026-06-30._
**Status:** core feature-complete. The working set landed in `fix(embed): IME typing +
host-drawn text selection for embedded surfaces` (commit `e0a2a9ab81`); subsequent
sessions added the magnifier, the `SelectionUiState` refactor, hybrid word+char
handle-extend, and a run of polish/bug fixes (below). **The one open platform bug —
full-screen round-trip kills selection paint — is now FIXED** (root cause was
process-global `pauseTimers()` + an attached `WebView.destroy()`; see that section).
**2026-06-26 fixes (branch `fix/embed-ime-selection`):**
- **No-blink word-select** — the overlay handles + toolbar blinked 23× on long-press
word-select; cause was the shim's selection-*reveal* scrolls (a `<textarea>` auto-
scrolling to show a forming/re-asserted range) tripping the hide-on-scroll path. The
shim now timestamps selection activity (`lastSelActivityAt`) and treats a scroll within
350 ms as a reveal-scroll (reposition, don't hide, don't re-arm the timer). Plus a
`RemoteImeView` range-lost debounce as a safety net. (#2, #3)
- **Page selection clears on field focus** — focusing a field left the page-text handles
+ Copy bar up (the shim's page `selectionchange` is muted once a field is focused), and
being z-above they STOLE the field handle's drag. Fixed: `focusin` emits `pagesel:false`
(+ resets the scroll state); host `ImeEvent.Focus` also drops the page overlay. (#2)
- **Caret handle drag** moved the loupe but not the caret — the unified `awaitEachGesture`
did `change.consume()` BEFORE `change.positionChange()`, and `positionChange()` returns
`Offset.Zero` once consumed, so `fp` never accumulated. Fixed with
`positionChangeIgnoreConsumed()` (also immune to the sandbox surface consuming the move). (#10)
- **Hybrid word+char handle-extend** completed (#5, below).
- **Full-screen round-trip corruption FIXED** (was the open bug; see section).
- **Embed WebViews follow the APP theme** — separate from selection, but same surfaces:
see `embed-webview-prefers-color-scheme-limitation` memo / `EmbedWebViewTheme.kt`.
## Why we draw selection ourselves
Editable fields inside an embedded napplet/nsite/browser tab live in the keyless
`:napplet` process and render through `SurfaceControlViewHost` /
`SandboxedSdkView` (privacy-sandbox UI). A WebView rendered into an off-window
surface like this **cannot host the soft keyboard and cannot present Chrome's
own text-selection UI** (handles, the floating action-mode toolbar, the
magnifier). Chrome detects it has nowhere to put that UI and collapses the
selection to the focus endpoint.
So, exactly like Flutter's `TextInputPlugin` did for its virtual-display era, we
relay editing to the main process: an invisible `EditText` (`RemoteImeView`)
hosts the keyboard, `shim.js` mirrors DOM selection/caret geometry out over the
Messenger channel, and `EmbeddedTabLayer` draws the selection UI in Compose on
top of the surface. Everything we want for parity, we draw — the platform gives
us nothing here.
## What native Android gives a text field (the parity target)
This is the full feature inventory we are cloning, with activation/deactivation
rules, so we can check off coverage. Native impl lives in `android.widget.Editor`
(+ `SelectionActionModeHelper`, `android.widget.Magnifier`,
`PopupTouchHandleDrawable` on the Chrome side).
| # | Native feature | Activates | Deactivates | Our status |
|---|----------------|-----------|-------------|------------|
| 1 | **Insertion handle** (the teardrop "blob" under the caret) | tap in editable text; tap again to re-show | typing, scroll start, focus loss, ~4s inactivity timeout | ✅ `InsertionHandle`. **Native availability rule now matched (2026-06-25):** only shown when the field is NON-EMPTY (`Editor` gates the handle behind `text.length() > 0`, via `SelectionUiState.fieldHasText`) — fixes it popping up on focus of an empty box; hides on typing (`onEdited`), scroll (`scrolling`), focus loss, and ~4s inactivity (`hideCaret` timeout), re-showing on the next tap — via an explicit `ime.carettap` shim signal (DOM `click`), so a tap that doesn't move the caret still re-shows it. Device-verified. |
| 2 | **Selection handles** (asymmetric left/right teardrops) | long-press word, double-tap word, drag-extend | tap-collapse, typing, new selection | ✅ `SelectionHandle(isStart)` + drag-to-extend, for BOTH plain page text (`pageExtend`) AND in-field `<input>`/`<textarea>` selections (`fieldExtend`, 2026-06-25). The shim reports the selection's caret feet (`sx/sb`,`ex/eb`, flagged `rng`) via the same mirror-div as the caret; the host holds the range geometry separately so Chrome's transient collapse-to-caret (the re-assert fight) doesn't yank the handles to the field edges. **Tap-to-collapse (2026-06-25):** a single tap inside a selection dismisses it to a caret at the tapped offset + insertion handle — the shim's `click` handler collapses explicitly via `offsetFromPoint` (off-window Chrome doesn't do it itself). Device-verified. |
| 3 | **Floating toolbar** (Cut/Copy/Paste/Select-All/Share/…) | selection made, or tap insertion handle (Paste/Select-All) | scroll/fling (hides, returns on settle), handle drag (hides), tap-collapse | ⚠️ `EmbeddedSelectionToolbar` (Cut/Copy/Paste/Select-All). **Hide-during-handle-drag ✅ device-verified.** Hide-during-scroll via `SelectionUiState.scrolling` (shim `ime.scroll` + re-report on settle). **2026-06-26: the scroll path was hardened** — selection-*reveal* scrolls (forming/re-asserting a range auto-scrolls a `<textarea>`) are no longer treated as user scrolls (they blinked the overlays); the shim guards them via `lastSelActivityAt` and the hide self-heals instead of re-arming. (User content-scroll-hide still wants a clean on-device pass.) Still missing: overflow, Share/Web-Search/process-text. |
| 4 | **Magnifier / loupe** (the zoom bubble above the finger while dragging a handle or the caret) | finger down + moving on a handle or the caret | finger up | ✅ **Built (2026-06-25).** `Magnifier` bubble in [EmbeddedMagnifier.kt] follows the dragged caret/selection handle, showing live magnified page pixels captured in the `:napplet` provider and shipped over IPC ([EmbeddedMagnifierProbe], option B). Both embed paths wired (browser + napplet); verified on device for the browser path. Capture Y is locked to the caret/selection line (X follows the finger). Possible further polish: RGB_565 to cut encode, themed crosshair, clamp capture X to the line so a fast drag past EOL doesn't show blank. |
| 5 | **Word-granularity long-press** then char-extend | long-press | — | ✅ HYBRID word+char in-field handle-extend (2026-06-25, completed): `fieldExtend` keeps per-drag state (`fieldDragWordEnd`/`fieldDragWordStart`, reset on a >250ms gap or edge switch). The drag baselines at the current selection edge; sweeping PAST that word's far boundary snaps to the next whole word (`wordEndAt`/`wordStartAt`), while moving within/back from the furthest-reached word gives CHARACTER precision — so you can fine-tune to a single character (the previously-missing "then char" mode). Page-text extend stays char (no offset model). |
| 6 | **Double-tap = word, long-press = word, (triple-tap/drag = paragraph)** | tap count | — | ✅ double-tap + long-press both select a word (2026-06-25). Chrome word-selects on the 2nd tap, then abandons it by collapsing to the end (off-window quirk); the host re-assert restores it. The shim `click` handler DEFERS its tap-to-collapse ~300ms and the real `dblclick` cancels that timer, so the word selection survives (a timing-only guard was flaky ~40%). Triple-tap/paragraph not done. |
| 7 | **Smart selection / entity expansion** (`TextClassifier`: phone, URL, address, date → entity actions in toolbar) | selection lands on an entity | — | ❌ not built (low priority) |
| 8 | **Drag selected text** (long-press a selection → drag-and-drop to move) | long-press on existing selection | drop | ❌ not built (low priority) |
| 9 | **Auto-scroll while dragging to a viewport edge** | handle dragged near top/bottom edge | finger leaves edge / up | ✅ works (2026-06-25, user-confirmed). The drag driver (`onMagnify`) detects the finger in the surface's top/bottom edge zone and sends `ime.autoscroll`; the shim scrolls the textarea (else the window) and re-reports geometry, flagged so the hide-on-scroll path doesn't fire. Scrolls per drag-move in the edge zone (not on a perfectly-held finger). **Fixed alongside:** the nav drawer's left-edge swipe was hijacking the edge drag — `EmbeddedSelectionDrag.dragging` (set by `onMagnify`) now suspends the drawer's `gesturesEnabled` while a handle is dragged. |
| 10 | **Caret snapping to character boundaries** | always during caret/handle drag | — | ✅ via `offsetFromPoint` binary search + Y-clamp. **2026-06-26 fix:** the insertion-handle drag stopped moving the caret (loupe showed, caret frozen) — the unified `awaitEachGesture` consumed the pointer change BEFORE reading `positionChange()`, which returns `Offset.Zero` once consumed, so the accumulated finger position never advanced. Now reads `positionChangeIgnoreConsumed()` first. |
| 11 | **Themed handle/caret drawables + blink** | always | — | ✅/⚠️ The host-drawn handles use `colorScheme.primary` — which IS the native `textSelectHandle`/accent color — so the handle drawables are themed (the actionable part). The caret bar + selection-highlight are drawn by Chrome inside the off-window surface: the caret already blinks natively, and theming its color/the highlight would mean injecting CSS into arbitrary third-party pages (intrusive; `::selection` was already ruled out as non-painting), so those are intentionally left to Chrome. |
| 12 | **Insertion-handle Paste/Select-All mini-popup** | tap the insertion handle | tap elsewhere | ✅ built (2026-06-25). Tapping the bare insertion handle toggles a Paste/Select-All bar above the caret (`SelectionUiState.insertionPopup`, toggled from the handle's unified tap/drag gesture); tap-elsewhere/typing/blur/selection/scroll dismiss it. Fixed a latent bug: toolbar items now consume the *down* (not just the up) so the tap doesn't bleed through to the surface and blur the field. Device-verified (Select-all selects all text, field stays focused). |
Legend: ✅ done · ⚠️ partial · ❌ missing.
### Activation/deactivation is the hard part
Most of the bugs we already fixed were activation-timing bugs (cursor-jumps-to-end,
collapse-on-tap, focus-transfer races). The remaining features each carry their
own state machine.
**Done (2026-06-25): `SelectionUiState`** (`SelectionUiState.kt`) now centralizes
what used to be scattered flags in `EmbeddedTabLayer` (`showInsertionHandle`,
`showSelectionToolbar`, `fieldGeometry`, `rangeFieldGeometry`, `pageSelection`).
It holds the three mutually-exclusive contexts (insertion caret / in-field range /
page-text range) plus the transient modifiers `dragging` and `scrolling`, and
exposes derived visibility (`insertionHandle`, `fieldHandles`, `fieldToolbar`,
`pageHandles`, `pageToolbar`) so the rules are expressed once:
- **toolbar hides while a handle is dragged** (`dragging`, set from the same
`OnMagnify` lifecycle that drives the loupe) — the dragged handle also hides its
own teardrop (the loupe stands in), like Android; the other handle stays.
- **all overlays hide while scrolling** (`scrolling`, from the shim's `ime.scroll`);
the shim re-reports geometry just before `active=false` so they reappear
repositioned.
Still emergent / TODO: insertion-handle auto-timeout, tap-to-re-show.
**Selection-blink fix (2026-06-25).** A field selection — especially in a `<textarea>`
flickered: off-window Chrome abandons the selection by collapsing the caret to an
endpoint every ~25 ms, and the FIELD re-assert round-tripped through the host EditText
(`RemoteImeView.onPageState``ime.set` → page), leaving a visible collapsed frame each
cycle. Fix: re-assert field selections **synchronously in the shim's `selectionchange`
handler** (mirror of the page-text path that never blinked) — `lastFieldRange`/`lastFieldAt`
tracked via `noteSel()`, and a collapse-to-endpoint within 1500 ms is reverted with `setSel`
(guarded, not re-reported) so it reverts before paint. The host re-assert stays as a
fallback. Device-verified: textarea selection is stable; input select still works.
## Priority order for parity work
1. **Magnifier (#4).** Biggest perceived gap. We already report caret/handle
geometry; the magnifier needs a *magnified pixel view of the surface* at the
drag point. Options:
- **A. Compose-side zoom of a surface snapshot. ❌ RULED OUT (spiked 2026-06-25).**
The surface is a `SurfaceControlViewHost` — we can't trivially `Bitmap`-grab a
remote surface from the main process. We spiked `PixelCopy.request(SurfaceView, …)`
against the live embedded surface (`SurfaceMagnifierProbe`, wired into
`EmbeddedTabLayer` behind `BuildConfig.DEBUG`, fired on field focus). The capture
target is the privacysandbox `ContentView extends SurfaceView` — the only real
child of `SandboxedSdkView` once the session opens. **Result on a clearly-painted
surface (1080×2088): every capture returns `ERROR_SOURCE_NO_DATA`** (center
region, repeated 3×). Cause: the WebView pixels live in a *child* `SurfaceControl`
reparented under the SurfaceView via `ContentView.setChildSurfacePackage(...)`; the
host SurfaceView's *own* buffer is never drawn into, so `PixelCopy` on the parent
reads an empty buffer. Host-side pixel capture of the sandboxed content is not
available. (A `PixelCopy.request(Window, …)` against the host window would also
miss it — the sandbox layer is a *separate* SurfaceControl z-ordered below the
window.)
- **B. Capture in `:napplet` and ship the loupe content. ✅ SPIKED & VIABLE
(2026-06-25).** Inside the keyless provider the WebView IS a real in-window view, so
`WebView.draw(Canvas)` into a software bitmap renders real DOM pixels. Spike added
`MSG_MAGNIFIER_REQUEST`/`MSG_MAGNIFIER_FRAME` to `NappletBrowserContract`:
`NappletBrowserService.onMagnifierRequest` draws a zoomed slice
(`canvas.scale(zoom); translate(-(cx-box/2), -(cy-box/2)); webView.draw(canvas)`),
PNG-encodes it, and ships the bytes back; `EmbeddedBrowserController` (now also an
`EmbeddedMagnifierProbe`) requests on focus and `EmbeddedTabLayer` logs the result.
**10-frame burst, 160px source × 1.5× zoom → 240×240 PNG, center `#FF111111`
(real opaque content):** provider draw 0.71.2 ms steady (≈5 ms cold), provider
total draw+PNG 34 ms steady (≈12 ms cold), client round-trip 48 ms steady
(occasional ~18 ms), payload 815 KB (far under the 1 MB Binder limit). Comfortably
within a frame budget if throttled to ~30 fps.
**✅ Real loupe shipped (2026-06-25).** `MagnifierUiState` + `Magnifier`
(`EmbeddedMagnifier.kt`); the caret/selection handles call an `OnMagnify` callback
on drag start/move/end; `EmbeddedTabLayer` tracks the drag point, throttles capture
requests to one in flight (100 ms timeout), decodes each `MagnifierFrame` to an
`ImageBitmap`, and floats the bubble above the finger (clamped, flips below near the
top). Capture mirrored onto BOTH embed paths (browser:
`NappletBrowserContract`/`NappletBrowserService`/`EmbeddedBrowserController`;
napplet: `NappletEmbedContract`/`NappletHostService`/`EmbeddedNappletController`).
Verified on device (browser): drag → bubble shows live magnified "ello world" with
the caret, centered on the line → follows finger → hides on release. The dead
Option-A probe (`SurfaceMagnifierProbe`) was removed. **Polish done (2026-06-25):**
capture Y is locked to the authoritative caret/selection line (the handles pass a
`lineHalfPx` so the box centers on the line, not the finger or the caret foot); X
still follows the finger. Remaining nice-to-haves: RGB_565/raw to cut PNG encode,
themed crosshair, clamp capture X to the line so a fast drag past EOL isn't blank,
reuse one off-screen bitmap.
- **Gesture-routing caveat (found during the spike).** The host-drawn handles'
drag is fragile: the sandbox `ContentView.onTouchEvent` always returns `true`, so
via Compose's `AndroidView` interop it consumes the drag-move pointer and cancels
the overlay handle's `detectDragGestures` (synthetic `adb` drags on the handle
never produced an `onDrag`). The magnifier trigger should ride the existing
caret/selection-move path (which already round-trips through the shim), not a fresh
Compose drag layered over the surface.
2. **Toolbar state rules (#3 hide-during-drag/scroll) + insertion-handle popup
(#12).** Pure Compose/state work, no platform unknowns. Do alongside the
`SelectionUiState` refactor.
3. **Handle inactivity timeout + tap-to-re-show (#1).** Small.
4. **Double-tap-to-select (#6)** and **word-granularity drag (#5).**
5. **Auto-scroll (#9), themed drawables/blink (#11).**
6. Defer: smart selection (#7), drag-to-move (#8).
## ✅ FIXED — full-screen round-trip corrupted the embedded WebViews (2026-06-26)
**Symptom (was).** Open an embedded field's page in its own full-screen activity,
then `back` to the embedded version. From then on **every** embedded surface in the
`:napplet` process was broken — and it was far more than the selection highlight: DOM
reads returned empty (a field that visibly showed text reported `value == ""`, so typing
prepended at offset 0 and backspace did nothing), DNS died (`ERR_NAME_NOT_RESOLVED`), the
selection highlight stopped painting, and IME broke. The page showed a stale last frame
over a functionally-dead renderer.
**Root cause — two process-global defects in the full-screen hosts** corrupting the
shared multiprocess WebView state the embedded surfaces rely on. (Diagnosed with temporary
logging: page console → logcat, all `onReceivedError`/`onReceivedHttpError`, and the shim's
focused-field type/caret. The user's own insight — "the activity is gone but the service
doesn't come back; am I using something from the activity?" — pointed straight at it.)
1. **`pauseTimers()`/`resumeTimers()` are PROCESS-GLOBAL** (they pause JS, layout and
parsing timers for *every* WebView in the process). `NappletBrowserActivity` /
`NappletHostActivity` `onPause`/`onResume` and `NappletHostService`'s embed
pause/resume all called them on their own lifecycle — so returning from full-screen
*froze* the embedded surfaces, which had no resume of their own. **Fix:** removed ALL
process-global timer calls; rely only on per-WebView `onPause()`/`onResume()` (which
pause just that surface's JS/DOM — still meets the napplet background-security goal).
2. **`WebView.destroy()` while still attached to the window** corrupts the shared
multiprocess renderer (`cr_AwContents: "WebView.destroy() called while WebView is still
attached to window"`). **Fix:** `stopLoading()` + `(parent as ViewGroup).removeView(...)`
before `destroy()` in both full-screen activities' `onDestroy`.
Device-verified: the round-trip no longer corrupts the embeds. This supersedes the old
"recreate the session on return" hypothesis and the ruled-out attempts (`::selection` CSS,
surface resize, focus-cycle, `MSG_WAKE`) — none were the real cause.
## ✅ Embed WebViews follow the app theme (2026-06-26, separate concern)
Not text-selection, but the same off-window surfaces: embedded (and full-screen) WebViews
rendered web content in the *device* theme, ignoring the app's DARK/LIGHT preference.
**Root cause:** WebView's dark decision (`prefers-color-scheme` via algorithmic darkening)
reads the context's **theme** (`?android:attr/isLightTheme`), NOT just `Configuration.uiMode`
— and the off-window `SurfaceControlViewHost` surface context carries neither. The old
`applyNightMode` used `UiModeManager.setNightMode` (permission-gated no-op). **Fix:** build
every WebView from `nightThemedContext()``ContextThemeWrapper(createConfigurationContext(
<night|day>), Theme.DeviceDefault.DayNight)` for the resolved theme — shared in
`nappletHost/.../EmbedWebViewTheme.kt`, used by both embed services + both full-screen
activities. The full debugging arc (config-only context fails; `setForceDark` gone at
targetSdk 37; `setApplicationNightMode` does nothing; it's the unthemed context, not the
process boundary) is in the `embed-webview-prefers-color-scheme-limitation` memo. Upstream
WebView is still buggy here (a cross-process SCVH WebView ignores `uiMode`); the
theme-wrapper is the app-side workaround.
## How to test
The on-device harness lives at **`tools/ime-test/`** (`index.html` + `README.md`).
It's a single page with an `<input>`, a `<textarea>`, and an on-page log that
timestamps focus/selection/input/composition events, **paint latency**,
long-tasks, and main-thread blocks — the instrumentation that pinned the erase,
caret-jump, and first-letter-freeze bugs, and exactly what we'll want when
profiling the magnifier.
Run it (full details in `tools/ime-test/README.md`):
1. `cd tools/ime-test && python3 -m http.server 8765`
2. Reach it: emulator → `http://10.0.2.2:8765`; USB device →
`adb reverse tcp:8765 tcp:8765` then `http://localhost:8765`.
3. Open that URL in the **in-app browser** to load it as an *embedded* tab. (Opening
the same URL full-screen and pressing `back` used to reproduce the
highlight/corruption bug — now fixed; it's still the regression test for it.)
Console log lines are tagged `[ImeDiag]` and surface in `adb logcat` (the
`:napplet` process owns the WebView console). This is a dev tool — nothing under
`tools/` ships, which is why those diagnostic strings are kept out of `src/`.
## Key files
- `commons/src/commonMain/composeResources/files/napplet/shim.js` — DOM bridge.
Geometry sources: `caretCoords` (287), `offsetFromPoint` (318), `fieldGeom`
(336), `reportState` (360), `pageGeom` (462), `sendPageSel` (470), `pageExtend`
(494). A magnifier built via option B would add a loupe-render here.
- `amethyst/.../embed/EmbeddedTabLayer.kt` — the Compose overlay. `InsertionHandle`
(557), `SelectionHandle` (496), `EmbeddedSelectionToolbar` (616),
`PageSelectionOverlay` (460), the `showInsertionHandle`/`showSelectionToolbar`
state to be folded into a `SelectionUiState`. Magnifier popup (option A) lands
here.
- `amethyst/.../embed/RemoteImeView.kt` — invisible host `EditText`; selection
re-assert + copy/cut/paste/select-all + edit callbacks.
- `amethyst/.../embed/EmbeddedImeBridge.kt``SelectionGeometry` (caret + handle
feet + viewport), `ImeEvent.{Focus,State,PageSelection}`, `parseSelectionGeometry`.
- `amethyst/.../{browser/EmbeddedBrowserController,favorites/EmbeddedNappletController}.kt`
— parse `ime.pagesel` + geometry off the Messenger channel.
- Context: `amethyst/plans/2026-06-19-napplet-sandbox-host.md`,
`2026-06-24-napplet-embedded-tabs.md`.

View File

@@ -0,0 +1,66 @@
# Web-app naming overhaul (favorites / browser / app surfaces)
> **Status:** shipped — Renames applied — `WebAppScreen`, `NostrAppScreen`, `EmbeddedWebAppController`, `FavoriteAppsScreen` all present.
> _Audited 2026-06-30._
## Problem
The in-app "app" surfaces had colliding, sometimes inaccurate names:
- `software_apps` (NIP-89 native Android apps, an install-from-a-store flow) showed as
**"Apps"** — colliding with the in-app favorites, which showed as **"Favorite apps"**.
- The **host screens** that render a single web client / nSite / nApplet were named
`FavoriteWebAppScreen` / `FavoriteNappletScreen`. But they open *any* url/coordinate,
favorited or not — "Favorite" described how they happened to be reached (a pinned
bottom-bar tab), not what they are. And `FavoriteNappletScreen` also renders **nSites**
(website-mode), so "Napplet" was narrower than reality.
- Three vocabularies for two model cases: model `WebUrl`/`NostrApp`, route
`FavoriteWebApp`/`FavoriteNostrApp`, screen `FavoriteWebApp`/`FavoriteNapplet`.
## Taxonomy (decided with maintainer)
User-facing terms, now distinct:
| Concept | User-facing | What it is |
|---|---|---|
| Native app store | **App Store** | NIP-89 native Android apps you install off-device |
| Nostr web client | **Web app** | an `https://` client that runs in-app (WebView) |
| nApplet | **nApplet** | NIP-5D sandboxed JS app |
| nSite | **nSite** | NIP-5A static website |
| Pinned set | **Favorite** | a cross-cutting attribute (the star), *not* a screen |
Code axis (favorites / route / screen / embedded-controller layer): **`WebApp`** (url-based,
no nostr identity) and **`NostrApp`** (coordinate-based nSite *or* nApplet). The cross-process
sandbox infra (`napplet/`, `nappletHost/`, `NappletHostService`, `NappletEmbedContract`)
keeps **"Napplet"** — that process genuinely is the napplet host (it serves nSites in
website-mode too, but the host *is* the napplet runtime).
"Favorite" is reserved for the **grid of pinned apps** (`FavoriteAppsScreen` /
`Route.FavoriteApps`) and the star toggle — the only things that are actually about favorites.
## Renames
Routes: `FavoriteWebApp(url)``WebApp(url)`; `FavoriteNostrApp(coordinate)``NostrApp(coordinate)`.
Screens: `FavoriteWebAppScreen``WebAppScreen`; `FavoriteNappletScreen``NostrAppScreen`.
Controllers: `EmbeddedBrowserController``EmbeddedWebAppController`;
`EmbeddedNappletController``EmbeddedNostrAppController`.
Factory: `acquireBrowser`/`browserId``acquireWebApp`/`webAppId`;
`acquireNapplet`/`nappletId``acquireNostrApp`/`nostrAppId`.
Model: `FavoriteApp.WebUrl``FavoriteApp.WebApp`. Registry: `WebUrlNetworkRegistry`
`WebAppNetworkRegistry`.
Strings: `software_apps` "Apps" → "App Store"; `favorite_apps_empty` reworded to name
nApplet/nSite.
## Stable (do NOT change — persistence / wire compat)
- Favorite `id` prefixes `"url:"` / `"nostr:"` (persisted dedup + bottom-bar keys).
- DataStore names `"favorite_apps"`, `"weburl_network"`; serialized type tags `"url"` / `"nostr"`.
- `napplet/` + `nappletHost/` sandbox infra names and IPC contracts.
## Follow-ups (not in this pass)
- Move `NostrAppScreen` + `EmbeddedNostrAppController` out of the `ui/...favorites/`
package (they are no longer favorites-specific) into a host package alongside the web side.
- Recent nApplets / nSites (parallel to the browser's recent web apps), surfaced on the
discovery screens.

View File

@@ -0,0 +1,80 @@
# nSite / nApplet favorite icons
> **Status:** shipped — `quartz/.../NappletIconPath.kt` and `amethyst/.../favorites/NappletFavoriteIcon.kt` are in tree.
> _Audited 2026-06-30._
**Date:** 2026-06-26
**Status:** implemented (pending on-device verification of the blob image-load path)
## Problem
When a user favorites a plain web app and pins it to the bottom nav, the generic globe
icon is replaced by the **site's favicon**. Favorited nSites (NIP-5A) and nApplets
(NIP-5D) did not get the same treatment — they fell back to the generic grid glyph.
## Why the webapp trick doesn't carry over
The webapp favicon is **captured live** from the WebView that loads the page
(`NappletBrowserActivity.onReceivedIcon` → IPC `MSG_RECORD_ICON`
`BrowserIconRegistry`, keyed by host). That works because, for a plain webapp, the site
**is** the WebView's main frame.
nSites/nApplets render differently: they always load inside a **cross-origin sandboxed
iframe** under a trusted shell document (`commons/.../composeResources/files/napplet/shell.html`,
`iframe.src = '__APP_ORIGIN__/'`). `WebChromeClient.onReceivedIcon` only reports the
**main frame's** favicon — i.e. the shell (`<title>Napplet</title>`, no icon), never the
applet's iframe. So the live-capture approach is structurally blind to the app's own
favicon here, and mirroring it would silently show nothing.
## Approach: derive the icon from the manifest's own bundled blobs
An nSite/nApplet ships its files as `path → sha256` (`path` tags). Its icon is almost
always one of those blobs (a conventional `/favicon.png`, `/icon.png`,
`/apple-touch-icon.png`, …). We already download + sha256-verify every manifest blob into
a shared, content-addressed cache (`NappletBlobCache` / `NappletBlobPrefetcher`,
Tor-routed). So the icon can be resolved from the manifest itself — no WebView, no iframe
problem, content-addressed and verifiable, on the same private network path as everything
else.
### Resolution priority (per favorite)
1. **Captured/bundled blob** (`iconModel`) — the conventional icon path picked from the
manifest's blobs, loaded from the verified cache as a `file://` model.
2. **Manifest `icon` tag** (`FavoriteApp.iconUrl`) — the publisher-declared icon URL
(already wired before this change).
3. **Type glyph** — grid (nostr app) / globe (web), already the fallback in
`FavoriteAppIcon`.
Blob beats the `icon` URL deliberately: the blob is verified and rides the site's
Tor-routed path, whereas a remote `icon` URL would be a clearnet fetch by Coil. Both still
beat the glyph.
## Changes
- **quartz** `nip5aStaticWebsites/NappletIconPath.kt` (new) — pure, unit-tested heuristic
that picks the best icon `PathTag` from a manifest's `path` tags (priority list of
conventional names + a loose raster fallback; prefers shallower paths; raster formats
over `.ico`/`.svg`). Tests in `NappletIconPathTest.kt`.
- **quartz** — `NappletManifest.iconBlob()` (covers nApplet kinds) and
`RootSiteEvent.iconBlob()` / `NamedSiteEvent.iconBlob()` (nSite kinds) delegate to it.
- **amethyst** `favorites/NappletFavoriteIcon.kt` (new) — `rememberNappletIconModel(coordinate)`:
re-resolves the live event from `LocalCache`, picks its icon blob, ensures it's in the
shared cache (prefetching on demand, off the composition thread), and returns a `file://`
Coil model. Returns null until the blob is on disk (icon appears on next recomposition).
- **amethyst** — `AppBottomBar` and `FavoriteAppsScreen` now resolve that model for
`FavoriteApp.NostrApp` and pass it as `iconModel`, exactly as they already did with the
captured favicon for `FavoriteApp.WebApp`.
`FavoriteApp` is unchanged (no persistence migration): the icon is resolved from the live
manifest at render time, consistent with the existing rule that a `NostrApp` favorite is
only usable while its event is resolvable in `LocalCache`.
## Follow-ups / not done
- **On-device verification** of the blob → Coil image load (the heuristic + wiring are
verified by unit tests + compilation; the actual image render needs a device).
- **HTML `<link rel="icon">` parsing.** The heuristic matches by conventional file name.
A future pass could fetch + parse the index blob to honor a non-conventional icon path.
- **`.svg` / `.ico` decoding.** Listed as low-priority candidates; if Coil can't decode
them the `FavoriteAppIcon` error fallback shows the glyph, so it's harmless but not
guaranteed to render.

View File

@@ -21,8 +21,8 @@
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.service.cashu.CashuParser
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.runBlocking

View File

@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst
import android.graphics.Bitmap
import android.graphics.Color
import androidx.core.graphics.createBitmap
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.model.AccountSettings
@@ -81,7 +82,7 @@ class ImageUploadTesting {
.build()
private fun getBitmap(): ByteArray {
val bitmap = Bitmap.createBitmap(200, 300, Bitmap.Config.ARGB_8888)
val bitmap = createBitmap(200, 300)
for (x in 0 until bitmap.width) {
for (y in 0 until bitmap.height) {
bitmap.setPixel(x, y, Color.rgb(Random.nextInt(), Random.nextInt(), Random.nextInt()))

View File

@@ -67,13 +67,16 @@ class NotificationFeedFilterModeOverrideTest {
private val client =
NostrClient(
OkHttpWebSocket.Builder {
OkHttpClient
.Builder()
.followRedirects(true)
.followSslRedirects(true)
.build()
},
OkHttpWebSocket.Builder(
httpClient = {
OkHttpClient
.Builder()
.followRedirects(true)
.followSslRedirects(true)
.build()
},
canDial = { true },
),
scope,
)

View File

@@ -58,13 +58,16 @@ class ThreadDualAxisChartAssemblerTest {
val client =
NostrClient(
OkHttpWebSocket.Builder {
OkHttpClient
.Builder()
.followRedirects(true)
.followSslRedirects(true)
.build()
},
OkHttpWebSocket.Builder(
httpClient = {
OkHttpClient
.Builder()
.followRedirects(true)
.followSslRedirects(true)
.build()
},
canDial = { true },
),
scope,
)

View File

@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.service.images
import android.graphics.Bitmap
import androidx.core.graphics.createBitmap
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.After
@@ -44,7 +45,7 @@ class ThumbnailDiskCacheInstrumentedTest {
cacheDir = File(appContext.cacheDir, "thumbnail-test-${UUID.randomUUID()}")
cache = ThumbnailDiskCache(cacheDir)
sourceFile = File(appContext.cacheDir, "source-${UUID.randomUUID()}.jpg")
val bitmap = Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888)
val bitmap = createBitmap(64, 64)
sourceFile.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) }
bitmap.recycle()
}

View File

@@ -97,7 +97,7 @@ class EventSyncTest {
RelayAuthenticator(
newClient,
appScope,
signWithAllLoggedInUsers = { authTemplate ->
signWithAllLoggedInUsers = { _, authTemplate, _ ->
listOf(signer.sign(authTemplate))
},
)

View File

@@ -54,6 +54,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- Phone calls -->
@@ -105,7 +106,6 @@
android:supportsRtl="true"
android:theme="@style/Theme.Amethyst"
android:largeHeap="true"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config"
android:hardwareAccelerated="true"
android:localeConfig="@xml/locales_config"
@@ -152,6 +152,14 @@
<data android:scheme="amethyst+walletconnect" />
</intent-filter>
<!-- NIP-46: an app's `nostrconnect://` offer opens the signer screen, which pairs it. -->
<intent-filter android:label="Amethyst">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nostrconnect" />
</intent-filter>
<intent-filter android:label="New Post">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
@@ -194,6 +202,16 @@
<data android:host="iris.to" />
</intent-filter>
<!-- Concord community invite links: https://amethyst.social/invite/<naddr>#<fragment> -->
<intent-filter android:label="Amethyst">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="amethyst.social" />
<data android:pathPrefix="/invite/" />
</intent-filter>
<intent-filter android:label="zap.stream">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@@ -347,6 +365,24 @@
android:stopWithTask="true"
android:exported="false" />
<!-- Keeps the NIP-13 mining queue schedulable after the user leaves the
app: shortService gives a ~3 min guaranteed window with no special
permission. Jobs are persisted, so a timeout only defers them. -->
<service
android:name=".service.pow.PowMiningForegroundService"
android:foregroundServiceType="shortService"
android:stopWithTask="false"
android:exported="false" />
<!-- Keeps the "sync all blobs to all servers" (BUD-04) sweep alive while backgrounded.
dataSync is the correct type for an upload/download/sync; it must be started from the
foreground, which "Sync all" (user-initiated in the manager) satisfies. -->
<service
android:name=".service.uploads.blossom.BlossomSyncForegroundService"
android:foregroundServiceType="dataSync"
android:stopWithTask="false"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
@@ -366,6 +402,17 @@
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." />
</service>
<service
android:name=".service.notifications.NotificationServiceTileService"
android:icon="@drawable/amethyst_service"
android:label="@string/always_on_notif_tile_label"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
android:exported="true">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<receiver
android:name=".service.notifications.BootCompletedReceiver"
android:exported="false">
@@ -402,6 +449,71 @@
android:name=".service.call.CallNotificationReceiver"
android:exported="false" />
<!-- Sandboxed napplet/nsite host. Runs in an isolated process that holds no keys. -->
<activity
android:name="com.vitorpamplona.amethyst.napplethost.NappletHostActivity"
android:process=":napplet"
android:exported="false"
android:autoRemoveFromRecents="true"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|keyboard|uiMode|navigation|fontScale|density"
android:launchMode="singleTask"
android:theme="@style/Theme.Amethyst" />
<!-- Direct-WebView browser for a single web client. Runs in the isolated, keyless `:napplet`
process and hosts the WebView directly (not a streamed surface), so scroll/zoom/keyboard work
natively. adjustResize shrinks the window for the soft keyboard. Its own task/recents entry. -->
<activity
android:name="com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity"
android:process=":napplet"
android:exported="false"
android:autoRemoveFromRecents="true"
android:documentLaunchMode="intoExisting"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|keyboard|uiMode|navigation|fontScale|density"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Amethyst" />
<!-- Capability-consent dialog. Runs in the main process (the only side trusted to grant). -->
<activity
android:name=".napplet.NappletConsentActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTop"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<!-- First-connect "Connect to Nostr" dialog. -->
<activity
android:name=".connectedApps.consent.SignerConnectActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTop"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<!-- Per-operation signer consent dialog. -->
<activity
android:name=".connectedApps.consent.SignerConsentActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTop"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<!-- Main-process broker: holds the signer and brokers capabilities for the sandbox. -->
<service
android:name=".napplet.NappletBrokerService"
android:exported="false" />
<!-- Embedded-browser provider: hosts the in-app browser WebView in the isolated, keyless
process and ships its rendered surface to the main app (SurfaceControlViewHost). -->
<service
android:name="com.vitorpamplona.amethyst.napplethost.NappletBrowserService"
android:process=":napplet"
android:exported="false" />
<!-- Embedded nsite/napplet provider: hosts the verified-blob WebView in the isolated, keyless
process and ships its surface to the main app, so a favorited nsite/napplet can render as
an in-app tab instead of taking over the screen. Same trust model as NappletHostActivity. -->
<service
android:name="com.vitorpamplona.amethyst.napplethost.NappletHostService"
android:process=":napplet"
android:exported="false" />
</application>

View File

@@ -21,11 +21,39 @@
package com.vitorpamplona.amethyst
import android.app.Application
import android.content.ComponentCallbacks2
import android.os.Build
import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
import com.vitorpamplona.amethyst.service.logging.Logging
import com.vitorpamplona.amethyst.service.nests.AppForegroundRecycleHook
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabHost
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.LogLevel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File
/**
* The Android [Application]. **Heads-up: this app runs in TWO OS processes**, and Android
* instantiates this same class in each of them:
*
* - **main** — the normal app (UI, account, signer, `LocalCache`, relay client). [instance]
* ([AppModules]) is built here in [onCreate].
* - **`:napplet`** — the sandboxed WebView host for NIP-5D napplets / NIP-5A nSites
* (`NappletHostActivity`, declared `android:process=":napplet"`). It holds **no** account or keys.
* [onCreate] early-returns here, so [instance] is **left unset** and any access throws.
*
* There is no per-process Application in Android — `android:name` is one class for the whole package —
* so the process-name guard ([isNappletSandbox]) is how the two are kept apart.
*
* **Processes don't share memory:** every `object`/companion/`static` (e.g. `LocalCache`,
* `NappletLaunchRegistry`) is a *separate copy per process*. Don't assume [instance] exists off the
* main process, and don't try to share state via a singleton across the boundary — use Messenger IPC.
*/
class Amethyst : Application() {
init {
Log.minLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.ERROR
@@ -37,11 +65,47 @@ class Amethyst : Application() {
private set
}
/**
* Android instantiates this single Application class in EVERY process (there's no per-process
* Application in the manifest). The sandboxed napplet host runs in `:napplet`, where we must not
* build [AppModules] — so this is computed once and gates every lifecycle callback that would
* otherwise touch the unset [instance].
*/
private val isNappletSandbox by lazy { isNappletSandboxProcess() }
override fun onCreate() {
super.onCreate()
Log.d("AmethystApp") { "onCreate $this" }
// Application.onCreate runs in every process. The sandboxed napplet host
// (`:napplet`) must NOT build AppModules — that would load the account and
// construct the signer in the very process we keep secret-free. The host
// is self-contained (WebView + IPC), so we skip all app init there and
// leave `instance` unset; any accidental use fails fast.
if (isNappletSandbox) {
Log.d("AmethystApp") { "Skipping AppModules init in sandbox process" }
return
}
instance = AppModules(this)
// Hydrate the device-local favorite-apps list (main process only; the sandbox never reads it).
FavoriteAppsRegistry.init(this)
// Hydrate the device-local browser visit history (main process only; feeds the omnibox suggestions).
BrowserHistoryRegistry.init(this)
// Index device-local captured favicons (main process only; decorates favorites + suggestions).
BrowserIconRegistry.init(this)
// Warm the global-settings prefs off-main so the first (deliberately synchronous) read of
// them does not hit disk on the main thread. See LocalPreferences.warmGlobalSettings.
CoroutineScope(Dispatchers.IO).launch { LocalPreferences.warmGlobalSettings() }
// Hydrate the per-web-client Tor routing preferences so a site opted out of Tor (some reject Tor
// exits) starts on the open web without first flashing a failed Tor load.
WebAppNetworkRegistry.init(this)
// After-background foreground recycle: when the app returns to
// the foreground after spending more than ~5 s in the
// background, publish a network-change event so every active
@@ -53,6 +117,10 @@ class Amethyst : Application() {
// kdoc for the threshold rationale.
registerActivityLifecycleCallbacks(AppForegroundRecycleHook())
// Foreground signal for the resource-usage ledger (fg/bg attribution
// of bytes and connection-time). Main process only.
registerActivityLifecycleCallbacks(instance.foregroundTracker)
if (isDebug) {
Logging.setup()
// Auto-enable the Nests session-trace recorder in debug
@@ -66,9 +134,22 @@ class Amethyst : Application() {
instance.initiate(this)
}
/** True when this process is the isolated `:napplet` WebView host (see AndroidManifest). */
private fun isNappletSandboxProcess(): Boolean {
val name =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
getProcessName()
} else {
runCatching { File("/proc/self/cmdline").readText().substringBefore('\u0000').trim() }.getOrNull()
}
return name?.endsWith(":napplet") == true
}
override fun onTerminate() {
super.onTerminate()
Log.d("AmethystApp") { "onTerminate $this" }
// The sandbox process never built AppModules; `instance` is unset there.
if (isNappletSandbox) return
instance.terminate(this)
}
@@ -80,6 +161,18 @@ class Amethyst : Application() {
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
Log.d("AmethystApp") { "onTrimMemory $level" }
instance.trim()
// onTrimMemory IS delivered to the `:napplet` process on real devices, where `instance`
// was never initialized — guard so a trim callback can't crash the sandbox.
if (isNappletSandbox) return
instance.trim(level)
// Drop warm embedded tab sessions under genuine memory pressure (decision: keep warm until the
// user or Android reclaims them). Since API 34 the OS only delivers UI_HIDDEN and BACKGROUND:
// BACKGROUND means the process is on the system LRU list (real reclaim pressure), while UI_HIDDEN
// fires on every app switch — so evict only at BACKGROUND and above, letting a pinned tab survive
// a plain backgrounding. R+ only.
val pressure = level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND
if (pressure && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
EmbeddedTabHost.evictAll()
}
}
}

View File

@@ -20,14 +20,24 @@
*/
package com.vitorpamplona.amethyst
import android.content.ComponentCallbacks2
import android.content.Context
import android.os.BatteryManager
import androidx.security.crypto.EncryptedSharedPreferences
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStore
import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlEndpointResolver
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue
import com.vitorpamplona.amethyst.commons.tor.TorSettings
import com.vitorpamplona.amethyst.connectedApps.DataStoreNostrSignerPermissionStore
import com.vitorpamplona.amethyst.connectedApps.nip46.DataStoreNip46ClientStore
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.UiSettings
@@ -43,6 +53,9 @@ import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector
import com.vitorpamplona.amethyst.model.torState.TorRelayState
import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderPrefs
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker
import com.vitorpamplona.amethyst.service.cast.CastRegistry
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus
@@ -60,24 +73,44 @@ import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManagerForRelays
import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
import com.vitorpamplona.amethyst.service.okhttp.SurgeDns
import com.vitorpamplona.amethyst.service.okhttp.SurgeDnsStore
import com.vitorpamplona.amethyst.service.okhttp.OnionLocationCache
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCacheFactory
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient
import com.vitorpamplona.amethyst.service.pow.PowJobRestorer
import com.vitorpamplona.amethyst.service.pow.PowJobStore
import com.vitorpamplona.amethyst.service.pow.PowMiningForegroundService
import com.vitorpamplona.amethyst.service.relayClient.CacheClientConnector
import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector
import com.vitorpamplona.amethyst.service.relayClient.TorCircuitHealthTracker
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator
import com.vitorpamplona.amethyst.service.relayClient.diagnostics.BootRelayDiagnostics
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger
import com.vitorpamplona.amethyst.service.resourceusage.BatteryDrainSampler
import com.vitorpamplona.amethyst.service.resourceusage.ForegroundTimeIntegrator
import com.vitorpamplona.amethyst.service.resourceusage.ForegroundTracker
import com.vitorpamplona.amethyst.service.resourceusage.HttpUsageMeter
import com.vitorpamplona.amethyst.service.resourceusage.MeteringNostrSigner
import com.vitorpamplona.amethyst.service.resourceusage.ProcessCpuSampler
import com.vitorpamplona.amethyst.service.resourceusage.RadioBurstEstimator
import com.vitorpamplona.amethyst.service.resourceusage.RelayConnectionTimeIntegrator
import com.vitorpamplona.amethyst.service.resourceusage.RelayUsageListener
import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageAccountant
import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageStore
import com.vitorpamplona.amethyst.service.resourceusage.ScreenTimeIntegrator
import com.vitorpamplona.amethyst.service.resourceusage.SessionTimeIntegrator
import com.vitorpamplona.amethyst.service.resourceusage.UsageCountingInterceptor
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
import com.vitorpamplona.amethyst.service.safeCacheDir
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStore
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorkGate
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomMirrorQueue
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomSyncForegroundService
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.LocalBlossomCacheProbe
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
@@ -87,13 +120,20 @@ import com.vitorpamplona.amethyst.ui.screen.AccountState
import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
import com.vitorpamplona.amethyst.ui.tor.TorManager
import com.vitorpamplona.amethyst.ui.tor.TorService
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker
import com.vitorpamplona.quartz.nip01Core.relay.client.limits.RelayLimitsTracker
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats.RelayReqStats
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CachingEventDecoder
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDns
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDnsStore
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
import com.vitorpamplona.quartz.nip03Timestamp.okhttp.OkHttpBitcoinExplorer
import com.vitorpamplona.quartz.nip03Timestamp.ots.OtsBlockHeightCache
@@ -110,19 +150,29 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinBackend
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinCoreRpcClient
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.TOR_ELECTRUMX_SERVERS
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.CachingOnchainBackend
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.EsploraBackend
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filterNotNull
@@ -148,6 +198,9 @@ class AppModules(
val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler)
private val _trimLevelEvents = MutableSharedFlow<Int>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val trimLevelEvents = _trimLevelEvents.asSharedFlow()
// Pre-load both preference DataStores in parallel on IO threads.
// Both constructors use runBlocking internally, so starting them concurrently
// reduces total blocking time from (torPrefs + uiPrefs) to ~max(torPrefs, uiPrefs).
@@ -190,7 +243,7 @@ class AppModules(
// App services that should be run as soon as there are subscribers to their flows
val locationManager by lazy {
Log.d("AppModules", "LocationManager Init")
LocationState(appContext, applicationIOScope)
LocationState(appContext, applicationIOScope, onListening = { locationSession.setActive(it) })
}
val connManager = ConnectivityManager(appContext, applicationIOScope)
@@ -199,7 +252,8 @@ class AppModules(
UiSettingsState(uiPrefs.value, connManager.isMobileOrFalse, applicationIOScope)
}
val torManager = TorManager(torPrefs, TorService(appContext), applicationIOScope)
private val torService = TorService(appContext)
val torManager = TorManager(torPrefs, torService, applicationIOScope)
// Network identity change (wifi↔cellular, regained from offline, captive portal
// cleared) — the old network's guards/circuits are dead, and Arti's in-memory
@@ -232,6 +286,109 @@ class AppModules(
// path on first lookup. Stored in cacheDir — pure perf data, OK if the OS evicts it.
val dnsStore = SurgeDnsStore(File(appContext.safeCacheDir(), SurgeDnsStore.FILE_NAME), surgeDns)
// Network identity change (same trigger as Tor's onNetworkChange above), but for DNS
// the response is deliberately SOFT: most cached answers are still correct on the new
// network, so staleAll() keeps serving every one of them and merely re-verifies —
// positives revalidate in the background on next use, negatives (e.g. hosts that only
// failed because the OLD network / captive portal couldn't resolve them) get re-tried
// on first touch instead of waiting out their TTL. Nothing is dropped, nothing blocks.
init {
applicationIOScope.launch {
connManager.status
.map { (it as? ConnectivityStatus.Active)?.networkId }
.filterNotNull()
.distinctUntilChanged()
.drop(1)
.collect { surgeDns.staleAll() }
}
}
// Shared cache populated by OnionLocationInterceptor from any HTTP/WebSocket
// response carrying an Onion-Location header. Consulted by OnionUrlRewriteInterceptor
// on Tor-enabled clients to transparently redirect to .onion addresses.
val onionLocationCache = OnionLocationCache()
// ---- Resource-usage ledger (battery/data accounting) ----
// Passive on-device counters (bytes per subsystem x network x visibility,
// relay connection-time, wakelock time, worker runs). Never transmitted;
// the user can review them in Settings and explicitly DM a report to the
// developers. See amethyst/plans/2026-07-12-resource-usage-ledger.md.
val foregroundTracker = ForegroundTracker()
val resourceUsageStore = ResourceUsageStore(File(appContext.filesDir, ResourceUsageStore.FILE_NAME))
val resourceUsage = ResourceUsageAccountant(resourceUsageStore, applicationIOScope)
// Estimates radio wake-ups from HTTP burst patterns — bytes alone don't
// predict battery; scattered small requests each pay the radio ramp+tail.
private val radioBurstEstimator =
RadioBurstEstimator(
accountant = resourceUsage,
isMobile = { connManager.isMobileOrFalse.value },
isForeground = { foregroundTracker.isForeground.value },
)
// Single catch-all counter on the shared non-relay HTTP client: role
// wrappers only relabel via request tags, so no HTTP traffic (including
// direct getHttpClient users like the napplet broker) escapes the ledger.
private val httpUsageInterceptor =
UsageCountingInterceptor(
accountant = resourceUsage,
isMobile = { connManager.isMobileOrFalse.value },
isForeground = { foregroundTracker.isForeground.value },
bursts = radioBurstEstimator,
)
private val httpUsageMeter = HttpUsageMeter()
// Session-time counters for the app's long-running battery consumers.
// All timer-free segment integrators: services and status flows flip them
// on/off, so tracking costs one counter write per transition.
val alwaysOnSession = SessionTimeIntegrator(resourceUsage, UsageKeys.ALWAYS_ON_MS, UsageKeys.ALWAYS_ON_STARTS).also { it.registerFlushHook() }
val callSession = SessionTimeIntegrator(resourceUsage, UsageKeys.CALL_MS, UsageKeys.CALL_SESSIONS).also { it.registerFlushHook() }
val nestsSession = SessionTimeIntegrator(resourceUsage, UsageKeys.NESTS_MS, UsageKeys.NESTS_SESSIONS).also { it.registerFlushHook() }
private val powSession = SessionTimeIntegrator(resourceUsage, UsageKeys.POW_MS, UsageKeys.POW_SESSIONS).also { it.registerFlushHook() }
private val torSession = SessionTimeIntegrator(resourceUsage, UsageKeys.TOR_MS, UsageKeys.TOR_STARTS).also { it.registerFlushHook() }
private val locationSession = SessionTimeIntegrator(resourceUsage, UsageKeys.LOCATION_MS).also { it.registerFlushHook() }
// Time-per-screen (route base names only — arguments never reach the
// ledger). Fed by the navigation listener in AppNavigation; foreground
// gating means backgrounding on a screen closes its segment.
val screenTime = ScreenTimeIntegrator(resourceUsage)
init {
screenTime.start(applicationIOScope, foregroundTracker.isForeground)
}
// In-app (Arti) Tor uptime. Watches the raw TorService status — NOT
// TorManager.status, whose upstream is WhileSubscribed and calls
// service.start() when collected, so a permanent ledger subscription
// there would keep Tor's control flow alive on its own. External Tor
// (Orbot) is deliberately untracked: its battery belongs to Orbot.
init {
applicationIOScope.launch {
torService.status
.map { it is TorServiceStatus.Active }
.distinctUntilChanged()
.collect { torSession.setActive(it) }
}
}
// Measured battery drain (percent while discharging, fg/bg) — the ground
// truth the app counters get correlated against. One binder read per
// ledger flush, nothing while idle.
init {
val batteryManager = appContext.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager
if (batteryManager != null) {
BatteryDrainSampler(
accountant = resourceUsage,
capacityPct = { batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY).takeIf { it in 1..100 } },
isCharging = { batteryManager.isCharging },
isForeground = { foregroundTracker.isForeground.value },
).register()
}
}
// manages all the other connections separately from relays.
val okHttpClients: DualHttpClientManager =
DualHttpClientManager(
@@ -250,10 +407,12 @@ class AppModules(
val profileOnly = settings?.localBlossomCacheProfilePicturesOnly?.value ?: false
master && !profileOnly && localBlossomCacheProbe.available.value
},
onionCache = onionLocationCache,
usageInterceptor = httpUsageInterceptor,
)
// Offers easy methods to know when connections are happening through Tor or not
val roleBasedHttpClientBuilder = RoleBasedHttpClientBuilder(okHttpClients, torPrefs.value)
val roleBasedHttpClientBuilder = RoleBasedHttpClientBuilder(okHttpClients, torPrefs.value, httpUsageMeter)
val electrumXClient by lazy {
Log.d("AppModules", "ElectrumXClient Init")
@@ -424,6 +583,7 @@ class AppModules(
isMobileDataProvider = connManager.isMobileOrNull,
scope = applicationIOScope,
dns = surgeDns,
onionCache = onionLocationCache,
)
// Connects the INostrClient class with okHttp
@@ -479,8 +639,26 @@ class AppModules(
OkHttpLnurlEndpointResolver(roleBasedHttpClientBuilder::okHttpClientForMoney)
}
// Provides a relay pool
val client: INostrClient = NostrClient(websocketBuilder, applicationIOScope)
// Provides a relay pool. The caching decoder skips re-parsing EVENT frames
// that arrive again via another subscription or relay (14-57% of frames in
// production measurements).
//
// Wrapped in BlockedRelayFilteringClient so the active account's NIP-51
// kind:10006 blocked relay list is enforced centrally on every REQ, COUNT
// and publish (relay targeting is otherwise distributed across dozens of
// feed/loader/finder/broadcast sites, most of which don't subtract it).
// The blocked set is read per-call from the logged-in account.
val client: INostrClient =
BlockedRelayFilteringClient(
NostrClient(websocketBuilder, applicationIOScope, CachingEventDecoder()),
blockedRelays = {
sessionManager
.loggedInAccount()
?.blockedRelayList
?.flow
?.value ?: emptySet()
},
)
// Self-heals the "Tor Active but every circuit dead" state the lifecycle watchdogs can't
// see (they only arm while Connecting). Watches Tor-routed relay outcomes and, when enough
@@ -509,8 +687,44 @@ class AppModules(
// Verifies and inserts in the cache from all relays, all subscriptions
val cacheClientConnector = CacheClientConnector(client, cache)
// Show messages from the Relay and controls their dismissal
val notifyCoordinator = NotifyCoordinator(client)
// Show messages from the Relay and controls their dismissal. Attributes each NOTIFY to the
// account whose AUTH the relay rejected (accountsCache is declared below; the lambda reads it
// lazily at NOTIFY time, long after init).
val notifyCoordinator = NotifyCoordinator(client) { pubkey -> accountsCache.accounts.value[pubkey] }
// Per-relay NIP-42 ALLOW/DENY overrides are now per-account (Account.relayAuthPermissions,
// backed by a file under accounts/<pubkey>/), so there is no app-wide store here anymore.
/**
* The account every napplet/web-app grant and byte of storage is scoped to. Read lazily on each
* call (never captured) so an account switch immediately moves embedded apps to the new account's
* namespace: an app authorized by one npub is never authorized under another.
*/
val nappletAccountScope: () -> String = { sessionManager.loggedInAccount()?.pubKey ?: "" }
// Singleton stores for napplet permissions — DataStore v1 enforces one instance per file.
val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appContext, nappletAccountScope) }
/**
* The one napplet permission ledger for the main process. Its persistent half is just the store
* above, but it also holds the in-memory ALLOW_SESSION grants — and *those* only work if every
* caller shares this instance. The broker service and the Connected Apps screens used to build
* a ledger each, so a "Forget"/revoke tapped in the UI cleared the screen's own (always empty)
* session map while the grants the broker was actually consulting lived on untouched.
*
* Session lifetime is bounded by [com.vitorpamplona.amethyst.napplet.NappletBrokerService]'s
* onDestroy (all applet/browser surfaces gone), which calls `endSession()`.
*/
val nappletPermissionLedger by lazy { NappletPermissionLedger(nappletPermissionStore, nappletAccountScope) }
// NOT account-scoped here on purpose: this store is shared with NIP-46, whose coordinates already
// carry their owning account (`nip46:<signer>:<client>`) and whose sessions run for a specific
// account rather than the active one. The napplet path namespaces its own coordinate the same way
// (see NappletBroker.signerCoordinateFor) instead.
val signerPermissionStore by lazy { DataStoreNostrSignerPermissionStore(appContext) }
// Display + relay info for connected NIP-46 remote-signer clients.
val nip46ClientStore by lazy { DataStoreNip46ClientStore(appContext) }
// Authenticates with relays.
val authCoordinator = AuthCoordinator(client, applicationIOScope)
@@ -529,6 +743,36 @@ class AppModules(
// Captures statistics about relays
val relayStats = RelayStats(client)
// Caches the latest LIMITS (rights + limits) each relay advertises.
val relayLimits = RelayLimitsTracker(client)
// Resource-usage ledger: relay traffic/reconnect + connection-time,
// foreground-time, process-CPU, and signature-verification collectors.
init {
client.addConnectionListener(
RelayUsageListener(
accountant = resourceUsage,
isMobile = { connManager.isMobileOrFalse.value },
isForeground = { foregroundTracker.isForeground.value },
),
)
RelayConnectionTimeIntegrator(
connectedCount = client.connectedRelaysFlow().map { it.size },
isMobile = connManager.isMobileOrNull,
isForeground = foregroundTracker.isForeground,
accountant = resourceUsage,
).start(applicationIOScope)
ForegroundTimeIntegrator(
isForeground = foregroundTracker.isForeground,
accountant = resourceUsage,
).start(applicationIOScope)
ProcessCpuSampler(resourceUsage).register()
cache.verifyMeter = { elapsedNanos, _ ->
resourceUsage.add(UsageKeys.VERIFY_COUNT, 1)
resourceUsage.add(UsageKeys.VERIFY_US, elapsedNanos / 1_000)
}
}
// Logs debug messages when needed
val detailedLogger = if (isDebug) RelayLogger(client, debugSending = false, debugReceiving = false) else null
val relayReqStats = if (isDebug) RelayReqStats(client) else null
@@ -537,6 +781,10 @@ class AppModules(
// Focused timeline for the DM / gift-wrap loading path (tag: DMPagination).
// val dmDiagnostics = if (isDebug) DmRelayDiagnosticsLogger(client) else null
// Per-relay cold-start census: connection outcome by cause, REQ/EOSE/CLOSED accounting,
// and which relays actually carried the boot (tag: BootRelayDiag).
val bootDiagnostics = if (isDebug) BootRelayDiagnostics(client) else null
// Coordinates all subscriptions for the Nostr Client
val sources: RelaySubscriptionsCoordinator =
RelaySubscriptionsCoordinator(
@@ -547,6 +795,50 @@ class AppModules(
applicationIOScope,
)
// fire-and-forget NIP-13 mining: posts queue here and publish when mined.
// One job mines at a time, racing half the cores over disjoint nonce
// slices — same total CPU budget as the old 2-job pool, but each post
// finishes ~minerThreads× sooner and the other half of the cores stays
// free for the UI. Template jobs checkpoint to disk (restored on login)
// and every enqueue raises the shortService shield so backgrounding
// doesn't freeze a miner.
val powJobStore by lazy {
PowJobStore(File(appContext.filesDir, PowJobStore.FILE_NAME), applicationIOScope)
}
val powPublishQueue by lazy {
PoWPublishQueue(
scope = applicationIOScope,
maxConcurrent = 1,
minerThreads = PoWPolicy.minerWorkers(Runtime.getRuntime().availableProcessors()),
persistence = powJobStore,
onQueueActive = { PowMiningForegroundService.start(appContext) },
).also { queue ->
// Resource ledger: mining burns half the cores flat-out for as
// long as it runs — without this, PoW shows up in cpu.ms as an
// unattributed mystery. Wired inside the lazy so the ledger never
// forces the queue to initialize.
applicationIOScope.launch {
queue.jobs
.map { jobs -> jobs.any { it.isMining } }
.distinctUntilChanged()
.collect { powSession.setActive(it) }
}
}
}
/** App-level BUD-04 mirror sweep, so "sync all" keeps running as the user navigates. */
val blossomMirrorQueue by lazy {
BlossomMirrorQueue(
scope = applicationIOScope,
onActive = { BlossomSyncForegroundService.start(appContext) },
)
}
val powJobRestorer by lazy {
PowJobRestorer(powPublishQueue, powJobStore, scheduledPostStore)
}
// keeps all accounts live
val accountsCache =
AccountCacheState(
@@ -560,6 +852,10 @@ class AppModules(
cache = cache,
client = client,
rootFilesDir = { appContext.filesDir },
powQueue = { powPublishQueue },
meterSigner = { MeteringNostrSigner(it, resourceUsage) },
signerPermissionStore = signerPermissionStore,
nip46ClientStore = nip46ClientStore,
)
val sessionManager =
@@ -638,7 +934,11 @@ class AppModules(
// Observes LocalCache for notification-relevant events and routes them to
// EventNotificationConsumer. Sources: FCM, UnifiedPush, Pokey, active relay
// subscriptions, and NotificationRelayService.
val notificationDispatcher = NotificationDispatcher(appContext, applicationIOScope)
val notificationDispatcher =
NotificationDispatcher(appContext, applicationIOScope) { heldMs ->
resourceUsage.add(UsageKeys.WAKELOCK_NOTIF_MS, heldMs)
resourceUsage.add(UsageKeys.WAKELOCK_NOTIF_COUNT, 1)
}
// Local store for posts the user has scheduled to publish later. Backed by a
// single JSON file under the app's private filesDir; read by ScheduledPostWorker.
@@ -711,7 +1011,9 @@ class AppModules(
diskCache = { diskCache },
memoryCache = { memoryCache },
blossomServerResolver = { blossomResolver },
callFactory = { okHttpClients.getHttpClient(roleBasedHttpClientBuilder.shouldUseTorForImageDownload(it)) },
// Through the role builder (not raw getHttpClient) so Coil's image
// traffic carries the "image" ledger tag. Same Tor decision inside.
callFactory = { roleBasedHttpClientBuilder.okHttpClientForImage(it) },
thumbnailCache = thumbnailDiskCache,
backgroundScope = applicationIOScope,
)
@@ -722,6 +1024,10 @@ class AppModules(
fun initiate(appContext: Context) {
Thread.setDefaultUncaughtExceptionHandler(UnexpectedCrashSaver(crashReportCache, applicationIOScope))
// Ledger: count process starts — high counts reveal WorkManager/restart
// churn that cold-starts the whole app graph repeatedly.
resourceUsage.add(UsageKeys.APP_STARTS, 1)
// Restore the persisted DNS cache before any networking starts. Lookups that fire
// before this completes fall through to the sync resolver path (existing behavior);
// once restored, every previously-seen host hits the stale-while-revalidate path
@@ -744,6 +1050,18 @@ class AppModules(
sessionManager.loginWithDefaultAccountIfLoggedOff()
}
// One-time hygiene: remove per-account directories (MLS/Marmot stores) left behind
// by accounts that are no longer saved — account deletion historically didn't clean
// them up, so they leaked disk across every add/remove.
applicationIOScope.launch {
val keep =
LocalPreferences
.allSavedAccounts()
.mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }
.toSet()
accountsCache.pruneOrphanAccountDirs(keep)
}
// forces initialization of uiPrefs in the main thread to avoid blinking themes
uiPrefs
@@ -765,35 +1083,96 @@ class AppModules(
resourceCacheInit()
}
// Initialize napplet permission stores on an IO thread to avoid StrictMode violations
// when ConnectedAppsScreen first accesses them on the main thread.
applicationIOScope.launch {
nappletPermissionStore
signerPermissionStore
}
// registers to receive events
pokeyReceiver.register(appContext)
// starts observing LocalCache for notification-worthy events
notificationDispatcher.start()
// Schedule the scheduled-posts worker (periodic + one-time catch-up).
// Runs independently of the always-on notification setting so scheduled
// posts still fire when always-on notifications are disabled.
ScheduledPostWorker.schedule(appContext)
ScheduledPostWorker.scheduleCatchUp(appContext)
// Keep the scheduled-posts worker (15-min periodic + one-time catch-up)
// enqueued exactly while the store holds a PENDING post — see
// ScheduledPostWorkGate. Runs independently of the always-on
// notification setting so scheduled posts still fire when always-on
// notifications are disabled.
ScheduledPostWorkGate(
store = scheduledPostStore,
scope = applicationIOScope,
onPendingWork = {
ScheduledPostWorker.schedule(appContext)
ScheduledPostWorker.scheduleCatchUp(appContext)
},
onNoPendingWork = { ScheduledPostWorker.cancelPeriodic(appContext) },
).start()
// Periodic scan that posts "starting soon" notifications for NIP-52 appointments the
// user has RSVP'd to as ACCEPTED. 15-minute cadence matches both the WorkManager
// periodic minimum and the lead-time window.
com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker
.schedule(appContext)
// "Starting soon" reminders for NIP-52 appointments the user RSVP'd to as
// ACCEPTED. The 15-min periodic scanner is only scheduled while it can
// plausibly fire: this observer enqueues it when an accepted RSVP lands in
// LocalCache, and the worker cancels its own chain when the cache holds
// nothing that could still start. LocalCache is memory-only, so the
// unconditional schedule this replaces could never fire from a WorkManager
// cold start anyway — it only cost battery.
applicationIOScope.launch {
LocalCache
.observeNewEvents<CalendarRSVPEvent>(Filter(kinds = listOf(CalendarRSVPEvent.KIND)))
.collect { rsvp ->
if (rsvp.status() == RSVPStatusTag.STATUS.ACCEPTED) {
CalendarReminderWorker.schedule(appContext)
}
}
}
// Watch for account login and start/stop always-on notification service
// A rescheduled appointment must also re-arm the chain: the worker
// cancels itself when every known target is in the past, and a
// kind-31922/31923 update (the organizer moving the event) arrives
// WITHOUT any new RSVP — the user's existing RSVP still points at the
// same address, and observeNewEvents never re-fires for it. conflate +
// delay bounds the cache rescan to one per 30s while event feeds
// stream slot events; the scan only runs for users with reminders on.
applicationIOScope.launch {
LocalCache
.observeNewEvents<Event>(
Filter(kinds = listOf(CalendarDateSlotEvent.KIND, CalendarTimeSlotEvent.KIND)),
).conflate()
.collect {
if (CalendarReminderPrefs(appContext).isEnabled() &&
CalendarReminderWorker.couldStillFire(CalendarReminderWorker.acceptedRsvpsInCache(), TimeUtils.now())
) {
CalendarReminderWorker.schedule(appContext)
}
delay(30_000)
}
}
// Watch for account login and start/stop always-on notification service.
// The manager gates on the global master switch + each account's participation
// (not the active account), so it only needs to run while someone is logged in.
applicationIOScope.launch {
sessionManager.accountContent.collectLatest { state ->
if (state is AccountState.LoggedIn) {
alwaysOnNotificationServiceManager.watchAccount(state.account)
alwaysOnNotificationServiceManager.start()
} else {
alwaysOnNotificationServiceManager.stop()
}
}
}
// Resume PoW mining jobs that were checkpointed before a process death,
// for EVERY loaded account (the always-on service preloads non-active
// accounts, whose pending posts must not stay stranded on disk).
// Idempotent (the queue dedupes by job id), so re-emissions are safe.
applicationIOScope.launch {
accountsCache.accounts.collect { loaded ->
loaded.values.forEach { powJobRestorer.restore(it) }
}
}
// Evict the BlossomServerResolver URL cache whenever either local-cache
// toggle flips or the probe transitions up/down so stale entries don't
// outlive the underlying decision.
@@ -852,12 +1231,48 @@ class AppModules(
accountsCache.clear()
}
fun trim() {
fun trim(level: Int) {
_trimLevelEvents.tryEmit(level)
// Backgrounding is a natural moment to flush the usage ledger too.
resourceUsage.flushAsync()
applicationIOScope.launch {
// Backgrounding is a natural moment to flush the DNS cache.
dnsStore.save()
val loggedIn = accountsCache.accounts.value.values
trimmingService.run(loggedIn, LocalPreferences.allSavedAccounts())
trimmingService.run(loggedIn, LocalPreferences.allSavedAccounts(), level)
// Trim in-process caches proportional to OS memory pressure.
//
// Since API 34 the OS only ever delivers two trim levels (the foreground
// RUNNING_* levels and the deeper MODERATE/COMPLETE background tiers were
// deprecated because apps are no longer notified of them):
// BACKGROUND(40) — process is on the system LRU list: real reclaim
// pressure, and the strongest signal we still get.
// UI_HIDDEN (20) — just backgrounded, no pressure yet. Fires on EVERY
// app switch.
//
// So we key off exactly those two. UI_HIDDEN is frequent, so it only trims
// images (bitmaps are the largest allocations) and keeps the CPU-heavy
// caches (Robohash SVG assembly, rich-text parsing) warm — clearing them
// would force a full rebuild on every resume and cause visible jank.
// BACKGROUND trims hard but keeps a small working set: it means "on the LRU
// list" (real reclaim pressure), not the imminent kill that COMPLETE used to
// signal — so leave just enough warm to redraw the screen the user left on.
when {
level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> {
// On the LRU list under real pressure: trim hard, but keep a small
// working set so a returning user doesn't rebuild the visible screen
// from scratch. memoryCache is byte-sized (Coil), the rest are entry counts.
memoryCache.trimToSize(memoryCache.maxSize / 10)
CachedRichTextParser.trimToSize(10)
CachedRobohash.trimToSize(20)
nip11Cache.trimToSize(10)
}
level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN -> {
// Just backgrounded, no pressure yet: trim images but keep the
// parsed-text and avatar caches warm so resuming is instant.
memoryCache.trimToSize(memoryCache.maxSize / 2)
}
}
}
}
}

View File

@@ -37,6 +37,58 @@ import kotlin.time.measureTimedValue
@Suppress("SENSELESS_COMPARISON")
val isDebug = BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark"
data class MemorySnapshot(
val heapUsedMb: Long,
val heapMaxMb: Long,
val nativeHeapUsedMb: Long,
val imageCacheUsedMb: Long,
val imageCacheMaxMb: Long,
val imageDiskUsedMb: Long,
val imageDiskMaxMb: Long,
val noteCount: Int,
val userCount: Int,
val addressableCount: Int,
val chatroomCount: Int,
val memoryClassMb: Int,
) {
val heapFraction: Float get() = heapUsedMb.toFloat() / heapMaxMb.coerceAtLeast(1L).toFloat()
}
fun collectMemorySnapshot(context: Context): MemorySnapshot {
val rt = Runtime.getRuntime()
val heapUsedMb = (rt.totalMemory() - rt.freeMemory()) / 1_048_576L
val heapMaxMb = rt.maxMemory() / 1_048_576L
val nativeHeapUsedMb = Debug.getNativeHeapAllocatedSize() / 1_048_576L
val app = Amethyst.instance
val imageCacheUsedMb = app.memoryCache.size / 1_048_576L
val imageCacheMaxMb = app.memoryCache.maxSize / 1_048_576L
val imageDiskUsedMb = app.diskCache.size / 1_048_576L
val imageDiskMaxMb = app.diskCache.maxSize / 1_048_576L
val activityManager: ActivityManager? = context.getSystemService()
val isLargeHeap = (context.applicationInfo.flags and ApplicationInfo.FLAG_LARGE_HEAP) != 0
val memoryClassMb =
activityManager?.let {
if (isLargeHeap) it.largeMemoryClass else it.memoryClass
} ?: 0
return MemorySnapshot(
heapUsedMb = heapUsedMb,
heapMaxMb = heapMaxMb,
nativeHeapUsedMb = nativeHeapUsedMb,
imageCacheUsedMb = imageCacheUsedMb,
imageCacheMaxMb = imageCacheMaxMb,
imageDiskUsedMb = imageDiskUsedMb,
imageDiskMaxMb = imageDiskMaxMb,
noteCount = LocalCache.notes.size(),
userCount = LocalCache.users.size(),
addressableCount = LocalCache.addressables.size(),
chatroomCount = LocalCache.chatroomList.size(),
memoryClassMb = memoryClassMb,
)
}
private const val STATE_DUMP_TAG = "STATE DUMP"
fun debugState(context: Context) {

View File

@@ -25,17 +25,23 @@ import android.content.Context
import android.content.SharedPreferences
import androidx.compose.runtime.Immutable
import androidx.core.content.edit
import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntry
import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntry
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
@@ -51,6 +57,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
@@ -58,6 +65,7 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
@@ -67,6 +75,7 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@@ -90,6 +99,11 @@ data class AccountInfo(
private object PrefKeys {
const val CURRENT_ACCOUNT = "currently_logged_in_account"
// Global (non-account) master switch for the always-on notification service.
// When off, the service is suppressed for every account regardless of each
// account's own participation flag. Persisted so it survives restarts/crashes.
const val NOTIFICATION_SERVICE_ENABLED = "notification_service_enabled"
const val SAVED_ACCOUNTS = "all_saved_accounts"
const val NOSTR_PRIVKEY = "nostr_privkey"
const val NOSTR_PUBKEY = "nostr_pubkey"
@@ -98,14 +112,24 @@ private object PrefKeys {
const val STRIP_LOCATION_ON_UPLOAD = "stripLocationOnUpload"
const val USE_LOCAL_BLOSSOM_CACHE = "useLocalBlossomCache"
const val LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY = "localBlossomCacheProfilePicturesOnly"
const val MIRROR_UPLOADS_TO_ALL_SERVERS = "mirrorUploadsToAllServers"
const val OPTIMIZE_MEDIA_ON_UPLOAD = "optimizeMediaOnUpload"
const val HIDE_COMMUNITY_RULES_VIOLATIONS = "hideCommunityRulesViolations"
const val NIP46_SIGNER_ENABLED = "nip46SignerEnabled"
const val NIP46_BUNKER_SECRET = "nip46BunkerSecret"
const val NIP46_TRANSPORT_KEY = "nip46TransportKey"
const val NIP46_SEEN_IDS = "nip46SeenRequestIds"
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList"
const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList"
const val DEFAULT_POLLS_FOLLOW_LIST = "defaultPollsFollowList"
const val DEFAULT_PICTURES_FOLLOW_LIST = "defaultPicturesFollowList"
const val DEFAULT_RELAY_GROUPS_DISCOVERY_FOLLOW_LIST = "defaultRelayGroupsDiscoveryFollowList"
const val DEFAULT_NAPPLETS_FOLLOW_LIST = "defaultNappletsFollowList"
const val DEFAULT_NSITES_FOLLOW_LIST = "defaultNsitesFollowList"
const val DEFAULT_WORKOUTS_FOLLOW_LIST = "defaultWorkoutsFollowList"
const val DEFAULT_GIT_REPOSITORIES_FOLLOW_LIST = "defaultGitRepositoriesFollowList"
const val DEFAULT_CALENDARS_FOLLOW_LIST = "defaultCalendarsFollowList"
const val DEFAULT_PRODUCTS_FOLLOW_LIST = "defaultProductsFollowList"
const val DEFAULT_SHORTS_FOLLOW_LIST = "defaultShortsFollowList"
@@ -123,6 +147,7 @@ private object PrefKeys {
const val DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST = "defaultBrowseEmojiSetsFollowList"
const val DEFAULT_COMMUNITIES_FOLLOW_LIST = "defaultCommunitiesFollowList"
const val DEFAULT_FOLLOW_PACKS_FOLLOW_LIST = "defaultFollowPacksFollowList"
const val DEFAULT_APP_RECOMMENDATIONS_FOLLOW_LIST = "defaultAppRecommendationsFollowList"
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer" // legacy, kept for migration
const val NWC_WALLETS = "nwcWallets"
const val DEFAULT_NWC_WALLET_ID = "defaultNwcWalletId" // legacy, migrated into DEFAULT_PAYMENT_SOURCE_ID
@@ -145,13 +170,29 @@ private object PrefKeys {
const val LATEST_HASHTAG_LIST = "latestHashtagList"
const val LATEST_GEOHASH_LIST = "latestGeohashList"
const val LATEST_EPHEMERAL_LIST = "latestEphemeralChatList"
const val LATEST_RELAY_GROUP_LIST = "latestRelayGroupList"
const val LATEST_CONCORD_LIST = "latestConcordList"
const val LATEST_TRUST_PROVIDER_LIST = "latestTrustProviderList"
const val LATEST_KEY_PACKAGE_RELAY_LIST = "latestKeyPackageRelayList"
const val LATEST_FAVORITE_ALGO_FEEDS_LIST = "latestFavoriteAlgoFeedsList"
const val CALLS_ENABLED = "calls_enabled"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later
const val ALWAYS_ON_NOTIFICATION_SERVICE = "always_on_notification_service"
const val DEFAULT_RELAY_AUTH_POLICY = "default_relay_auth_policy"
const val RELAY_GROUP_VIEW_MODE = "relay_group_view_mode"
const val CONCORD_VIEW_MODE = "concord_view_mode"
// Stores the DISABLED chat feed types (comma-joined codes) so absence = all-on and any newly
// added type defaults enabled for accounts that customized before it existed.
const val DISABLED_CHAT_FEEDS = "disabled_chat_feeds"
const val RELAY_AUTH_TRUST_MY_RELAYS = "relay_auth_trust_my_relays_and_venues"
const val RELAY_AUTH_TRUST_READ_FOLLOWS = "relay_auth_trust_read_follows"
const val RELAY_AUTH_TRUST_MESSAGE_FOLLOWS = "relay_auth_trust_message_follows"
const val RELAY_AUTH_TRUST_MESSAGE_STRANGERS = "relay_auth_trust_message_strangers"
const val SPLIT_NOTIFICATIONS_ENABLED = "split_notifications_enabled"
const val SHOW_MESSAGES_IN_NOTIFICATIONS = "show_messages_in_notifications"
// One-shot stamp: set once an account has gone through the notifications
// Global -> Selected (Curated) migration (or was created after it shipped).
@@ -179,8 +220,57 @@ object LocalPreferences {
private var currentAccount: String? = null
private val savedAccounts: MutableStateFlow<List<AccountInfo>?> = MutableStateFlow(null)
// Guards the one-time lazy population of [savedAccounts]. Without it, concurrent callers
// of savedAccounts() (e.g. the account-load path, the always-on notification service, and
// the orphan-dir sweep, all launched at startup) would each see a null value, run the IO
// read in parallel, and the migration branch could double-write ALL_ACCOUNT_INFO.
private val savedAccountsMutex = Mutex()
private val cachedAccounts: MutableMap<String, AccountSettings?> = mutableMapOf()
// Global master switch for the always-on notification service ("Background
// notification service"). Default ON: existing users keep current behavior, and
// per-account participation decides who actually stays active.
//
// Stored in PLAIN (non-encrypted) SharedPreferences on purpose. It is a non-sensitive
// global boolean, and — unlike encryptedPreferences(), which asserts non-main — plain
// prefs can be read synchronously on ANY thread. The restart-layer gate
// (NotificationRelayService.isEnabled) is synchronous and runs in fresh processes (boot
// receiver, WorkManager), so it MUST read the persisted value without a suspend hop;
// otherwise a saved OFF would be missed on cold boot and the service would resurrect.
// The flow is lazily seeded from disk once (synchronous, main-safe) and is thereafter
// the source of truth, so there is no async hydrate that could clobber a user toggle.
private fun globalSettingsPrefs(): SharedPreferences = Amethyst.instance.appContext.getSharedPreferences("amethyst_global_settings", Context.MODE_PRIVATE)
/**
* Loads the global-settings prefs file into SharedPreferences' in-memory cache, off the main
* thread, so the first synchronous read below hits memory rather than disk.
*
* The read itself is deliberately synchronous — see [setNotificationServiceEnabled]: an async
* hydrate reintroduces a window where a late disk read clobbers a user's toggle. So this warms
* the cache instead of deferring the read. Best-effort: if a main-thread reader wins the race it
* simply pays the disk hit once, exactly as before.
*/
fun warmGlobalSettings() {
globalSettingsPrefs().getBoolean(PrefKeys.NOTIFICATION_SERVICE_ENABLED, true)
}
private val notificationServiceEnabled: MutableStateFlow<Boolean> by lazy {
MutableStateFlow(globalSettingsPrefs().getBoolean(PrefKeys.NOTIFICATION_SERVICE_ENABLED, true))
}
fun notificationServiceEnabledFlow(): StateFlow<Boolean> = notificationServiceEnabled
fun isNotificationServiceEnabled(): Boolean = notificationServiceEnabled.value
fun setNotificationServiceEnabled(enabled: Boolean) {
// In-memory update is the source of truth (main-safe); plain-prefs edit{} persists
// asynchronously via apply(), also main-safe. No suspend/hydrate hop, so no window
// where a late disk read can clobber this write.
notificationServiceEnabled.value = enabled
globalSettingsPrefs().edit { putBoolean(PrefKeys.NOTIFICATION_SERVICE_ENABLED, enabled) }
}
suspend fun currentAccount(): String? {
if (currentAccount == null) {
currentAccount =
@@ -208,41 +298,46 @@ object LocalPreferences {
}
private suspend fun savedAccounts(): List<AccountInfo> {
if (savedAccounts.value == null) {
withContext(Dispatchers.IO) {
with(encryptedPreferences()) {
val newSystemOfAccounts =
getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let {
JsonMapper.fromJson<List<AccountInfo>>(it)
}
// Fast path: already populated, no lock needed.
savedAccounts.value?.let { return it }
if (!newSystemOfAccounts.isNullOrEmpty()) {
savedAccounts.emit(newSystemOfAccounts)
} else {
val oldAccounts = getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(COMMA) ?: listOf()
return savedAccountsMutex.withLock {
// Re-check under the lock: another coroutine may have populated it while we waited.
savedAccounts.value ?: loadSavedAccountsFromStorage().also { savedAccounts.emit(it) }
}
}
val migrated =
oldAccounts.map { npub ->
AccountInfo(
npub,
encryptedPreferences(npub).getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false),
(encryptedPreferences(npub).getString(PrefKeys.NOSTR_PRIVKEY, "") ?: "").isNotBlank(),
false,
)
}
savedAccounts.emit(migrated)
edit {
putString(PrefKeys.ALL_ACCOUNT_INFO, JsonMapper.toJson(migrated))
}
private suspend fun loadSavedAccountsFromStorage(): List<AccountInfo> =
withContext(Dispatchers.IO) {
with(encryptedPreferences()) {
val newSystemOfAccounts =
getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let {
JsonMapper.fromJson<List<AccountInfo>>(it)
}
if (!newSystemOfAccounts.isNullOrEmpty()) {
newSystemOfAccounts
} else {
val oldAccounts = getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(COMMA) ?: listOf()
val migrated =
oldAccounts.map { npub ->
AccountInfo(
npub,
encryptedPreferences(npub).getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false),
(encryptedPreferences(npub).getString(PrefKeys.NOSTR_PRIVKEY, "") ?: "").isNotBlank(),
false,
)
}
edit {
putString(PrefKeys.ALL_ACCOUNT_INFO, JsonMapper.toJson(migrated))
}
migrated
}
}
}
// it's always not null when it gets here.
return savedAccounts.value!!
}
fun accountsFlow() = savedAccounts
@@ -340,7 +435,27 @@ object LocalPreferences {
}
}
suspend fun setDefaultAccount(accountSettings: AccountSettings) {
/**
* Make [accountSettings] the current account, persisting + caching it. Returns the settings that
* actually became current — normally [accountSettings] itself, but see the downgrade guard below.
*/
suspend fun setDefaultAccount(accountSettings: AccountSettings): AccountSettings {
val npub = accountSettings.keyPair.pubKey.toNpub()
// Downgrade guard: adding a read-only npub for a pubkey we already hold a SIGNING account for
// must not clobber that account. Accounts dedup by npub, so saving fresh read-only settings
// here would overwrite the signing account's per-npub file — wiping its cached follow/relay/
// mute lists and flipping hasPrivKey off, which silently disables its push notifications. A
// signing account already does everything the read-only one would, so keep it and just make
// it current instead of degrading it.
if (!accountSettings.isWriteable()) {
val existing = loadAccountConfigFromEncryptedStorage(npub)
if (existing != null && existing.isWriteable()) {
setCurrentAccount(existing)
return existing
}
}
// Save the per-npub file before emitting onto the savedAccounts flow.
// Otherwise a collector (e.g. AlwaysOnNotificationServiceManager) can race in
// and call loadAccountConfigFromEncryptedStorage(npub) before NOSTR_PUBKEY is
@@ -348,9 +463,9 @@ object LocalPreferences {
// rest of the session — making every later switch to this account land on
// LoggedOff instead of LoggedIn.
saveToEncryptedStorage(accountSettings)
val npub = accountSettings.keyPair.pubKey.toNpub()
mutex.withLock { cachedAccounts.put(npub, accountSettings) }
setCurrentAccount(accountSettings)
return accountSettings
}
suspend fun allSavedAccounts(): List<AccountInfo> = savedAccounts()
@@ -379,7 +494,13 @@ object LocalPreferences {
putBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, settings.stripLocationOnUpload)
putBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, settings.useLocalBlossomCache.value)
putBoolean(PrefKeys.LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY, settings.localBlossomCacheProfilePicturesOnly.value)
putBoolean(PrefKeys.MIRROR_UPLOADS_TO_ALL_SERVERS, settings.mirrorUploadsToAllServers.value)
putBoolean(PrefKeys.OPTIMIZE_MEDIA_ON_UPLOAD, settings.optimizeMediaOnUpload.value)
putBoolean(PrefKeys.HIDE_COMMUNITY_RULES_VIOLATIONS, settings.hideCommunityRulesViolations.value)
putBoolean(PrefKeys.NIP46_SIGNER_ENABLED, settings.nip46SignerEnabled.value)
putString(PrefKeys.NIP46_BUNKER_SECRET, settings.nip46BunkerSecret.value)
putString(PrefKeys.NIP46_TRANSPORT_KEY, settings.nip46TransportKey.value)
putStringSet(PrefKeys.NIP46_SEEN_IDS, settings.nip46SeenRequestIds.value)
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHomeFollowList.value))
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultStoriesFollowList.value))
@@ -388,7 +509,11 @@ object LocalPreferences {
putString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPollsFollowList.value))
putString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPicturesFollowList.value))
putString(PrefKeys.DEFAULT_RELAY_GROUPS_DISCOVERY_FOLLOW_LIST, JsonMapper.toJson(settings.defaultRelayGroupsDiscoveryFollowList.value))
putString(PrefKeys.DEFAULT_NAPPLETS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNappletsFollowList.value))
putString(PrefKeys.DEFAULT_NSITES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNsitesFollowList.value))
putString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultWorkoutsFollowList.value))
putString(PrefKeys.DEFAULT_GIT_REPOSITORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultGitRepositoriesFollowList.value))
putString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultCalendarsFollowList.value))
putString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultProductsFollowList.value))
putString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultShortsFollowList.value))
@@ -406,6 +531,7 @@ object LocalPreferences {
putString(PrefKeys.DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultBrowseEmojiSetsFollowList.value))
putString(PrefKeys.DEFAULT_COMMUNITIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultCommunitiesFollowList.value))
putString(PrefKeys.DEFAULT_FOLLOW_PACKS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultFollowPacksFollowList.value))
putString(PrefKeys.DEFAULT_APP_RECOMMENDATIONS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultAppRecommendationsFollowList.value))
val walletEntries = settings.nwcWallets.value.mapNotNull { it.denormalize() }
if (walletEntries.isNotEmpty()) {
@@ -456,7 +582,11 @@ object LocalPreferences {
putOrRemove(PrefKeys.LATEST_HASHTAG_LIST, settings.backupHashtagList)
putOrRemove(PrefKeys.LATEST_GEOHASH_LIST, settings.backupGeohashList)
putOrRemove(PrefKeys.LATEST_EPHEMERAL_LIST, settings.backupEphemeralChatList)
putOrRemove(PrefKeys.LATEST_RELAY_GROUP_LIST, settings.backupRelayGroupList)
putOrRemove(PrefKeys.LATEST_CONCORD_LIST, settings.backupConcordList)
putOrRemove(PrefKeys.LATEST_TRUST_PROVIDER_LIST, settings.backupTrustProviderList)
putOrRemove(PrefKeys.LATEST_KEY_PACKAGE_RELAY_LIST, settings.backupKeyPackageRelayList)
putOrRemove(PrefKeys.LATEST_FAVORITE_ALGO_FEEDS_LIST, settings.backupFavoriteAlgoFeedsList)
putOrRemove(PrefKeys.LATEST_PAYMENT_TARGETS, settings.backupNipA3PaymentTargets)
putOrRemove(PrefKeys.LATEST_CASHU_WALLET, settings.backupCashuWallet)
putOrRemove(PrefKeys.LATEST_NUTZAP_INFO, settings.backupNutzapInfo)
@@ -466,7 +596,16 @@ object LocalPreferences {
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog)
putBoolean(PrefKeys.CALLS_ENABLED, settings.callsEnabled.value)
putBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, settings.alwaysOnNotificationService.value)
putString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, settings.defaultRelayAuthPolicy.value.name)
putString(PrefKeys.RELAY_GROUP_VIEW_MODE, settings.relayGroupViewMode.value.name)
putString(PrefKeys.CONCORD_VIEW_MODE, settings.concordViewMode.value.name)
putString(PrefKeys.DISABLED_CHAT_FEEDS, ChatFeedType.encode(ChatFeedType.ALL - settings.enabledChatFeeds.value))
putBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, settings.relayAuthTrustMyRelaysAndVenues.value)
putBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, settings.relayAuthTrustReadFollows.value)
putBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, settings.relayAuthTrustMessageFollows.value)
putBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_STRANGERS, settings.relayAuthTrustMessageStrangers.value)
putBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, settings.splitNotificationsEnabled.value)
putBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, settings.showMessagesInNotifications.value)
// Any account that reaches a save has its notification filter in its
// post-split meaning, so stamp it as migrated. This keeps the one-shot
// Global -> Selected rewrite from ever touching it again and preserves a
@@ -575,13 +714,31 @@ object LocalPreferences {
val stripLocationOnUpload = getBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, true)
val useLocalBlossomCache = getBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, true)
val localBlossomCacheProfilePicturesOnly = getBoolean(PrefKeys.LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY, false)
val mirrorUploadsToAllServers = getBoolean(PrefKeys.MIRROR_UPLOADS_TO_ALL_SERVERS, true)
val optimizeMediaOnUpload = getBoolean(PrefKeys.OPTIMIZE_MEDIA_ON_UPLOAD, false)
val hideCommunityRulesViolations = getBoolean(PrefKeys.HIDE_COMMUNITY_RULES_VIOLATIONS, false)
val nip46SignerEnabled = getBoolean(PrefKeys.NIP46_SIGNER_ENABLED, false)
val nip46BunkerSecret = getString(PrefKeys.NIP46_BUNKER_SECRET, "") ?: ""
val nip46TransportKey = getString(PrefKeys.NIP46_TRANSPORT_KEY, "") ?: ""
val nip46SeenRequestIds = getStringSet(PrefKeys.NIP46_SEEN_IDS, null) ?: setOf()
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
val callsEnabled = getBoolean(PrefKeys.CALLS_ENABLED, true)
val alwaysOnNotificationService = getBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, false)
val defaultRelayAuthPolicy =
getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null)
?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() }
?: RelayAuthPolicy.CUSTOM
val relayGroupViewMode = RelayGroupViewMode.fromName(getString(PrefKeys.RELAY_GROUP_VIEW_MODE, null))
val concordViewMode = ConcordViewMode.fromName(getString(PrefKeys.CONCORD_VIEW_MODE, null))
val enabledChatFeeds = ChatFeedType.ALL - ChatFeedType.decode(getString(PrefKeys.DISABLED_CHAT_FEEDS, null))
val relayAuthTrustMyRelays = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, true)
val relayAuthTrustReadFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, true)
val relayAuthTrustMessageFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, true)
val relayAuthTrustMessageStrangers = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_STRANGERS, false)
val splitNotificationsEnabled = getBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, false)
val showMessagesInNotifications = getBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, true)
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
val dismissedPollNoteIds = getStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, null) ?: setOf()
val viewedPollResultNoteIdsStr = getString(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null)
@@ -614,7 +771,11 @@ object LocalPreferences {
val latestHashtagListStr = getString(PrefKeys.LATEST_HASHTAG_LIST, null)
val latestGeohashListStr = getString(PrefKeys.LATEST_GEOHASH_LIST, null)
val latestEphemeralListStr = getString(PrefKeys.LATEST_EPHEMERAL_LIST, null)
val latestRelayGroupListStr = getString(PrefKeys.LATEST_RELAY_GROUP_LIST, null)
val latestConcordListStr = getString(PrefKeys.LATEST_CONCORD_LIST, null)
val latestTrustProviderListStr = getString(PrefKeys.LATEST_TRUST_PROVIDER_LIST, null)
val latestKeyPackageRelayListStr = getString(PrefKeys.LATEST_KEY_PACKAGE_RELAY_LIST, null)
val latestFavoriteAlgoFeedsListStr = getString(PrefKeys.LATEST_FAVORITE_ALGO_FEEDS_LIST, null)
val latestPaymentTargetsStr = getString(PrefKeys.LATEST_PAYMENT_TARGETS, null)
val latestCashuWalletStr = getString(PrefKeys.LATEST_CASHU_WALLET, null)
val latestNutzapInfoStr = getString(PrefKeys.LATEST_NUTZAP_INFO, null)
@@ -673,7 +834,11 @@ object LocalPreferences {
val latestHashtagList = async { parseEventOrNull<HashtagListEvent>(latestHashtagListStr) }
val latestGeohashList = async { parseEventOrNull<GeohashListEvent>(latestGeohashListStr) }
val latestEphemeralList = async { parseEventOrNull<EphemeralChatListEvent>(latestEphemeralListStr) }
val latestRelayGroupList = async { parseEventOrNull<SimpleGroupListEvent>(latestRelayGroupListStr) }
val latestConcordList = async { parseEventOrNull<ConcordCommunityListEvent>(latestConcordListStr) }
val latestTrustProviderList = async { parseEventOrNull<TrustProviderListEvent>(latestTrustProviderListStr) }
val latestKeyPackageRelayList = async { parseEventOrNull<KeyPackageRelayListEvent>(latestKeyPackageRelayListStr) }
val latestFavoriteAlgoFeedsList = async { parseEventOrNull<FavoriteAlgoFeedsListEvent>(latestFavoriteAlgoFeedsListStr) }
val latestPaymentTargets = async { parseEventOrNull<PaymentTargetsEvent>(latestPaymentTargetsStr) }
val latestCashuWallet =
async {
@@ -693,23 +858,75 @@ object LocalPreferences {
Log.d("LocalPreferences") { "Load account from file $npub - asyncs created" }
// Resolve every parallel parse into a local before constructing AccountSettings.
// Awaiting inside the 70-argument constructor expression below would place ~27
// suspension points in the middle of a single huge operand stack, forcing the
// coroutine state machine to spill/restore every partially-evaluated argument at
// each point. That bloats the generated method past the compiler's per-method
// instruction limit ("Method exceeds compiler instruction limit"). Awaiting into
// vals first keeps each suspension point at a statement boundary (near-empty
// operand stack) and leaves the constructor as straight-line, suspension-free code.
val nwcWalletsResolved = nwcWalletsLoaded.await()
val clinkDebitsResolved = clinkDebitsLoaded.await()
val defaultFileServerResolved = defaultFileServer.await()
val viewedPollResultNoteIdsResolved = viewedPollResultNoteIds.await()
val pendingAttestationsResolved = pendingAttestations.await()
val lastReadPerRouteResolved = lastReadPerRoute.await()
val latestUserMetadataResolved = latestUserMetadata.await()
val latestContactListResolved = latestContactList.await()
val latestDmRelayListResolved = latestDmRelayList.await()
val latestNip65RelayListResolved = latestNip65RelayList.await()
val latestSearchRelayListResolved = latestSearchRelayList.await()
val latestIndexRelayListResolved = latestIndexRelayList.await()
val latestRelayFeedsListResolved = latestRelayFeedsList.await()
val latestBlockedRelayListResolved = latestBlockedRelayList.await()
val latestTrustedRelayListResolved = latestTrustedRelayList.await()
val latestMuteListResolved = latestMuteList.await()
val latestPrivateHomeRelayListResolved = latestPrivateHomeRelayList.await()
val latestAppSpecificDataResolved = latestAppSpecificData.await()
val latestChannelListResolved = latestChannelList.await()
val latestCommunityListResolved = latestCommunityList.await()
val latestHashtagListResolved = latestHashtagList.await()
val latestGeohashListResolved = latestGeohashList.await()
val latestEphemeralListResolved = latestEphemeralList.await()
val latestRelayGroupListResolved = latestRelayGroupList.await()
val latestConcordListResolved = latestConcordList.await()
val latestTrustProviderListResolved = latestTrustProviderList.await()
val latestKeyPackageRelayListResolved = latestKeyPackageRelayList.await()
val latestFavoriteAlgoFeedsListResolved = latestFavoriteAlgoFeedsList.await()
val latestPaymentTargetsResolved = latestPaymentTargets.await()
val latestCashuWalletResolved = latestCashuWallet.await()
val latestNutzapInfoResolved = latestNutzapInfo.await()
Log.d("LocalPreferences") { "Load account from file $npub - asyncs resolved" }
return@with AccountSettings(
keyPair = keyPair,
transientAccount = false,
externalSignerPackageName = externalSignerPackageName,
localRelayServers = MutableStateFlow(localRelayServers),
defaultFileServer = defaultFileServer.await(),
defaultFileServer = defaultFileServerResolved,
stripLocationOnUpload = stripLocationOnUpload,
useLocalBlossomCache = MutableStateFlow(useLocalBlossomCache),
localBlossomCacheProfilePicturesOnly = MutableStateFlow(localBlossomCacheProfilePicturesOnly),
mirrorUploadsToAllServers = MutableStateFlow(mirrorUploadsToAllServers),
optimizeMediaOnUpload = MutableStateFlow(optimizeMediaOnUpload),
hideCommunityRulesViolations = MutableStateFlow(hideCommunityRulesViolations),
nip46SignerEnabled = MutableStateFlow(nip46SignerEnabled),
nip46BunkerSecret = MutableStateFlow(nip46BunkerSecret),
nip46TransportKey = MutableStateFlow(nip46TransportKey),
nip46SeenRequestIds = MutableStateFlow(nip46SeenRequestIds),
defaultHomeFollowList = MutableStateFlow(followListPrefs.home),
defaultStoriesFollowList = MutableStateFlow(followListPrefs.stories),
defaultNotificationFollowList = MutableStateFlow(followListPrefs.notification),
defaultDiscoveryFollowList = MutableStateFlow(followListPrefs.discovery),
defaultPollsFollowList = MutableStateFlow(followListPrefs.polls),
defaultPicturesFollowList = MutableStateFlow(followListPrefs.pictures),
defaultRelayGroupsDiscoveryFollowList = MutableStateFlow(followListPrefs.relayGroupsDiscovery),
defaultNappletsFollowList = MutableStateFlow(followListPrefs.napplets),
defaultNsitesFollowList = MutableStateFlow(followListPrefs.nsites),
defaultWorkoutsFollowList = MutableStateFlow(followListPrefs.workouts),
defaultGitRepositoriesFollowList = MutableStateFlow(followListPrefs.gitRepositories),
defaultCalendarsFollowList = MutableStateFlow(followListPrefs.calendars),
defaultProductsFollowList = MutableStateFlow(followListPrefs.products),
defaultShortsFollowList = MutableStateFlow(followListPrefs.shorts),
@@ -727,47 +944,61 @@ object LocalPreferences {
defaultBrowseEmojiSetsFollowList = MutableStateFlow(followListPrefs.browseEmojiSets),
defaultCommunitiesFollowList = MutableStateFlow(followListPrefs.communities),
defaultFollowPacksFollowList = MutableStateFlow(followListPrefs.followPacks),
nwcWallets = MutableStateFlow(nwcWalletsLoaded.await().first),
clinkDebitWallets = MutableStateFlow(clinkDebitsLoaded.await()),
defaultAppRecommendationsFollowList = MutableStateFlow(followListPrefs.appRecommendations),
nwcWallets = MutableStateFlow(nwcWalletsResolved.first),
clinkDebitWallets = MutableStateFlow(clinkDebitsResolved),
// Prefer the new unified default; migrate from the legacy NWC default;
// else fall back to the first configured source (NWC before debits).
defaultPaymentSourceId =
MutableStateFlow(
defaultPaymentSourceIdStr
?: nwcWalletsLoaded.await().second
?: clinkDebitsLoaded.await().firstOrNull()?.id,
?: nwcWalletsResolved.second
?: clinkDebitsResolved.firstOrNull()?.id,
),
hideDeleteRequestDialog = hideDeleteRequestDialog,
hideBlockAlertDialog = hideBlockAlertDialog,
hideNIP17WarningDialog = hideNIP17WarningDialog,
alwaysOnNotificationService = MutableStateFlow(alwaysOnNotificationService),
defaultRelayAuthPolicy = MutableStateFlow(defaultRelayAuthPolicy),
relayGroupViewMode = MutableStateFlow(relayGroupViewMode),
concordViewMode = MutableStateFlow(concordViewMode),
enabledChatFeeds = MutableStateFlow(enabledChatFeeds),
relayAuthTrustMyRelaysAndVenues = MutableStateFlow(relayAuthTrustMyRelays),
relayAuthTrustReadFollows = MutableStateFlow(relayAuthTrustReadFollows),
relayAuthTrustMessageFollows = MutableStateFlow(relayAuthTrustMessageFollows),
relayAuthTrustMessageStrangers = MutableStateFlow(relayAuthTrustMessageStrangers),
splitNotificationsEnabled = MutableStateFlow(splitNotificationsEnabled),
backupUserMetadata = latestUserMetadata.await(),
backupContactList = latestContactList.await(),
backupNIP65RelayList = latestNip65RelayList.await(),
backupDMRelayList = latestDmRelayList.await(),
backupSearchRelayList = latestSearchRelayList.await(),
backupIndexRelayList = latestIndexRelayList.await(),
backupRelayFeedsList = latestRelayFeedsList.await(),
backupBlockedRelayList = latestBlockedRelayList.await(),
backupTrustedRelayList = latestTrustedRelayList.await(),
backupPrivateHomeRelayList = latestPrivateHomeRelayList.await(),
backupMuteList = latestMuteList.await(),
backupAppSpecificData = latestAppSpecificData.await(),
backupChannelList = latestChannelList.await(),
backupCommunityList = latestCommunityList.await(),
backupHashtagList = latestHashtagList.await(),
backupGeohashList = latestGeohashList.await(),
backupEphemeralChatList = latestEphemeralList.await(),
backupTrustProviderList = latestTrustProviderList.await(),
lastReadPerRoute = MutableStateFlow(lastReadPerRoute.await()),
showMessagesInNotifications = MutableStateFlow(showMessagesInNotifications),
backupUserMetadata = latestUserMetadataResolved,
backupContactList = latestContactListResolved,
backupNIP65RelayList = latestNip65RelayListResolved,
backupDMRelayList = latestDmRelayListResolved,
backupSearchRelayList = latestSearchRelayListResolved,
backupIndexRelayList = latestIndexRelayListResolved,
backupRelayFeedsList = latestRelayFeedsListResolved,
backupBlockedRelayList = latestBlockedRelayListResolved,
backupTrustedRelayList = latestTrustedRelayListResolved,
backupPrivateHomeRelayList = latestPrivateHomeRelayListResolved,
backupMuteList = latestMuteListResolved,
backupAppSpecificData = latestAppSpecificDataResolved,
backupChannelList = latestChannelListResolved,
backupCommunityList = latestCommunityListResolved,
backupHashtagList = latestHashtagListResolved,
backupGeohashList = latestGeohashListResolved,
backupEphemeralChatList = latestEphemeralListResolved,
backupRelayGroupList = latestRelayGroupListResolved,
backupConcordList = latestConcordListResolved,
backupTrustProviderList = latestTrustProviderListResolved,
backupKeyPackageRelayList = latestKeyPackageRelayListResolved,
backupFavoriteAlgoFeedsList = latestFavoriteAlgoFeedsListResolved,
lastReadPerRoute = MutableStateFlow(lastReadPerRouteResolved),
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
dismissedPollNoteIds = MutableStateFlow(dismissedPollNoteIds),
viewedPollResultNoteIds = MutableStateFlow(viewedPollResultNoteIds.await()),
pendingAttestations = MutableStateFlow(pendingAttestations.await()),
backupNipA3PaymentTargets = latestPaymentTargets.await(),
backupCashuWallet = latestCashuWallet.await(),
backupNutzapInfo = latestNutzapInfo.await(),
viewedPollResultNoteIds = MutableStateFlow(viewedPollResultNoteIdsResolved),
pendingAttestations = MutableStateFlow(pendingAttestationsResolved),
backupNipA3PaymentTargets = latestPaymentTargetsResolved,
backupCashuWallet = latestCashuWalletResolved,
backupNutzapInfo = latestNutzapInfoResolved,
callsEnabled = MutableStateFlow(callsEnabled),
)
}
@@ -797,7 +1028,11 @@ object LocalPreferences {
val discovery: TopFilter,
val polls: TopFilter,
val pictures: TopFilter,
val relayGroupsDiscovery: TopFilter,
val napplets: TopFilter,
val nsites: TopFilter,
val workouts: TopFilter,
val gitRepositories: TopFilter,
val calendars: TopFilter,
val products: TopFilter,
val shorts: TopFilter,
@@ -815,6 +1050,7 @@ object LocalPreferences {
val browseEmojiSets: TopFilter,
val communities: TopFilter,
val followPacks: TopFilter,
val appRecommendations: TopFilter,
)
/**
@@ -849,7 +1085,11 @@ object LocalPreferences {
discovery = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null), TopFilter.Global),
polls = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, null), TopFilter.Global),
pictures = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, null), TopFilter.Global),
relayGroupsDiscovery = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_RELAY_GROUPS_DISCOVERY_FOLLOW_LIST, null), TopFilter.Mine),
napplets = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NAPPLETS_FOLLOW_LIST, null), TopFilter.Global),
nsites = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NSITES_FOLLOW_LIST, null), TopFilter.Global),
workouts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, null), TopFilter.Global),
gitRepositories = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_GIT_REPOSITORIES_FOLLOW_LIST, null), TopFilter.Global),
calendars = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, null), TopFilter.Global),
products = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, null), TopFilter.AroundMe),
shorts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, null), TopFilter.Global),
@@ -867,6 +1107,7 @@ object LocalPreferences {
browseEmojiSets = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST, null), TopFilter.Global),
communities = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_COMMUNITIES_FOLLOW_LIST, null), TopFilter.AllFollows),
followPacks = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_FOLLOW_PACKS_FOLLOW_LIST, null), TopFilter.Global),
appRecommendations = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_APP_RECOMMENDATIONS_FOLLOW_LIST, null), TopFilter.Global),
)
private inline fun <reified T : Any> parseOrNull(value: String?): T? {

View File

@@ -0,0 +1,196 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.connectedApps
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrOpDecision
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import java.io.File
import java.security.MessageDigest
/**
* Per-coordinate DataStore-backed [NostrSignerPermissionStore]. One small `.preferences_pb`
* file per app (keyed by a SHA-256 prefix of the coordinate) so loading or saving one app's
* permissions never touches another app's data — essential at scale with 1000s of apps.
*
* The coordinate is stored inside each file under [KEY_COORDINATE] so [allPolicies] can
* reverse-map file → coordinate without scanning the filesystem.
*/
class DataStoreNostrSignerPermissionStore(
private val filesDir: File,
) : NostrSignerPermissionStore {
constructor(context: Context) : this(context.applicationContext.filesDir)
private val cache = LargeCache<String, DataStore<Preferences>>()
private fun storeFor(coordinate: String): DataStore<Preferences> {
val file = File(filesDir, "datastore/nsp_${hash(coordinate)}.preferences_pb")
return cache.getOrCreate(file.absolutePath) {
PreferenceDataStoreFactory.create(produceFile = { file })
}
}
override suspend fun loadPolicy(coordinate: String): AppSignerPolicy? {
val raw = storeFor(coordinate).data.first()[KEY_POLICY] ?: return null
return runCatching { AppSignerPolicy.valueOf(raw) }.getOrNull()
}
override suspend fun storePolicy(
coordinate: String,
policy: AppSignerPolicy,
) {
storeFor(coordinate).edit {
it[KEY_COORDINATE] = coordinate
it[KEY_POLICY] = policy.name
}
}
override suspend fun clearPolicy(coordinate: String) {
storeFor(coordinate).edit { it.remove(KEY_POLICY) }
}
override suspend fun loadOpDecision(
coordinate: String,
op: NostrSignerOp,
): NostrOpDecision? {
val raw = storeFor(coordinate).data.first()[opKey(op)] ?: return null
return runCatching { NostrOpDecision.valueOf(raw) }.getOrNull()
}
override suspend fun storeOpDecision(
coordinate: String,
op: NostrSignerOp,
decision: NostrOpDecision,
) {
storeFor(coordinate).edit {
it[KEY_COORDINATE] = coordinate
it[opKey(op)] = decision.name
}
}
override suspend fun clearOpDecision(
coordinate: String,
op: NostrSignerOp,
) {
storeFor(coordinate).edit { it.remove(opKey(op)) }
}
override suspend fun allPolicies(): Map<String, AppSignerPolicy> =
// Enumerates the datastore directory + reads each file — blocking disk IO, so keep it off the
// caller's thread (callers invoke this from Compose LaunchedEffects on the main dispatcher).
withContext(Dispatchers.IO) {
val dir = File(filesDir, "datastore")
if (!dir.exists()) return@withContext emptyMap()
val result = mutableMapOf<String, AppSignerPolicy>()
for (file in dir.listFiles { f -> f.name.startsWith("nsp_") } ?: emptyArray()) {
val ds =
cache.getOrCreate(file.absolutePath) {
PreferenceDataStoreFactory.create(produceFile = { file })
}
val coordinate = ds.data.first()[KEY_COORDINATE] ?: continue
val policy = loadPolicy(coordinate) ?: continue
result[coordinate] = policy
}
result
}
override suspend fun allOpDecisions(coordinate: String): Map<String, NostrOpDecision> {
val prefs = storeFor(coordinate).data.first()
val result = mutableMapOf<String, NostrOpDecision>()
for ((key, value) in prefs.asMap()) {
val name = key.name
if (!name.startsWith(OP_PREFIX)) continue
// Skip expiry metadata keys — they end with the expiry suffix
if (name.endsWith(OP_EXPIRY_SUFFIX)) continue
val opKey = name.removePrefix(OP_PREFIX)
val decision = runCatching { NostrOpDecision.valueOf(value as String) }.getOrNull() ?: continue
result[opKey] = decision
}
return result
}
override suspend fun loadOpExpiry(
coordinate: String,
op: NostrSignerOp,
): Long? {
val raw = storeFor(coordinate).data.first()[opExpiryKey(op)] ?: return null
return raw.toLongOrNull()
}
override suspend fun storeOpExpiry(
coordinate: String,
op: NostrSignerOp,
expiresAt: Long,
) {
storeFor(coordinate).edit { it[opExpiryKey(op)] = expiresAt.toString() }
}
override suspend fun clearOpExpiry(
coordinate: String,
op: NostrSignerOp,
) {
storeFor(coordinate).edit { it.remove(opExpiryKey(op)) }
}
override suspend fun loadLastUsed(coordinate: String): Long? {
val raw = storeFor(coordinate).data.first()[KEY_LAST_USED] ?: return null
return raw.toLongOrNull()
}
override suspend fun storeLastUsed(
coordinate: String,
epochSeconds: Long,
) {
storeFor(coordinate).edit { it[KEY_LAST_USED] = epochSeconds.toString() }
}
override suspend fun clearAll(coordinate: String) {
storeFor(coordinate).edit { it.clear() }
}
private fun opKey(op: NostrSignerOp) = stringPreferencesKey("$OP_PREFIX${op.key}")
private fun opExpiryKey(op: NostrSignerOp) = stringPreferencesKey("$OP_PREFIX${op.key}$OP_EXPIRY_SUFFIX")
companion object {
private val KEY_COORDINATE = stringPreferencesKey("coordinate")
private val KEY_POLICY = stringPreferencesKey("policy")
private val KEY_LAST_USED = stringPreferencesKey("lastused")
private const val OP_PREFIX = "op:"
private const val OP_EXPIRY_SUFFIX = ":exp"
private fun hash(coordinate: String): String {
val digest = MessageDigest.getInstance("SHA-256").digest(coordinate.toByteArray())
return digest.take(8).joinToString("") { "%02x".format(it) }
}
}
}

View File

@@ -0,0 +1,68 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.connectedApps.consent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
/**
* The account a signer request acts as — avatar + display name — so it's clear WHICH logged-in
* identity is approving/signing/encrypting/decrypting. Shown in both consent dialogs in place of a
* raw pubkey. Falls back to a robohash avatar seeded on [pubKey] when there's no [picture].
*/
@Composable
fun ConnectedAccountRow(
name: String,
picture: String?,
pubKey: String?,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
RobohashFallbackAsyncImage(
robot = pubKey ?: name,
model = picture,
contentDescription = null,
modifier = Modifier.size(26.dp).clip(CircleShape),
loadProfilePicture = true,
loadRobohash = true,
)
Text(
name,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}

View File

@@ -0,0 +1,328 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.connectedApps.consent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult
import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
class SignerConnectActivity : ComponentActivity() {
private var token: String? = null
private var decided = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val token = intent.getStringExtra(SignerConnectCoordinator.EXTRA_TOKEN)
this.token = token
val info = token?.let { SignerConnectCoordinator.infoFor(it) }
if (token == null || info == null) {
finish()
return
}
setContent {
AmethystTheme {
SignerConnectScreen(
info = info,
onConnect = { policy ->
decided = true
SignerConnectCoordinator.complete(token, AppConnectResult.Connected(policy))
finish()
},
onBlock = {
decided = true
SignerConnectCoordinator.complete(token, AppConnectResult.Blocked)
finish()
},
onCancel = {
decided = true
SignerConnectCoordinator.complete(token, AppConnectResult.Cancelled)
finish()
},
)
}
}
}
override fun finish() {
if (!decided) token?.let { SignerConnectCoordinator.cancel(it) }
super.finish()
}
}
@Composable
private fun SignerConnectScreen(
info: SignerConnectInfo,
onConnect: (AppSignerPolicy) -> Unit,
onBlock: () -> Unit,
onCancel: () -> Unit,
) {
var selected by remember { mutableStateOf(AppSignerPolicy.REASONABLE) }
val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.9f
Dialog(
onDismissRequest = onCancel,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.heightIn(max = maxHeight),
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp,
) {
Column(
modifier =
Modifier
.verticalScroll(rememberScrollState())
.padding(vertical = 24.dp),
) {
// Centered header: icon + app name + connect subtitle
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val isBrowser = info.coordinate.startsWith("browser:")
FavoriteAppIcon(
app =
if (isBrowser) {
FavoriteApp.WebApp(info.coordinate.substringAfter(':'), info.appletTitle, 0L, info.iconUrl)
} else {
FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, info.iconUrl)
},
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(56.dp),
)
Text(
info.appletTitle,
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
)
Text(
stringResource(R.string.napplet_connect_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
// Show WHICH account is being connected (avatar + name), not a raw pubkey.
if (info.accountName != null) {
ConnectedAccountRow(info.accountName, info.accountPicture, info.accountPubKey)
} else {
Text(
info.domain,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
// What the app declared it needs (nostrconnect `perms`) — so consent is informed,
// not a silent grant. Approving connects and pre-grants exactly these.
if (info.requestedPermissions.isNotEmpty()) {
Spacer(Modifier.height(16.dp))
Surface(
modifier = Modifier.padding(horizontal = 24.dp).fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.medium,
) {
Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
stringResource(R.string.nip46_connect_requests_title),
style = MaterialTheme.typography.labelLarge,
)
info.requestedPermissions.forEach { perm ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(
MaterialSymbols.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp),
)
Text(perm, style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
Spacer(Modifier.height(16.dp))
HorizontalDivider()
Spacer(Modifier.height(12.dp))
Text(
stringResource(R.string.napplet_connect_how_handle),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
)
Spacer(Modifier.height(8.dp))
// Trust level options
Column(
modifier = Modifier.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
PolicyOption(
selected = selected == AppSignerPolicy.FULL_TRUST,
symbol = MaterialSymbols.LockOpen,
label = stringResource(R.string.napplet_policy_full_trust),
description = stringResource(R.string.napplet_policy_full_trust_desc),
onClick = { selected = AppSignerPolicy.FULL_TRUST },
)
PolicyOption(
selected = selected == AppSignerPolicy.REASONABLE,
symbol = MaterialSymbols.Shield,
label = stringResource(R.string.napplet_policy_reasonable),
description = stringResource(R.string.napplet_policy_reasonable_desc),
onClick = { selected = AppSignerPolicy.REASONABLE },
)
PolicyOption(
selected = selected == AppSignerPolicy.PARANOID,
symbol = MaterialSymbols.Lock,
label = stringResource(R.string.napplet_policy_paranoid),
description = stringResource(R.string.napplet_policy_paranoid_desc),
onClick = { selected = AppSignerPolicy.PARANOID },
)
}
Spacer(Modifier.height(16.dp))
HorizontalDivider()
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.cancel))
}
Button(onClick = { onConnect(selected) }, modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.napplet_connect_button))
}
}
OutlinedButton(
onClick = onBlock,
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text(
stringResource(R.string.napplet_connect_block, info.domain),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
}
@Composable
private fun PolicyOption(
selected: Boolean,
symbol: MaterialSymbol,
label: String,
description: String,
onClick: () -> Unit,
) {
val borderColor = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
val bgColor = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) else MaterialTheme.colorScheme.surface
Surface(
modifier =
Modifier
.fillMaxWidth()
.border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = RoundedCornerShape(12.dp))
.clickable(onClick = onClick),
shape = RoundedCornerShape(12.dp),
color = bgColor,
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
symbol = symbol,
contentDescription = null,
tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(26.dp),
)
Column(modifier = Modifier.weight(1f)) {
Text(label, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface)
Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (selected) {
Icon(
symbol = MaterialSymbols.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}

View File

@@ -0,0 +1,116 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.connectedApps.consent
import android.content.Context
import android.content.Intent
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult
import kotlinx.coroutines.CompletableDeferred
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/** Everything the "Connect to Nostr" dialog needs to render. */
data class SignerConnectInfo(
val appletTitle: String,
val coordinate: String,
val domain: String,
val iconUrl: String? = null,
/**
* The account the app is connecting to, shown as an avatar + name instead of a raw pubkey. When
* [accountName] is null (e.g. napplet/browser paths that don't resolve it) the dialog falls back
* to [domain]. [accountPubKey] seeds the robohash avatar fallback when there's no picture.
*/
val accountName: String? = null,
val accountPicture: String? = null,
val accountPubKey: String? = null,
/**
* Human-readable permissions the app declared it needs (from a `nostrconnect://…?perms=` offer),
* shown so the user gives INFORMED consent before those ops are pre-granted. Empty for flows that
* carry no declaration (bunker connect), which just show the trust picker.
*/
val requestedPermissions: List<String> = emptyList(),
)
/**
* Bridges the broker to the "Connect to Nostr" first-connect UI. Suspends in [requestConnect];
* the Activity resolves the deferred with the user's choice.
* A dismissed dialog resolves to [AppConnectResult.Cancelled] — fails closed, no silent grant.
*/
object SignerConnectCoordinator {
private class Pending(
val info: SignerConnectInfo,
val deferred: CompletableDeferred<AppConnectResult>,
)
private val pending = ConcurrentHashMap<String, Pending>()
suspend fun requestConnect(
context: Context,
info: SignerConnectInfo,
): AppConnectResult {
val token = UUID.randomUUID().toString()
val deferred = CompletableDeferred<AppConnectResult>()
pending[token] = Pending(info, deferred)
// Fast path when Amethyst already owns the foreground; the full-screen-intent notification
// below is what surfaces the dialog when a connect request arrives while backgrounded (see
// SignerConsentNotifier). Wrapped because a BAL-blocked launch can throw on some OEMs.
runCatching {
context.startActivity(
Intent(context, SignerConnectActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(EXTRA_TOKEN, token),
)
}
val notificationId =
SignerConsentNotifier.show(
context = context,
activityClass = SignerConnectActivity::class.java,
extraKey = EXTRA_TOKEN,
token = token,
titleRes = R.string.nip46_signer_notif_connect_title,
)
return try {
deferred.await()
} finally {
pending.remove(token)
SignerConsentNotifier.cancel(context, notificationId)
}
}
fun infoFor(token: String): SignerConnectInfo? = pending[token]?.info
fun complete(
token: String,
result: AppConnectResult,
) {
pending[token]?.deferred?.complete(result)
}
fun cancel(token: String) {
pending[token]?.deferred?.complete(AppConnectResult.Cancelled)
}
const val EXTRA_TOKEN = "napplet_connect_token"
}

View File

@@ -0,0 +1,591 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.connectedApps.consent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.call.CallSessionBridge
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
import com.vitorpamplona.quartz.utils.TimeUtils
class SignerConsentActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
AmethystTheme {
// The signer services requests concurrently, so more than one may await consent. We never
// mix accounts in one sheet: render only the requests for the OLDEST-pending account as a
// group. When that account's group clears, the next account's requests render (a fresh
// per-account sheet). One request → the rich dialog; several → a batched list. When the
// whole queue empties, close.
val pending by SignerConsentCoordinator.pending.collectAsStateWithLifecycle()
val group =
run {
val account = pending.firstOrNull()?.info?.accountPubKey
pending.filter { it.info.accountPubKey == account }
}
LaunchedEffect(pending.isEmpty()) { if (pending.isEmpty()) finish() }
when {
group.isEmpty() -> Unit
group.size == 1 -> {
val p = group.first()
SignerConsentDialog(
info = p.info,
onGrant = { SignerConsentCoordinator.complete(p.token, it) },
onDismiss = { SignerConsentCoordinator.complete(p.token, SignerOpGrant.DenyOnce) },
)
}
else ->
BatchedConsentDialog(
pending = group,
onResolve = { tokens, grant -> SignerConsentCoordinator.completeAll(tokens, grant) },
// Dismissing denies only THIS account's group; other accounts' requests stay
// pending and render next as their own sheet.
onDismiss = { SignerConsentCoordinator.completeAll(group.map { it.token }, SignerOpGrant.DenyOnce) },
)
}
}
}
}
// Dismissal is failed-closed at the source: each dialog's onDismissRequest (back / tap-outside)
// denies its own request(s). We deliberately do NOT deny-all in onDestroy — a request arriving as
// this Activity finishes is owned by a freshly-launched instance, and denying it here would race
// that instance and reject a legitimate request. A process kill falls back to the bridge's 120s
// timeout, which also fails closed.
}
@Composable
private fun SignerConsentDialog(
info: SignerConsentInfo,
onGrant: (SignerOpGrant) -> Unit,
onDismiss: () -> Unit,
) {
var showMoreOptions by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.85f
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.heightIn(max = maxHeight),
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp,
) {
Column(
modifier =
Modifier
.verticalScroll(scrollState)
.padding(vertical = 24.dp),
) {
// Centered header: icon + title + description
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val isBrowser = info.coordinate.startsWith("browser:")
FavoriteAppIcon(
app =
if (isBrowser) {
FavoriteApp.WebApp(info.coordinate.substringAfter(':'), info.appletTitle, 0L, info.iconUrl)
} else {
FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, info.iconUrl)
},
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(56.dp),
)
Text(
info.appletTitle,
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
)
Text(
stringResource(R.string.napplet_consent_wants_to, info.operationSummary),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
// Show WHICH account would sign/encrypt/decrypt (avatar + name), not the coordinate hex.
if (info.accountName != null) {
ConnectedAccountRow(info.accountName, info.accountPicture, info.accountPubKey)
}
// For a decrypt request, WHOSE conversation is being read is the decision. Show
// that person as an avatar + name, never as nothing.
if (info.counterpartyName != null) {
Text(
stringResource(R.string.nip46_signer_messages_with),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
ConnectedAccountRow(info.counterpartyName, info.counterpartyPicture, info.counterpartyPubKey)
}
}
Spacer(Modifier.height(12.dp))
Box(modifier = Modifier.padding(horizontal = 24.dp)) {
SignerConsentPreview(info)
}
Spacer(Modifier.height(16.dp))
HorizontalDivider()
Spacer(Modifier.height(8.dp))
// Primary: the NARROWEST "remember" available. For decrypt that is "always allow for
// Alice" — one broad decrypt grant would otherwise hand over every conversation
// forever, and scoping the op itself would mean a prompt per conversation.
val narrowOp = info.narrowOp
if (narrowOp != null && info.narrowOpLabel != null) {
Button(
onClick = { onGrant(SignerOpGrant.AllowForOp(narrowOp)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(info.narrowOpLabel)
}
// The broad grant stays available, but demoted below the scoped one.
OutlinedButton(
onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_consent_allow_always))
}
} else {
Button(
onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_consent_allow_always))
}
}
// Secondary: allow just once
OutlinedButton(
onClick = { onGrant(SignerOpGrant.AllowOnce) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_signer_allow_once))
}
// "More options" toggle: session and time-bound grants
TextButton(
onClick = { showMoreOptions = !showMoreOptions },
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
if (showMoreOptions) {
stringResource(R.string.napplet_consent_fewer_options)
} else {
stringResource(R.string.napplet_consent_more_options)
},
style = MaterialTheme.typography.bodyMedium,
)
Icon(
if (showMoreOptions) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
}
}
if (showMoreOptions) {
OutlinedButton(
onClick = { onGrant(SignerOpGrant.AllowForSession(info.op)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_signer_allow_session))
}
OutlinedButton(
onClick = { onGrant(SignerOpGrant.AllowUntil(info.op, TimeUtils.now() + 86_400L)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_signer_allow_24h))
}
OutlinedButton(
onClick = { onGrant(SignerOpGrant.AllowUntil(info.op, TimeUtils.now() + 30L * 86_400L)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_signer_allow_30d))
}
OutlinedButton(
onClick = { onGrant(SignerOpGrant.AllowAll) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_signer_allow_all))
}
}
Spacer(Modifier.height(4.dp))
HorizontalDivider()
Spacer(Modifier.height(8.dp))
OutlinedButton(
onClick = { onGrant(SignerOpGrant.DenyOnce) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text(stringResource(R.string.napplet_signer_deny_once))
}
OutlinedButton(
onClick = { onGrant(SignerOpGrant.DenyForOp(info.op)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text(stringResource(R.string.napplet_signer_deny_op, info.operationSummary))
}
}
}
}
}
/**
* The "what you're acting on" block: the unsigned event rendered as a real NoteCompose (what it will
* look like once signed) with a JSON toggle for sign/publish, or the raw content / decrypted plaintext
* for encrypt/decrypt. Shared by the single-request dialog and each expanded batch row so a user can
* always inspect exactly what they are signing/encrypting/decrypting. Best-effort: if the main Activity
* is gone (only the foreground signer service alive) the NoteCompose is skipped and the JSON stands in.
*/
@Composable
private fun SignerConsentPreview(info: SignerConsentInfo) {
var showRawData by remember(info) { mutableStateOf(false) }
val accountViewModel = remember { CallSessionBridge.accountViewModel }
val previewNav = remember { EmptyNav() }
val previewNote =
remember(info, accountViewModel) {
val template = info.previewTemplate
val author = info.accountPubKey ?: accountViewModel?.account?.signer?.pubKey
if (template != null && author != null && accountViewModel != null) {
runCatching {
val unsigned = RumorAssembler.assembleRumor<Event>(author, template)
accountViewModel.createTempDraftNote(unsigned, LocalCache.getOrCreateUser(author))
}.getOrNull()
} else {
null
}
}
val hasContent = previewNote != null || info.contentPreview.isNotBlank() || info.rawData.isNotBlank()
if (!hasContent) return
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.medium,
) {
Column(modifier = Modifier.padding(12.dp)) {
if (previewNote != null && accountViewModel != null) {
NoteCompose(
baseNote = previewNote,
isQuotedNote = true,
quotesLeft = 0,
accountViewModel = accountViewModel,
nav = previewNav,
)
} else if (info.contentPreview.isNotBlank()) {
Text("${info.contentPreview}", style = MaterialTheme.typography.bodySmall)
}
if (info.rawData.isNotBlank()) {
if (showRawData) {
Spacer(Modifier.height(8.dp))
Box(modifier = Modifier.horizontalScroll(rememberScrollState())) {
SelectionContainer {
Text(
info.rawData,
style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant,
softWrap = false,
)
}
}
}
TextButton(
onClick = { showRawData = !showRawData },
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp),
) {
Text(
if (showRawData) {
stringResource(R.string.napplet_consent_hide_event)
} else {
stringResource(R.string.napplet_consent_show_event)
},
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
}
/**
* Shown when more than one request is awaiting consent at once (the signer services requests
* concurrently). Lists each with a checkbox — all selected by default — and resolves the selected
* ones together as Allow or Deny. "Remember" makes an Allow persist per-op ([SignerOpGrant.AllowForOp]);
* off is a one-time [SignerOpGrant.AllowOnce]. Requests left unselected stay pending and re-render
* (as this list, or the single-request dialog once one remains).
*/
@Composable
private fun BatchedConsentDialog(
pending: List<PendingConsent>,
onResolve: (tokens: List<String>, grant: SignerOpGrant) -> Unit,
onDismiss: () -> Unit,
) {
val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.85f
val tokens = pending.map { it.token }.toSet()
// Seed all-selected ONCE for the initial batch the user opened. The signer services requests
// concurrently, so `tokens` can change under an open sheet; reconcile incrementally instead of
// re-seeding — drop resolved tokens but KEEP the user's deselections, and never auto-select a
// newly-arrived request. Otherwise a request landing (or resolving) mid-decision would silently
// re-check everything, and an "Allow selected" tap would grant ops the user deselected or never saw.
var selected by remember { mutableStateOf(tokens) }
LaunchedEffect(tokens) { selected = selected intersect tokens }
var rememberChoice by remember { mutableStateOf(false) }
// Tokens whose full preview (rendered event + JSON, or encrypt/decrypt plaintext) is expanded.
var expanded by remember { mutableStateOf(emptySet<String>()) }
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.heightIn(max = maxHeight),
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp,
) {
Column(modifier = Modifier.padding(vertical = 20.dp)) {
// The sheet is single-account (grouped upstream), so the account is a header, not a
// per-row label. It says WHO every request in this sheet would act as.
val account = pending.first().info
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
account.accountName?.let { name ->
RobohashFallbackAsyncImage(
robot = account.accountPubKey ?: name,
model = account.accountPicture,
contentDescription = null,
modifier = Modifier.size(34.dp).clip(CircleShape),
loadProfilePicture = true,
loadRobohash = true,
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
pluralStringResource(R.plurals.nip46_signer_batch_title, pending.size, pending.size),
style = MaterialTheme.typography.titleLarge,
)
account.accountName?.let { name ->
Text(
stringResource(R.string.nip46_signer_batch_signing_as, name),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
TextButton(
onClick = {
selected = if (selected.size == pending.size) emptySet() else pending.map { it.token }.toSet()
},
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 2.dp),
) {
Text(
stringResource(
if (selected.size == pending.size) R.string.nip46_signer_batch_select_none else R.string.nip46_signer_batch_select_all,
),
style = MaterialTheme.typography.labelLarge,
)
}
Column(
modifier =
Modifier
.weight(1f, fill = false)
.verticalScroll(rememberScrollState()),
) {
pending.forEach { p ->
val isExpanded = p.token in expanded
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable {
expanded = if (isExpanded) expanded - p.token else expanded + p.token
}.padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
// Checkbox handles its own tap (select); tapping elsewhere on the row expands.
Checkbox(
checked = p.token in selected,
onCheckedChange = { on -> selected = if (on) selected + p.token else selected - p.token },
)
Column(modifier = Modifier.weight(1f)) {
Text(
"${p.info.appletTitle} · ${p.info.operationSummary}",
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
)
if (p.info.contentPreview.isNotBlank()) {
Text(
p.info.contentPreview,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
Icon(
if (isExpanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
if (isExpanded) {
Box(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 8.dp)) {
SignerConsentPreview(p.info)
}
}
}
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Switch(checked = rememberChoice, onCheckedChange = { rememberChoice = it })
Text(
stringResource(R.string.nip46_signer_batch_remember),
style = MaterialTheme.typography.bodyMedium,
)
}
Spacer(Modifier.height(8.dp))
HorizontalDivider()
Spacer(Modifier.height(8.dp))
Button(
onClick = {
val tokens = pending.filter { it.token in selected }
// Per-op remember uses each request's own op; one-time is a single AllowOnce.
if (rememberChoice) {
tokens.forEach { onResolve(listOf(it.token), SignerOpGrant.AllowForOp(it.info.op)) }
} else {
onResolve(tokens.map { it.token }, SignerOpGrant.AllowOnce)
}
},
enabled = selected.isNotEmpty(),
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.nip46_signer_batch_allow, selected.size))
}
OutlinedButton(
onClick = { onResolve(pending.filter { it.token in selected }.map { it.token }, SignerOpGrant.DenyOnce) },
enabled = selected.isNotEmpty(),
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text(stringResource(R.string.nip46_signer_batch_deny, selected.size))
}
}
}
}
}

View File

@@ -0,0 +1,175 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.connectedApps.consent
import android.content.Context
import android.content.Intent
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.updateAndGet
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/** Everything the per-operation consent dialog needs to render. */
data class SignerConsentInfo(
val appletTitle: String,
val coordinate: String,
val op: NostrSignerOp,
val operationSummary: String,
/** Short excerpt shown in the dialog body (≤ 160 chars). */
val contentPreview: String,
/**
* Full raw content for the "See more" toggle — event JSON for sign/encrypt operations,
* decrypted plaintext for decrypt (Amethyst decrypts first, then asks permission to expose).
*/
val rawData: String = "",
val iconUrl: String? = null,
/**
* The account that would sign/encrypt/decrypt, shown as an avatar + name so it's clear which
* logged-in identity is acting. Null on paths that don't resolve it; [accountPubKey] seeds the
* robohash avatar fallback when there's no picture.
*/
val accountName: String? = null,
val accountPicture: String? = null,
val accountPubKey: String? = null,
/**
* The unsigned event a `sign_event`/publish request would sign, so the dialog can render it as a
* note preview (what it will look like) in addition to the raw JSON. Null for encrypt/decrypt and
* non-event ops.
*/
val previewTemplate: EventTemplate<Event>? = null,
/**
* The OTHER party of a decrypt request — whose conversation the app is asking to read — shown as
* an avatar + name. "X wants to read your messages with Alice" is a categorically different
* decision from "X wants to read your private messages", so this must reach the dialog.
* Null for every op that has no counterparty (signing, and the napplet/browser paths).
*/
val counterpartyName: String? = null,
val counterpartyPicture: String? = null,
val counterpartyPubKey: String? = null,
/**
* A NARROWER op the dialog may offer to remember instead of [op] — today only
* [com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp.DecryptFrom], i.e.
* "always allow, but only for this counterparty". Offered ALONGSIDE the broad "Always allow" so
* the user gets granularity without a prompt per conversation. [narrowOpLabel] is its button text.
*/
val narrowOp: NostrSignerOp? = null,
val narrowOpLabel: String? = null,
)
/** One pending per-operation consent request, as the batched sheet renders it. */
data class PendingConsent(
val token: String,
val info: SignerConsentInfo,
)
/**
* Bridges the broker to the per-operation signer consent UI. The signer services requests
* concurrently (so their prompts can batch), so several requests can await consent at once: they all
* land in [pending], one [SignerConsentActivity] observes that list and shows a single-request dialog
* or a batched list, and each resolved token completes its own deferred. A dismissed/ignored request
* resolves to [SignerOpGrant.DenyOnce] — fails closed.
*/
object SignerConsentCoordinator {
private val deferreds = ConcurrentHashMap<String, CompletableDeferred<SignerOpGrant>>()
private val _pending = MutableStateFlow<List<PendingConsent>>(emptyList())
/** The live set of requests awaiting the user's decision; the Activity renders this. */
val pending: StateFlow<List<PendingConsent>> = _pending
// A stable notification id (one prompt notification for the whole batch, updated as requests
// arrive) so concurrent requests don't each post their own.
private val batchNotificationId = "nip46-signer-consent".hashCode()
// Guards the surface (post/cancel of the one shared notification) against the pending set so a
// concurrent arrival's post can't be clobbered by another request's teardown cancel. Without it,
// request A could read "pending now empty" and then cancel AFTER request B posted a fresh
// notification under the same id, leaving B with no UI while backgrounded (silent deny at timeout).
private val surfaceLock = Mutex()
suspend fun requestConsent(
context: Context,
info: SignerConsentInfo,
): SignerOpGrant {
val token = UUID.randomUUID().toString()
val deferred = CompletableDeferred<SignerOpGrant>()
deferreds[token] = deferred
surfaceLock.withLock {
_pending.update { it + PendingConsent(token, info) }
// Fast path when Amethyst already owns the foreground: open the dialog directly. When the app
// is backgrounded this is silently dropped by Android 12+ BAL, so the full-screen-intent
// notification is what surfaces the prompt. Both are idempotent — the Activity is singleTop and
// observes [pending], and the notification uses a stable id, so concurrent requests just
// refresh the one prompt. Wrapped because a BAL-blocked launch can throw rather than no-op.
runCatching {
context.startActivity(
Intent(context, SignerConsentActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP),
)
}
SignerConsentNotifier.show(
context = context,
activityClass = SignerConsentActivity::class.java,
extraKey = EXTRA_TOKEN,
token = "nip46-signer-consent",
titleRes = R.string.nip46_signer_notif_sign_title,
)
}
return try {
deferred.await()
} finally {
deferreds.remove(token)
surfaceLock.withLock {
// Remove + emptiness check + cancel are one critical section vs. another request's
// add + show, so a fresh notification is never cancelled out from under a live request.
val stillPending = _pending.updateAndGet { list -> list.filterNot { it.token == token } }
if (stillPending.isEmpty()) SignerConsentNotifier.cancel(context, batchNotificationId)
}
}
}
fun complete(
token: String,
grant: SignerOpGrant,
) {
deferreds[token]?.complete(grant)
}
fun completeAll(
tokens: Collection<String>,
grant: SignerOpGrant,
) {
tokens.forEach { complete(it, grant) }
}
const val EXTRA_TOKEN = "napplet_signer_consent_token"
}

View File

@@ -0,0 +1,141 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.connectedApps.consent
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
/**
* Surfaces a signer consent/connect [android.app.Activity] from the **background**.
*
* A bare `context.startActivity(...)` from the application context only opens a window while
* Amethyst already owns the foreground. When a signing request arrives over a relay while the app
* is backgrounded, Android 12+ background-activity-launch (BAL) restrictions silently drop that
* `startActivity`, so the dialog would never appear and the request would sit until it times out.
*
* A full-screen-intent notification on an `IMPORTANCE_HIGH` channel (with the
* `USE_FULL_SCREEN_INTENT` permission the manifest declares) is the documented BAL exception — the
* same mechanism [com.vitorpamplona.amethyst.service.call.notification.CallNotifier] uses for
* incoming calls. On a locked/idle screen it launches the Activity immediately; while the user is
* actively on another app it shows as a heads-up banner they tap to review.
*
* Each coordinator posts one notification keyed by the request token's hash so concurrent requests
* don't clobber each other, and cancels it once the deferred resolves (approved, denied, or timed
* out) so no stale prompt lingers.
*/
object SignerConsentNotifier {
private const val CHANNEL_ID = "com.vitorpamplona.amethyst.SIGNER_CONSENT_CHANNEL"
private fun ensureChannel(context: Context): NotificationChannel {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.getNotificationChannel(CHANNEL_ID)?.let { return it }
val channel =
NotificationChannel(
CHANNEL_ID,
stringRes(context, R.string.nip46_signer_notif_channel_name),
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = stringRes(context, R.string.nip46_signer_notif_channel_desc)
}
manager.createNotificationChannel(channel)
return channel
}
/**
* Posts a full-screen-intent notification whose content/full-screen [PendingIntent] opens
* [activityClass] carrying [token]. Returns the notification id to pass to [cancel] once the
* request resolves.
*/
fun show(
context: Context,
activityClass: Class<*>,
extraKey: String,
token: String,
titleRes: Int,
): Int {
// When Amethyst already owns the foreground the direct startActivity opens the dialog, so a
// heads-up notification would just be redundant noise on top of it. Only fall back to the
// full-screen intent when we're backgrounded — the case where startActivity is BAL-blocked.
if (appInForeground()) return NO_NOTIFICATION
val channel = ensureChannel(context)
val notificationId = token.hashCode()
val intent =
Intent(context, activityClass)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
.putExtra(extraKey, token)
val pendingIntent =
PendingIntent.getActivity(
context,
notificationId,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val notification =
NotificationCompat
.Builder(context, channel.id)
.setSmallIcon(R.drawable.amethyst)
.setContentTitle(stringRes(context, titleRes))
.setContentText(stringRes(context, R.string.nip46_signer_notif_tap))
.setContentIntent(pendingIntent)
.setFullScreenIntent(pendingIntent, true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_RECOMMENDATION)
.setAutoCancel(true)
.setOngoing(true)
.setTimeoutAfter(TIMEOUT_MS)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.build()
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(notificationId, notification)
return notificationId
}
fun cancel(
context: Context,
notificationId: Int,
) {
if (notificationId == NO_NOTIFICATION) return
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.cancel(notificationId)
}
private fun appInForeground(): Boolean =
// Defensive: the signer consent path only runs in the main process (where Amethyst.instance
// is set), but touching it from the keyless :napplet process would throw. Treat any failure
// as "not foreground" so the notification fallback still fires.
runCatching { Amethyst.instance.foregroundTracker.isForeground.value }.getOrDefault(false)
private const val NO_NOTIFICATION = Int.MIN_VALUE
private const val TIMEOUT_MS = 120_000L
}

View File

@@ -0,0 +1,128 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.connectedApps.nip46
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientInfo
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore
import kotlinx.coroutines.flow.first
import java.io.File
import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap
/**
* Single-file DataStore-backed [Nip46ClientStore]. Every connected client's
* display + relay info lives in one `datastore/nip46_clients.preferences_pb`
* file; a SHA-256 prefix of the coordinate is the key so the (already public)
* coordinate is kept alongside for [all]'s reverse lookup. Fields are stored
* individually so no serialization library is needed; [relays] is newline-joined.
*/
class DataStoreNip46ClientStore(
private val filesDir: File,
) : Nip46ClientStore {
constructor(context: Context) : this(context.applicationContext.filesDir)
private val store: DataStore<Preferences> get() = dataStoreFor(File(filesDir, "datastore/nip46_clients.preferences_pb"))
override suspend fun load(coordinate: String): Nip46ClientInfo? {
val prefs = store.data.first()
if (prefs[coordKey(coordinate)] == null) return null
return Nip46ClientInfo(
name = prefs[nameKey(coordinate)],
url = prefs[urlKey(coordinate)],
image = prefs[imageKey(coordinate)],
relays = prefs[relaysKey(coordinate)].toRelaySet(),
)
}
override suspend fun store(
coordinate: String,
info: Nip46ClientInfo,
) {
store.edit { prefs ->
prefs[coordKey(coordinate)] = coordinate
info.name?.let { prefs[nameKey(coordinate)] = it } ?: prefs.remove(nameKey(coordinate))
info.url?.let { prefs[urlKey(coordinate)] = it } ?: prefs.remove(urlKey(coordinate))
info.image?.let { prefs[imageKey(coordinate)] = it } ?: prefs.remove(imageKey(coordinate))
if (info.relays.isNotEmpty()) prefs[relaysKey(coordinate)] = info.relays.joinToString("\n") else prefs.remove(relaysKey(coordinate))
}
}
override suspend fun remove(coordinate: String) {
store.edit { prefs ->
prefs.remove(coordKey(coordinate))
prefs.remove(nameKey(coordinate))
prefs.remove(urlKey(coordinate))
prefs.remove(imageKey(coordinate))
prefs.remove(relaysKey(coordinate))
}
}
override suspend fun all(): Map<String, Nip46ClientInfo> {
val prefs = store.data.first()
val result = mutableMapOf<String, Nip46ClientInfo>()
for ((key, value) in prefs.asMap()) {
if (!key.name.startsWith(COORD_PREFIX)) continue
val coordinate = value as? String ?: continue
result[coordinate] =
Nip46ClientInfo(
name = prefs[nameKey(coordinate)],
url = prefs[urlKey(coordinate)],
image = prefs[imageKey(coordinate)],
relays = prefs[relaysKey(coordinate)].toRelaySet(),
)
}
return result
}
private fun String?.toRelaySet(): Set<String> = this?.split("\n")?.filterTo(mutableSetOf()) { it.isNotEmpty() } ?: emptySet()
private fun coordKey(coordinate: String) = stringPreferencesKey("$COORD_PREFIX${hash(coordinate)}")
private fun nameKey(coordinate: String) = stringPreferencesKey("name:${hash(coordinate)}")
private fun urlKey(coordinate: String) = stringPreferencesKey("url:${hash(coordinate)}")
private fun imageKey(coordinate: String) = stringPreferencesKey("img:${hash(coordinate)}")
private fun relaysKey(coordinate: String) = stringPreferencesKey("relays:${hash(coordinate)}")
companion object {
private val stores = ConcurrentHashMap<String, DataStore<Preferences>>()
private fun dataStoreFor(file: File): DataStore<Preferences> =
stores.computeIfAbsent(file.absolutePath) {
PreferenceDataStoreFactory.create(produceFile = { file })
}
private const val COORD_PREFIX = "coord:"
private fun hash(coordinate: String): String {
val digest = MessageDigest.getInstance("SHA-256").digest(coordinate.toByteArray())
return digest.take(8).joinToString("") { "%02x".format(it) }
}
}
}

View File

@@ -0,0 +1,154 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.favorites
import android.content.Context
import android.util.Log
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
private val Context.browserHistoryDataStore by preferencesDataStore(name = "browser_history")
/**
* One device-local visited site, keyed by full [url]. [visitCount]/[lastVisitedAt] drive frecency ranking
* in the omnibox suggestions.
*/
@Serializable
data class BrowserHistoryEntry(
val url: String,
val title: String,
val host: String,
val lastVisitedAt: Long,
val visitCount: Int,
)
/**
* The browser's visit history — the data behind the omnibox suggestions, alongside the user's favorites.
*
* **Only pages that actually loaded land here.** [record] is called from the `:napplet` browser host
* (relayed over IPC through `NappletBrokerService`) on a *successful* main-frame page-finish — never from
* the address bar as the user types — so misspelled/never-resolved hosts never pollute the list. Bounded
* to [MAX_ENTRIES] most-recent entries.
*
* Lives only in the **main process** (the launcher/omnibox consume it; the keyless `:napplet` sandbox
* never reads it). Same shape as [FavoriteAppsRegistry]: an authoritative in-memory [StateFlow] for
* synchronous Compose reads, with write-through persistence to a DataStore on a background scope.
*/
object BrowserHistoryRegistry {
private val KEY = stringPreferencesKey("history")
private const val MAX_ENTRIES = 500
private val _history = MutableStateFlow<List<BrowserHistoryEntry>>(emptyList())
val history: StateFlow<List<BrowserHistoryEntry>> = _history.asStateFlow()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Volatile private var appContext: Context? = null
@Volatile private var hydrated = false
/** Binds the app context and hydrates the on-disk list into [history]. Idempotent. */
fun init(context: Context) {
if (appContext != null) return
val ctx = context.applicationContext
appContext = ctx
scope.launch {
val json = ctx.browserHistoryDataStore.data.first()[KEY]
val loaded = if (json != null) decode(json) else emptyList()
// Merge disk under anything already recorded this session (session wins, newest-first).
update { current -> dedupeNewestFirst(current + loaded) }
hydrated = true
}
}
/**
* Records a successful visit to [url], moving it to the front. An existing entry for the same URL is
* bumped (visit count +1, title refreshed if non-blank); otherwise a new entry is prepended.
*/
fun record(
url: String,
title: String,
) {
val host = OmniboxInput.hostOf(url) ?: url
val now = System.currentTimeMillis()
update { current ->
val existing = current.firstOrNull { it.url == url }
val entry =
if (existing != null) {
existing.copy(
title = title.ifBlank { existing.title },
host = host,
lastVisitedAt = now,
visitCount = existing.visitCount + 1,
)
} else {
BrowserHistoryEntry(url = url, title = title, host = host, lastVisitedAt = now, visitCount = 1)
}
(listOf(entry) + current.filterNot { it.url == url }).take(MAX_ENTRIES)
}
}
fun remove(url: String) = update { current -> current.filterNot { it.url == url } }
fun clear() = update { emptyList() }
private fun dedupeNewestFirst(list: List<BrowserHistoryEntry>): List<BrowserHistoryEntry> =
list
.sortedByDescending { it.lastVisitedAt }
.distinctBy { it.url }
.take(MAX_ENTRIES)
private inline fun update(transform: (List<BrowserHistoryEntry>) -> List<BrowserHistoryEntry>) {
val next = transform(_history.value)
if (next == _history.value) return
_history.value = next
persist(encode(next))
}
private fun persist(json: String) {
val ctx = appContext ?: return
scope.launch {
ctx.browserHistoryDataStore.edit { it[KEY] = json }
}
}
private fun encode(list: List<BrowserHistoryEntry>): String = JsonMapper.toJson(list)
private fun decode(json: String): List<BrowserHistoryEntry> =
try {
JsonMapper.fromJson<List<BrowserHistoryEntry>>(json)
} catch (e: Exception) {
Log.w("BrowserHistoryRegistry", "Failed to decode history", e)
emptyList()
}
}

View File

@@ -0,0 +1,124 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.favorites
import android.content.Context
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.io.File
/**
* Device-local favicon store for browsed sites, keyed by host. Favicons are **captured from the WebView
* that already loaded the page** in the keyless `:napplet` browser host (where they ride the page's own —
* Tor-routed — network path) and relayed here as PNG bytes over IPC; this is the privacy-preserving
* alternative to the main app fetching `host/favicon.ico` itself, which would bypass Tor and leak the
* visit. Used to decorate favorite cards and omnibox suggestion rows.
*
* Lives only in the **main process**. Bytes are persisted as one small PNG per host under
* `filesDir/browser_icons`; the deterministic path means the only in-memory state is [keys] — the set of
* hosts that currently have an icon — which exists purely to drive Compose recomposition (and to keep
* `File.exists()` disk checks out of composition).
*/
object BrowserIconRegistry {
private const val DIR = "browser_icons"
private val _keys = MutableStateFlow<Set<String>>(emptySet())
/** Sanitized host keys that currently have a stored icon. Observe to recompose when an icon arrives. */
val keys: StateFlow<Set<String>> = _keys.asStateFlow()
@Volatile private var iconDir: File? = null
// Disk work runs here, never on the caller's thread. Both entry points are reached from threads
// that must not block: init() from app startup and record() from the broker's IPC handler, which
// is the main looper — StrictMode flagged the write, and a slow filesystem would have stalled the
// UI while a favicon was saved.
private val io = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/**
* Binds the app context and indexes already-stored icons. Idempotent.
*
* [iconDir] is published synchronously so [iconModelFor] and [record] work immediately; only the
* directory scan is deferred. Until it lands [keys] is empty, so an icon simply renders its
* placeholder for one frame and then recomposes — [keys] is a StateFlow precisely so that arrival
* drives recomposition.
*/
fun init(context: Context) {
if (iconDir != null) return
val dir = File(context.applicationContext.filesDir, DIR)
iconDir = dir
io.launch {
dir.mkdirs()
_keys.value = dir.listFiles()?.mapNotNull { it.name.removeSuffix(PNG).takeIf { n -> n.isNotBlank() } }?.toSet() ?: emptySet()
}
}
/** Persists [bytes] as the favicon for [host] and marks it available. Called from the broker on IPC. */
fun record(
host: String,
bytes: ByteArray,
) {
val dir = iconDir ?: return
if (host.isBlank() || bytes.isEmpty()) return
val key = sanitize(host)
// Fire-and-forget: a favicon is a decoration, and the IPC handler must not wait on disk.
// [keys] updates only after the bytes are actually on disk, so a reader can never be told an
// icon exists before the file backing it does.
io.launch {
try {
dir.mkdirs()
File(dir, key + PNG).writeBytes(bytes)
_keys.update { it + key }
} catch (e: Exception) {
Log.w("BrowserIconRegistry", "Failed to store favicon for $host", e)
}
}
}
/**
* A Coil model (`file://…`) for [host]'s favicon, or null when none is stored. Reads [keys] so callers
* that observe the flow recompose as icons arrive — pass [keys]'s value as a `remember` key.
*/
fun iconModelFor(host: String): String? {
val dir = iconDir ?: return null
val key = sanitize(host)
if (key !in _keys.value) return null
return "file://" + File(dir, key + PNG).absolutePath
}
// Hosts map to a flat, filesystem-safe filename. Collisions (two hosts → one key) only mean a shared
// icon file, which is harmless for a decoration.
private fun sanitize(host: String): String =
host
.lowercase()
.map { if (it.isLetterOrDigit() || it == '.' || it == '-') it else '_' }
.joinToString("")
.take(120)
private const val PNG = ".png"
}

View File

@@ -0,0 +1,231 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.favorites
import android.app.Activity
import android.content.Context
import android.content.res.Configuration
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.napplet.NappletLauncher
import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
import com.vitorpamplona.amethyst.napplethost.HostProfile
import com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent
import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
/**
* Turns a [FavoriteApp] back into a running app. The two cases map to the two launch paths in the
* codebase, nothing more:
*
* - [FavoriteApp.WebApp] → a full-screen direct-WebView
* [NappletBrowserActivity][com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity] (its own
* task/recents entry), so the web client owns the whole screen and scrolls/zooms natively.
* - [FavoriteApp.NostrApp] → re-resolve the live event from [LocalCache] by coordinate, read its
* `requires`/website-mode off the event, then hand to [NappletLauncher] (the sandboxed `:napplet`
* host). nsite vs napplet is decided *here*, from the event, never from stored state.
*
* A [FavoriteApp.NostrApp] whose event hasn't loaded yet can't launch; we surface that instead of
* failing silently.
*/
object FavoriteAppLauncher {
fun launch(
context: Context,
app: FavoriteApp,
) {
when (app) {
is FavoriteApp.WebApp -> launchUrl(context, app.url)
is FavoriteApp.NostrApp -> launchNostrApp(context, app.coordinate)
}
}
/**
* Opens [url] full-screen in its own task, so back/recents treat it like a separate app. Uses the
* direct-WebView [NappletBrowserActivity] (page scrolls/zooms and the keyboard resizes natively),
* resolving the proxy port + this site's remembered Tor choice here in the main process. [preferTor]
* forces Tor (when available) regardless of the remembered choice — used for `.onion`, which only
* resolves over Tor.
*/
fun launchUrl(
context: Context,
url: String,
preferTor: Boolean = false,
) {
val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
val useTor = proxyPort > 0 && (preferTor || WebAppNetworkRegistry.useTor(url))
val themeType = Amethyst.instance.uiPrefs.value.theme.value
val theme =
when (themeType) {
ThemeType.DARK -> "DARK"
ThemeType.LIGHT -> "LIGHT"
ThemeType.SYSTEM -> {
val nightMask = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
if (nightMask == Configuration.UI_MODE_NIGHT_YES) "DARK" else "LIGHT"
}
}
val isFavorite = FavoriteAppsRegistry.isFavorite("url:$url")
val intent =
NappletBrowserActivity
.intent(
context,
url,
proxyPort,
useTor,
theme = theme,
isFavorite = isFavorite,
// Opaque per-account storage partition, so a web app can't carry one npub's session
// into another. Derived here (the sandbox never sees the pubkey).
webViewProfile = NappletWebViewProfiles.current(),
).apply {
if (context !is Activity) addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
private fun launchNostrApp(
context: Context,
coordinate: String,
) {
val event = LocalCache.getAddressableNoteIfExists(coordinate)?.event
when (event) {
is RootNappletEvent ->
NappletLauncher.launch(context, event, event.pubKey, "")
is NamedNappletEvent ->
NappletLauncher.launch(context, event, event.pubKey, event.identifier())
is RootSiteEvent ->
NappletLauncher.launch(
context = context,
paths = event.paths(),
servers = event.servers(),
authorPubKey = event.pubKey,
identifier = "",
aggregateHash = null,
title = event.title() ?: "nsite",
requires = emptyList(),
profile = HostProfile.WEBSITE,
)
is NamedSiteEvent ->
NappletLauncher.launch(
context = context,
paths = event.paths(),
servers = event.servers(),
authorPubKey = event.pubKey,
identifier = event.identifier(),
aggregateHash = null,
title = event.title() ?: event.identifier(),
requires = emptyList(),
profile = HostProfile.WEBSITE,
)
else -> {
Log.w("FavoriteAppLauncher", "Favorited app not resolvable yet: $coordinate")
Toast.makeText(context, R.string.favorite_app_still_loading, Toast.LENGTH_SHORT).show()
}
}
}
/**
* Builds the main-process-minted launch parameters for embedding the nsite/napplet at [coordinate]
* as an in-app tab (see `NappletHostService`). Returns null when the event isn't resolvable in
* [LocalCache] yet — the caller shows a loading state. nsite vs napplet (and website mode) is decided
* here from the live event, exactly as in [launchNostrApp].
*/
fun embedParams(
context: Context,
coordinate: String,
): Bundle? {
val event = LocalCache.getAddressableNoteIfExists(coordinate)?.event
return when (event) {
is RootNappletEvent ->
NappletLauncher.buildLaunchParams(
context,
event.paths(),
event.servers(),
event.pubKey,
"",
event.declaredAggregateHash() ?: event.computeAggregateHash(),
event.title() ?: "Napplet",
event.requires(),
HostProfile.NAPPLET,
)
is NamedNappletEvent ->
NappletLauncher.buildLaunchParams(
context,
event.paths(),
event.servers(),
event.pubKey,
event.identifier(),
event.declaredAggregateHash() ?: event.computeAggregateHash(),
event.title() ?: event.identifier(),
event.requires(),
HostProfile.NAPPLET,
)
is RootSiteEvent ->
NappletLauncher.buildLaunchParams(
context,
event.paths(),
event.servers(),
event.pubKey,
"",
null,
event.title() ?: "nsite",
emptyList(),
HostProfile.WEBSITE,
)
is NamedSiteEvent ->
NappletLauncher.buildLaunchParams(
context,
event.paths(),
event.servers(),
event.pubKey,
event.identifier(),
null,
event.title() ?: event.identifier(),
emptyList(),
HostProfile.WEBSITE,
)
else -> null
}
}
/**
* The addressable coordinate `kind:pubkey:dtag` used to key an nsite/napplet favorite. Stored
* instead of the content hash so the favorite survives routine code/manifest updates.
*/
fun coordinateOf(event: Event): String {
val dTag =
when (event) {
is NamedNappletEvent -> event.identifier()
is NamedSiteEvent -> event.identifier()
else -> ""
}
return "${event.kind}:${event.pubKey}:$dTag"
}
}

View File

@@ -0,0 +1,214 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.favorites
import android.content.Context
import android.util.Log
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import java.util.concurrent.ConcurrentHashMap
private val Context.favoriteAppsDataStore by preferencesDataStore(name = "favorite_apps")
/**
* The user's device-local list of [FavoriteApp]s — the single source of truth shared by the bottom
* bar, the Favorite Apps grid, and the browser launcher. Ordered (the user can reorder); de-duplicated
* by [FavoriteApp.id].
*
* Lives only in the **main process** (the launcher/UI consume it); the keyless `:napplet` sandbox never
* touches it. An in-memory [StateFlow] is authoritative for the session so Compose can observe it
* synchronously, with write-through persistence to a DataStore on a background scope. The list is
* stored as a single JSON array under one key (small, bounded, hand-curated data — no need for one key
* per entry).
*/
object FavoriteAppsRegistry {
private val KEY = stringPreferencesKey("favorites")
// Raw manifest event JSON for each favorited [FavoriteApp.NostrApp], keyed by its addressable
// coordinate. Cached so a pinned nsite/napplet resolves instantly on the next cold start — and
// offline — instead of waiting on a relay round-trip the way a [FavoriteApp.WebApp]'s URL never
// has to. The relay subscription that warms these favorites keeps the cache fresh.
private val MANIFESTS_KEY = stringPreferencesKey("manifests")
private val _favorites = MutableStateFlow<List<FavoriteApp>>(emptyList())
val favorites: StateFlow<List<FavoriteApp>> = _favorites.asStateFlow()
private val manifestCache = MutableStateFlow<Map<String, String>>(emptyMap())
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Volatile private var appContext: Context? = null
// Hydration runs async on a background scope, so the user can add/remove before the disk list
// merges in. [removedBeforeHydration] tombstones any id removed in that window, so the merge can't
// resurrect a just-deleted favorite from disk.
@Volatile private var hydrated = false
private val removedBeforeHydration = ConcurrentHashMap.newKeySet<String>()
/** Binds the app context and hydrates the on-disk list into [favorites]. Idempotent. */
fun init(context: Context) {
if (appContext != null) return
val ctx = context.applicationContext
appContext = ctx
scope.launch {
val prefs = ctx.favoriteAppsDataStore.data.first()
val loaded = prefs[KEY]?.let { decode(it) } ?: emptyList()
// Don't clobber adds made in this session before hydration finished, and don't resurrect
// anything the user removed in that same window.
update { current -> (loaded.filterNot { it.id in removedBeforeHydration } + current).distinctBy { it.id } }
// Same race rules for the manifest cache: a cacheManifest() in this session wins over the
// disk copy, and a manifest whose favorite was removed pre-hydration must not come back.
val loadedManifests = prefs[MANIFESTS_KEY]?.let { decodeManifests(it) } ?: emptyMap()
updateManifests { current -> loadedManifests.filterKeys { "nostr:$it" !in removedBeforeHydration } + current }
hydrated = true
removedBeforeHydration.clear()
}
}
fun isFavorite(id: String): Boolean = _favorites.value.any { it.id == id }
/** Adds [app] to the end if not already present (by [FavoriteApp.id]). */
fun add(app: FavoriteApp) = update { current -> if (current.any { it.id == app.id }) current else current + app }
fun remove(id: String) {
if (!hydrated) removedBeforeHydration.add(id)
update { current -> current.filterNot { it.id == id } }
// Drop the cached manifest too — favorite ids for nsites/napplets are "nostr:<coordinate>".
if (id.startsWith("nostr:")) updateManifests { it - id.removePrefix("nostr:") }
}
/** The cached manifest event JSON for a favorited nsite/napplet [coordinate], or null if none. */
fun cachedManifest(coordinate: String): String? = manifestCache.value[coordinate]
/**
* Caches the raw manifest event [eventJson] for a favorited nsite/napplet [coordinate] so the next
* launch can resolve it instantly / offline. Write-through; no-ops when the JSON is unchanged.
*/
fun cacheManifest(
coordinate: String,
eventJson: String,
) = updateManifests { if (it[coordinate] == eventJson) it else it + (coordinate to eventJson) }
/** Replaces the whole list, e.g. after a drag-reorder. */
fun setOrder(newOrder: List<FavoriteApp>) = update { newOrder }
private inline fun update(transform: (List<FavoriteApp>) -> List<FavoriteApp>) {
val next = transform(_favorites.value)
if (next == _favorites.value) return
_favorites.value = next
persist(encode(next))
}
private inline fun updateManifests(transform: (Map<String, String>) -> Map<String, String>) {
val next = transform(manifestCache.value)
if (next == manifestCache.value) return
manifestCache.value = next
persistManifests(encodeManifests(next))
}
private fun persist(json: String) {
val ctx = appContext ?: return
scope.launch {
ctx.favoriteAppsDataStore.edit { it[KEY] = json }
}
}
private fun persistManifests(json: String) {
val ctx = appContext ?: return
scope.launch {
ctx.favoriteAppsDataStore.edit { it[MANIFESTS_KEY] = json }
}
}
// --- Persistence DTO ------------------------------------------------------------------------
// A flat, type-tagged record so we serialize one concrete shape instead of relying on
// polymorphic (sealed) (de)serialization. Mapping to/from the sealed model lives here.
@Serializable
private data class Entry(
val type: String,
val ref: String,
val label: String,
val addedAt: Long,
val iconUrl: String? = null,
)
private fun encode(list: List<FavoriteApp>): String =
JsonMapper.toJson(
list.map {
when (it) {
is FavoriteApp.NostrApp -> Entry(TYPE_NOSTR, it.coordinate, it.label, it.addedAt, it.iconUrl)
is FavoriteApp.WebApp -> Entry(TYPE_URL, it.url, it.label, it.addedAt, it.iconUrl)
}
},
)
private fun decode(json: String): List<FavoriteApp> =
try {
JsonMapper.fromJson<List<Entry>>(json).mapNotNull { entry ->
when (entry.type) {
TYPE_NOSTR -> FavoriteApp.NostrApp(entry.ref, entry.label, entry.addedAt, entry.iconUrl)
TYPE_URL -> FavoriteApp.WebApp(entry.ref, entry.label, entry.addedAt, entry.iconUrl)
else -> null
}
}
} catch (e: Exception) {
Log.w("FavoriteAppsRegistry", "Failed to decode favorites", e)
emptyList()
}
private const val TYPE_NOSTR = "nostr"
private const val TYPE_URL = "url"
// --- Manifest cache persistence -------------------------------------------------------------
// Stored as a flat list of (coordinate, json) records under one key — same single-key, hand-curated
// shape as the favorites list, so we never serialize a raw polymorphic map.
@Serializable
private data class ManifestEntry(
val coordinate: String,
val json: String,
)
private fun encodeManifests(manifests: Map<String, String>): String = JsonMapper.toJson(manifests.map { ManifestEntry(it.key, it.value) })
private fun decodeManifests(json: String): Map<String, String> =
try {
JsonMapper.fromJson<List<ManifestEntry>>(json).associate { it.coordinate to it.json }
} catch (e: Exception) {
Log.w("FavoriteAppsRegistry", "Failed to decode favorite manifests", e)
emptyMap()
}
}

View File

@@ -0,0 +1,67 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.favorites
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
* Pre-fetches the addressable events behind the user's favorited [FavoriteApp.NostrApp]s (nSites /
* nApplets) while a screen that can launch them is on screen.
*
* A favorite stores only the addressable coordinate `kind:pubkey:dtag`, never the event. The launch
* path ([FavoriteAppLauncher.launchNostrApp] / [FavoriteAppLauncher.embedParams]) re-resolves the live
* event from [LocalCache] at tap time, so a favorite whose event hasn't streamed in yet can't launch —
* the user gets the "isn't loaded yet" toast / unavailable tab. Before this preloader, the only thing
* that pulled those events into the cache was visiting the nsite/napplet feed (it subscribes by author),
* which is why opening that feed and coming back made a favorite suddenly launchable.
*
* This subscribes each favorited coordinate to the shared
* [EventFinder][com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssembler]
* — the same lifecycle-aware loader [observeNote][com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote]
* uses — so the manifests fetch (via the author's outbox relays) as soon as the launcher opens and are
* already in [LocalCache] by the time the user taps. The loader drops each coordinate from its filter
* once the event arrives, so this is a one-shot fetch, not a standing feed.
*/
@Composable
fun PreloadFavoriteNostrApps(
apps: List<FavoriteApp>,
accountViewModel: AccountViewModel,
) {
val account = accountViewModel.account
// Reuse the same query-state instances across recompositions (the manager ref-counts by identity),
// recomputing only when the favorite list or account changes. Events that already loaded are still
// included; the loader simply skips them because their note already has an event.
val states =
remember(apps, account) {
apps
.filterIsInstance<FavoriteApp.NostrApp>()
.mapNotNull { LocalCache.checkGetOrCreateAddressableNote(it.coordinate) }
.map { EventFinderQueryState(it, account) }
}
LifecycleAwareKeyDataSourceSubscription(states, accountViewModel.dataSources().eventFinder)
}

View File

@@ -0,0 +1,147 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.favorites
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.napplethost.NappletBlobCache
import com.vitorpamplona.amethyst.napplethost.NappletBlobPrefetcher
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent
import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
/** The chosen icon blob and the servers that hold it — resolved from the live manifest. */
private class IconBlob(
val path: PathTag,
val servers: List<String>,
)
/**
* A Coil model (`file://…`) for the app icon an nsite/napplet bundles in its own content — the verified,
* content-addressed blob the [NappletIconPath][com.vitorpamplona.quartz.nip5aStaticWebsites.NappletIconPath]
* heuristic picks from the manifest's `path` tags — or null when the manifest declares no such icon (or it
* hasn't downloaded yet). Used to decorate an nsite/napplet favorite the same way a captured favicon
* decorates a web favorite, except this rides the same Tor-routed, sha256-verified blob path as the rest of
* the site instead of a clearnet favicon fetch.
*
* Unlike a webapp's favicon, this can't be captured live: the applet runs in a cross-origin sandboxed
* iframe under the trusted shell, so `WebChromeClient.onReceivedIcon` only ever reports the shell's icon,
* never the applet's. Deriving it from the bundled blobs is the only path that sees the real icon.
*
* Observes the addressable note, so the icon resolves whenever the manifest arrives or updates in
* LocalCache — not only if it already happened to be cached at first composition (on a cold start the
* event streams in from relays a moment later). Returns null until the blob is on disk; the icon then
* appears on the next recomposition. All disk + network work runs off the composition thread. The blob is
* usually already cached (the browse/feed card prefetches every manifest blob, this one included); the
* on-demand fetch here just covers favorites whose card isn't currently on screen.
*/
@Composable
fun rememberNappletIconModel(coordinate: String): String? {
val context = LocalContext.current
// checkGetOrCreate returns null only for a malformed coordinate, so this early return is stable for a
// given coordinate (it never flips across recompositions, which would break composition structure).
val note = remember(coordinate) { LocalCache.checkGetOrCreateAddressableNote(coordinate) } ?: return null
val noteState by note
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
// Key on the event itself, not the NoteState wrapper: re-resolve only when the manifest actually
// changes, not on every unrelated metadata bump (a reaction/zap tracked on the note).
val event = noteState.note.event
val icon = remember(event) { resolveIconBlob(event) } ?: return null
var model by remember(icon.path.hash) { mutableStateOf<String?>(null) }
LaunchedEffect(icon.path.hash) {
withContext(Dispatchers.IO) {
val file = File(NappletBlobCache.dirFor(context.cacheDir), icon.path.hash.lowercase())
if (!file.isFile) {
val torPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
runCatching { NappletBlobPrefetcher.prefetch(listOf(icon.path), icon.servers, context.cacheDir, torPort) }
}
if (file.isFile) model = "file://" + file.absolutePath
}
}
return model
}
/** Asks each nsite/napplet event type for its bundled icon blob + the servers that hold it. */
private fun resolveIconBlob(event: Event?): IconBlob? =
when (event) {
is RootNappletEvent -> event.iconBlob()?.let { IconBlob(it, event.servers()) }
is NamedNappletEvent -> event.iconBlob()?.let { IconBlob(it, event.servers()) }
is RootSiteEvent -> event.iconBlob()?.let { IconBlob(it, event.servers()) }
is NamedSiteEvent -> event.iconBlob()?.let { IconBlob(it, event.servers()) }
else -> null
}
/**
* A Coil model (`file://…`) for the cached favicon of [url]'s host, or null when no favicon
* has been captured yet. The favicon is stored by [BrowserIconRegistry] at browse time (the
* WebView captures it in the sandboxed `:napplet` process); this composable just reads the cache.
*
* Early-returns null when [url] is blank or has no parseable host — this early return is stable
* for a given [url] (the host either always parses or never does), so composition structure is
* preserved across recompositions.
*/
@Composable
fun rememberWebAppIconModel(url: String): String? {
val host = remember(url) { OmniboxInput.hostOf(url) } ?: return null
val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle()
return remember(host, iconKeys) { BrowserIconRegistry.iconModelFor(host) }
}
/**
* A Coil model for the bundled icon of an napplet or nsite identified by [author] and
* [identifier]. Tries the napplet manifest (kinds 15129 / 35129) first, then falls back to the
* nsite manifest (kinds 15128 / 35128). Returns null until the blob lands on disk.
*/
@Composable
fun rememberManifestIconModel(
author: String,
identifier: String,
): String? {
val nappletCoord =
remember(author, identifier) {
if (identifier.isEmpty()) "${RootNappletEvent.KIND}:$author:" else "${NamedNappletEvent.KIND}:$author:$identifier"
}
val nsiteCoord =
remember(author, identifier) {
if (identifier.isEmpty()) "${RootSiteEvent.KIND}:$author:" else "${NamedSiteEvent.KIND}:$author:$identifier"
}
return rememberNappletIconModel(nappletCoord) ?: rememberNappletIconModel(nsiteCoord)
}

View File

@@ -22,16 +22,24 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm
import com.vitorpamplona.amethyst.commons.model.concord.ConcordListRepository
import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatRepository
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListRepository
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupRepository
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSourceResolver
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
@@ -57,6 +65,7 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType
import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.Permission
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
@@ -118,6 +127,14 @@ sealed class TopFilter(
@Serializable
object AroundMe : TopFilter(" Around Me ")
/**
* Not a real selection: a sentinel for the "Teleport" chip in the top-nav filter.
* The spinner intercepts it to open the map picker and then applies the chosen
* [Geohash] instead — it is never persisted or dispatched to a feed flow.
*/
@Serializable
object TeleportPicker : TopFilter(" Teleport ")
@Serializable
object Mine : TopFilter(" Mine ")
@@ -177,6 +194,45 @@ class AccountSettings(
var stripLocationOnUpload: Boolean = true,
val useLocalBlossomCache: MutableStateFlow<Boolean> = MutableStateFlow(true),
val localBlossomCacheProfilePicturesOnly: MutableStateFlow<Boolean> = MutableStateFlow(false),
/**
* BUD-04: after uploading a blob to the primary Blossom server, replicate it to
* the user's other configured servers (kind 10063) for redundancy.
*/
val mirrorUploadsToAllServers: MutableStateFlow<Boolean> = MutableStateFlow(true),
/**
* BUD-05: upload media through the server's `/media` endpoint so the server may
* strip metadata and optimize it, instead of the bit-exact `/upload`.
*/
val optimizeMediaOnUpload: MutableStateFlow<Boolean> = MutableStateFlow(false),
/**
* NIP-46: when true, this account acts as a remote signer (a "bunker") for
* other apps, listening on the user's inbox relays for kind:24133 requests.
* See [com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState].
*/
val nip46SignerEnabled: MutableStateFlow<Boolean> = MutableStateFlow(false),
/**
* The active pairing secret advertised in this account's `bunker://` URI. An
* app that connects with this secret is accepted and registered as a
* connected app; regenerating it revokes the ability of not-yet-connected
* apps to pair with an old string.
*/
val nip46BunkerSecret: MutableStateFlow<String> = MutableStateFlow(""),
/**
* A dedicated per-account transport keypair (hex private key) for the NIP-46
* bunker. The kind-24133 envelope is wrapped with THIS key, not the account's
* identity key, so the bunker address and on-relay traffic don't reveal which
* user the bunker belongs to (the identity is disclosed only to a connected
* app via `get_public_key`). Generated once and kept stable so the advertised
* `bunker://` address doesn't change.
*/
val nip46TransportKey: MutableStateFlow<String> = MutableStateFlow(""),
/**
* The kind-24133 **event ids** this signer recently serviced. Persisted so that a relay replaying
* stored ephemeral requests across an app restart doesn't make it sign the same request twice —
* matched by exact event id, so it is immune to client clock skew (unlike a timestamp watermark,
* a global timestamp would wrongly drop a second app whose clock lags). Bounded to a recent window.
*/
val nip46SeenRequestIds: MutableStateFlow<Set<String>> = MutableStateFlow(emptySet()),
/**
* NIP-9B opt-in: when true, community feeds drop events whose latest cached
* `kind:34551` rules document fails [com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesValidator].
@@ -189,7 +245,10 @@ class AccountSettings(
val defaultDiscoveryFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultPollsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultPicturesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultNappletsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultNsitesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultWorkoutsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultGitRepositoriesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultCalendarsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultProductsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AroundMe),
val defaultShortsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
@@ -207,6 +266,8 @@ class AccountSettings(
val defaultBrowseEmojiSetsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultCommunitiesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val defaultFollowPacksFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultAppRecommendationsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultRelayGroupsDiscoveryFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Mine),
val nwcWallets: MutableStateFlow<List<NwcWalletEntryNorm>> = MutableStateFlow(emptyList()),
val clinkDebitWallets: MutableStateFlow<List<ClinkDebitWalletEntryNorm>> = MutableStateFlow(emptyList()),
// The unified default spend rail (an NWC wallet OR a CLINK debit). Persisted under a
@@ -217,6 +278,7 @@ class AccountSettings(
var hideNIP17WarningDialog: Boolean = false,
val alwaysOnNotificationService: MutableStateFlow<Boolean> = MutableStateFlow(false),
val splitNotificationsEnabled: MutableStateFlow<Boolean> = MutableStateFlow(false),
val showMessagesInNotifications: MutableStateFlow<Boolean> = MutableStateFlow(true),
var backupUserMetadata: MetadataEvent? = null,
var backupContactList: ContactListEvent? = null,
var backupDMRelayList: ChatMessageRelayListEvent? = null,
@@ -236,6 +298,8 @@ class AccountSettings(
var backupFavoriteAlgoFeedsList: FavoriteAlgoFeedsListEvent? = null,
var backupGeohashList: GeohashListEvent? = null,
var backupEphemeralChatList: EphemeralChatListEvent? = null,
var backupRelayGroupList: SimpleGroupListEvent? = null,
var backupConcordList: ConcordCommunityListEvent? = null,
var backupTrustProviderList: TrustProviderListEvent? = null,
var backupCashuWallet: CashuWalletEvent? = null,
var backupNutzapInfo: NutzapInfoEvent? = null,
@@ -262,7 +326,20 @@ class AccountSettings(
var callVideoResolution: CallVideoResolution = CallVideoResolution.HD_720,
var callMaxBitrateBps: Int = 1_500_000,
val callsEnabled: MutableStateFlow<Boolean> = MutableStateFlow(true),
val defaultRelayAuthPolicy: MutableStateFlow<RelayAuthPolicy> = MutableStateFlow(RelayAuthPolicy.CUSTOM),
val relayGroupViewMode: MutableStateFlow<RelayGroupViewMode> = MutableStateFlow(RelayGroupViewMode.DEFAULT),
val concordViewMode: MutableStateFlow<ConcordViewMode> = MutableStateFlow(ConcordViewMode.DEFAULT),
// Which conversation protocols the Messages inbox loads and shows. A disabled type is both hidden
// from the inbox and dropped from the always-on downloading routes. Defaults to everything on.
val enabledChatFeeds: MutableStateFlow<Set<ChatFeedType>> = MutableStateFlow(ChatFeedType.ALL),
// The per-situation toggles applied under RelayAuthPolicy.CUSTOM.
val relayAuthTrustMyRelaysAndVenues: MutableStateFlow<Boolean> = MutableStateFlow(true),
val relayAuthTrustReadFollows: MutableStateFlow<Boolean> = MutableStateFlow(true),
val relayAuthTrustMessageFollows: MutableStateFlow<Boolean> = MutableStateFlow(true),
val relayAuthTrustMessageStrangers: MutableStateFlow<Boolean> = MutableStateFlow(false),
) : EphemeralChatRepository,
RelayGroupRepository,
ConcordListRepository,
PublicChatListRepository {
val saveable = MutableStateFlow(AccountSettingsUpdater(null))
val syncedSettings: AccountSyncedSettings = AccountSyncedSettings(AccountSyncedSettingsInternal())
@@ -277,6 +354,34 @@ class AccountSettings(
fun isWriteable(): Boolean = keyPair.privKey != null || externalSignerPackageName != null
fun updateRelayGroupViewMode(mode: RelayGroupViewMode) {
if (relayGroupViewMode.value != mode) {
relayGroupViewMode.tryEmit(mode)
saveAccountSettings()
}
}
fun updateConcordViewMode(mode: ConcordViewMode) {
if (concordViewMode.value != mode) {
concordViewMode.tryEmit(mode)
saveAccountSettings()
}
}
fun isChatFeedEnabled(type: ChatFeedType): Boolean = type in enabledChatFeeds.value
fun setChatFeedEnabled(
type: ChatFeedType,
enabled: Boolean,
) {
val current = enabledChatFeeds.value
val next = if (enabled) current + type else current - type
if (next != current) {
enabledChatFeeds.tryEmit(next)
saveAccountSettings()
}
}
// ---
// Always-on Notification Service
// ---
@@ -295,6 +400,13 @@ class AccountSettings(
return newValue
}
fun toggleShowMessagesInNotifications(): Boolean {
val newValue = !showMessagesInNotifications.value
showMessagesInNotifications.tryEmit(newValue)
saveAccountSettings()
return newValue
}
// ---
// Zaps and Reactions
// ---
@@ -535,6 +647,35 @@ class AccountSettings(
}
}
fun changeNip46SignerEnabled(enabled: Boolean) {
if (nip46SignerEnabled.value != enabled) {
nip46SignerEnabled.tryEmit(enabled)
saveAccountSettings()
}
}
fun changeNip46BunkerSecret(secret: String) {
if (nip46BunkerSecret.value != secret) {
nip46BunkerSecret.tryEmit(secret)
saveAccountSettings()
}
}
fun changeNip46TransportKey(hexPrivKey: String) {
if (nip46TransportKey.value != hexPrivKey) {
nip46TransportKey.tryEmit(hexPrivKey)
saveAccountSettings()
}
}
/** Replaces the recent serviced-request id set (already bounded by the caller). */
fun changeNip46SeenRequestIds(ids: Set<String>) {
if (nip46SeenRequestIds.value != ids) {
nip46SeenRequestIds.tryEmit(ids)
saveAccountSettings()
}
}
fun changeLocalBlossomCacheProfilePicturesOnly(enabled: Boolean) {
if (localBlossomCacheProfilePicturesOnly.value != enabled) {
localBlossomCacheProfilePicturesOnly.tryEmit(enabled)
@@ -542,6 +683,20 @@ class AccountSettings(
}
}
fun changeMirrorUploadsToAllServers(enabled: Boolean) {
if (mirrorUploadsToAllServers.value != enabled) {
mirrorUploadsToAllServers.tryEmit(enabled)
saveAccountSettings()
}
}
fun changeOptimizeMediaOnUpload(enabled: Boolean) {
if (optimizeMediaOnUpload.value != enabled) {
optimizeMediaOnUpload.tryEmit(enabled)
saveAccountSettings()
}
}
fun updateAddClientTag(add: Boolean): Boolean =
if (syncedSettings.security.updateAddClientTag(add)) {
saveAccountSettings()
@@ -550,6 +705,25 @@ class AccountSettings(
false
}
fun updatePowDifficulty(difficulty: Int): Boolean =
if (syncedSettings.proofOfWork.updateDifficulty(difficulty)) {
saveAccountSettings()
true
} else {
false
}
fun updatePowCategory(
category: PoWCategory,
enabled: Boolean,
): Boolean =
if (syncedSettings.proofOfWork.updateCategory(category, enabled)) {
saveAccountSettings()
true
} else {
false
}
// ---
// list names
// ---
@@ -631,6 +805,39 @@ class AccountSettings(
}
}
fun changeDefaultRelayGroupsDiscoveryFollowList(name: FeedDefinition) {
changeDefaultRelayGroupsDiscoveryFollowList(name.code)
}
fun changeDefaultRelayGroupsDiscoveryFollowList(name: TopFilter) {
if (defaultRelayGroupsDiscoveryFollowList.value != name) {
defaultRelayGroupsDiscoveryFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultNappletsFollowList(name: FeedDefinition) {
changeDefaultNappletsFollowList(name.code)
}
fun changeDefaultNappletsFollowList(name: TopFilter) {
if (defaultNappletsFollowList.value != name) {
defaultNappletsFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultNsitesFollowList(name: FeedDefinition) {
changeDefaultNsitesFollowList(name.code)
}
fun changeDefaultNsitesFollowList(name: TopFilter) {
if (defaultNsitesFollowList.value != name) {
defaultNsitesFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultWorkoutsFollowList(name: FeedDefinition) {
changeDefaultWorkoutsFollowList(name.code)
}
@@ -642,6 +849,17 @@ class AccountSettings(
}
}
fun changeDefaultGitRepositoriesFollowList(name: FeedDefinition) {
changeDefaultGitRepositoriesFollowList(name.code)
}
fun changeDefaultGitRepositoriesFollowList(name: TopFilter) {
if (defaultGitRepositoriesFollowList.value != name) {
defaultGitRepositoriesFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultCalendarsFollowList(name: FeedDefinition) {
changeDefaultCalendarsFollowList(name.code)
}
@@ -818,6 +1036,17 @@ class AccountSettings(
}
}
fun changeDefaultAppRecommendationsFollowList(name: FeedDefinition) {
changeDefaultAppRecommendationsFollowList(name.code)
}
fun changeDefaultAppRecommendationsFollowList(name: TopFilter) {
if (defaultAppRecommendationsFollowList.value != name) {
defaultAppRecommendationsFollowList.tryEmit(name)
saveAccountSettings()
}
}
// ---
// language services
// ---
@@ -1155,6 +1384,33 @@ class AccountSettings(
}
}
override fun relayGroupList() = backupRelayGroupList
override fun updateRelayGroupListTo(newRelayGroupList: SimpleGroupListEvent?) {
// Joined groups can live in the NIP-44 private items (encrypted content),
// so an empty `tags` is NOT an empty list — guard only on null.
if (newRelayGroupList == null) return
// Events might be different objects, we have to compare their ids.
if (backupRelayGroupList?.id != newRelayGroupList.id) {
backupRelayGroupList = newRelayGroupList
saveAccountSettings()
}
}
override fun concordList() = backupConcordList
override fun updateConcordListTo(newConcordList: ConcordCommunityListEvent?) {
// The joined list lives entirely in NIP-44-encrypted content (secrets),
// so an empty `tags` is NOT an empty list — guard only on null.
if (newConcordList == null) return
if (backupConcordList?.id != newConcordList.id) {
backupConcordList = newConcordList
saveAccountSettings()
}
}
fun updateTrustProviderListTo(trustProviderList: TrustProviderListEvent?) {
if (trustProviderList == null || trustProviderList.tags.isEmpty()) return
@@ -1420,6 +1676,31 @@ class AccountSettings(
saveAccountSettings()
}
}
fun changeDefaultRelayAuthPolicy(policy: RelayAuthPolicy) {
if (defaultRelayAuthPolicy.value != policy) {
defaultRelayAuthPolicy.tryEmit(policy)
saveAccountSettings()
}
}
private fun changeToggle(
flow: MutableStateFlow<Boolean>,
enabled: Boolean,
) {
if (flow.value != enabled) {
flow.tryEmit(enabled)
saveAccountSettings()
}
}
fun changeRelayAuthTrustMyRelaysAndVenues(enabled: Boolean) = changeToggle(relayAuthTrustMyRelaysAndVenues, enabled)
fun changeRelayAuthTrustReadFollows(enabled: Boolean) = changeToggle(relayAuthTrustReadFollows, enabled)
fun changeRelayAuthTrustMessageFollows(enabled: Boolean) = changeToggle(relayAuthTrustMessageFollows, enabled)
fun changeRelayAuthTrustMessageStrangers(enabled: Boolean) = changeToggle(relayAuthTrustMessageStrangers, enabled)
}
@Serializable

View File

@@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
@@ -72,6 +74,11 @@ class AccountSyncedSettings(
AccountChatPreferences(
MutableStateFlow(internalSettings.chats.toChatroomKeys()),
)
val proofOfWork =
AccountPoWPreferences(
MutableStateFlow(internalSettings.proofOfWork.difficulty),
MutableStateFlow(PoWCategory.fromIds(internalSettings.proofOfWork.enabledCategories)),
)
fun toInternal(): AccountSyncedSettingsInternal =
AccountSyncedSettingsInternal(
@@ -104,6 +111,14 @@ class AccountSyncedSettings(
videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value),
media = AccountMediaPreferencesInternal(media.audioVisualizer.value.name),
chats = AccountChatPreferencesInternal(chats.pinnedChatrooms.value.map { it.users.sorted() }),
proofOfWork =
AccountPoWPreferencesInternal(
proofOfWork.difficulty.value,
// sorted so the serialized form is deterministic
proofOfWork.enabledCategories.value
.map { it.id }
.sorted(),
),
)
fun updateFrom(syncedSettingsInternal: AccountSyncedSettingsInternal) {
@@ -182,6 +197,19 @@ class AccountSyncedSettings(
if (chats.pinnedChatrooms.value != newPinnedChatrooms) {
chats.pinnedChatrooms.tryEmit(newPinnedChatrooms)
}
// clamp like the local setter: a synced NIP-78 event from another
// client could carry an out-of-range value that would crash the miner
// (>256) or mine forever (41+).
val newDifficulty = syncedSettingsInternal.proofOfWork.difficulty.coerceIn(0, PoWPolicy.MAX_DIFFICULTY)
if (proofOfWork.difficulty.value != newDifficulty) {
proofOfWork.difficulty.tryEmit(newDifficulty)
}
val newPoWCategories = PoWCategory.fromIds(syncedSettingsInternal.proofOfWork.enabledCategories)
if (proofOfWork.enabledCategories.value != newPoWCategories) {
proofOfWork.enabledCategories.tryEmit(newPoWCategories)
}
}
fun dontTranslateFromFilteredBySpokenLanguages(): Set<String> = languages.dontTranslateFrom.value - getLanguagesSpokenByUser()
@@ -285,6 +313,43 @@ class AccountChatPreferences(
val pinnedChatrooms: MutableStateFlow<Set<ChatroomKey>>,
)
@Stable
class AccountPoWPreferences(
val difficulty: MutableStateFlow<Int> = MutableStateFlow(0),
val enabledCategories: MutableStateFlow<Set<PoWCategory>> = MutableStateFlow(PoWCategory.DEFAULT_ENABLED),
) {
fun updateDifficulty(newDifficulty: Int): Boolean {
// compare the coerced value: reporting a change for an out-of-range
// input that clamps to the current value would republish identical
// settings to relays.
val coerced = newDifficulty.coerceIn(0, MAX_POW_DIFFICULTY)
return if (difficulty.value != coerced) {
difficulty.tryEmit(coerced)
true
} else {
false
}
}
fun updateCategory(
category: PoWCategory,
enabled: Boolean,
): Boolean {
val current = enabledCategories.value
val updated = if (enabled) current + category else current - category
return if (updated != current) {
enabledCategories.tryEmit(updated)
true
} else {
false
}
}
companion object {
const val MAX_POW_DIFFICULTY = PoWPolicy.MAX_DIFFICULTY
}
}
internal fun AccountChatPreferencesInternal.toChatroomKeys(): Set<ChatroomKey> = pinnedRooms.mapTo(mutableSetOf()) { ChatroomKey(it.toSet()) }
@Stable

View File

@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model
import android.content.res.Resources
import androidx.core.os.ConfigurationCompat
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import kotlinx.serialization.Serializable
import java.util.Locale
@@ -157,6 +158,7 @@ class AccountSyncedSettingsInternal(
val videoPlayer: AccountVideoPlayerPreferencesInternal = AccountVideoPlayerPreferencesInternal(),
val media: AccountMediaPreferencesInternal = AccountMediaPreferencesInternal(),
val chats: AccountChatPreferencesInternal = AccountChatPreferencesInternal(),
val proofOfWork: AccountPoWPreferencesInternal = AccountPoWPreferencesInternal(),
)
@Serializable
@@ -206,6 +208,14 @@ class AccountMediaPreferencesInternal(
var audioVisualizer: String = "CLASSIC",
)
@Serializable
class AccountPoWPreferencesInternal(
// NIP-13 target difficulty in leading zero bits; 0 = don't mine anything.
val difficulty: Int = 0,
// PoWCategory ids the user wants mined when difficulty > 0.
val enabledCategories: List<String> = PoWCategory.DEFAULT_ENABLED.map { it.id },
)
@Serializable
class AccountChatPreferencesInternal(
// Rooms pinned to the top of the chat list. Each room is its member

View File

@@ -82,10 +82,12 @@ class AntiSpamFilter {
(recentAddressables[hash] != null && recentAddressables[hash] != address) ||
(spamMessages[hash] != null && !spamMessages[hash].duplicatedEventAddresses.contains(address))
) {
// may be null if the first duplicate was evicted from the LRU cache
// while the spammer record still matches this hash.
val existingAddress = recentAddressables[hash]
val link1 = njumpLink(NAddress.create(existingAddress.kind, existingAddress.pubKeyHex, existingAddress.dTag, relay))
val link2 = njumpLink(NAddress.create(event.kind, event.pubKey, event.dTag(), relay))
val link1 = existingAddress?.let { njumpLink(NAddress.create(it.kind, it.pubKeyHex, it.dTag, relay)) } ?: link2
Log.w("Duplicated/SPAM") { "${relay?.url} $link1 $link2" }
@@ -111,8 +113,10 @@ class AntiSpamFilter {
(existingEvent != null && existingEvent != event.id) ||
(spamMessages[hash] != null && !spamMessages[hash].duplicatedEventIds.contains(event.id))
) {
val link1 = njumpLink(NEvent.create(existingEvent, null, null, relay))
val link2 = njumpLink(NEvent.create(event.id, null, null, relay))
// existingEvent may be null if the first duplicate was evicted from the
// LRU cache while the spammer record still matches this hash.
val link1 = existingEvent?.let { njumpLink(NEvent.create(it, null, null, relay)) } ?: link2
Log.w("Duplicated/SPAM") { "${relay?.url} $link1 $link2" }
@@ -149,12 +153,12 @@ class AntiSpamFilter {
Spammer(
pubkeyHex = event.pubKey,
duplicatedEventIds = setOf(),
duplicatedEventAddresses = setOf(recentAddressables[hashCode], event.address()),
duplicatedEventAddresses = setOfNotNull(recentAddressables[hashCode], event.address()),
)
} else {
Spammer(
pubkeyHex = event.pubKey,
duplicatedEventIds = setOf(recentEventIds[hashCode], event.id),
duplicatedEventIds = setOfNotNull(recentEventIds[hashCode], event.id),
duplicatedEventAddresses = setOf(),
)
}

View File

@@ -0,0 +1,63 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model
/**
* The outcome of redeeming a Concord invite link (CORD-05). Separating the failure
* modes lets the UI tell the user *why* it failed and — crucially — whether
* retrying could ever help, so a link we can never open doesn't strand the user on
* an endless "redeeming…" spinner with a retry button that loops forever.
*/
sealed interface ConcordInviteResult {
/** Redeemed and joined; navigate to [communityId]. */
data class Joined(
val communityId: String,
) : ConcordInviteResult
/** The link itself is malformed, or this account can't join (read-only key). Retrying can't help. */
data object InvalidLink : ConcordInviteResult
/**
* No invite bundle was reachable on any relay — a transient miss (relays down,
* link too new to have propagated, or expired). Retrying may help.
*/
data object NotReachable : ConcordInviteResult
/**
* The link was revoked: the newest event at its coordinate is a `vsk=9` revocation
* tombstone (CORD-05 §2). Retrying can't help — the owner retired this link.
*/
data object Revoked : ConcordInviteResult
/**
* The bundle opened fine, but its `expires_at` has passed. Retrying can't help —
* unlike [Revoked] the owner didn't retire the link, it simply timed out, so the
* user's next step is to ask for a fresh one.
*/
data object Expired : ConcordInviteResult
/**
* The bundle event was found but could not be opened with the link's token —
* typically because it was minted by a newer/incompatible Concord client whose
* bundle format this app can't read yet. Retrying can't help.
*/
data object Incompatible : ConcordInviteResult
}

View File

@@ -0,0 +1,123 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model
import androidx.core.content.edit
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.quartz.experimental.bitchat.identity.GeohashKeyDerivation
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.utils.RandomInstance
/**
* The account's anonymous, per-geohash chat identities.
*
* Geohash channels are location-tagged, so posting under the account's real npub
* would publish the user's movements tied to their public identity. Instead each
* cell gets a throwaway key that is unlinkable to the npub (and to the user's key
* in every other cell). This state object caches the derived keys and owns the
* seed they come from, keyed to a single account — so switching accounts (or
* logging out) switches identities with it.
*
* The seed is chosen per signer:
* - **Local key account** → derived from the account private key
* ([GeohashKeyDerivation.accountSeed]). Stable across all of the user's devices
* and recoverable from the account, while staying publicly unlinkable.
* - **Remote (NIP-46) / external (NIP-55) signer** → the raw key is unreachable,
* so a random 32-byte seed is kept in this account's encrypted storage. Because
* the store is scoped to the account's pubkey, two accounts on one device get
* different seeds (a global seed would have made their throwaway identities
* collide, linking the accounts in every cell).
*/
class GeohashChatIdentityState(
private val signer: NostrSigner,
) {
private val lock = Any()
private val cache = HashMap<String, KeyPair>()
@Volatile private var cachedDeviceSeed: ByteArray? = null
@Volatile private var cachedNickname: String? = null
/**
* The user's display handle for location chats: a single global nickname, persisted per account.
* Bitchat carries this as the per-message `["n", …]` tag rather than a kind-0 profile, and kind-20000
* messages are ephemeral (relays needn't store them), so the only durable home for it is the device.
* Kept in this account's encrypted storage, so it survives restarts and switches with the account.
* Empty string means "no nickname set". Reads touch disk on first call — invoke off the main thread.
*/
fun nickname(): String {
cachedNickname?.let { return it }
synchronized(lock) {
cachedNickname?.let { return it }
val value = Amethyst.instance.encryptedStorage(signer.pubKey).getString(PREF_NICKNAME, "") ?: ""
cachedNickname = value
return value
}
}
/** Persists the global location-chat nickname (trimmed) for this account. */
fun setNickname(value: String) {
val trimmed = value.trim()
synchronized(lock) {
cachedNickname = trimmed
Amethyst.instance.encryptedStorage(signer.pubKey).edit { putString(PREF_NICKNAME, trimmed) }
}
}
/** The Nostr key pair to use inside [geohash]. Derivation is cheap but cached; call off the main thread. */
fun keyPair(geohash: String): KeyPair =
synchronized(lock) {
cache.getOrPut(geohash) { GeohashKeyDerivation.deriveKeyPair(seed(), geohash) }
}
private fun seed(): ByteArray = accountPrivKey()?.let { GeohashKeyDerivation.accountSeed(it) } ?: deviceSeed()
private fun accountPrivKey(): ByteArray? = (signer as? NostrSignerInternal)?.keyPair?.privKey
/** Random per-account seed, used only when the account key is unreachable (bunker / external signer). */
private fun deviceSeed(): ByteArray {
cachedDeviceSeed?.let { return it }
synchronized(lock) {
cachedDeviceSeed?.let { return it }
val prefs = Amethyst.instance.encryptedStorage(signer.pubKey)
val existing = prefs.getString(PREF_KEY, null)
val seed =
if (existing != null && existing.length == GeohashKeyDerivation.SEED_SIZE * 2) {
existing.hexToByteArray()
} else {
val fresh = RandomInstance.bytes(GeohashKeyDerivation.SEED_SIZE)
prefs.edit { putString(PREF_KEY, fresh.toHexKey()) }
fresh
}
cachedDeviceSeed = seed
return seed
}
}
companion object {
private const val PREF_KEY = "geohash_chat_device_seed"
private const val PREF_NICKNAME = "geohash_chat_nickname"
}
}

View File

@@ -0,0 +1,71 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.model.LocalCache.observeEvents
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
/**
* Cross-screen index of the most recent NIP-34 pull-request update event
* (kind 1619) per parent pull-request id, kept up to date from
* [LocalCache.observeEvents]. A PR update *revises* its parent PR with a
* newer commit / merge base, so the UI folds the latest one into the PR rather
* than listing updates separately. Like [GitStatusIndex], updates aren't tracked
* in `Note.replies`, so a per-row cache scan would otherwise be required.
*
* The kind-indexed [observeEvents] re-emits the whole matching list on every new
* 1619 (and seeds it from the cache index via `init()`), so [latestByPullRequest]
* is just that list reduced to the latest-per-parent map. Shared [SharingStarted.Eagerly]
* — never `WhileSubscribed` — because callers read `.value` synchronously and must
* not see a stale map when no one is actively collecting. `null` means "not loaded yet".
*/
object GitPullRequestUpdateIndex {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
val latestByPullRequest: StateFlow<Map<HexKey, GitPullRequestUpdateEvent>?> =
LocalCache
.observeEvents<GitPullRequestUpdateEvent>(Filter(kinds = listOf(GitPullRequestUpdateEvent.KIND)))
.map { latestByParent(it) }
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, null)
private fun latestByParent(events: List<GitPullRequestUpdateEvent>): Map<HexKey, GitPullRequestUpdateEvent> {
val latest = HashMap<HexKey, GitPullRequestUpdateEvent>()
for (event in events) {
val target = event.parentPullRequestId() ?: continue
val current = latest[target]
if (current == null || event.createdAt > current.createdAt) {
latest[target] = event
}
}
return latest
}
}

View File

@@ -20,66 +20,78 @@
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.model.LocalCache.observeEvents
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
/**
* Cross-screen index of the most recent NIP-34 status event (kinds
* 1630-1633) per target id, kept up to date from
* [LocalCache.live.newEventBundles]. Status events are not tracked in
* [LocalCache.observeEvents]. Status events are not tracked in
* `Note.replies` (see `LocalCache.computeReplyTo`), so the only way to
* find them otherwise would be a full cache scan per row.
*
* The kind-indexed [observeEvents] re-emits the whole matching list on every new
* status event (and seeds it from the cache index via `init()`), so [latestByTarget]
* is just that list reduced to the latest-per-target map. Shared [SharingStarted.Eagerly]
* — never `WhileSubscribed` — because callers (e.g. [isClosedOrResolved] and the feed
* filters) read `.value` synchronously and must not see a stale map when no one is
* actively collecting. `null` means "not loaded yet".
*/
object GitStatusIndex {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val started = AtomicBoolean(false)
private val mutableLatestByTarget = MutableStateFlow<Map<HexKey, GitStatusEvent>?>(null)
val latestByTarget: StateFlow<Map<HexKey, GitStatusEvent>?> = mutableLatestByTarget.asStateFlow()
private val statusKinds =
listOf(
GitStatusEvent.KIND_OPEN,
GitStatusEvent.KIND_APPLIED,
GitStatusEvent.KIND_CLOSED,
GitStatusEvent.KIND_DRAFT,
)
fun startIfNeeded() {
if (!started.compareAndSet(false, true)) return
scope.launch {
// Subscribe to bundle updates BEFORE the initial scan via onStart, so any
// events that arrive between scan start and collector attach are picked up.
LocalCache.live.newEventBundles
.onStart {
val initial = HashMap<HexKey, GitStatusEvent>()
LocalCache.notes.forEach { _, note ->
val event = note.event as? GitStatusEvent ?: return@forEach
val target = event.rootEventId() ?: return@forEach
val current = initial[target]
if (current == null || event.createdAt > current.createdAt) {
initial[target] = event
}
}
mutableLatestByTarget.value = initial
}.collect { bundle -> processBundle(bundle) }
}
}
val latestByTarget: StateFlow<Map<HexKey, GitStatusEvent>?> =
LocalCache
.observeEvents<GitStatusEvent>(Filter(kinds = statusKinds))
.map { reduceLatestByTarget(it) }
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, null)
private fun processBundle(bundle: Set<Note>) {
val snapshot = mutableLatestByTarget.value ?: emptyMap()
var modified: HashMap<HexKey, GitStatusEvent>? = null
for (note in bundle) {
val event = note.event as? GitStatusEvent ?: continue
private fun reduceLatestByTarget(events: List<GitStatusEvent>): Map<HexKey, GitStatusEvent> {
val latest = HashMap<HexKey, GitStatusEvent>()
for (event in events) {
val target = event.rootEventId() ?: continue
val map = modified ?: snapshot
val current = map[target]
val current = latest[target]
if (current == null || event.createdAt > current.createdAt) {
if (modified == null) modified = HashMap(snapshot)
modified[target] = event
latest[target] = event
}
}
modified?.let { mutableLatestByTarget.value = it }
return latest
}
/**
* Whether the latest status for [targetId] marks it as closed (kind 1632)
* or applied/resolved/merged (kind 1631). Items with no status event, or
* whose latest status is open (1630) or draft (1633), are considered open.
*
* Reads from the synchronous snapshot in [latestByTarget]; pass an explicit
* [map] to avoid re-reading the value across a batch.
*/
fun isClosedOrResolved(
targetId: HexKey,
map: Map<HexKey, GitStatusEvent>? = latestByTarget.value,
): Boolean {
val event = map?.get(targetId) ?: return false
return event is GitStatusClosedEvent || event is GitStatusAppliedEvent
}
}

View File

@@ -20,99 +20,12 @@
*/
package com.vitorpamplona.amethyst.model
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.hashtags.Amethyst
import com.vitorpamplona.amethyst.commons.hashtags.Btc
import com.vitorpamplona.amethyst.commons.hashtags.Cashu
import com.vitorpamplona.amethyst.commons.hashtags.Coffee
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.commons.hashtags.Flowerstr
import com.vitorpamplona.amethyst.commons.hashtags.Footstr
import com.vitorpamplona.amethyst.commons.hashtags.Gamestr
import com.vitorpamplona.amethyst.commons.hashtags.Grownostr
import com.vitorpamplona.amethyst.commons.hashtags.Lightning
import com.vitorpamplona.amethyst.commons.hashtags.Mate
import com.vitorpamplona.amethyst.commons.hashtags.Nostr
import com.vitorpamplona.amethyst.commons.hashtags.Plebs
import com.vitorpamplona.amethyst.commons.hashtags.Skull
import com.vitorpamplona.amethyst.commons.hashtags.Tunestr
import com.vitorpamplona.amethyst.commons.hashtags.Weed
import com.vitorpamplona.amethyst.commons.hashtags.Zap
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
import com.vitorpamplona.amethyst.ui.components.HashTag
import com.vitorpamplona.amethyst.ui.components.RenderRegular
import com.vitorpamplona.amethyst.ui.components.RenderTextParagraph
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.richtext.HashtagIcon as CommonsHashtagIcon
import com.vitorpamplona.amethyst.commons.ui.richtext.checkForHashtagWithIcon as commonsCheckForHashtagWithIcon
@Preview
@Composable
fun RenderHashTagIconsPreview() {
ThemeComparisonColumn {
RenderRegular(
"Testing rendering of hashtags: #flowerstr #Bitcoin, #nostr, #lightning, #zap, #amethyst, #cashu, #plebs, #coffee, #skullofsatoshi, #grownostr, #footstr, #tunestr, #weed, #mate, #gamestr, #gamechain",
EmptyTagList,
) { paragraph, _, spaceWidth, modifier ->
RenderTextParagraph(paragraph, spaceWidth, modifier) { word ->
when (word) {
is HashTagSegment -> HashTag(word, EmptyNav())
is RegularTextSegment -> Text(word.segmentText)
}
}
}
}
}
// The hashtag-icon table now lives in commons/ui/richtext so the shared
// RichTextViewer and both front ends resolve the same icons. These re-exports keep
// the historical `com.vitorpamplona.amethyst.model` call sites working.
typealias HashtagIcon = CommonsHashtagIcon
fun checkForHashtagWithIcon(tag: String): HashtagIcon? =
when (tag.lowercase()) {
"₿itcoin", "bitcoin", "btc", "timechain", "bitcoiner", "bitcoiners" -> bitcoin
"nostr", "nostrich", "nostriches", "thenostr" -> nostr
"lightning", "lightningnetwork" -> lightning
"zap", "zaps", "zapper", "zappers", "zapping", "zapped", "zapathon", "zapraiser", "zaplife", "zapchain" -> zap
"amethyst" -> amethyst
"cashu", "ecash", "nut", "nuts", "deeznuts" -> cashu
"plebs", "pleb", "plebchain" -> plebs
"coffee", "coffeechain", "cafe" -> coffee
"skullofsatoshi" -> skull
"grownostr", "gardening", "garden" -> growstr
"footstr" -> footstr
"flowerstr" -> flowerstr
"tunestr", "music", "nowplaying" -> tunestr
"mate", "matechain", "matestr" -> matestr
"weed", "weedstr", "420", "cannabis", "marijuana" -> weed
"gamestr", "gaming", "gamechain" -> gamestr
else -> null
}
val bitcoin = HashtagIcon(CustomHashTagIcons.Btc, "Bitcoin", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val nostr = HashtagIcon(CustomHashTagIcons.Nostr, "Nostr", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val lightning = HashtagIcon(CustomHashTagIcons.Lightning, "Lightning", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val zap = HashtagIcon(CustomHashTagIcons.Zap, "Zap", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val amethyst = HashtagIcon(CustomHashTagIcons.Amethyst, "Amethyst", Modifier.padding(start = 2.dp, bottom = 1.dp, top = 1.dp))
val cashu = HashtagIcon(CustomHashTagIcons.Cashu, "Cashu", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val plebs = HashtagIcon(CustomHashTagIcons.Plebs, "Pleb", Modifier.padding(start = 2.dp, bottom = 1.dp, top = 1.dp))
val coffee = HashtagIcon(CustomHashTagIcons.Coffee, "Coffee", Modifier.padding(start = 3.dp, bottom = 1.dp, top = 1.dp))
val skull = HashtagIcon(CustomHashTagIcons.Skull, "SkullofSatoshi", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val growstr = HashtagIcon(CustomHashTagIcons.Grownostr, "GrowNostr", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val footstr = HashtagIcon(CustomHashTagIcons.Footstr, "Footstr", Modifier.padding(start = 2.dp, bottom = 1.dp, top = 1.dp))
val flowerstr = HashtagIcon(CustomHashTagIcons.Flowerstr, "Flowerstr", Modifier.padding(start = 2.dp, bottom = 1.dp, top = 1.dp))
val tunestr = HashtagIcon(CustomHashTagIcons.Tunestr, "Tunestr", Modifier.padding(start = 1.dp, bottom = 1.dp, top = 1.dp))
val weed = HashtagIcon(CustomHashTagIcons.Weed, "Weed", Modifier.padding(start = 1.dp, bottom = 0.dp, top = 0.dp))
val matestr = HashtagIcon(CustomHashTagIcons.Mate, "Mate", Modifier.padding(start = 1.dp, bottom = 0.dp, top = 0.dp))
val gamestr = HashtagIcon(CustomHashTagIcons.Gamestr, "GameStr", Modifier.padding(start = 1.dp, bottom = 0.dp, top = 0.dp))
@Immutable
class HashtagIcon(
val icon: ImageVector,
val description: String,
val modifier: Modifier = Modifier,
)
fun checkForHashtagWithIcon(tag: String): HashtagIcon? = commonsCheckForHashtagWithIcon(tag)

View File

@@ -30,8 +30,11 @@ import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtIdHexComparator
import com.vitorpamplona.amethyst.commons.model.observables.EventListMatchingFilter
@@ -47,6 +50,8 @@ import com.vitorpamplona.amethyst.model.nipBCOnchainZaps.OnchainZapResolver
import com.vitorpamplona.amethyst.service.BundledInsert
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.dateFormatter
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
@@ -54,11 +59,14 @@ import com.vitorpamplona.quartz.experimental.attestations.recommendation.Attesto
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.fitness.workout.ExerciseTemplateEvent
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent
@@ -75,6 +83,7 @@ import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.nns.NNSEvent
import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.experimental.ps1saves.Ps1SaveEvent
import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent
import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
@@ -149,6 +158,24 @@ import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMuteUserEvent
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupParticipantsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteEventEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteGroupEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent
import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent
import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.nip31Alts.AltTag
@@ -192,6 +219,7 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent
import com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
@@ -216,10 +244,13 @@ import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent
import com.vitorpamplona.quartz.nip59Giftwrap.HasInnerEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent
import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent
import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent
@@ -248,6 +279,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefiniti
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
@@ -286,6 +318,8 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
import com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.Log
@@ -327,6 +361,9 @@ object LocalCache : ILocalCache, ICacheProvider {
val publicChatChannels = LargeCache<HexKey, PublicChatChannel>()
val liveChatChannels = LargeCache<Address, LiveActivitiesChannel>()
val ephemeralChannels = LargeCache<RoomId, EphemeralChatChannel>()
val geohashChannels = LargeCache<String, GeohashChatChannel>()
val relayGroupChannels = LargeCache<GroupId, RelayGroupChannel>()
val concordChannels = LargeCache<ConcordChannelId, ConcordChannel>()
val paymentTracker = NwcPaymentTracker()
@@ -600,6 +637,13 @@ object LocalCache : ILocalCache, ICacheProvider {
fun getEphemeralChatChannelIfExists(key: RoomId): EphemeralChatChannel? = ephemeralChannels.get(key)
fun getGeohashChannelIfExists(geohash: String): GeohashChatChannel? = geohashChannels.get(geohash)
fun getRelayGroupChannelIfExists(key: GroupId): RelayGroupChannel? = relayGroupChannels.get(key)
/** Every relay group we know of that is hosted on [relay] (its channel directory). */
fun getRelayGroupChannelsOnRelay(relay: NormalizedRelayUrl): List<RelayGroupChannel> = relayGroupChannels.filter { key, _ -> key.relayUrl == relay }
fun getLiveActivityChannelIfExists(key: Address): LiveActivitiesChannel? = liveChatChannels.get(key)
fun getNoteIfExists(event: Event): Note? =
@@ -688,6 +732,85 @@ object LocalCache : ILocalCache, ICacheProvider {
fun getOrCreateEphemeralChannel(key: RoomId): EphemeralChatChannel = ephemeralChannels.getOrCreate(key) { EphemeralChatChannel(key) }
fun getOrCreateGeohashChannel(geohash: String): GeohashChatChannel = geohashChannels.getOrCreate(geohash) { GeohashChatChannel(geohash) }
fun getOrCreateRelayGroupChannel(key: GroupId): RelayGroupChannel = relayGroupChannels.getOrCreate(key) { RelayGroupChannel(key) }
fun getConcordChannelIfExists(key: ConcordChannelId): ConcordChannel? = concordChannels.get(key)
fun getOrCreateConcordChannel(key: ConcordChannelId): ConcordChannel = concordChannels.getOrCreate(key) { ConcordChannel(key) }
/**
* Lands a decrypted Concord chat rumor in the cache as a real Note and, for
* message-like kinds, attaches it to its channel so the shared chat feed and
* the Messages inbox render it (with previews, threading, OTS, reactions/zaps
* reusing the same id-keyed machinery as every other chat). Reactions (kind 7),
* deletes (kind 5), etc. are consumed too — they wire to their target Note by
* `e`-tag through [justConsume] — but are not themselves added as channel rows.
*
* Fed by [com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager]
* once a wrap decrypts + validates against the folded Control Plane.
*/
fun consumeConcordRumor(
communityId: String,
channelIdHex: String,
rumor: Event,
seenOnRelays: Set<NormalizedRelayUrl> = emptySet(),
) {
// Attach to the channel BEFORE justConsume sets the event and notifies feeds,
// so the note already carries its ConcordChannel gatherer when it flows through
// the Messages-list incremental filter (which routes rows by that gatherer).
val messageRow =
if (rumor is ChatEvent || rumor is CommentEvent) {
val ch = getOrCreateConcordChannel(ConcordChannelId(communityId, channelIdHex))
val note = getOrCreateNote(rumor.id)
// Skip attaching a row for a message we already know is deleted (its kind-5 delete
// was processed first). Otherwise every reproject — which re-emits the whole wrap
// buffer — would re-add then re-remove it, churning the feed. justConsume still
// records the (already-known) deletion below; a delete arriving LATER is handled by
// the normal deletion cascade unlinking the note from its gatherers.
if (!deletionIndex.hasBeenDeleted(rumor)) ch.addNote(note)
ch to note
} else {
null
}
// wasVerified = true: a Concord rumor is unsigned (its `sig` is empty), so a signature
// check would fail and the event would never load onto its Note — leaving the chat row
// stuck on the "loading / not found" placeholder. Its authenticity is already established
// by the envelope open path (ConcordStreamEnvelope.open verifies the seal signature,
// binds rumor.pubKey == seal.pubKey, and checks rumor.verifyId()), exactly like a NIP-59
// gift-wrapped DM rumor, so we consume it as pre-verified.
justConsume(rumor, null, true)
// A Concord rumor is decrypted locally with the plane key, so it arrives with no
// per-relay attribution (relay = null above). Stamp the relays its carrying wrap was
// seen on — mirroring NIP-17's addRelayToNoteAndInners — so the chat UI can show where
// the message actually came from, not just the channel's configured relays.
if (seenOnRelays.isNotEmpty()) {
getNoteIfExists(rumor.id)?.takeIf { it.event != null }?.let { note ->
seenOnRelays.forEach { note.addRelay(it) }
}
}
// justConsume bails without loading the event when the rumor has already been deleted
// (a kind-5 delete referencing it was processed first — easy to hit in Concord because a
// reproject re-emits the whole wrap buffer and ordering isn't guaranteed) or fails to
// verify. We attached the row up front, so an unpopulated note would otherwise linger as a
// permanent "Event is loading…" ghost. Drop it; the reverse order (delete after the message)
// is already handled by the normal deletion cascade unlinking the note from its gatherers.
messageRow?.let { (ch, note) ->
if (note.event == null) {
ch.removeNote(note)
} else {
// The row was attached (addNote) BEFORE justConsume set the event, so addNote saw a
// null createdAt and could not pick lastNote or order the feed. The event is loaded
// now — refresh so the channel's last-message preview, unread count, and ordering are
// correct (otherwise lastNote stays null forever and every row reads "No messages yet").
ch.refreshAfterEventLoad(note)
}
}
}
fun checkGetOrCreatePublicChatChannel(key: String): PublicChatChannel? {
if (isValidHex(key)) {
return getOrCreatePublicChatChannel(key)
@@ -773,7 +896,11 @@ object LocalCache : ILocalCache, ICacheProvider {
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
// A gift wrap re-delivered by another relay is a duplicate (returns
// false below and is never re-processed), so drill into the already
// unwrapped chain here — otherwise the relay never reaches the
// rumor note that the chat UI actually renders.
addRelayToNoteAndInners(note, relay)
}
// Already processed this event.
@@ -1368,6 +1495,7 @@ object LocalCache : ILocalCache, ICacheProvider {
is LiveActivitiesChatMessageEvent -> noteEvent.activityAddress()?.let { getLiveActivityChannelIfExists(it) }
is LiveActivitiesEvent -> getLiveActivityChannelIfExists(noteEvent.address())
is EphemeralChatEvent -> noteEvent.roomId()?.let { getEphemeralChatChannelIfExists(it) }
is GeohashChatEvent -> noteEvent.geohash()?.let { getGeohashChannelIfExists(it) }
else -> null
}
@@ -1427,6 +1555,11 @@ object LocalCache : ILocalCache, ICacheProvider {
): Boolean {
val note = getOrCreateNote(event.id)
if (relay != null) {
getOrCreateUser(event.pubKey).addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return false
@@ -1457,6 +1590,11 @@ object LocalCache : ILocalCache, ICacheProvider {
): Boolean {
val note = getOrCreateNote(event.id)
if (relay != null) {
getOrCreateUser(event.pubKey).addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return false
@@ -1488,6 +1626,14 @@ object LocalCache : ILocalCache, ICacheProvider {
): Boolean {
val note = getOrCreateNote(event.id)
// Approval notes render their own relay list directly in community feeds
// (there is no repost-style indirection to a replyTo for them), so without
// this attribution the "accepted by relays" gallery line stays empty forever.
if (relay != null) {
getOrCreateUser(event.pubKey).addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return false
@@ -1715,6 +1861,216 @@ object LocalCache : ILocalCache, ICacheProvider {
return new
}
/**
* Public geohash chat message (kind 20000). Routes into the cell's
* [GeohashChatChannel]. Presence (kind 20001) is deliberately NOT consumed
* here — it is an empty-content heartbeat handled by the live chat screen, so
* it never becomes a room's "last message".
*/
fun consume(
event: GeohashChatEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
val geohash = event.geohash() ?: return false
val new = consumeRegularEvent(event, relay, wasVerified)
if (new) {
val note = getOrCreateNote(event.id)
val channel = getOrCreateGeohashChannel(geohash)
channel.addNote(note, relay)
}
return new
}
/**
* NIP-29 addressables (kinds 39000-39005) are authoritative for a group's metadata,
* roster, roles and pins ONLY when signed by the relay's own key — the NIP-11 `self`
* pubkey. This returns false only when we can positively tell an event is NOT relay-signed
* (the relay advertises a `self` and the event's author differs), so a stray or malicious
* user-published 39000/39001/… served by a lax relay can't overwrite a group's state (e.g.
* inject itself into the admin list). When `self` isn't known yet — the NIP-11 doc hasn't
* loaded, or the relay doesn't advertise one — we don't block, so legitimate groups still
* populate and this never regresses a relay whose key we simply haven't fetched.
*/
private fun isRelaySignedGroupEvent(
event: Event,
relay: NormalizedRelayUrl,
): Boolean {
val self =
Amethyst.instance.nip11Cache
.getFromCache(relay)
.self ?: return true
return event.pubKey == self
}
/**
* NIP-29 relay-signed group metadata (kind 39000). Stored as an addressable
* note and used to populate the [RelayGroupChannel]'s name/picture/about/
* flags. The group is keyed by (host relay + group id): unlike NIP-C7, a
* NIP-29 event does not carry its host relay in a tag — the host is the relay
* that served it — so the channel can only be associated when we know the
* serving relay (provenance). With no relay we still store the metadata.
*/
fun consume(
event: GroupMetadataEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
val new = consumeBaseReplaceable(event, relay, wasVerified)
if (relay != null && isRelaySignedGroupEvent(event, relay)) {
val note = getOrCreateAddressableNote(event.address())
val channel = getOrCreateRelayGroupChannel(GroupId(event.groupId(), relay))
(note.event as? GroupMetadataEvent)?.let { channel.updateGroupInfo(it, note) }
}
return new
}
/** NIP-29 relay-signed member list (kind 39002) → the group's roster. */
fun consume(
event: GroupMembersEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
val new = consumeBaseReplaceable(event, relay, wasVerified)
if (relay != null && isRelaySignedGroupEvent(event, relay)) {
val latest = getOrCreateAddressableNote(event.address()).event as? GroupMembersEvent
latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updateMembers(it) }
}
return new
}
/** NIP-29 relay-signed admin list (kind 39001) → the group's roster. */
fun consume(
event: GroupAdminsEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
val new = consumeBaseReplaceable(event, relay, wasVerified)
if (relay != null && isRelaySignedGroupEvent(event, relay)) {
val latest = getOrCreateAddressableNote(event.address()).event as? GroupAdminsEvent
latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updateAdmins(it) }
}
return new
}
/** NIP-29 relay-signed pinned-message list (kind 39005) → the group's pins. */
fun consume(
event: GroupPinnedEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
val new = consumeBaseReplaceable(event, relay, wasVerified)
if (relay != null && isRelaySignedGroupEvent(event, relay)) {
val latest = getOrCreateAddressableNote(event.address()).event as? GroupPinnedEvent
latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updatePinned(it) }
}
return new
}
/** NIP-29 relay-declared supported roles (kind 39003) → the group's role set. */
fun consume(
event: SupportedRolesEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
val new = consumeBaseReplaceable(event, relay, wasVerified)
if (relay != null && isRelaySignedGroupEvent(event, relay)) {
val latest = getOrCreateAddressableNote(event.address()).event as? SupportedRolesEvent
latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updateSupportedRoles(it) }
}
return new
}
/**
* Attach a group-scoped content event (a kind-9 chat, kind-1068 poll, …
* carrying an `h` tag) to its [RelayGroupChannel]. NIP-29 reuses the generic
* content kinds and scopes them with `h`, so the note is consumed normally
* and then, when it belongs to a group and we know the serving relay, added
* to that group's channel timeline.
*/
private fun attachToRelayGroupIfScoped(
event: Event,
relay: NormalizedRelayUrl?,
) {
val groupId = event.groupId() ?: return
val note = getOrCreateNote(event.id)
// Only attach a note we've actually loaded — never a placeholder for an
// unverified/not-yet-seen event. This is checked here (not via the "was
// newly consumed" flag) so the host relay's echo of an event we already
// stored from our own send still lands in the channel.
if (note.event == null) return
if (relay != null) {
val exact = GroupId(groupId, relay)
val existing = getRelayGroupChannelIfExists(exact)
if (existing != null) {
// Normal arrival: the group's host-pinned filters served it, so the serving relay IS the
// group's key and its channel already exists. Fast O(1) path — no scan.
existing.addNote(note, relay)
} else {
// No channel keyed to the serving relay: this may be a stray from a NON-host relay (e.g. a
// quoted kind-9 resolved by id). Redirect it to the group's single confirmed host rather
// than mint a phantom channel the group's screens never read (the serving-relay hazard);
// fall back to the serving-relay key when there is no single host (new/ambiguous group).
val target = redirectStrayRelayGroupContent(relayGroupCandidatesFor(groupId)) ?: exact
getOrCreateRelayGroupChannel(target).addNote(note, relay)
}
} else {
// Our own optimistic send has no provenance relay, so we can't build the (groupId,
// relay) key. Attach only when a SINGLE open channel has this group id (the room being
// composed in). When the id is ambiguous across relays — e.g. the relay-wide "_" group
// joined on several relays — skip: attaching to all of them bleeds the message into
// rooms it wasn't sent to. The host relay's echo (relay != null) lands it on the right key.
relayGroupChannels
.filter { key, _ -> key.id == groupId }
.singleOrNull()
?.addNote(note, null)
}
}
/** Candidate group channels for the [redirectStrayRelayGroupContent] slow path — one scan by group id. */
private fun relayGroupCandidatesFor(groupId: String): List<RelayGroupTargetCandidate> =
relayGroupChannels
.filter { key, _ -> key.id == groupId }
.map { RelayGroupTargetCandidate(it.groupId, it.hasRelaySignedState()) }
/**
* Same routing as [attachToRelayGroupIfScoped] but for kind-11 threads, which
* are kept in a separate collection from the chat timeline so the two content
* types don't mix in one feed.
*/
private fun attachThreadToRelayGroupIfScoped(
event: Event,
relay: NormalizedRelayUrl?,
) {
val groupId = event.groupId() ?: return
val note = getOrCreateNote(event.id)
if (note.event == null) return
if (relay != null) {
val exact = GroupId(groupId, relay)
val existing = getRelayGroupChannelIfExists(exact)
if (existing != null) {
existing.addThread(note)
} else {
// Same serving-relay hazard as the chat path: prefer the single confirmed host over a phantom.
val target = redirectStrayRelayGroupContent(relayGroupCandidatesFor(groupId)) ?: exact
getOrCreateRelayGroupChannel(target).addThread(note)
}
} else {
// See attachToRelayGroupIfScoped: only attach when the group id is unambiguous.
relayGroupChannels
.filter { key, _ -> key.id == groupId }
.singleOrNull()
?.addThread(note)
}
}
fun consume(
event: LiveActivitiesChatMessageEvent,
relay: NormalizedRelayUrl?,
@@ -2518,7 +2874,6 @@ object LocalCache : ILocalCache, ICacheProvider {
}
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
return liveChatChannels.filter { _, channel ->
@@ -2642,6 +2997,10 @@ object LocalCache : ILocalCache, ICacheProvider {
pruneHiddenMessagesChannel(channel, account)
}
geohashChannels.forEach { _, channel ->
pruneHiddenMessagesChannel(channel, account)
}
liveChatChannels.forEach { _, channel ->
pruneHiddenMessagesChannel(channel, account)
}
@@ -2649,6 +3008,10 @@ object LocalCache : ILocalCache, ICacheProvider {
publicChatChannels.forEach { _, channel ->
pruneHiddenMessagesChannel(channel, account)
}
relayGroupChannels.forEach { _, channel ->
pruneHiddenMessagesChannel(channel, account)
}
}
// 2× the 10-min `PRESENCE_FRESHNESS_WINDOW_SECONDS` used by
@@ -2691,6 +3054,10 @@ object LocalCache : ILocalCache, ICacheProvider {
pruneOldMessagesChannel(channel)
}
geohashChannels.forEach { _, channel ->
pruneOldMessagesChannel(channel)
}
liveChatChannels.forEach { _, channel ->
pruneOldMessagesChannel(channel)
}
@@ -2699,6 +3066,10 @@ object LocalCache : ILocalCache, ICacheProvider {
pruneOldMessagesChannel(channel)
}
relayGroupChannels.forEach { _, channel ->
pruneOldMessagesChannel(channel)
}
chatroomList.forEach { userHex, room ->
// History floors are pinned per scope on first advance; null means that window never paged
// history, so its cursors hold no position to misalign and nothing needs rewinding. Only the
@@ -2992,7 +3363,28 @@ object LocalCache : ILocalCache, ICacheProvider {
}
}
note?.addRelay(relay)
note?.let { addRelayToNoteAndInners(it, relay) }
}
/**
* Adds [relay] to [note] and to every already-unwrapped inner note of its
* gift-wrap chain (wrap → seal → rumor). The chat UI renders the inner
* rumor, so a relay recorded only on the outer envelope never surfaces as
* an icon. Inner notes that don't exist yet are not lost: the unwrap path
* copies the envelope's relays down via [copyRelaysFromTo] when it runs.
*/
fun addRelayToNoteAndInners(
note: Note,
relay: NormalizedRelayUrl,
) {
note.addRelay(relay)
val noteEvent = note.event
if (noteEvent is HasInnerEvent) {
noteEvent.innerEventId?.let { innerId ->
getNoteIfExists(innerId)?.let { addRelayToNoteAndInners(it, relay) }
}
}
}
// Observers line up here.
@@ -3019,10 +3411,28 @@ object LocalCache : ILocalCache, ICacheProvider {
live.removedNote(newNote)
}
/**
* Resource-usage ledger hook: called with (elapsedNanos, valid) for every
* signature verification so the app can account crypto CPU per day.
* Wired by AppModules like [onchainBackend]; null costs nothing.
*/
@Volatile
var verifyMeter: ((elapsedNanos: Long, valid: Boolean) -> Unit)? = null
fun justVerify(event: Event): Boolean {
checkNotInMainThread()
return if (!event.verify()) {
val meter = verifyMeter
if (meter == null) return justVerifyInner(event)
val start = System.nanoTime()
val valid = justVerifyInner(event)
meter(System.nanoTime() - start, valid)
return valid
}
private fun justVerifyInner(event: Event): Boolean =
if (!event.verify()) {
try {
event.checkSignature()
} catch (e: Exception) {
@@ -3033,7 +3443,6 @@ object LocalCache : ILocalCache, ICacheProvider {
} else {
true
}
}
fun consume(
event: DraftWrapEvent,
@@ -3484,6 +3893,14 @@ object LocalCache : ILocalCache, ICacheProvider {
consumeBaseReplaceable(event, relay, wasVerified)
}
is BirdDetectionEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
is Ps1SaveEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
is CommentEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
@@ -3524,10 +3941,101 @@ object LocalCache : ILocalCache, ICacheProvider {
consume(event, relay, wasVerified)
}
is GeohashChatEvent -> {
consume(event, relay, wasVerified)
}
is EphemeralChatListEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
// NIP-51 "simple groups" list (kind 10009): the user's joined NIP-29 groups +
// servers. Replaceable like its sibling lists; RelayGroupListState reads it from the
// addressable cache, so it must be stored (it was silently dropped before).
is SimpleGroupListEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
// Concord private joined-communities list (kind 13302). Replaceable, self-encrypted;
// ConcordChannelListState observes it via the addressable cache (Address(13302, me, "")),
// so — exactly like the 10009 list above — it must be stored replaceably or the Concord
// hub stays empty even after the event arrives.
is ConcordCommunityListEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
is GroupMetadataEvent -> {
consume(event, relay, wasVerified)
}
is GroupMembersEvent -> {
consume(event, relay, wasVerified)
}
is GroupAdminsEvent -> {
consume(event, relay, wasVerified)
}
is GroupPinnedEvent -> {
consume(event, relay, wasVerified)
}
// 39003 (relay-declared roles) is durable group state like 39000/39001/39002:
// route it onto the channel so a moderation UI can offer the relay's role set.
is SupportedRolesEvent -> {
consume(event, relay, wasVerified)
}
// Remaining NIP-29 relay-group kinds. The relay-signed 39004 AV-participants
// addressable is durable group state, so it's stored replaceably. The 9xxx
// moderation actions and join/leave requests are regular one-shot events the
// relay is authoritative for (it applies them and republishes the
// 39000/39001/39002); we store them so they're queryable and don't fall through
// to the "Not Supported" warning, but we don't act on them client-side.
is GroupParticipantsEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
is PutUserEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
is RemoveUserEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
is EditMetadataEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
is DeleteEventEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
is UpdatePinListEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
is DeleteGroupEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
is CreateGroupEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
is CreateInviteEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
is JoinRequestEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
is LeaveRequestEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
is ExternalIdentitiesEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
@@ -3573,7 +4081,15 @@ object LocalCache : ILocalCache, ICacheProvider {
}
is GiftWrapEvent -> {
consumeRegularEvent(event, relay, wasVerified)
// A wrap with an empty content carries no NIP-44 ciphertext and can
// never be unwrapped — reject it before paying for a signature check
// and a cache slot. Locally stripped copies (copyNoContent) are
// assigned straight to note.event and never pass through here.
if (event.content.isEmpty()) {
false
} else {
consumeRegularEvent(event, relay, wasVerified)
}
}
is GroupEvent -> {
@@ -3624,6 +4140,14 @@ object LocalCache : ILocalCache, ICacheProvider {
consumeBaseReplaceable(event, relay, wasVerified)
}
is RootNappletEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
is NamedNappletEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
is ChessGameEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
@@ -3741,7 +4265,12 @@ object LocalCache : ILocalCache, ICacheProvider {
}
is PodcastMetadataEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
// Drop the known "Mock Podcast" spam flood instead of caching thousands of them.
if (event.isMockSpam()) {
false
} else {
consumeBaseReplaceable(event, relay, wasVerified)
}
}
is AuthoredPodcastsEvent -> {
@@ -3752,6 +4281,14 @@ object LocalCache : ILocalCache, ICacheProvider {
consumeBaseReplaceable(event, relay, wasVerified)
}
is Podcasting20EpisodeEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
is Podcasting20TrailerEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
is LnZapEvent -> {
consume(event, relay, wasVerified)
}
@@ -3861,11 +4398,25 @@ object LocalCache : ILocalCache, ICacheProvider {
}
is ChatEvent -> {
consumeRegularEvent(event, relay, wasVerified)
consumeRegularEvent(event, relay, wasVerified).also {
// Attach on every arrival, not just the newly-consumed one:
// our own send is consumed first with a null relay, so the
// host relay's later echo (new == false) is what carries the
// provenance needed to key the channel. attach is idempotent.
attachToRelayGroupIfScoped(event, relay)
}
}
is PollEvent -> {
consumeRegularEvent(event, relay, wasVerified)
consumeRegularEvent(event, relay, wasVerified).also {
attachToRelayGroupIfScoped(event, relay)
}
}
is ThreadEvent -> {
consumeRegularEvent(event, relay, wasVerified).also {
attachThreadToRelayGroupIfScoped(event, relay)
}
}
is PollResponseEvent -> {
@@ -4004,6 +4555,10 @@ object LocalCache : ILocalCache, ICacheProvider {
consumeRegularEvent(event, relay, wasVerified)
}
is ExerciseTemplateEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
is PaymentTargetsEvent -> {
consume(event, relay, wasVerified)
}

Some files were not shown because too many files have changed in this diff Show More