v1.12.4 got the keychain right ("...in keychain [/Users/.../amethyst-signing
.keychain-db]") but createReleaseDistributable still failed with "Could not
find certificate". Different layer of the same problem:
Compose's MacSigner maps its identity to a cert by running `security
find-certificate -c <identity>`, prepending "Developer ID Application: " when
the identity doesn't already start with it. `codesign --sign` (used by the
signMacJarNatives task, which succeeds in the same job) instead matches a
SHA-1 hash OR any common-name substring. So a MAC_SIGN_IDENTITY secret that is
a fingerprint or a team-ID/partial name signs fine with codesign but, once
prefixed by Compose, is not a substring of the cert's common name -> zero
matches -> failure.
Reproduced locally against the real Developer ID cert:
find-certificate -c "Developer ID Application: <TEAMID>" -> 0 matches
find-certificate -c "Developer ID Application: <full CN>" -> 1 match
Fix: import-macos-cert now resolves the certificate's full "Developer ID
Application: NAME (TEAMID)" common name from the keychain (via find-identity)
and exposes it as an `identity` output. The desktop build feeds that to
Compose's signing.identity, falling back to the raw secret if resolution
fails. Independent of whatever form the secret takes. The amy CLI leg keeps
using bare codesign with the secret directly and is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The desktop DMG release leg (build-desktop macos, packageReleaseDmg) has never
produced a signed artifact: createReleaseDistributable fails with "Could not
find certificate for '***' in keychain []". This is independent of the v1.12.3
notarization fix, which addressed the separate amy CLI leg.
Root cause: Compose's MacSignerImpl maps the signing identity to a certificate
by running `security find-certificate -a -c <identity>` with no keychain
argument. On the GitHub macOS runners that lookup does not resolve the cert that
import-macos-cert imported into a throwaway keychain and added only to the user
search list — even though bare `codesign --sign` (e.g. the signMacJarNatives
task, which succeeds in the same job) finds it fine. The "keychain []" in the
error is just the null settings.keychain being echoed.
Fix: export the throwaway keychain path from the import-macos-cert action and
feed it to Compose's `signing.keychain` via AMETHYST_MAC_SIGN_KEYCHAIN, so the
certificate lookup searches that keychain directly. Also set it as the default
keychain for good measure. No-op on local/PR builds (env unset -> Compose keeps
its previous default-search-list behavior).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The v1.12.2 release was the first to actually codesign + notarize the macOS
artifacts (signing was wired after v1.12.1, which shipped unsigned). Both macOS
legs failed with "Notarization status: Invalid": Apple's notary service recurses
into the bundled jars and rejects the unsigned Mach-O natives inside them
(secp256k1, sqlite-bundled, jna, skiko, jkeychain, kdroidFilter mediaplayer) —
codesign on the .app and the CLI's loose-file loop never descend into jars.
Notary log confirmed the offending entries, e.g.
sqlite-bundled-jvm.jar/natives/osx_arm64/libsqliteJni.dylib
-> "not signed with a valid Developer ID certificate" / "no secure timestamp"
Add scripts/sign-macos-jar-natives.sh: a shared helper that signs every macOS
Mach-O inside the bundled jars with hardened runtime + a secure timestamp,
skipping Linux ELF via a `file` Mach-O gate and no-opping when no identity is
set (local/PR builds unchanged). Wire it into:
- the CLI notarize step (runs before the loose-file signing loop)
- a desktop signMacJarNatives Gradle task that signs the proguarded jars
between proguardReleaseJars and createReleaseDistributable, so Compose
seals already-signed code.
Validated locally on arm64: clean signed createReleaseDistributable produces an
.app that passes `codesign --verify --deep --strict`, with every nested native
carrying Developer ID + hardened runtime + secure timestamp.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The original "ProGuard strips the keychain backend" hypothesis turned out
to be wrong twice (PR 3260 comments document the binary PoW that refuted
both H1 strip-of-classes and H1b strip-of-native-resource). The full
117 KB osxkeychain.so resource ships intact in the proguarded
jkeychain-1.1.0-*.jar today, and Keyring.create() round-trips fine
against the proguarded classpath on macOS.
But the user-reported bug pattern (every cold boot, keychain key missing
→ forced re-login) maps so cleanly onto a hypothetical future
strip-of-native-resource that the guard is worth keeping. Cheap to run
(one unzip scan after proguardReleaseJars), wired onto every release
packaging task (DMG, MSI, DEB, RPM, current-OS distributable, runRelease)
so a regression can't slip past. Fails the build with a self-contained
explanation pointing at the next person who has to debug it.
The actual root cause of the reported bug remains unidentified after
three refuted hypotheses (see plan doc PoW table); needs the affected
user's Console.app logs + ~/.amethyst state to make further progress.
The LoginScreen "keychain-unavailable" diagnostic banner from the
earlier commit is unchanged and still earns its keep regardless of
which failure mode eventually turns out to be the cause.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Back out the hashtag-screen wiring (its `t` tag is the event category,
not a topic, so road events only matched category-named hashtags). Keep
the geohash data source + feed filter, where road events belong by their
`g` tags.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017tYbcy4UGWxqQbcycyL7Yd
Add the Roadstr report (1315) and confirmation (1316) kinds to both the
relay subscription filters and the local feed gates for the hashtag and
geohash screens (the geohash kind list is shared with the home "around
me" feed). Reports carry geohash `g` tags and a category `t` tag, so they
now surface in nearby-geohash feeds and in the matching category hashtag
feed; confirmations carry `g` tags and surface in geohash feeds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017tYbcy4UGWxqQbcycyL7Yd
The previous night filter used a desaturating invert whose coefficients
summed to ~1.4 per channel, which pushed every (light) OSM tone to
near-black and erased forest/water/land differentiation. Replace it with
a lightness invert composed with a 180° hue rotation: the map darkens but
hue is preserved, so forests stay dark green, water dark blue, and labels
invert to readable white.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017tYbcy4UGWxqQbcycyL7Yd
osmdroid's MAPNIK tiles are always light, so in dark mode the bright map
clashed with the UI and light overlays. Apply a night-mode colour-matrix
filter to the tiles overlay when MaterialTheme.colorScheme.isLight is
false (lightness-inverted + desaturated for a clean dark-grey map, not a
plain invert that turns forests magenta). Light theme keeps normal tiles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017tYbcy4UGWxqQbcycyL7Yd
The comment Text inherited LocalContentColor, which resolved to a
near-white in this card and made the report content hard to read on the
light map card. Color it onSurface so it stays readable in both themes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017tYbcy4UGWxqQbcycyL7Yd
The category pill picked black/white text from a luminance threshold
(0.55) that left white text on mid-tone colors (fog gray, ice turquoise,
"other" gray), barely legible over a light map. Choose the text color by
the higher WCAG contrast ratio against the pill color instead, so light
pills now get black text.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017tYbcy4UGWxqQbcycyL7Yd
Redesign the kind 1315/1316 cards from a text-title-over-small-map
layout into a map-hero card: the OSM preview becomes a full-bleed hero
with a floating, color-coded category pill overlaid on it (auto-contrast
text), and the comment/status sits in a clean padded block below. Shared
RoadEventCard + CategoryPill chrome backs both the report and the
confirmation, with a graceful no-location fallback.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017tYbcy4UGWxqQbcycyL7Yd
NoteCompose's header already shows the post time and the NIP-40
expiration. The inner card's own "🕒 age · expires in …" line duplicated
that and, worse, conflicted with it: the header reads the relay-side
`expiration` tag (always created_at + 14d) while the card used the
per-type effective TTL (e.g. 30d for a speed camera), so the two showed
different countdowns for the same note. Remove the inner row; keep the
freshness-based pin dimming, which is visual-only and doesn't conflict.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017tYbcy4UGWxqQbcycyL7Yd
Align Amethyst's road event cards with the roadstr reference clients
(https://github.com/jooray/roadstr) for both interoperability and a
richer presentation.
Interop:
- Match roadstr's exact emoji set: road_closure 🚫 (was ⛔) and
other ℹ️ (was 📍). The t codes and per-type TTLs already matched.
- Make the kind 1316 NIP-31 alt status-dependent ("Roadstr: event
confirmed" / "Roadstr: event denied") per the spec, instead of a
single "Roadstr: event confirmation".
Rendering:
- Colored teardrop map pin per category, using roadstr's exact color
palette, with the category emoji on the head (new MapPinIcon).
- Freshness: fade the report pin to 0.6 under 25% of effective TTL and
0.4 once effectively expired, matching roadstr's opacity rule.
- Subtitle meta line: "🕒 23m · expires in 1h" / "· Expired" on reports
and "🕒 23m" on confirmations.
- Confirmations get a green ✅ / red ❌ status pin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017tYbcy4UGWxqQbcycyL7Yd
A batchSize <= 0 made the SQLite resumable reindex select no rows yet
never report done, so a caller's loop would spin forever. Clamp the page
size to at least one in both stores and add a regression test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BZqPFds2TPPUKkMmBngwys
A full FTS rebuild can run for a long time on a big store, so add a
resumable, batched overload alongside the one-shot:
reindexFullTextSearch(resumeFrom: String?, batchSize): FtsReindexProgress
Each call processes ~batchSize events in its own write transaction and
returns an opaque cursor + done flag. The caller loops until done and may
stop at any point — the cursor is durable across crash/app-restart, and
the writer lock is released between batches, so "pause" is just "don't
make the next call". The path is additive/refresh and keeps search usable
throughout (no up-front wipe); the one-shot variant remains for a
guaranteed-clean rebuild.
- SQLite: FullTextSearchModule.reindexBatch walks event_headers ordered
by the monotonic row_id (a free, stable cursor), restricted to
searchable kinds, delete-then-insert per event so batches are
idempotent and never duplicate rows.
- Filesystem: FsEventStore walks one idx/kind/<k>/ dir per step (linear,
no re-sort); cursor is the next searchable kind. Idempotent linkFts, so
nothing is wiped. Pauses between kinds.
- Wrappers delegate; new FtsReindexProgress value type carries cursor +
progress + done.
- cli: `amy store reindex-fts` now loops the batched path to completion
and reports processed/batch counts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BZqPFds2TPPUKkMmBngwys
When Tor is Active but every Tor-routed relay fails (the ExitTimeout /
RESOLVEFAILED barrage), onTorCircuitsDead() now does a warm reset() —
drop the in-process client to rebuild the circuit pool with a fresh exit
draw, while keeping guards and the consensus cache — instead of
resetWithCleanState().
The failure is exit-side, not entry-side: circuits build fine, but the
exits can't reach the relays. Wiping arti/state/ + cache can't improve
exit selection (exits aren't persisted) and only forces a ~60s cold
bootstrap — exactly the blackout that strands users on the
connection-failure dialog. A warm restart reconnects in ~5s.
The poisoned-guards safety net is preserved: the next start() re-runs
noUsableGuards(), so genuinely unusable guards are still wiped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The set of event kinds that implement SearchableEvent — and the text
each contributes via indexableContent() — is baked into the quartz
build, so it changes across app versions. Events stored under older code
keep their old (or missing) NIP-50 full-text-search rows, so search
silently misses them after an upgrade.
Add IEventStore.reindexFullTextSearch() so the app can wipe and rebuild
the FTS index from already-stored events when it has spare cycles.
Speed: only kinds that currently map to a SearchableEvent are scanned.
Kind alone selects the event class in EventFactory, so a single probe per
distinct kind is authoritative, letting us push a `kind IN (...)` filter
(SQLite) / skip whole idx/kind dirs (filesystem) so the non-searchable
bulk — reactions, zaps, follow lists — is never deserialised.
- SQLite: FullTextSearchModule.reindexAll drops+recreates the virtual
table (O(1) wipe) then streams only searchable-kind rows in one write
transaction, reusing a single INSERT statement.
- Filesystem: rebuilds only idx/fts/, driving the walk from
idx/kind/<searchable kind>/ via the new FsIndexer.linkFts.
- Wrappers (EventStore, ObservableEventStore, InterningEventStore)
delegate; the observable layer emits nothing since no event changes.
- cli: `amy store reindex-fts`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BZqPFds2TPPUKkMmBngwys
Replace the reverse-geocoded "📍 City" line on the Roadstr road event
report and confirmation cards with an osmdroid OpenStreetMap preview
pinned at the event's coordinates.
- Add osmdroid-android 6.1.20 (Apache-2.0) to the version catalog and
the amethyst module.
- New LocationPreviewMap composable: an AndroidView-wrapped MapView with
MAPNIK tiles, a single marker, lifecycle-aware onResume/onPause/onDetach,
and nested-scroll-friendly touch handling so panning the map doesn't
fight the feed scroll. Sets the OSM User-Agent to the package name
(required or OSM returns 403).
- RoadEvent cards resolve a point from the explicit lat/lon tags, falling
back to the center of the finest published geohash.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017tYbcy4UGWxqQbcycyL7Yd
ChatMessageEvent (kind 14) is the decrypted NIP-17 rumor; its content is the
plaintext message. Index it so DMs are locally searchable, consistent with
messaging-client search. Opt-in confirmed by the maintainer; private zaps
(9733) remain excluded.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
Restructure the experimental roadstr events to mirror the nip88Polls
file layout, and wire the kinds into LocalCache (previously unhandled,
so they hit the "Event Not Supported" fallback and were dropped).
- Split into report/ and confirmation/ sub-packages, each with
XEvent.kt + TagArrayBuilderExt.kt + TagArrayExt.kt + tags/, matching
nip88Polls/{poll,response}. build() now uses eventTemplate + typed
builder-ext functions; accessors delegate to TagArray ext functions.
- Bundle each enum into its tag class (RoadEventType in RoadEventTypeTag,
RoadEventStatus in RoadEventStatusTag), like PollType in PollTypeTag.
- Add RoadReportTag (a GenericETag `e`-reference) mirroring PollTag, and
have RoadEventConfirmationEvent implement EventHintProvider so the
referenced report is linkable — parallel to PollResponseEvent.
- Shared lat/lon/geohash tag codecs live in roadstr/tags/ with generic
builder/parse extensions.
- LocalCache: consume kinds 1315/1316 as regular events.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cn4Lk53CcXqYFYHMut4YfY
Zaps carry an optional human-readable message; index it cheaply:
- LnZapRequestEvent (9734): content (public zap comment). Private-zap messages
live encrypted in the `anon` tag, not content, so they stay out.
- LnZapEvent (9735): the comment is in the embedded zap request, which init{}
already parses into `zapRequest` unconditionally — so indexing
`zapRequest?.content` adds no extra parse cost.
- OnchainZapEvent (8333): content (optional message).
- NutzapEvent (9321): content (nutzap message).
LnZapPrivateEvent (9733) is left out: its content is a decrypted *private* zap
message (same privacy class as DMs) — deferred to an explicit opt-in.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
Put an explicit newline in the plural so "+1\nreply" and "+3\nreplies" both
render the same way (count over word), instead of only the longer plural
wrapping under the width cap. Drops the now-unneeded widthIn constraint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hwQiQbgboo8LScPdDHDJn
Drop the oversized 55dp centering box that left an extra gap between the
avatar and the name. Use NoteComposeLayout's own metrics instead: 12dp
leading padding and a 10dp avatar-to-name gap, so the spacing around the
collapsed avatar matches an expanded note.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hwQiQbgboo8LScPdDHDJn
Compose Multiplatform string resources don't use Android res/values
escaping, so \' rendered literally as didn\'t on the DM history card.
Use a plain apostrophe to match the sibling new_key_continue_button string.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds Quartz protocol support and Amethyst rendering for the Roadstr
decentralized traffic-reporting events (https://github.com/jooray/roadstr):
Quartz:
- RoadEventReportEvent (kind 1315) and RoadEventConfirmationEvent
(kind 1316), registered in EventFactory.
- RoadEventType (13 categories with client-side effective TTLs) and
RoadEventStatus enums; lat/lon/status tag codecs with 7-decimal
coordinate formatting; NIP-40 expiration + NIP-31 alt + multi-precision
geohash (4/5/6) tags.
- Generalize the GeoHash encoder/decoder into commonMain
(nip01Core.tags.geohash.GeoHash); the Android module keeps only the
Location <-> GeoHash glue.
- Unit tests for parsing, building, TTLs, coordinate formatting and the
geohash prefix hierarchy.
Amethyst:
- RenderRoadEventReport / RenderRoadEventConfirmation cards wired into
NoteCompose (feed) and ThreadFeedView/NoteMaster (thread), mirroring
the Birdex renderer pattern; reverse-geocoded location line and
localized category labels.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cn4Lk53CcXqYFYHMut4YfY
Adds SearchableEvent to more text-bearing kinds:
- PollEvent (1068, NIP-88): question (content) + option labels
- CodeSnippetEvent (1337, NIP-C0): name, description + code
- GitReplyEvent (1622) and TorrentCommentEvent (2004): reply/comment content
- AudioHeaderEvent (1808): description (content)
- AudioTrackEvent (31337): subject
- InterestSetEvent (30015): title, description + public interest hashtags
(private hashtags in NIP-44 content are not indexed)
- SoftwareReleaseEvent (30063): release notes (content) — note: kind 30063 is
registered to NIP-51 ReleaseArtifactSetEvent in EventFactory, so this is for
completeness and not exercised at runtime.
CommunityRulesEvent (34551) was intentionally left out: it is the NIP-9B
machine-readable rules companion; the human-readable community rules live on
CommunityDefinitionEvent.rules(), which is already indexed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
The test asserted exactly two EOSEs (`assertEquals(2, eoseCount)`) and
looped `while (eoseCount < 2)`. But a re-REQ on the same subscription id
silently replaces the previous subscription (NIP-01), and
RelaySession.handleReq cancels the in-flight query coroutine without
emitting an EOSE for the superseded filter. So when both mid-stream
re-subscriptions get collapsed before reaching EOSE, only the final,
never-superseded filter emits one — the consumer loop then blocks on
receive() until the 30s timeout and the assertion fails.
Drain until the relay goes quiet (idle-gap timeout) instead of counting
on a fixed number of EOSEs, and assert only invariants that hold for
every interleaving: at least one EOSE arrives, every non-EOSE entry is a
valid 64-char id, the final advertised-relay-list filter actually
streamed its events, and the total stays under a sane upper bound.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018e1bfPf4HZDCvwcfiEHECk
A collapsed reply now displays "+N replies" on the right side, counting the
descendant replies hidden underneath it, next to the expand indicator. The
count is computed in the same depth-first pass that builds the visible list.
Uses a <plurals> resource so the noun declines correctly across locales.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hwQiQbgboo8LScPdDHDJn
Adds SearchableEvent to the rest of the event kinds that carry
human-readable fields, indexing only natural-language values (no structural
labels), consistent with the existing implementations.
Public/discovery: relay groups (NIP-29), file/media headers (NIP-94), web
bookmarks (NIP-B0), podcasts (NIP-F4 episode + show), static sites (NIP-5a),
live clips and meeting rooms (NIP-53), contact cards (NIP-85), and the
NIP-51 curation sets (article/video/picture/app curation, media starter
pack, release artifacts).
Personal lists (NIP-51): bookmark/labeled-bookmark/follow/people lists and
relay sets — indexes the list's own title/description so users can find
their lists by name.
Experimental: software apps (NIP-82), fundraisers, interactive stories,
workout records, birdex, attestations, NIP-95 file storage header, profile
gallery.
content is indexed only where it is confirmed human-readable prose
(file caption, podcast notes, clip caption, app/fundraiser/story/workout
body). It is excluded where content is HTML (static sites), base64 (NIP-95),
NIP-44 encrypted (contact cards), an encrypted/JSON private-tag blob (lists),
or always empty — those index their parsed tag fields only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
The "title: ", "summary: ", "name: ", "Subject: ", "Option: " etc. prefixes
were tokenized into the single FTS content column as literal words, so every
event of a type matched bare terms like "title" or "summary" and the index
carried useless tokens — the same field-name pollution we avoid for JSON
kinds. The FTS table has one content column and search is plain-text MATCH,
so the labels enabled no fielded search; they were pure noise.
Index bare field values instead (listOfNotNull(...).joinToString("\n")),
which also drops the "null" token the older one-liners produced for absent
fields. Applied across all SearchableEvent implementations for consistency.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
Kinds 0 (profile), 40/41 (channel create/metadata) and 31990 (app handler)
store their data as JSON in content. Implement SearchableEvent on them by
parsing the JSON (via the existing UserMetadata/ChannelData/AppMetadata
accessors) and indexing only the meaningful fields — names, bio/about, and
the addresses people search by: nip05 email, lightning addresses
(lud06/lud16), and website/picture/banner URLs. This avoids indexing the
JSON keys and structural punctuation that raw-content indexing would add.
Updates the FsSearchTest "non-searchable" case to use an unknown kind, since
MetadataEvent is now searchable, and adds SearchTest coverage for profile
and channel JSON fields (name, about, email, lightning, URL).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
Drops the _connectedRelays changes (the removeRelayInner prune and the
derived-projection refresh) and restores RelayPool to match main. The
incremental onConnected/onDisconnected maintenance is sufficient; the
user-visible background relay-count issues are addressed by the lifecycle
teardown timing and the notification-update throttle, not here.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae
The channel metadata save/create button is now disabled until the channel
has a non-blank name AND at least one relay. A public chat with no relay
declares an empty relay list, which leaves messages with no reliable home
(the root cause behind the relay-targeting issue). Gating the button at the
screen level keeps the relay-list StateFlow reactive (a ViewModel
derivedStateOf would not recompose on relay add/remove).
Also reverts the temporary PublicChatRelayDebug diagnostic logging.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYtHYEob2THu74inTxZxCh
Replaces the incremental add/remove maintenance of _connectedRelays
(including the removeRelayInner prune) with a recompute from the source of
truth: a relay is connected iff it is in the pool AND its socket reports
ready (isConnected()). refreshConnectedRelays() runs on connect, disconnect
and pool-membership changes.
The earlier prune patched the *readout* on the assumption that "removed
from pool ⟹ disconnected", which is only incidentally true. A set that is
hand-maintained per event drifts from reality whenever an event is missed —
OkHttp's async cancel() callback being dropped under mass teardown, or a
socket dying without an onDisconnected. Projecting the set from each pooled
relay's actual isConnected() can't drift: removed relays are already
disconnected so they fall out, and a silently-dead socket stops being
counted on the next refresh.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae
Reverts the event_fts rowid-alignment refactor and the background reindex
that it required. Aligning the FTS rowid with event_headers.row_id was a
schema change, which forced a v2->v3 migration to rebuild the index from
~all cached events — and on large caches that reindex was the expensive,
risky part (slow startup, all-or-nothing transaction, resumability and
malformed-row concerns). The cleanup it bought (not tokenizing the numeric
foreign key into the index) isn't worth that cost.
Restores the original design: event_fts keeps its dedicated
event_header_row_id column, queries join on it, DATABASE_VERSION stays 2,
and there is no FTS migration or reindex at all.
Kept: the newly searchable event kinds (they implement SearchableEvent and
work unchanged with the original table) and their test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
The v2->v3 upgrade previously rebuilt the entire full-text index inside
the migration transaction. With a large cache (e.g. 100k events) that
blocked every DB operation behind a single long transaction at startup:
the app appeared frozen, risked an ANR if reached on the main thread, and
— because it was all-or-nothing with the version bumped only on success —
a crash, kill, or one malformed cached row could roll everything back and
retry from scratch on every launch (worst case: an unrecoverable boot loop)
while the WAL ballooned.
Decouple the reindex from the migration:
- The migration now only recreates the empty FTS table and writes a
persistent `fts_reindex` marker holding a progress cursor, then bumps the
version. It is cheap and atomic.
- A background coroutine (Dispatchers.IO, cancelled on close()) backfills the
index from event_headers in small committed batches via useWriter, so live
relay inserts/queries interleave between batches instead of waiting.
- Backfill is idempotent (INSERT OR IGNORE), resumable (cursor persists, so a
kill resumes on next launch), and resilient (a row that fails to parse/index
is skipped while the cursor still advances — no stuck retries).
Search is merely degraded (partial results) until the backfill finishes,
never blocked. Adds a test covering backfill + marker clearing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
removeAllRelays() has no call sites — it's dead code — so clearing
_connectedRelays there was never exercised. The live fix for the stale
connected count is the prune in removeRelayInner (driven by updatePool),
which keeps removeAllRelays untouched relative to main.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae
Strips the BgRelayTrace instrumentation added while diagnosing the
background relay-count issues and restores the production grace period.
- LifecycleAwareKeyDataSourceSubscription: UNSUBSCRIBE_GRACE_MILLIS back to
30s, drop the per-subscription label + logs, refresh the doc to describe
the LifecycleEventObserver detection.
- RelayPool: drop updatePool trace logs and the now-unused Log import; keep
the _connectedRelays prune (with a trimmed comment).
- BaseEoseManager: drop the per-assembler relay-count log + Log import.
- SubscriptionController: drop activeRelays(), which only fed that log.
The actual fixes stay: lifecycle-observer teardown detection, the
connected-set prune, and the notification-count throttle + fg/bg wording.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae
A device log showed the persistent notification stuck on a stale count
(e.g. "44 inbox relays") while the pool had actually settled lower
(flowConnected=8). Cause: the count collector posted the notification on
every connectedRelaysFlow delta — ~90 updates during feed load, then ~22
in ~250ms during background teardown. Android rate-limits notification
updates (~10/s) and silently drops the excess, so the last value the
framework rendered (a mid-cascade 44) stuck instead of the final 8.
Sample connectedRelaysFlow at 1s before updating the notification. That
caps updates to ~1/s — comfortably under the limit — and the settled
count always lands. Also drops the now-confirmed notif-collector/
notif-popup debug logging.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae
A fresh process reset TorManager.hasEverBootstrapped to false, so the
stuck-Connecting self-heal watchdog used the gentle reset() (drop client,
keep state) instead of resetWithCleanState() (wipe state). When guards.json
carried guards poisoned by TooManyIndeterminateFailures from a prior
session, every retry reloaded the same poisoned guards and Tor stayed stuck
in Connecting forever — never wiping the one thing blocking it.
Seed hasEverBootstrapped at startup from durable on-disk evidence: Arti only
writes confirmed_at on a guard after it has built real circuits, so a
confirmed guard proves Tor bootstrapped successfully on this install before,
even across the restarts that clear the in-memory flag. With it set, a stuck
bootstrap correctly wipes the stale/poisoned state and rebuilds a fresh
guard sample.
- ArtiGuardState: pure, file/JNI-free parsers over guards.json
(hasConfirmedGuard + hasNoUsableGuards extracted from TorService).
- TorService.hasBootstrappedBefore() reads the file off-thread.
- TorBackend gains the suspend method; TorManager seeds in init.
- Tests cover the parser against a real captured poisoned-but-confirmed
guards.json fixture, plus a watchdog test for the wipe-on-first-stuck path.
Verified on an emulator stuck in Connecting from real poisoned guards:
self-heal wiped state and Tor reached Active in ~5s (clean 0-disabled
guard sample).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Several event kinds carry human-readable text (titles, summaries,
descriptions, names, free-text content) but were never added to the
full-text search index. Implement SearchableEvent on them so their
natural-language fields become searchable, while keeping non-prose data
(hex ids, URLs, relay hints, hashtags, geohashes, JSON config) out of the
index.
Kinds added:
- Classifieds (30402): title + summary + content
- Calendar (31924) + date/time slots (31922/31923): title + summary + content
- Community Definition (34550): name + description + rules
- Live Activities (30311): title + summary + content
- Meeting Space (30312): room + summary
- Status (30315): content
- Picture (20) and Video (NIP-71, all variants): title + content
- Goal (9041): summary + content
- Torrent (2003): title + content
- Git Repository (30617): name + description
- Git Pull Request (1618): subject + content
- Git Patch (1617): content
- Badge Definition (30009): name + description
- Emoji Pack (30030): title + description
- Feed Definition (31890): title only (content is JSON config)
JSON-content kinds (profile, channel, app handler) are intentionally left
out for now since they require parsing the content JSON to extract only
the natural-language fields. Existing v2->v3 FTS reindex repopulates the
index for already-cached events of these kinds on upgrade.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
The always-on notification now reads "Connected to N relays" while the
app is foreground (the pool also holds feed/finder outbox relays) and
"Connected to N inbox relays" once backgrounded (feeds torn down, only
inbox + DM relays remain). The label is chosen from MainActivity.isResumed
at each notification refresh; since foreground/background transitions
always change the connected count, the existing count-driven re-post
picks up the new wording.
Both messages are now <plurals> (relay/relays declines in many locales),
converting the existing always_on_notif_connected across all 11 locales
that had it (other-only; Crowdin fans out the remaining CLDR categories).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae
Logs the relay set targeted by the public-chat send path and the broadcast
path under tag "PublicChatRelayDebug", so an on-device repro can show whether
the channel-declared relay is actually in the target set. To be reverted once
the declared-relay case is diagnosed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYtHYEob2THu74inTxZxCh
The persistent notification's relay count is rendered by a collector on
the service's Dispatchers.IO scope. If that collector is throttled while
backgrounded — the same throttling that delayed the lifecycle teardown by
60s — the popup would show a stale count while the real pool (logged as
flowConnected in updatePool) has already shrunk. Log every value the
collector receives and every count it actually posts, so we can tell a
stale popup from real connections.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae
The FTS table declared event_header_row_id as a regular full-text column,
which means the numeric foreign key was tokenized into the searchable
index — a bare MATCH could match an event by its internal row id, and the
column wasted index space.
Drop the dedicated column and instead align the FTS table's implicit
rowid with event_headers.row_id at insert time, joining on it (rowid
joins are also the fastest possible). This works across fts3/4/5.
Also make FullTextSearchModule.drop() remove its trigger explicitly so
the module is self-contained, and add a v2->v3 migration that rebuilds
the FTS index in place from event_headers, preserving the cached events.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
PublicChatChannel.relays() was `info.relays?.toSet() ?: super.relays()`.
An empty (non-null) declared-relay list — `emptyList()?.toSet()` — yields an
empty set and short-circuits the elvis, so the channel reported zero relays
instead of falling back to the relays it was actually observed on. Both the
message-send path and the broadcast path (computeRelaysForChannels /
wantsBroadcastRelays) read relays(), so the message was published to nowhere
while a manual broadcast still reached the user's personal relays — matching
the reported symptom.
Treat an empty declared list like "no declared relays" via ifEmpty, so it
falls back to observed relays. Adds PublicChatChannelRelayTest covering the
declared-relay round-trip, message->channel resolution, and the empty-list
fallback regression.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYtHYEob2THu74inTxZxCh
A device log showed the foreground feeds (and ~150 relays) staying
connected for a full ~60s after the app was paused, then collapsing to
the 11-relay floor all at once:
11:26:02 HomeOutboxEventsEoseManager — keys=2, relays=344 (paused here)
… 60s of silence …
11:27:02 grace-start(HomeFilterAssembler) — lifecycle=CREATED
11:27:02 updatePool done — flowConnected=9, inPool=11
The lifecycle-aware subscription detected ON_STOP by collecting
lifecycle.currentStateFlow on Dispatchers.Default. Backgrounded, that
collector wasn't resumed until the next NostrClient keep-alive tick
(KEEP_ALIVE_INTERVAL_MS = 60s), so teardown — and the relay disconnects
it drives — lagged a minute behind the actual pause.
Switch detection to a main-thread LifecycleEventObserver, which fires
synchronously during onStop. Only the grace delay still runs on the
background scope (so it isn't gated by the stopped UI frame clock).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae
Public chat (NIP-28) messages were sent only to the channel's own
declared relay set via signAndSendPrivately, which has no fallback.
When channel.relays() resolved to an empty set (channel created
without relay hints, or sparse observation data), the message was
published to zero relays and never left the device — yet hitting the
Broadcast button later delivered it fine, since broadcast computes a
full relay list (author outbox + channel + hints + broadcast relays).
Route every channel through signAndSendPrivatelyOrBroadcast, which
already falls back to computeRelayListToBroadcast when the supplied
relay list is empty. LiveActivities chat already used this path; this
extends the same fallback to public chats.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYtHYEob2THu74inTxZxCh
After fixing the stale connected-count, the background footprint settles
at ~25 relays (desired=22) — higher than the inbox+DM target. Add a
per-EoseManager log (assembler name -> key count + distinct relay count)
so we can attribute the 25 to specific always-on loaders (metadata/drafts
on homeRelays, gift-wrap history, marmot groups, notifications) and trim
precisely instead of guessing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae
Backgrounding the app correctly collapsed the desired relay set (e.g.
desired=22, toRemove=343) and the pool cache shrank accordingly, yet the
always-on notification kept reporting ~110 connected relays. Measured:
updatePool done — cacheConnected=18, flowConnected=87, inPool=22
_connectedRelays (exposed via connectedRelaysFlow() and read by the
notification) was only ever pruned from the async onClosed/onFailure
websocket callback. disconnect() uses OkHttp cancel(), which kills the
socket immediately but whose callback is unreliable when hundreds of
sockets are cancelled at once in the background — so the connected set
stayed stale long after the real connections were gone.
Prune _connectedRelays directly in removeRelayInner (and clear it in
removeAllRelays) so the connected set tracks the pool's membership
immediately. The async callback remains as an idempotent backstop.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae
The background teardown works (desired collapses to 11, toRemove=349) but
the persistent notification still reports ~110 connected relays. That
points at the _connectedRelays StateFlow (what the notification reads)
being decoupled from the pool's desired set: it is only decremented from
the async onFailure/onClosed websocket callback, while disconnect() uses
OkHttp cancel() (immediate/violent). Add a post-reconcile log comparing
cacheConnected (relays still in the pool reporting isConnected) against
flowConnected (_connectedRelays.size) to confirm whether the 110 are live
sockets or a stale count.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae
Stack the with-fork and without-fork variants of the quote/repost popup
in the @Preview so both the text-note (fork available) and long-form/wiki
(fork hidden) layouts render side by side in dark and light themes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016a27kWKUcpADUC1KhXHRpV
The Homebrew formula is the cli module's product (amy), and the CLI already
owns its packaging artifacts under cli/packaging/ (cli/packaging/macos/
amy.entitlements). The root packaging/ dir was new in this branch and held
nothing else, so co-locate the formula with the module that owns it and drop
the stray root dir. Updates the two BUILDING.md references.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sso31DfSF9B6EFCVkEqWD
Phase 3 of relay-latency-health: wire the Phase 1 tracker and Phase 2 store
into the desktop UI across three surfaces. After this commit the feature is
end-to-end usable in the running app.
Wiring (Main.kt):
- Construct a RelayLatencyTracker per account (same lifetime as
RelayHealthStore).
- Install a RelayLatencyListener alongside the existing RelayHealthListener
on relayManager.client; uninstall both on account switch / app exit.
- Pass the tracker to the store via the new latencyTracker constructor
param so sweep + snapshot happen on the existing 60 s reclassify tick.
- nip11Provider: read live from Nip11Fetcher's session cache (new
`allCached()` accessor). The classifier reads it every tick.
- authProvider: hardcoded `{ false }` for desktop — NIP-42 isn't wired in
desktop yet, so any auth-required or payment-required relay is treated
as "auth not complete" and excluded from the slow cohort. Avoids
perpetually flagging paid relays that CLOSED our anonymous queries.
RelayMetricsTab + RelayMetricCard (dashboard):
- Tab collects latencySnapshots + slowRelays ONCE; per-row passes the
per-relay value snapshots (not the whole map). Strong-skipping then
handles the rest — unchanged rows skip on 60 s ticks.
- Each row gains three compact columns: OK / EOSE / FR p50s (in ms).
Missing metrics omit their cell — common in the first ~60 s before the
tracker's first snapshot lands.
- A red "Slow: <metric> 2.4×" AssistChip appears next to the columns
when the classifier flags the relay.
RelayDetailPanel (the per-row NIP-11 popup):
- New "Latency (rolling last 50 samples)" section below the existing
NIP-11 fields, listing each metric's p50, sample count, and cohort
multiplier when the relay is currently flagged on that metric.
- First-result row carries a tooltip explaining filter-dependence so
users don't misread "slow first-result" as pure network slowness.
UnhealthyRelaysPopup:
- Now also collects store.slowRelays and renders a "Slow relays" section
below the existing "Unresponsive relays" list (when slowRelays is
non-empty). Each slow row: relay URL, metric + p50 vs cohort, slow
chip, Dashboard + Snooze actions. Snooze reuses the existing 7-day
snooze field on RelayHealthRecord.
UnhealthyRelayBannerHost:
- Banner now visible when either unhealthy OR slowRelays is non-empty.
- Count text reads "$dead relays unresponsive — Review" /
"$slow slow relays — Review" / "${dead+slow} relays need attention —
Review" depending on which buckets have entries.
Compose stability:
- All public StateFlow types from RelayHealthStore expose ImmutableMap,
and RelayLatencySnapshot is @Immutable with ImmutableMap fields, so
strong-skipping engages.
- Per-row composables (RelayMetricCard, SlowRelayPopupRow) only take
@Immutable value parameters — no maps passed in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 of relay-latency-health: hook the Phase 1 tracker into the existing
RelayHealthStore lifecycle and persist its rings via the existing
PreferencesRelayHealthPersistence so samples survive restarts.
commonMain:
- RelayLatencyProvider: small interface so the store can drive a tracker
that lives in jvmAndroidMain (the impl needs ConcurrentHashMap).
- RelayHealthSnapshot: optional `latencySamples` field. Default empty;
older saved snapshots load cleanly without it.
- RelayHealthStore now takes optional `latencyTracker` / `nip11Provider`
/ `authProvider` constructor params:
* exposes `latencySnapshots: StateFlow<ImmutableMap<Url, RelayLatencySnapshot>>`
— MutableStateFlow updated inside the existing 60 s reclassify tick
(one timer, not two — the tracker is scope-less and gets
`sweep(now)` called from reclassify).
* exposes `slowRelays: StateFlow<ImmutableMap<Url, SlowReason>>` —
derived via `_latencySnapshots.map(classifySlowRelays).stateIn(
scope, SharingStarted.Eagerly, persistentMapOf())`. The classifier
reads `nip11Provider()` / `authProvider` live, so paid/auth-only
relays only join the cohort once their auth completes.
* `init {}` restores persisted samples into the tracker; the
existing `schedulePersist()` now bundles `tracker.samplesForPersistence()`
into the saved snapshot via a new private `snapshotForPersist()`
helper. The same helper feeds the final flush in `close()`.
No new dispatcher / scope / timer — everything piggybacks on the
existing infra (single SupervisorJob, 5 s persist debounce, 60 s tick).
jvmAndroidMain:
- RelayLatencyTracker now implements RelayLatencyProvider. Overrides drop
the inline `System.currentTimeMillis()` default; callers from commonMain
pass `TimeUtils.nowMillis()` explicitly.
desktopApp (jvmMain):
- PreferencesRelayHealthPersistence persists per-relay latency rings in
separate keys (`lat_<account-prefix>_<sha256(url)[..16]>`) so the 8 KB
Preferences ceiling on the main `health_<account>` key isn't blown by a
user with many relays. Each key holds one relay's four metric rings as
`wss://relay.url\tok:csv|eose:csv|fr:csv|ping:csv`. On save, keys for
relays no longer in the snapshot get removed so the prefs node doesn't
grow unboundedly across account churn.
Notes:
- Persistence still uses the existing 5 s debounce path. The deepened plan
called for 30 s for `lat_*` keys; deferring that micro-optimization
until we observe write thrash in practice. The cap on writes is
one-rewrite-per-5s-of-activity which matches what the existing snooze
persistence already does, so latency adds zero new flush events.
- Tracker is wired only when a `RelayLatencyProvider` is passed to the
store. Existing tests / Android continue to compile and run with
latency unconfigured — `latencySnapshots` stays empty and `slowRelays`
derives to empty. Desktop wiring lands in Phase 3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 of the desktop relay-latency-health feature: add a rolling-window
latency tracker that decorates the quartz RelayConnectionListener, plus a
pure classifier that flags relays whose per-metric p50 exceeds 2× the cohort
median. No store integration or UI yet — those come in follow-up commits.
commons commonMain (CLI-safe, no Compose runtime, no JVM-only deps):
- LatencyMetric: OK_ACK / EOSE / FIRST_RESULT / PING
- MetricSample: @Immutable (p50Ms, count)
- RelayLatencySnapshot: @Immutable, backed by ImmutableMap so strong
skipping engages when unchanged rows are re-emitted
- SlowReason: @Immutable (metric, relayP50, cohortP50, multiplier)
- HealthReason sealed interface: Unresponsive(gap) | Slow(SlowReason)
- classifySlowRelays(): pure. Honors NIP-11 auth_required /
payment_required (paid/auth-only relays are excluded from both cohort
and target until auth completes — otherwise they'd be perpetually
flagged while CLOSED'ing anonymous queries).
commons jvmAndroid (ConcurrentHashMap is JVM-only):
- LatencyRingBuffer: fixed-capacity (default 50) IntArray ring,
synchronized push, snapshotMedian / snapshotSamples / restore.
- RelayLatencyTracker: pending-eventId / pending-subId / firstResultSeen
maps + per-(relay, metric) ring buffers. Handles every pairing rule
the deepened plan called out:
* onSent EventCmd → record eventId timestamp
* onSent ReqCmd → record subId timestamp; clear firstResultSeen
* onSent CloseCmd → drop pending subId (no sample) — prevents
ComposeSubscriptionManager's sub-id reuse from pairing late
events with a new REQ
* success=false → no-op (websocket buffer was full)
* OkMessage → pair by eventId, push OK_ACK
* EventMessage → first-only, push FIRST_RESULT
* EoseMessage → pair by subId, push EOSE
* ClosedMessage → drop pending (fast negative response, not a
latency signal — was previously recording 300s TTL samples for
any auth-required relay)
* onConnected → push PING
* onDisconnected → drop all pending (no TTL samples)
* sweep(now) → TTL-expire pending entries (60s OK / 300s REQ),
record TTL value as the sample
AUTH retries: the second onSent overwrites the timestamp, so samples
reflect the retry leg — matches the user's mental model of "speed of
the actual publish". Pending maps are size-capped at 256 entries per
relay as a safety net against adversarial relays. Tracker owns no
CoroutineScope — RelayHealthStore drives sweep + snapshot from its
existing 60s reclassify tick (Phase 2).
- RelayLatencyListener: thin RelayConnectionListener decorator,
installInto / uninstallFrom paralleling RelayHealthListener.
Tests:
- LatencyRingBufferTest (7): wrap, median odd/even, restore from larger
or smaller arrays, chronological snapshotSamples.
- RelayLatencyTrackerTest (13): OK pairing, EOSE + FIRST_RESULT pairing,
success=false ignore, CloseCmd drops pending, ClosedMessage drops
pending, AUTH retry overwrites timestamp, disconnect drops all,
sweep TTL semantics (OK vs REQ), FIRST_RESULT only sampled when not
yet seen, ping, per-relay isolation, 256-entry cap, restore
round-trip.
- ClassifySlowRelaysTest (9): empty, Tor short-circuit, cohort < 2,
2× flag, count-below-min excludes from cohort, NIP-11 auth_required
excludes / includes once auth complete, payment_required excludes,
worst-metric-multiplier wins when multiple flag, exact-2× does not
flag (strict greater-than).
Note: a pre-existing RelayHealthStoreCloseTest case on the base branch
(fix/relay-health-threading-and-sleep-resume) hangs in advanceUntilIdle.
Not related to this commit; new tests pass cleanly with a tighter test
filter. Will revisit when integrating Phase 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Built ./gradlew :desktopApp:proguardReleaseJars on both main and this
branch and inspected the shrunk java-keyring-1.0.4-*.jar in
desktopApp/build/compose/tmp/main-release/proguard/. Both branches
contain byte-identical macOS Keychain backend bytecode:
OsxKeychainBackend, ModernOsxKeychainBackend,
pt/davidafsilva/apple/OSXKeychain, plus all _addGenericPassword /
_findGenericPassword / _deleteGenericPassword / loadSharedObject native
methods. ProGuard is NOT stripping the macOS backend.
The compose-rules.pro comment had misled me. pt.davidafsilva.apple IS a
real transitive runtime dep of com.github.javakeyring:java-keyring —
ModernOsxKeychainBackend has a private pt.davidafsilva.apple.OSXKeychain
field. The original keep rule was correct; restore it and clarify the
comment about the transitive relationship so the next person to read
this code doesn't repeat the same mistake.
The AccountManager keychain-unavailable diagnostic + LoginScreen banner
introduced earlier in this branch are kept — they're useful for any
future failure mode in this area, not just the (refuted) ProGuard one.
See https://github.com/vitorpamplona/amethyst/pull/3260#issuecomment-4740073787
for the full PoW jar inspection. Remaining hypotheses (H2 hardened-runtime
unsigned-dylib block, H4 jpackage stripping the bundled libosxkeychain.dylib,
H5 v1.11.0 migration gap) are documented in the plan doc.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1.4 (App() smoke test), Phase 2.4 (fixture-relay wire-up), and
Phase 5.2 (bootstrap-gate fix + regression tests) all land in commit
48a8178c9. The progress-log table, acceptance-criteria checkboxes, and
pending-work section are updated to reflect the new state. 278/278
desktopApp tests pass.
Only follow-up enhancements remain — the cold-fork shell driver, a
Compose-driving benchmark variant, and Phase 5.3 (sequential remember
chain in MainContent). None are required for the in-scope set.
Adds a `LaunchTestOverrides` bundle (default null in production) so
`App()` can be driven from `createComposeRule()` against the in-process
fixture relay instead of the OkHttp + kmp-tor + DesktopHttpClient stack
it normally constructs via `remember { … }`. `DesktopRelayConnectionManager`
gains a secondary constructor taking a `WebsocketBuilder` so the
`LocalRelayManager` composition local (typed as
`DesktopRelayConnectionManager?` and consumed by ~20 screens) does not
have to be relaxed.
`AppStateMachineTest` exercises four scenarios:
1. `appShowsLoginScreenWhenNoSavedAccountExists` — App() with no
`accounts.json.enc` reaches LoggedOut and renders LoginScreen.
2. `appWithViewOnlyAccountReachesLoggedInWithoutCrashing` — App() with
a pre-seeded ViewOnly account reaches LoggedIn end-to-end through
`MainContent`, the deck columns, NWC wiring, etc.
3. `bootstrapSubscriptionFiresEagerlyEvenWhenRelayNeverConnects` —
wires a `NeverConnectsWebsocketBuilder` so no connection ever opens,
yet App() still reaches LoggedIn within 5s instead of the previous
30s gate timeout. Direct regression test for the Phase 5.2
bootstrap-gate removal.
4. `bootstrapSubscriptionFiresAtMostOncePerAccountLoad` — wraps the
fixture builder with a `RecordingWebsocketBuilder` and asserts the
bootstrap REQ does not loop or double-fire.
The `LaunchScenario` benchmark drops its private
`BenchmarkRelayConnectionManager` subclass in favor of the new
secondary `DesktopRelayConnectionManager(WebsocketBuilder)` constructor.
278/278 desktopApp tests pass.
ProGuard in the release DMG (compose-rules.pro) was keeping
pt.davidafsilva.apple.** — a library no longer in the dependency graph.
The actual macOS-keychain dependency is com.github.javakeyring:java-keyring,
which reflection-loads its OS-specific backend (OSXKeychainBackend /
SecretServiceBackend / WinCredentialStoreBackend) at Keyring.create()
time. The shrinker stripped the backend classes, Keyring.create() threw
BackendNotSupportedException on every cold boot, SecureKeyStorage's
fallback silently returned null (no password prompt in a GUI cold-boot),
and every account whose key lived in the OS keychain (nsec, NIP-46
bunker ephemeral, NWC secret) was forced back to the login screen on
each launch of the release DMG. Dev/Gradle runs skip ProGuard, which is
why this never surfaced in development.
Primary fix:
- Replace dead pt.davidafsilva.apple.** keep rules with
com.github.javakeyring.** and keep native methods + constructors on
internal.** backends.
Defense in depth (so a future regression is visible, not silent):
- AccountManager._keychainUnavailable: StateFlow<Boolean> mirrors the
existing _storageCorruption / _forceLogoutReason channels.
- loadInternalAccount / loadBunkerAccount raise the signal when
accounts.json.enc points at a key the keychain cannot return.
- LoginScreen shows a one-line error banner when the signal is set;
cleared on any successful login.
Tests:
- AccountManagerLoadAccountTest gains four cases: Internal-no-privkey
signals, Bunker-no-ephemeral signals, clearKeychainUnavailable
resets, happy path does NOT signal.
See docs/plans/2026-06-18-fix-desktop-macos-bunker-relogin-plan.md for
brainstorm + plan + deferred follow-ups (Linux/Windows DMG verification,
signed-DMG smoke test, ProGuard mapping regression guard).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1.4 / 2.4 / the four Phase 5.2 regression tests are all blocked on
the same broader App() dependency-injection refactor — relayManager,
localCache, localRelayStore, and subscriptionsCoordinator are still
constructed inside App() via remember { … }. The torManager slot has
been loosened to ITorManager in preparation, but the rest is wider work
than this session can absorb.
App() only consumes torManager.status, which is on ITorManager.
Loosening the parameter type lets future tests substitute a fake without
having to construct the concrete DesktopTorManager (which eagerly builds
a kmp-tor TorRuntime on first status access). No production behavior
change: DesktopTorManager already implements ITorManager and the existing
call site at Main.kt:633 upcasts naturally.
This is a small intermediate step on the road to the still-pending Phase
1.4 App() Compose smoke test, which is the last item blocked on broader
App() dependency injection (relayManager / localCache / localRelayStore
are still remember'd internally).
Phases 2.1/2.2/2.3, 3.1/3.2, 4, 5.2, and 6 of the launch-optimization plan
land together because they share a single set of seams and a single
benchmark report.
* InProcessWebsocketBuilder + LaunchFixtureRelay wrap quartz's existing
InProcessWebSocket + NostrServer (with EmptyPolicy) so any test can
drive a NostrClient against an in-memory relay seeded with arbitrary
events. Roundtrip verified by LaunchFixtureRelayTest.
* LaunchFixture builds a deterministic 50-note synthetic home-feed
snapshot from a fixed RNG seed (kind:1 + author kind:0 + kind:3 +
kind:10002). A real-world JSONL artifact is a drop-in replacement.
* NoteCard gets a stable testTag + a CompositionLocal-backed
onPlaced hook. Production overhead is one composition-local read
plus one null check per placement (default
LocalNoteCardInstrumentation = null).
* LaunchMarkers records named markers against TimeSource.Monotonic.
LaunchScenario.coldBoot drives the AccountManager (ViewOnly path)
+ DesktopLocalCache + RelayConnectionManager + LocalRelayStore
stack against the fixture relay and reports t_account_logged_in,
t_first_event, t_n_events.
* LaunchBenchmark runs 2 warmup + 5 measured iterations, computes
min/q1/median/q3/max, atomically writes the report file, and is
skipped by default — opt in via AMETHYST_BENCH=true. Baseline +
post-fix snapshots committed under desktopApp/benchmarks/.
* SubscribeBeforeConnectTest proves NostrClient / RelayPool queue REQs
issued before connect() and flush them when the connection comes up.
The bootstrap-config subscription in Main.kt drops its
`connectedRelays.first { isNotEmpty() }` + 30s withTimeoutOrNull gate
on the strength of that invariant — the subscription now fires
eagerly and recovers when no relay ever connects instead of silently
giving up after 30s.
All 274 desktopApp tests pass. No flaky tests introduced.
Replace the bare row of three flat primary-coloured buttons in the
boost/quote/fork popup with the same card-and-pill treatment used by the
reaction and zap popups: an elevated surfaceVariant card holding pill
chips, each with a coloured leading icon (repost, quote, fork) next to
its label and the soft outline the zap rails use, so the three action
popups read as one family.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016a27kWKUcpADUC1KhXHRpV
ZapPollEvent.indexableContent() concatenated a List onto a String, so
String.plus(Any?) appended the list's toString() — leaking literal "[",
"]" and ", " separators into the full-text index
(e.g. "Best color?[\nOption: Red, \nOption: Blue]"). Build the string
explicitly so only the natural-language poll descriptors are indexed,
matching the buildString style used by the music events.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
Tapping a reply in the thread view now collapses it instead of opening it
as a new thread. A collapsed reply renders only its author and the first two
lines of its content, and all of its descendant replies are hidden. An
ExpandMore indicator on the collapsed row (or tapping the row) reopens it
and restores its children.
- LevelFeedViewModel tracks the collapsed reply ids and exposes toggle/query
helpers; collapsing also flags the thread as interacted so it stops
auto-scrolling to the focused note.
- RenderThreadFeed filters out descendants of collapsed replies (the feed is
depth-first ordered, so descendants are the contiguous deeper-level items)
and renders the compact CollapsedNoteCompose for collapsed entries.
- NoteCompose gains an optional onClick override so the thread view can
intercept the tap for collapsing without changing default navigation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hwQiQbgboo8LScPdDHDJn
When immersive scrolling hides the bottom navigation and the OS status bar,
the small reverse drag a finger makes while catching/stopping a fast scroll
was enough to immediately bring the chrome back.
Two changes make the reveal a more deliberate gesture:
- Damp the reveal direction in DisappearingBarNestedScroll: hiding still
tracks the finger 1:1, while revealing applies REVEAL_SENSITIVITY (0.5),
so bringing the in-app bars back needs twice the scroll distance.
- Add hysteresis to the OS status bar toggle: it hides once the chrome is
fully settled (>= 0.999) but only reappears after the chrome is pulled
back below STATUS_BAR_SHOW_THRESHOLD (0.7), so a stray reverse reveal no
longer flips the binary status bar.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SN3JhbbvjE7BVKuSRMZmsU
Investigating why backgrounding the app on the all-follows feed leaves
~172 outbox relays connected when only inbox + DM relays (~8) should
remain. The static teardown chain (lifecycle ON_STOP -> unsubscribe ->
client.unsubscribe -> PoolRequests.remove -> RelayPool.updatePool
disconnect) is correct, so this adds runtime tracing at the two decisive
hops to find where it stalls on-device:
- LifecycleAwareKeyDataSourceSubscription: log subscribe/grace-start/
unsubscribe/dispose with the assembler name (tag BgRelayTrace).
- RelayPool.updatePool: log desired/inPool/toRemove/connected counts.
Also drops UNSUBSCRIBE_GRACE_MILLIS 30s -> 0 as an experiment: if the
grace delay() was being starved on Dispatchers.Default once backgrounded
(Doze/app-standby suspends timers), unsubscribing immediately on ON_STOP
both proves and fixes the leak. To be reverted to a wakelock-safe grace
once confirmed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae
amy is headless and compiles against zero Compose UI (the Compose deps are
`implementation` in :commons, so they never hit the CLI compile classpath),
but they still rode the runtime classpath into the shipped image — ~29 MB of
Compose desktop render stack, including skiko's native .dylibs that enlarged
the macOS notarization surface.
Exclude skiko + the org.jetbrains.compose UI groups (ui/foundation/material/
material3/animation) from :cli runtimeClasspath. Keep androidx.compose.runtime
(snapshot state + @Stable/@Immutable) — that IS CLI-safe and used by commons
models/state. This avoids the commons → commons/commons-ui module split: the
single-module, feature-cohesive design (commons/ARCHITECTURE.md §1/§3) is
preserved; only the runtime artifact is trimmed.
Result: amy image lib 77 MB -> 48 MB (-38%), and all 4 Compose/skiko notary
dylibs gone (only secp256k1/jna/sqlite natives remain — the ones actually
loaded). A create-release.yml assertion fails the build if the UI stack ever
leaks back.
Verified with the SDK hidden + an offline amy command battery (init/whoami/
--json/relay/marmot/login, plus a real 6-relay key-package round-trip): zero
NoClassDefFoundError/linkage errors; init derives a secp256k1 key cleanly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sso31DfSF9B6EFCVkEqWD
macOS validation (Developer ID D77MCV9NZ7) confirmed the hardened-runtime
entitlements are correct and load-bearing: amy init derives a secp256k1 key
cleanly, and dropping disable-library-validation reproduces the runtime
dlopen Team-ID failure. The one unverified gap is whether Apple's notary
service accepts the unsigned Mach-O dylibs embedded inside lib/*.jar
(secp256k1/jna/sqlite/skiko), which it inspects recursively.
- create-release.yml: the notarize step now submits with --output-format json,
and on any non-Accepted status dumps `notarytool log` (per-file issues) and
fails — so the first real run names the offending files instead of failing
opaquely. No speculative in-jar signing yet; gather the log first.
- BUILDING.md: record the validation result, the embedded-jar-native risk, the
one-run way to decide it (workflow_dispatch dry_run with MAC_* secrets), and
the staged fixes (sign-in-jar and/or strip the skiko/Compose leak). Note the
desktop app shares the same jars and needs its own dry-run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sso31DfSF9B6EFCVkEqWD
Sign the macOS jlink image (amy-<version>-macos-arm64.tar.gz) so users who
download it directly clear Gatekeeper. Reuses the same Developer ID cert and
the six MAC_* secrets as the desktop DMG; no-op when they're absent.
- .github/actions/import-macos-cert: factor the throwaway-keychain cert import
into a composite action; the desktop leg now uses it too (was inline).
- create-release.yml (build-cli macOS leg): import the cert, then codesign
every Mach-O binary in the bundled JRE (executables get hardened-runtime
entitlements, dylibs don't) and notarize via notarytool --wait. Runs before
the collect step so the tarred image is signed. Job timeout 30->45 min for
notarization headroom.
- cli/packaging/macos/amy.entitlements: hardened-runtime entitlements; the
disable-library-validation key lets the JVM load the secp256k1 native dylib
it extracts from a jar at runtime (would otherwise crash under notarization).
- BUILDING.md: document the tarball signing, the no-stapling/online-check
caveat, and that the Homebrew-core jvm bundle is intentionally left unsigned.
Untested end-to-end (no macOS runner / Apple creds here) — validate with a
workflow_dispatch dry-run once the secrets are provisioned.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sso31DfSF9B6EFCVkEqWD
Enable distributing the `amy` CLI via Homebrew-core (mainline formulae).
Homebrew-core builds in a network-sandboxed env, so a from-source Gradle
build can't resolve Maven deps there; the accepted pattern for JVM tools is a
pre-built no-JRE jar bundle + `depends_on "openjdk"`. installDist already
produces exactly that (bin/amy + lib/*.jar, no bundled runtime).
- create-release.yml: publish `amy-<version>-jvm.tar.gz` (the installDist tree)
as a release asset on the linux leg. Pure JVM bytecode, so one
platform-independent artifact serves every OS.
- packaging/homebrew/amy.rb: reference formula (depends_on openjdk, livecheck
for BrewTestBot auto-bumps, `amy --help` smoke test). Not consumed by any
build here — it's the artifact to submit to Homebrew/homebrew-core.
- BUILDING.md: homebrew-core submission runbook; note that the desktop app is
already on mainline Homebrew (homebrew/cask); document name-collision and
pre-built-jar review caveats.
- asset-name.sh: document the jvm bundle naming exception.
Verified locally: :cli:installDist builds with only a JDK (no Android SDK),
and the extracted bundle runs via JAVA_HOME (`amy --help` exits 0).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sso31DfSF9B6EFCVkEqWD
Add gated code-signing + notarization for the macOS desktop DMG so it can
clear Gatekeeper and stay in Homebrew's main cask (unsigned casks are
rejected after 2026-09-01).
- desktopApp/build.gradle.kts: macOS signing{}/notarization{} blocks, gated
on the AMETHYST_MAC_SIGN_IDENTITY env var. Absent => unsigned DMG, exactly
as before, so local dev and PR CI are unaffected.
- create-release.yml: import a Developer ID cert into a throwaway keychain on
the macOS leg and export the signing/notary env. Soft-gated on the
MAC_CERTIFICATE_P12 secret — no secret => unsigned build.
- BUILDING.md: document the six MAC_* secrets, how to generate them, and flip
the unsigned-cask fallback note to reflect the wiring is now in place
(pending Apple credentials).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sso31DfSF9B6EFCVkEqWD
NUT-09 "Recover from seed" iterated only the configured kind:17375 mint
list, so funds at a mint dropped from the wallet config (while still
holding tokens) or auto-redeemed from a nutzap on an unconfigured mint
were silently skipped by recovery. Scan displayMints (configured plus any
mint we currently hold tokens at) instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TK5eNfhkNR1svcQxjY1JvR
Builds on the untrusted-mint highlight: the warning banner and each
flagged mint row are now actionable, opening an EvacuateMintDialog that
offers the three exits whose backends already exist —
- Move to a mint you trust: a new rebalanceOut() over the tested
CashuWalletState.rebalance (mint-to-mint, no new Lightning sats). The
amount is editable and defaults to the balance, with a hint that the
Lightning fee is taken from the source so the full balance may not fit.
- Withdraw via Lightning: hands off to the existing Send-LN dialog.
- Export as Cashu token: hands off to the existing Send-token dialog.
The two Send dialogs now source from displayMints (not just configured
mints) and accept an initial mint, so they can be pre-pointed at the
mint being evacuated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TK5eNfhkNR1svcQxjY1JvR
Surface coins sitting at a mint the user never configured — almost always
auto-redeemed from a NIP-61 nutzap sent on a mint outside the recipient's
kind:10019. Until now such a balance counted toward the total and showed a
plain mint row, with nothing to tell the user it came from an unvetted
issuer.
- `CashuWalletState.unconfiguredMintBalances`: token-held mints minus the
configured (kind:17375) set, keyed by mint URL -> sats.
- Wallet screen shows an error-styled recommendation banner when any exist
and badges the offending mint rows ("Not in your wallet").
Informational first cut; the per-mint "move these coins to a trusted mint
or withdraw to Lightning" action (reusing rebalance / meltToLightning /
sendAsToken) follows separately.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TK5eNfhkNR1svcQxjY1JvR
Two related gaps around nutzaps redeemed from mints not in the user's
configured kind:17375 list (e.g. a NIP-61 nutzap auto-redeemed from a
mint outside the recipient's kind:10019):
- The wallet screen's per-mint list iterated only the configured mints,
so a token-only mint contributed to the total balance but had no row —
the displayed per-mint balances under-counted the wallet. Add
`displayMints` (union of configured + token-derived mints) so the rows
sum to the full balance.
- Stale-proof reconciliation (`scrubLocallyStaleProofs`) only ran for the
single mint a spend targeted, so proofs held at a non-configured mint
were never checked until spent. Add `syncAllMints()` (an all-mint,
non-destructive sweep) and wire it to the wallet screen opening via
`CashuWalletViewModel.refresh()`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TK5eNfhkNR1svcQxjY1JvR
- Invalidate the cached NUT-13 seed in applyEvents whenever the live kind:17375
changes, so after a P2PK key rotation (recreateNutzapKey, or a rotation from
another client) deterministic secrets re-derive from the new key instead of a
stale cached seed. Removes the now-redundant reset in recreateNutzapKey.
- AccountSettings.updateNutzapInfo no longer backs up a mints-less kind:10019
(the "stop receiving nutzaps" tombstone), clearing the backup instead — so the
empty event round-tripping back through LocalCache can't undo clearNutzapInfo()
and resurrect a withdrawn nutzap advertisement on next launch.
- Key keyMode's remember on isEditMode in AddCashuWalletScreen so a wallet
delivered after first composition flips to KeepCurrent, preventing a silent
key rotation on save in the cold-open race.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXRAunSJS2dBx7B79qTMew
- Restore the per-mint Verify button + reachability status on the already-added
mints list. Verify is now in BOTH places: the current mints and the
Matching/Popular suggestions (it was meant to be added to suggestions, not
moved off the current list).
- Settings hub order: My mints → Mint recommendations → Recover from seed →
Danger Zone, so the occasional recovery action sits last before the
destructive section.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXRAunSJS2dBx7B79qTMew
Adjusts the mint editor to the post-key-rotation reality and tidies its UI:
- Settings hub: "Edit wallet details / Mints, nutzap key" row becomes
"My mints / Add or remove the mints your wallet uses." The edit screen title
changes from "Edit Cashu wallet" to "Edit mints".
- The per-mint Verify button moves off the already-added mints list and into
the Matching/Popular mints suggestion rows, sitting to the left of the +
button, with the reachability result shown under each suggestion. Reuses the
existing per-URL mintVerifications state.
- Fixes the Mint URL placeholder wrapping onto two lines (and inflating the
field height) by capping it to a single ellipsized line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXRAunSJS2dBx7B79qTMew
The Cashu wallet settings screen becomes a thin redirector. The NIP-87 mint
recommendations management (add-input + autocomplete + own-list + retract
dialog) moves out into a dedicated CashuMintRecommendationsScreen, reached via
a new "Mint recommendations" nav row. Recover-from-seed and the Danger Zone
stay inline on the hub.
- New Route.CashuMintRecommendations + AppNavigation registration.
- New CashuMintRecommendationsScreen with its own top bar; carries the
recommendation composables + previews that used to live in the settings file.
- CashuWalletSettingsScreen trimmed to nav rows + the recover action + the
Danger Zone dialogs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXRAunSJS2dBx7B79qTMew
Splits the destructive P2PK key rotation out of the routine "edit wallet"
(mints) flow so editing mints can no longer accidentally orphan inbound
nutzaps.
- AddCashuWalletScreen: the P2PK key chooser now shows only at wallet
creation. In edit mode the key is always kept (KeepCurrent), so saving
mint changes never rotates the key.
- CashuWalletSettingsScreen Danger Zone: two new red, confirm-gated actions,
each with a description of what it does and a note that it's rarely needed:
* Recreate nutzap key — generate a fresh P2PK key.
* Import nutzap key — adopt a pasted hex key (e.g. restore from backup),
with inline validation/error surfacing.
- CashuWalletState.recreateNutzapKey / CashuWalletViewModel.recreateNutzapKey:
re-publish kind:17375 + kind:10019 with a new/supplied key, keeping the
current mint list, and invalidate the cached NUT-13 seed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXRAunSJS2dBx7B79qTMew
Edit Cashu wallet screen changes:
- The mint directory suggestions now carry a "+" button that adds the mint
straight to the wallet's mint list, instead of an arrow that only copied the
URL into the text field.
- Each already-selected mint row gets a small Verify button so the user can
check reachability of mints they've already added (not just a freshly typed
URL). Results are tracked per-mint via a new mintVerifications map on the
view model, independent of the input-field ping state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXRAunSJS2dBx7B79qTMew
Match the main Settings screen's danger styling: the "Danger Zone" header and
the Stop-nutzaps / Delete-wallet rows now use colorScheme.error for the header
text, row title, and leading icon. Reuses the shared R.string.danger_zone
instead of a duplicate cashu-specific string.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXRAunSJS2dBx7B79qTMew
Commit 270d229d renamed the relay's RelayInfo.NAME constant from "geode" to
"Geode" but left KtorRelayTest asserting the old lowercase value, failing the
pre-push test gate. Align the assertion with the source constant.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXRAunSJS2dBx7B79qTMew
Adds two user-facing teardown options to the Cashu wallet settings:
- "Stop receiving nutzaps": replaces kind:10019 with an empty event (the
durable signal, honored by every relay since it's a replaceable-event
replacement) and then NIP-09 deletes it (best-effort, since deletions are
optional on Nostr). The wallet and balance are untouched.
- "Delete wallet": withdraws the nutzap advertisement as above, then NIP-09
deletes the kind:17375 wallet definition. Held kind:7375 proofs are not
deleted (the ecash still exists at the mint), with a UI warning that any
remaining balance / unredeemed nutzaps may become unrecoverable.
The on-disk backups of kind:17375 / kind:10019 are cleared when those events
are deleted, so a relaunch doesn't resurrect a deleted wallet from settings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXRAunSJS2dBx7B79qTMew
The NIP-31 "alt" summary for kind:1 notes was built with msg.take(50),
which counts UTF-16 code units. When the 50th unit landed between the two
halves of an astral character (e.g. the 🫡 emoji, U+1FAE1), it left a lone
surrogate at the end of the alt tag.
A lone surrogate is unencodable as UTF-8: it is kept in memory while the
event id is hashed (so the external signer signs that id), but it is
replaced by '?' the moment the event is serialized to a relay. Every relay
then recomputes a different id and rejects the event as having an invalid
id — making the affected note impossible to post.
Add a surrogate-aware String.takeKeepingSurrogatePairs() helper and route
TextNoteEvent's alt summary and the clink OfferClient description trim
through it. Adds regression tests covering the reported note and the helper.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HTHsaW6FVjvPqnrSGiT5ee
MeltProcessor (the "Redeem received cashu token → my Lightning address"
button) was hand-coded against the deprecated pre-v1 Cashu API (POST /melt
and POST /checkfees with {pr, proofs}). Those endpoints are gone on CDK and
other modern mints, and the path never accounted for NUT-02 per-input fees,
so it failed on fee-charging keysets the same way the wallet melt did.
Route it through the same NUT-05 CashuMintOperations the NIP-60 wallet uses:
requestMeltQuote + meltProofs. A probe quote at the full token value reveals
the LN fee_reserve, to which we add inputFeeFor(proofs) before fetching the
real invoice for (total − fees) and melting. No change is requested — there
is no wallet to hold leftover proofs, so the unused reserve stays with the
mint, matching the legacy behavior.
Supporting changes in CashuMintOperations:
- meltProofs gains requestChange (default true) so the redeem path can melt
without minting orphan change outputs.
- inputFeeFor(proofs) exposes the per-keyset NUT-02 fee for invoice sizing.
Also drops the dead empty melt(...) overload that was a stub with a TODO.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Ffjz3doZ5CtFtAWSpvmqR
Enrich the Zapstore listing with the 512x512 launcher icon and the full
supported_nips list (synced with the README checklist). Relays are not a
zapstore.yaml field — zsp reads RELAY_URLS (default wss://relay.zapstore.dev) —
so document how to publish to additional relays in the yaml and RELEASE_OPS.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VK27apdHs4Yzxa54qx44oJ
meltToLightning selects proofs covering amount+fee_reserve and, on
overshoot, swaps them down to exactly that before melting. But the melt
mints its inputs on the active keyset and the mint then charges its own
NUT-02 input fee on them — which the swap-down target didn't include. On a
fee-charging mint (mint.coinos.io active keyset = 100 ppk) the melt was
left a sat short and threw "Inputs total X < required Y", so fixing the
per-keyset swap fee alone just moved the failure from the swap to the melt.
Reserve activeKeysetInputFeeFor(required) on top of amount+fee_reserve for
both proof selection and the swap-down target so the subsequent melt has
room for its input fee.
Also fix the reported fee in MeltCompleted: the pre-paid swap "keep" was
split off before the melt and never spent, so subtract it instead of
counting the whole selected total minus change (which overstated fees by
the keep amount in the swap path).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Ffjz3doZ5CtFtAWSpvmqR
Two changes:
1. "Mine" top-nav filter for the Music and Playlists feeds. Adds a shared
`musicRoutes` option list (the content-style catalog plus "Mine") to
TopNavFilterState and points both music top bars at it. The local-cache
feed filters and the relay sub-assemblers now handle TopFilter.Mine by
restricting to the logged-in user's own tracks/playlists (by author, over
their outbox relays) — same pattern as the badges/communities feeds.
2. Fix: editing a track/playlist showed the empty upload placeholder even when
the event already had a cover. The shared CoverImagePicker now renders the
already-published cover URL (with tap-to-replace and a remove button) when no
new local file is picked. The track composer's clearPickedCover now also
clears the saved URL so "remove cover" sticks on save.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013oofoSH7eMXrs2TCU4uncS
When melting/swapping Cashu proofs, the wallet computed the NUT-02 input
fee from the mint's currently-active keyset (`keyset.inputFeePpk`) for
every input. But NUT-02 charges the fee per the keyset each input proof
was minted under, which can be an inactive, rotated-out keyset with a
different fee.
On mint.coinos.io the old keyset (004f7adf2a04356c) charges 0 ppk and the
active keyset (007311aa2fa58cc8) charges 100 ppk. A wallet holding proofs
on the old keyset reserved a fee the mint never takes, leaving the swap
outputs one sat short — surfacing as "Mint Error (HTTP 400), inputs 84 -
fees 0 vs output (83) are not balanced" when sending over Lightning.
Fee is now `ceil(sum(input_fee_ppk_i) / 1000)` over each input's own
keyset, read from /v1/keysets (which lists inactive keysets too, unlike
/v1/keys). Applied in swap, swapToLocked, and meltProofs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Ffjz3doZ5CtFtAWSpvmqR
Add in-playlist track management to the music playlist composer: each track in
the working list shows its artwork/title/artist with move-up, move-down and
remove controls. The list is seeded from the loaded event when editing and
published in its new order on save. Adding new tracks still happens via the
per-song "Add to playlist" sheet.
- quartz: MusicPlaylistEvent.edit() now takes the ordered track list and resets
the playlist's music-track `a` tags to it (preserving any non-track `a` tags,
the d tag, custom hashtags and other metadata). Add MusicPlaylistEventEditTest
covering reorder, removal, visibility switch, cover/description clearing and
tag preservation.
- amethyst: NewMusicPlaylistViewModel gains the working track list plus
moveTrackUp/moveTrackDown/removeTrackAt; NewMusicPlaylistScreen renders the
editable track section; new string resources.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013oofoSH7eMXrs2TCU4uncS
Chatroom.addMessageSync evaluated `activeSenders + author` but discarded the
result, so `activeSenders` stayed permanently empty and
`Chatroom.senderIntersects()` always returned false. The Known-rooms filter is
`senderIntersects(follows) || hasSentMessagesTo(room)`, so with the follow path
dead a room only counted as Known once the user's own self-addressed NIP-17
gift wrap decrypted. Incoming DMs from followed contacts were misrouted to New
Requests, and the Known tab sat on the "Loading Feed" spinner (empty feed shows
the spinner until gift-wrap history exhausts — minutes with many relays/Tor).
Assign the new set. Prune/remove intentionally do not recompute activeSenders so
a room never flips Known->New when old messages are pruned.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the name-only "new playlist" dialog with a full-screen create/edit
composer reachable via a dedicated route (Route.NewMusicPlaylist).
The composer surfaces a cover-image upload (same Blossom/NIP-96 pipeline as the
music-track composer), plus title, short description, long-form notes, a
public/private toggle and a collaborative toggle. An edit affordance now appears
on the user's own playlist cards.
- quartz: add MusicPlaylistEvent.edit() — updates the composer-owned metadata
while preserving the track `a` tags and every other tag of the prior version.
- amethyst: NewMusicPlaylistViewModel + NewMusicPlaylistScreen (create/edit/
delete); extract the shared cover-picker/placeholder/progress-banner into
MusicComposerUploadUi so the track composer reuses them; FAB now navigates to
the route; add edit icon on owned playlist cards; new string resources.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013oofoSH7eMXrs2TCU4uncS
Attach the cached PNG's content URI as ClipData on the ACTION_SEND intent
so the Android share sheet itself gets read access and renders the image
preview at the top, instead of a generic file icon.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JM7z36GaqCm612rjPnX2G9
The Receive dialog polls the mint every 3s to see if the bolt11 has
been paid. Each poll flipped the flow state AwaitingPayment ->
Completing -> AwaitingPayment, and since the dialog renders a totally
different body for Completing (an "issuing proofs" spinner, no invoice,
no buttons), the invoice view was replaced by a spinner and then
recreated every 3 seconds — a constant flicker.
Add a `checking` flag to AwaitingPayment instead. The routine poll now
stays in AwaitingPayment and only toggles that flag, so the invoice (and
the Discard button) remain on screen and the dialog just swaps its
status line between "Waiting for the invoice to be paid…" and "Checking
the mint…". The full Completing body is shown only once payment is
actually confirmed and proofs are being issued.
The compareAndSet gate that prevents two concurrent polls from both
reaching completeMintFromLightning is preserved (now gating on
checking=false -> checking=true), with an extra early-out when a check
is already in flight.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjNqJZFxQinvuR4sGwpWQR
Adds a second "Share as Image" entry to a note's 3-dot menu that renders
the same framed card, captures it to a PNG and hands the local file
straight to the Android share sheet — no preview, no upload. The existing
upload-and-share-URL flow is renamed to "Share as Image Url".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JM7z36GaqCm612rjPnX2G9
Make BUILDING.md everything a fork needs to build and release, and add a
maintainer-facing ship checklist.
- BUILDING.md: full CI secrets inventory (Android keystore vs GPG/Maven, with
generation commands), a distribution-channels table (GitHub, Maven, Play,
F-Droid pull, Zapstore, Homebrew/Winget), and a "generated & vendored
artifacts" section (Material Symbols subset, Arti native libs).
- RELEASE_OPS.md (new, repo root): Amethyst-specific shipping — pre-tag
checklist, per-channel rollout (manual Play AAB, Zapstore `zsp publish` with
our nsec, F-Droid build-from-source), the push-notification server
(amethyst-push-notif-server), secret ownership, and verification.
- README.md: slim the Deploying section to point at both docs, fix the stale
quartz dependency version, and add a Maven Central badge plus JitPack
snapshot docs.
- android-expert skill: drop the duplicated build-config block, delegate to
gradle-expert, keep only the Android-specific flavor note.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump the app version to 1.12.1 (versionCode 449) in the catalog, which now
drives every module. Add the v1.12.01 changelog (Health Connect workouts,
share-as-image, immersive system bars, deterministic Tor Active fix) and index
it. Refresh the quartz-integration skill's artifact-version references.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make every module read its release version from gradle/libs.versions.toml
instead of carrying its own literal, so a release bump is a single-file edit.
- Move the Android versionCode out of amethyst/build.gradle.kts into the
catalog as `appCode`; amethyst reads it via libs.versions.appCode.get().
- quartz publishes with version = libs.versions.app.get().
- geode generates a BuildConfig.VERSION from the catalog (new
generateVersionFile task) and RelayInfo.VERSION reads it, so the NIP-11
software version tracks releases.
- Update the root build comment to point at the appCode entry.
Version-neutral: everything still resolves to the current 1.12.0 / 448.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
LocaleData.getMeasurementSystem requires API level 28 but the module's
minSdk is 26, which lint (NewApi) flagged as an error. Guard the ICU call
behind a Build.VERSION.SDK_INT check and fall back to a country-code based
heuristic on API 26-27.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDcx7i1VDRyT7rDcuSB5Co
Replace the plain-text note with TranslatableRichTextViewer (inside
SensitivityWarning), the same renderer kind-1 notes use — so workout notes get
links, mentions, hashtags, embeds, image/URL previews and inline translations.
WorkoutDisplay now receives backgroundColor, canPreview, quotesLeft,
accountViewModel and nav; both call sites (NoteCompose, ThreadFeedView) pass
them through.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgqQKHSewRHVM8vSCLt9P
- Hoist the constant systemBarsBehavior assignment out of the snapshotFlow collect loop
- Track the top in-app bar's collapse fraction (falling back to the bottom bar on screens with no top bar) instead of max(top, bottom)
Install a DisposableEffect in ZoomableImageDialog that hides both status
and navigation bars via WindowInsetsControllerCompat when the full-screen
media dialog opens, and restores them on dispose.
Add a Progress Log table to the plan summarizing what landed in this
worktree, with commit refs and a clear pointer to the next critical-path
work (Phase 1.4 App() smoke test, then Phase 2 relay seam, then Phase 3
benchmark harness, then Phase 4 baseline, then Phase 5.2 bootstrap-gate
fix). Phase 5.1 ships but its end-to-end delta is still pending the
benchmark harness.
Phase 5.1 of the launch-optimization plan: the cold-boot critical path
loaded /icon.png up to four separate times (taskbar setup, Window icon,
Tor splash, account-loading splash). Two of those sites also paid an
ImageIO.read to obtain a BufferedImage, and the Window-icon site
additionally round-tripped the image back through ImageIO.write so Skia
could re-decode it.
IconResources holds one lazy each for the bytes, the decoded
BufferedImage, the platform-adapted BufferedImage (squircle on macOS),
and the two BitmapPainters (raw + adapted). All four call sites in
Main.kt now consume the cached values directly — no remember, no
re-decode.
IconResourcesTest pins the memoization invariants (same instance on
repeated access). All 271 desktopApp tests pass.
End-to-end delta vs the baseline will be measured once Phase 3
benchmarks land; the worst-case savings on cold boot are two
ImageIO.read calls plus three resource reads plus one ImageIO.write,
all on the main thread.
Phase 1 of the desktop launch-optimization plan: pin the behavior of
the cold-boot critical path before any launch refactor lands.
* Plan document committed to desktopApp/plans/.
* AccountManager: ViewOnly load + decode-failure state transitions are
pinned by AccountManagerLoadStateTransitionsTest (2 tests).
* LocalRelayStore: gains a homeDir constructor parameter so tests can
point the SQLite event store at a temp directory; production callers
unchanged via default argument.
* LocalRelayStoreHydrationTest pins hydrate's contract:
- empty DB is a no-op,
- kind:3 contact list is consumed before kind:0 metadata,
- kind:1 within the 7-day window is hydrated,
- kind:1 older than 7 days is excluded.
All 266 desktopApp tests pass. No production behavior change.
- Render the kind 1301 content (the user's notes) below the stats grid in
WorkoutDisplay; previously it was published but never shown.
- Convert distance, pace, speed, elevation, and weight to the viewer's preferred
measurement system (phonePrefersMiles) via WorkoutInfo.inUnits, so a workout
stored in km shows in miles for an imperial viewer (and vice versa).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgqQKHSewRHVM8vSCLt9P
Read the phone's measurement preference (Android 14+ Regional preferences
override via the locale's -u-ms- extension, else ICU's locale-derived
US/UK = miles) and default the New Workout composer's km/mi selector to it.
Health Connect distances (always metres) are converted into the chosen unit on
pre-fill instead of being forced to km.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgqQKHSewRHVM8vSCLt9P
Instead of a generic "health_connect" source, capture the app/device that wrote
the record (the Health Connect data origin) and use it as the workout source —
so the card shows e.g. "SAMSUNG HEALTH" / "GOOGLE FIT" instead of "HEALTH
CONNECT".
resolveSourceName resolves the data-origin package to the installed app's
label, falling back to a known-package map (Samsung Health, Google Fit, Fitbit,
Garmin, Strava, Nike Run Club), then the raw package, then "Health Connect".
DetectedWorkout carries this as `source` and it flows into the published kind
1301 source tag.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgqQKHSewRHVM8vSCLt9P
Per request, the Workouts feed no longer has any Health Connect code. Both the
permission prompt and the detected-workout list now live in the New Workout
carousel: it shows a Connect card when permission is missing and the workout
list once granted.
- Remove WorkoutConnectBanner and the feed wiring; revert the FeedLoaded header
slot that fed it.
- Restore the Connect prompt in DetectedWorkoutCarousel (tri-state permission:
unknown renders nothing, denied shows Connect, granted shows the list).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgqQKHSewRHVM8vSCLt9P
Refine the previous change: the Workouts feed shows a connect-only Health
Connect banner (a new WorkoutConnectBanner) so permission can be granted there,
but never lists detected workouts. The detected-workout list is shown solely in
the New Workout composer's carousel, which reverts to list-only (no connect
card).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgqQKHSewRHVM8vSCLt9P
Per request, Health Connect suggestions no longer appear on the Workouts feed.
The integration now lives solely in the New Workout composer's carousel.
- Remove the feed banner wiring from WorkoutsScreen and revert the FeedLoaded
header slot that fed it.
- Delete the banner (WorkoutSuggestionCard), its state holder
(WorkoutSuggestionState), and the handled-id store (HealthConnectStore);
the carousel shows the full window and needs no dedup. LOOKBACK_DAYS moves to
HealthConnectManager.
- Fold the Connect prompt into the carousel so Health Connect permission can
still be granted (the banner was the previous entry point) — it shows a
Connect card when permission is missing, the carousel once granted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgqQKHSewRHVM8vSCLt9P
Give the workout composer more energy and a clearer hierarchy:
- Hero header: a large activity badge (primaryContainer circle) plus the
activity name, both crossfading as the type changes — the form's focal point.
- Activity selector chips now carry their per-activity icon.
- Distance unit switched from two FilterChips to a Material3 segmented button.
- Leading icons on the inputs (title, distance, calories, notes) and an
icon + label section header for Duration, for faster visual scanning.
- Roomier 16dp spacing and padding.
Pure presentation; the ViewModel and published event are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgqQKHSewRHVM8vSCLt9P
The Workouts feed banner now only surfaces workouts that started today; older
workouts from the past week remain reachable from the New Workout composer's
carousel. Keeps the feed focused on what just happened while still letting
people post earlier workouts on demand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgqQKHSewRHVM8vSCLt9P
Show a horizontal list of workouts found in Health Connect over the last 7 days
at the top of the New Workout screen; tapping one pre-loads the form (activity,
duration, distance, calories, heart rate, steps, elevation, start time).
Unlike the feed banner, the carousel shows every workout in the window (not
filtered by what was already shared/dismissed) and re-reads on resume. The
DetectedWorkout→Route mapping and duration/relative-time formatting are
extracted to a shared file so the banner and carousel stay in sync, and the
ViewModel gains applyPrefill() to overwrite fields on tap (vs the once-only
prefill used for the nav argument).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgqQKHSewRHVM8vSCLt9P
The workout FAB used the directions_run glyph, which is visually off-center
inside the circular button and looked misaligned next to the app's other
"create" FABs. Use MaterialSymbols.Add (the + the other new-item FABs use) so it
matches; the button's size/shape/color were already identical.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgqQKHSewRHVM8vSCLt9P
HealthConnectManager only logged on failure, so a successful-but-empty scan was
invisible. Add info logs (tag HealthConnectManager) for the permission check
result, the number of exercise sessions read in the window, and per session the
activity type with its mapping (or the skip reason + data origin). Makes it
possible to see why a watch workout does or doesn't surface as a suggestion.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgqQKHSewRHVM8vSCLt9P
hasPermission started false while the async permission check ran, so the
"Share your workouts" connect card flashed for ~1s on every open before the
check confirmed permission was already granted. Make permission a tri-state
(null = not checked yet) and render nothing until it resolves, so the connect
prompt only appears once we actually know permission is missing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgqQKHSewRHVM8vSCLt9P
The suggestion only scanned once when the Workouts screen first opened, so a
workout that synced afterwards (e.g. a watch workout reaching Health Connect
while Amethyst was backgrounded) never appeared until a cold restart. Replace
the one-shot LaunchedEffect with a LifecycleResumeEffect so we scan on entry and
again every time the app returns to the foreground.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgqQKHSewRHVM8vSCLt9P
HealthConnectStore opened SharedPreferences in its constructor, which ran during
composition on the main thread and tripped a StrictMode DiskReadViolation
(~265ms). Make the prefs lazy so construction touches no disk, and move the
read (refresh) and write (handle) onto Dispatchers.IO via the state holder's
scope.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgqQKHSewRHVM8vSCLt9P
Move the Health Connect suggestion banner from a pinned position above the feed
into the feed's LazyColumn as the first item, so it scrolls away with the list.
Adds an optional `header` slot to the shared FeedLoaded (default none, so other
feeds are unaffected) and passes the banner through WorkoutsScreen's onLoaded.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgqQKHSewRHVM8vSCLt9P
The Connect button did nothing on Android 14+ because the permission request
fails silently without a ViewPermissionUsageActivity declaration. We only had
the pre-14 ACTION_SHOW_PERMISSIONS_RATIONALE intent-filter; add the required
activity-alias handling VIEW_PERMISSION_USAGE + HEALTH_PERMISSIONS, guarded by
START_VIEW_PERMISSION_USAGE, routed into MainActivity, so Health Connect shows
the permission dialog.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgqQKHSewRHVM8vSCLt9P
Reviewed RUNSTR's healthConnectService.ts and workoutPublishingService.ts and
matched two details so our imported kind 1301 events line up with theirs:
- Calories: RUNSTR reads ActiveCaloriesBurned; we were using TotalCaloriesBurned
(active + basal), which over-reports. Now prefer active calories and fall back
to total only when a source records no active energy. Adds the
READ_ACTIVE_CALORIES_BURNED permission.
- Title: RUNSTR always emits a title, generated from the activity when none
exists. Default the pre-filled title to the activity name so every shared
event carries one.
The 1301 wire format already matched (distance [value,unit], HH:MM:SS duration,
lowercase exercise, plain-text content). We remain a richer superset — we also
publish avg/max heart rate and elevation_gain, which RUNSTR reads but does not
publish — and RUNSTR's lax parser ingests these without issue. Parity notes
recorded in the plan doc.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgqQKHSewRHVM8vSCLt9P
- Re-layout the screen as a centered "hero" preview on a subtle gradient
backdrop, with a soft drop shadow, instead of a top-aligned full-width
bitmap above a settings row.
- Move the primary action into a bottom bar: a clearer server picker plus
a full-width "Share" button with an icon. Top bar is now a plain back bar.
- Brand the captured card with a divider + Amethyst logo watermark and
roomier padding.
- Capture in two passes (quick first preview, then a refined snapshot after
a short settle delay) so async media has time to load into the image
instead of showing empty placeholders.
- Show real upload progress (stage + percentage) via the shared
UploadProgressIndicator instead of a bare spinner.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5ipS9wu6ffCPk5rtB8k3x
Reworks the suggestion UI from a plain overlay into a designed, in-scroll card:
- Inline placement: the banner now sits above the feed (pushing it down) inside
a Column rather than overlaying and covering the first feed items. The feed's
scaffold top padding is applied to the Column and stripped from the feed to
avoid doubling; the bottom padding (and the disappearing-bar animation) are
preserved.
- Per-activity icon (run/ride/swim/hike/strength/yoga…) in a circular tinted
badge, reusing the existing ExerciseType.symbol() mapping — no new glyphs.
- Metric chips (duration, distance, heart rate, calories, steps) in place of the
single truncated summary line.
- Title shows the workout name or activity, subtitle shows a relative time;
filled-tonal Share + text Dismiss actions; rounded 16dp cards.
- animateContentSize so dismissing a suggestion collapses smoothly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgqQKHSewRHVM8vSCLt9P
Adds a "Suggest workouts to share" toggle under Compose Settings (defaults on,
preserving current behavior) wired through UiSettings / UiSettingsFlow /
UiSharedPreferences. When set to NEVER, the Workouts screen no longer scans
Health Connect or shows the connect/suggestion banner.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgqQKHSewRHVM8vSCLt9P
The macOS icon.icns shipped inconsistent artwork across its embedded
sizes — a transparent full-bleed glyph at 256/512 (shown on the DMG mount
window and Spotlight) but a white-carded glyph at 128 (shown in the Dock).
It was also missing every @2x Retina tier and its 16/32/48 entries decoded
to corrupt noise, a signature of a generic PNG->ICNS converter rather than
iconutil. Regenerate from a single transparent glyph master into a proper
iconset (all standard sizes + @2x) compositing one consistent rounded-card
look at every size, then assemble with iconutil.
The Windows icon.ico held a single 32x32 BMP, so Windows upscaled a blurry
32px everywhere it needed a larger icon. Rebuild as a multi-size .ico
(16/32/48/64/128/256, PNG-encoded) from the same glyph, full-bleed and
transparent per Windows convention (matches the Linux icon.png).
Linux icon.png is unchanged — a single transparent PNG never had the
inconsistency and Linux desktops expect transparent icons.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds foreground auto-detection of finished workouts on Android via Google
Health Connect — the single aggregator every Android health source funnels
into (Samsung Health/Galaxy Watch, Google Fit, Fitbit, Garmin, Strava), the
same Android path RUNSTR uses. On opening the Workouts screen, Amethyst scans
Health Connect for sessions the user hasn't handled and surfaces a banner that
opens the existing workout composer pre-filled, ready to publish as a NIP-101e
WorkoutRecordEvent (kind 1301).
- HealthConnectManager reads ExerciseSessionRecord + aggregated distance,
calories, heart rate, steps and elevation, mapping each to DetectedWorkout.
- ExerciseTypeMapper maps Health Connect activity types to NIP-101e verbs.
- HealthConnectStore remembers handled sessions per account so each is
offered once; 7-day foreground lookback, no background service.
- WorkoutSuggestions banner: connect prompt (on-demand permission request,
never on cold start) or detected-workout rows on the Workouts screen.
- Route.NewWorkout carries optional pre-fill; NewWorkoutViewModel publishes the
richer metrics (heart rate, steps, elevation, start time) with
source=health_connect (new SourceTag constant in quartz).
- androidx.health.connect:connect-client (Apache-2.0) + read-only health
permissions and the required privacy-rationale manifest entries.
Design notes in amethyst/plans/2026-06-16-health-connect-workout-detection.md;
background ~15-min polling + notification left as a documented future seam.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgqQKHSewRHVM8vSCLt9P
composemediaplayer 0.10.0 publishes kotlinx-coroutines-test as a runtime
dependency in its POM. That jar ships a META-INF/services registration for
kotlinx.coroutines.CoroutineExceptionHandler -> ExceptionCollectorAsService.
The release-only ProGuard pass strips the unreferenced provider class but
keeps the services manifest, so the packaged dmg crashed at startup with a
ServiceConfigurationError the first time the coroutine exception handler
loaded (DesktopHttpClient.<init>). Dev runs were unaffected since they don't
run ProGuard.
Exclude the test-only artifact so it never lands on the production classpath.
Verified: rebuilt release dmg no longer bundles the jar and launches clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Convert the share-as-image flow from a dialog into a proper navigation
screen (Route.ShareNoteAsImage), reached from the note 3-dot menu. The
note is rendered off-screen into a GraphicsLayer, captured to a bitmap,
and the captured bitmap is shown as the preview via an Image composable —
so the preview is exactly what gets uploaded and shared.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5ipS9wu6ffCPk5rtB8k3x
Add a "Share as Image" action to the note 3-dot menu. It renders the
post into a framed card, captures it to a bitmap via a GraphicsLayer,
uploads the PNG to one of the user's Blossom media servers, and hands
the resulting URL to the Android share sheet.
The on-screen preview is the capture source, so what the user sees is
exactly what gets shared. A server spinner lets the user pick which
Blossom server to upload to, defaulting to their configured default.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5ipS9wu6ffCPk5rtB8k3x
TorService.start() drove the Connecting→Active transition off Arti's
"Sufficiently bootstrapped" log line, gated on proxyRunning.get(). That line
is emitted from a tokio task spawned inside the native startSocksProxy
(arti-android-wrapper/lib.rs), which races startSocksProxy returning and
start() setting proxyRunning = true. When the task wins, the guard is false,
the transition is dropped, status stays Connecting, and the 60s
connection-failure screen fires even though the SOCKS proxy is up and
bootstrapped.
Set _status.value = Active(socksPort) directly in start() right after the
proxy binds and proxyRunning is set — under the lifecycleMutex start()
already holds, so a concurrent reset/stop can't clobber it. Reduce the log
callback to plain log forwarding now that it no longer drives status.
Verified on emulator-5554: Active now logged from start() before the
"Sufficiently bootstrapped" callback, zero ECONNREFUSED on the 9050 fallback
port, relays connecting through Tor.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Expand the Tor entry into the full reliability cluster (warm-cache speedup,
Active-but-dead watchtower, wedged-guard recovery, 60s bootstrap timeout, dial
gating, lifecycle serialization) and add the other user-facing changes from
recent commits that weren't captured yet: pre-loaded reply threads, richer
workout cards, relay recovery after device sleep, and not resetting relay
backoff on momentary connections.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 60s bootstrap timeout this branch adds is invisible to every JVM test —
it only manifests against a real radio. Add a device-driven harness that drives
the network transitions that have historically wedged Tor and asserts the
lifecycle invariants from logcat, so the behavior can be re-verified whenever
Arti is bumped or the Tor management path changes.
tools/tor-network-tests/run.sh — adb-driven runner, one function per scenario,
PASS/FAIL per check, restores a clean network state on exit:
- cold_start Active reached + no pre-ready dial storm (the #3223 gate)
- offline_bootstrap empty cache + airplane: asserts the bootstrap is bounded
('bootstrap timed out' logged), then recovers on restore.
This is the regression test for this PR — it FAILS on a
build without the timeout (create_bootstrapped blocks 95s+).
- wifi_cellular WiFi->Cellular handover recovers to Active
- airplane offline pauses relays cleanly; restore recovers
- pause_resume backgrounding winds relays down (~30s); resume reconnects
README.md documents each scenario's rationale, device setup, when to re-run
(new Arti version, TorService/TorManager/dial-gate changes), and how the suite
relates to TorManagerTest / TorCircuitHealthTrackerTest / the instrumented test.
Verified on a Pixel emulator (WiFi + Cellular): all scenarios pass on this
branch; offline_bootstrap fails on main (no timeout), confirming the suite
discriminates fixed from broken.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On-device testing caught a false positive: the original 8-failures-in-a-30s-
window detector fired ~2s after Tor flipped Active. The instant Tor goes Active
the pool dials the whole Tor-routed relay set at once, and on freshly built
circuits a burst of >=8 can fail before the first relay completes its handshake
— so the detector wiped the good, just-bootstrapped client.
Replace the count-window with a sustained-streak model: fire only when an
unbroken run of Tor-routed failures both reaches FAIL_THRESHOLD and spans at
least SUSTAINED_MS (30s), with zero successes interrupting it (any success ends
the streak). A gap longer than SUSTAINED_MS also restarts the streak. The span
floor doubles as a post-Active warmup grace, so the warmup burst — and the
identical reconnection burst on app resume — no longer trips it.
Verified on device across cold start, WiFi<->Cellular handover, airplane
on/off, and app pause/resume: 0 false self-heals.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TorManager's watchdogs (selfHealSignal, connectionFailure) only arm while
status is Connecting. Once Arti logs "Sufficiently bootstrapped" off cached
consensus, status flips to Active and every watchdog disarms — even if no exit
circuit actually works (the ExitTimeout/RESOLVEFAILED barrage seen on device).
The SOCKS proxy is up, so nothing in the lifecycle looks wrong, and Tor can sit
Active-but-useless indefinitely with no recovery.
The relay layer is the only place with both halves of the signal: per-relay
success (onConnected) and failure (onCannotConnect), plus the Tor-routing of
each url. Arti emits no per-stream success log, so a Tor-internal trigger could
only count failures — and couldn't tell "all dead" from "some dead", resetting
Tor every time a few dead relays are dialed.
Add TorCircuitHealthTracker, a RelayConnectionListener that fires only when,
while Tor is Active and connectivity is up, there are >= FAIL_THRESHOLD (8)
Tor-routed failures within WINDOW_MS (30s) AND zero Tor-routed successes in that
window. The zero-success clause is the whole discriminator: one successful Tor
open means circuits work, so it suppresses. It pokes TorManager.onTorCircuitsDead(),
which mirrors the post-Active stuck-Connecting recovery — resetWithCleanState()
(wipe the suspect guard/circuit state behind a healthy-looking guards.json) plus
a resetEpoch bump to force a full re-init — and shares lastSelfHealAtMs /
SELF_HEAL_COOLDOWN_MS with the Connecting watchdog so the two can't thrash. If
circuits are still dead after the reset, the cooldown suppresses further resets
and the 60s connectionFailure dialog still offers the user the bypass.
Tests: TorManagerTest covers onTorCircuitsDead (fires + wipes + re-inits when
Active; no-op while not-Active / bypassing; shares the cooldown).
TorCircuitHealthTrackerTest covers the discriminator (threshold, single-success
disarm, clearnet ignored, connectivity/Active gating, window aging, re-arm).
Verified on device: no crash, tracker stays silent during normal operation
(168 relays opened, 0 self-heal fires).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TorService.start() called clearArtiCache() on every cold start (and every
reset, since reset flips initialized back to false), deleting the
consensus/microdescriptor cache and forcing a full re-download on the next
bootstrap. Measured on device, that turned a ~7.7s warm bootstrap into ~24.2s
— a ~3.2x slowdown paid on every launch.
The wipe was an early attempt at what we later understood to be the wedged-
guard problem (now handled by noUsableGuards()/clearAllArtiData()). It never
actually helped: guards live in state/, not cache/, so wiping the cache can't
fix a stale guard sample; and Arti already validates consensus freshness and
refetches whatever has expired, so there is no stale-consensus risk to guard
against here. The reset/clean-state self-heal paths still call
clearAllArtiData() for genuine corruption recovery.
Drop clearArtiCache() entirely and preserve the cache for warm bootstraps.
Also add bootstrap-timing instrumentation: the "SOCKS proxy active" log now
reports elapsed bootstrap ms, and a cache-size log line correlates cache
state with bootstrap time.
Verified on device: warm bootstrap 7,670ms vs 24,200ms cold, with no
regression — a .onion relay and 200 clearnet relays connected over Tor, 0
pre-ready doomed dials, guards healthy (59/60 usable).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A self-heal reset()/resetWithCleanState() (and the lifecycle serialization added
in the previous commit) can only recover Tor if initialize() actually returns.
ArtiNative.initialize() calls TorClient::create_bootstrapped, which on a hostile
network (unreachable guards, wiped consensus) retries internally for many
minutes. While it blocks it holds lifecycleMutex, so the watchdog's reset can
never run — Tor stays wedged at Connecting.
Wrap create_bootstrapped in a 60s tokio::time::timeout. On timeout the future is
dropped (tearing down the half-built client) and initialize() returns -4; the
JNI ABI is unchanged (still one String arg), so the checked-in CI host .so and
TorArtiNativeIntegrationTest keep working without a rebuild. TorService treats
-4 specially: drop the init flag and leave status Connecting (don't wipe+retry
inline under the lock, don't go Off) so TorManager's self-heal watchdog resets
and re-inits on its own cadence, and connectionFailure can still surface the
"use regular connection" dialog.
Rebuilt libarti_android.so for arm64-v8a + x86_64.
Verified on device: a no-network cold-start bootstrap timed out at exactly 60s
(previously hung 7+ min), released the lock, and on network restore the watchdog
re-init'd and Tor reached Active. Addresses #3225.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ArtiNative is a process-global singleton over a single native Tor client, and
initialize/destroy are blocking JNI calls that ignore coroutine cancellation.
TorService guarded it with only two AtomicBooleans and no mutual exclusion
across start/stop/reset/resetWithCleanState. A self-heal reset() (stuck-
Connecting watchdog or onNetworkChange) could therefore call destroy() while a
start() was mid-initialize(): the freshly bootstrapped client was destroyed
~0.5s after coming up, leaving the SOCKS listener bound with no live client
behind it. Every Tor dial then timed out at the exit (ExitTimeout barrage) and
status was left at a stale Active.
Serialize all four native lifecycle transitions behind a single lifecycleMutex
so destroy() can never overlap initialize() — a reset now waits for an
in-flight bootstrap to finish before tearing it down cleanly. reset() and
resetWithCleanState() share a private resetLocked() helper (Mutex is not
reentrant). Also gate the "Sufficiently bootstrapped" -> Active callback on
proxyRunning so a late callback from a torn-down client can't resurrect a stale
Active status.
Verified on device: forced reset+start now fully serialize (destroy completes
before create begins), and a clean start bootstraps to Active in one cycle with
no churn.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
recordIncoming_after_close_does_not_schedule_persist called advanceUntilIdle()
while RelayHealthStore's init ticker (`while(true){ reclassify(); delay(60s) }`)
was still live on the shared StandardTestDispatcher scheduler. advanceUntilIdle()
chases that periodic delay forever, so the test spun at 100% CPU and never
returned — wedging :commons:jvmTest at "373 tests completed" and hanging the
pre-push hook (and leaving orphaned, CPU-pegging Gradle test workers behind).
Advance just past PERSIST_DEBOUNCE_MS and runCurrent() instead, so init's
debounced save fires for the baseline while the 60s ticker stays parked. The
post-close advanceUntilIdle() calls are fine — close() cancels the ticker.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pass the reply's id straight to the thread subscription instead of resolving
the root in composition. ThreadFilterSubAssembler already runs findRoot in
updateFilter, so the extra compose-side findRoot was duplicate work; gate on
replyTo so only replies (not roots or quotes) pre-load.
Before Tor finishes bootstrapping, the relay pool dialed every Tor-routed
relay against the not-yet-listening SOCKS proxy. On a cold start this was
~580 doomed dials (all "SOCKS: Connection refused") concentrated in the
seconds before Tor went Active, churning sockets/CPU and inflating each
relay's backoff. The cost scaled with bootstrap latency, and the same
storm recurred on every network switch (which resets and re-bootstraps Arti).
Add an optional WebsocketBuilder.canConnect(url) gate (defaults to true,
so other implementors are untouched), checked at the top of
BasicRelayClient.connect() before the mutex/onConnecting/build — so a
gated relay opens no socket, fires no listener events, and grows no
backoff. The Android builder gates Tor-routed relays on
torManager.isSocksReady(); RelayProxyClientConnector already reconnects
them with ignoreRetryDelays=true the instant Tor flips to Active, so they
dial as soon as the transport is usable.
Measured on-device: pre-ready doomed Tor dials 581 -> 0 across cold starts
and WiFi<->Mobile switches; clearnet connections stay untouched and Tor
relays self-heal once Tor is Active.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Vitor's review of #3221: wake-detection is platform-specific UX, not
NostrClient's job. Quartz already exposes `reconnect(onlyIfChanged = false,
ignoreRetryDelays = true)` which does the full disconnect + connect — the
app layer just needs to call it when it detects a wake.
- Revert the keep-alive heuristic in NostrClient.kt; the loop is back to the
conservative `reconnectIfNeedsTo` path it had before.
- Add `runSleepResumeMonitor` (desktopApp/network/SleepResumeMonitor.kt): a
60s tick that watches for wall-clock overshoot and calls the supplied
`onWake` lambda. No native deps.
- Wire it in `Main.kt` next to the metrics LaunchedEffect: on >5x overshoot
call `relayManager.client.reconnect(onlyIfChanged = false,
ignoreRetryDelays = true)`.
Real OS sleep events (NSWorkspace on macOS, D-Bus PrepareForSleep on Linux,
WM_POWERBROADCAST on Windows) can be layered in later as platform improvements
without touching Quartz again.
When a reply is visible in NoteCompose, eagerly subscribe to its thread
root (filter on the root's e/a tag, like the thread screen) so opening the
conversation finds it already loaded. The observer resolves the root and
keys on its id, so sibling replies share one subscription and root notes
are skipped.
Make the workout note render with more visual punch, modeled on RUNSTR's
workout cards:
- Promote the headline metric (distance for cardio, steps when there is no
distance, otherwise duration) to a large hero number, skipping it in the
grid below so it is not repeated.
- Lay out secondary metrics in a fixed 3-column grid instead of a free-flowing
row so values line up in tidy columns.
- Give the activity icon a tinted circular chip for more prominence.
Surface more of the parsed kind-1301 data in WorkoutDisplay, modeled on how
RUNSTR renders workout records:
- Source badge (GPS / RUNSTR / HEALTHKIT / MANUAL) in the header
- Average speed (km/h or mph) for cycling instead of pace
- Elevation loss alongside elevation gain
- Max heart rate alongside average heart rate
Relabels 'Elevation' to 'Elevation gain' now that loss is shown.
Switch the Workouts screen from the custom WorkoutCardCompose card to the
standard NoteCompose feed via the default FeedLoaded renderer. NoteCompose
already dispatches WorkoutRecordEvent to WorkoutDisplay, so workouts render
with the full note chrome (author header, reactions, replies, etc.).
Removes the now-unused WorkoutFeedLoaded and WorkoutCardCompose.
Follow-up to #3186, addressing the unresolved review feedback:
- RelayHealthStore.schedulePersist() wrapped the blocking save() in withContext(ioDispatcher)
so prefs.flush() no longer sits on the Compose composition thread on Desktop.
- close() now fires the final save on a detached IO-bound scope instead of blocking
the composition thread for ~50ms during account switch / app exit.
- @Volatile on persistJob/tickJob and a closed-flag guard so the relay-network thread
and composition thread no longer race on plain vars (and post-close work is dropped).
- desktopApp/Main.kt passes Dispatchers.IO to RelayHealthStore so persistence flushes
land on the IO dispatcher instead of Dispatchers.Default.
Plus a separate-but-related fix to the offline-banner-stuck-after-Mac-sleep issue:
NostrClient.keepAliveJob now tracks wall-clock overshoot of its scheduled tick.
If the OS suspended us (laptop lid closed, system sleep), delay() returns far
past its deadline and the OkHttp websockets we held are dead even though
BasicRelayClient.isConnected() still reads true until the next ping fails.
On a >5x interval overshoot, force relayPool.disconnect() + connect() instead
of trusting needsToReconnect(), so feeds resume without an app restart.
A relay that accepts the WebSocket handshake and then immediately resets
the connection (e.g. essayist.decentnewsroom.com) defeated the exponential
reconnect backoff.
UrlParser.parseValidUrls filtered every detected URL through
isValidTopLevelDomain(), which requires the TLD's first character to be
an ASCII letter. IPv6 literal hosts are bracketed (e.g. [2001:db8::1])
and have no dotted TLD, so the whole bracketed host became the candidate
"TLD", starting with '[' and failing the check. As a result, valid
IPv6 URLs like http://[302:68d0:f0d5:b88d::bdb]/<hash> were dropped and
rendered as plain text instead of links.
The UrlDetector already validates the bracketed address as syntactically
correct IPv6, so accept bracketed hosts directly in isValidTopLevelDomain.
The "Updates all dependencies" commit (ece719445) bumped Kotlin to 2.4.0,
which removed the `DisableCacheInKotlinVersion.2_3_21` enum value. Rather
than bumping the guard, the whole `disableUiKitPrebuiltCache()` workaround
was deleted — which reintroduced the iOS test link failure on CI
Drop three lower-impact items from the Highlights list (still documented
in the body): audio visualizer (author notes minimal visibility), LaTeX
math (niche), and pinned DMs (minor QoL). Keeps the headline payment,
privacy, and new-feed features front and center.
https://claude.ai/code/session_01Y8cDkDRV48GYdQd3CR4kuV
Reviewed all merged PRs since the v1.11.0 release. Fixed the misleading
'new money operations' Tor line (the toggle already existed; what changed
is relay sockets now honor it). Added gaps the PR titles surfaced: NIP-14
group DM subjects (#3163), removal of wallet reordering drag-and-drop
(#3114), and diagnosable relay failure logs (#3197).
https://claude.ai/code/session_01Y8cDkDRV48GYdQd3CR4kuV
A file-level audit (not just commit subjects) surfaced major features hidden
under misleading commit messages: the new NIP-60 Cashu ecash wallet, music
tracks/playlists, the NIP-82 Software Apps directory, the live audio
visualizer, and multi-wallet management (NWC/CLINK-debit/Cashu).
https://claude.ai/code/session_01Y8cDkDRV48GYdQd3CR4kuV
Adds Hidden Words unblock, Share to DM, reply-to-zaps from notifications,
the Selected/Global notification split, the podcasts subsystem, desktop
unhealthy-relay review, the live zap-popup rails, and the commons KMP
migration scope.
https://claude.ai/code/session_01Y8cDkDRV48GYdQd3CR4kuV
Summarizes the 506 commits / 131 PRs merged since the v1.11.0 release:
CLINK payments, private posts/reactions via NIP-17, NIP-101e workouts,
LaTeX math rendering, NIP-89 app recommendations, pinned DMs, activity
cards, desktop image compression + new media player, and the Quartz
relay-server toolkit. User-facing sections are written for end users;
build/quartz/cli sections keep developer-level language.
https://claude.ai/code/session_01Y8cDkDRV48GYdQd3CR4kuV
OkHttp 5.4.0 expanded Interceptor.Chain from ~10 to ~40 members, breaking
the test's hand-rolled `object : Interceptor.Chain` fake. Replace it with a
java.lang.reflect.Proxy that implements only request()/proceed() and throws
for the rest, so it survives future interface growth without dozens of empty
stubs. A real OkHttpClient can't be used here because Platform init fails
outside Robolectric (android.util.Log.isLoggable is unavailable).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the Namecoin diagnostics card that Android renders below the
per-server test results to the desktop settings panel, so support
requests carry the same information on both platforms.
- last test timestamp + pass/fail tally
- host OS (name/version/arch) — desktop equivalent of Android's
Build.MANUFACTURER/MODEL row
- JVM name + version — desktop equivalent of Android's API level row
- distinct TLS versions observed during the test run
Also adds a 'Testing next server...' inline progress row matching the
Android section's behaviour when the test loop is mid-run.
Pure UI addition; no model, preference, or callback changes.
The single CHANGELOG.md had grown to ~7.9k lines. Move it to
docs/changelog/ with one file per release, named with zero-padded
minor/patch versions (e.g. v1.11.00.md) so plain file browsers sort
them correctly. Add a README.md index (newest first) and a TEMPLATE.md
for cutting new releases. The root CHANGELOG.md now points to the new
location.
The RelayStat.lastConnectAt / lastIncomingAt fields added in #3186 were
written on every connect and every incoming relay message (a TimeUtils.now()
call plus a volatile store on the Android hot path) but never read.
The notifications "Global" mode was split into a raw Global (every event
that p-tags the user) and a curated Selected mode. Existing users who had
selected the old curated-Global kept a persisted value that now
deserializes to the much-more-permissive raw Global, so they suddenly saw
everything.
Add a one-shot, per-account migration: on load, if the account hasn't been
stamped yet and its notification filter is Global, rewrite it to Selected
and stamp the account. saveToEncryptedStorage always stamps the account as
migrated, so brand-new accounts (and any deliberate raw-Global choice made
after the split) are never reverted.
Only the notifications key is touched; Global keeps its original meaning
for every other feed.
Reactions and zaps keep the post they target in Note.replyTo so the card
can embed it, but that edge bridges into a different conversation. The
anchorsItsOwnThread guard only covered clicking the like/zap itself, not
clicking a kind-1111 reply to one — so opening such a reply's thread had
searchRoot/loadUp climb reply -> like -> liked post -> ... -> root of the
liked post's thread, dropping the reply into the wrong conversation, and
replyLevel buried it several indents deep.
Treat reactions/zaps as thread boundaries everywhere reposts already are:
- ThreadAssembler.searchRoot stops at a reaction/zap node.
- ThreadAssembler.loadUp adds the node but does not climb its replyTo.
- ThreadLevelCalculator (replyLevel + replyLevelSignature) treats them as
level-0 roots.
The boundary predicate is promoted to a shared Event?.anchorsItsOwnThread().
The data layer (replyTo/replies/computeReplyTo) is unchanged — card
rendering and the notification relevance filter still rely on the link;
only thread traversal stops crossing it.
https://claude.ai/code/session_01LM3KTECMMAdNBHZfs1dANa
Chips render through a selectable Surface that enforces a 48dp minimum
touch target, inflating each chip's measured height above its visible 32dp
pill. That invisible padding dominated the gap between wrapped rows, so the
previous verticalArrangement bump was imperceptible. Disable the minimum
interactive size around the chips so they measure at their visible height
and the row spacing matches the 6dp horizontal spacing.
Each hidden word now displays an inline unblock button when not in
selection mode, mirroring the Blocked Users screen. Long-press to
multi-select still works for batch unblocking.
InputChip clips its avatar slot to a circle, which cut off the following
badge that BaseUserPicture draws slightly outside the picture's circle.
Pass the picture through the leadingIcon slot instead, which is not clipped.
Replace the plain solid-color buttons used for Notify: mentions with
Material3 InputChips that show each user's avatar and display name plus a
remove (X) trailing icon, matching the modern contact-chip pattern. The
add-user action now uses an AssistChip with a PersonAdd icon.
Since the Notifying composable is shared, this updates the look across all
new post screens (short notes, comments/replies, and polls).
Five previews covering the gradient cards: like (+ heart), custom emoji
reaction, lightning zap, cashu nutzap, and onchain zap — each rendered in
the light/dark ThemeComparisonColumn with a hand-built target note so the
embedded makeItShort post shows. RenderLnZap splits into a thin resolver
plus a stateless RenderLnZapCard so the preview can feed the decrypted
sender/amount/comment directly instead of waiting on async decryption.
https://claude.ai/code/session_01LM3KTECMMAdNBHZfs1dANa
Extends the onchain zap card's visual language to the other interaction
kinds. A shared ActivityCardFrame draws the rounded gradient wash, with
a circular kind badge, sender → recipient avatars, and a kind pill on
the first line; the makeItShort target post embeds between that line and
the amount, exactly as in the onchain card, followed by the comment.
- Lightning zaps: bitcoin orange, bolt badge, LIGHTNING pill, big sats
amount, decrypted sender avatar.
- Cashu nutzaps: bitcoin orange, cashu badge, CASHU pill.
- Reactions: tinted with the Liked heart rose, heart badge (or the
emoji itself for custom reactions), REACTION pill, no amount row.
- Onchain zaps keep their card; the embedded target post moves inside
it, between the header row and the amount.
The body TransferCard and the trailing reaction emoji are replaced by
the cards; TransferCard stays for its preview.
https://claude.ai/code/session_01LM3KTECMMAdNBHZfs1dANa
Captures the verified current behavior (author-keyed DeletionIndex, no
wrap→rumor cascade, accidental seal-id blocking) and the agreed design:
recipient special case in hasBeenDeleted, recipient field on HostStub,
and the reverse-lookup live cascade in LocalCache.
https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo
Reaction, lightning-zap, and nutzap cards used to render as a note frame
inside a note frame: two identical author headers with the action demoted
to a trailing emoji or a transfer row. They now read as activity cards:
- The action renders as a chip in the author header, next to the actor's
name: the reaction emoji (custom emoji included) or amount-to-recipient
(bolt/cashu icon + sats + recipient avatar). New ActivityActionChip,
wired into FirstUserInfoRow and the thread master header.
- The target post renders as a quoted, bordered, compact embed
(RenderActionTarget, formerly RenderZappedPost) — the only
note-looking frame in the card.
- The zap comment, when present, becomes the card's own text between
header and quote, like a quote-post. The body TransferCard and the
floating reaction emoji are gone.
Onchain zaps keep their gradient tx-status card (already visually
distinct) and only inherit the quoted target styling.
https://claude.ai/code/session_01LM3KTECMMAdNBHZfs1dANa
The Curated (Selected) notification mode gates events through
tagsAnEventByUser, which only recognized a reply by its parent note's
author in the local cache. That made replies to the user's reactions
disappear whenever the reaction event wasn't cached (nothing re-fetches
old kind 7s after a restart), and replies to the user's zaps never
matched at all because zap receipts are authored by the lightning
provider. Detect both from the kind 1111 event itself: reaction parents
via the NIP-22 root/reply author tags, zap parents via the k tag plus
the reply's explicit p tag on the user. Covered by
NotificationTagsAnEventByUserTest.
Also renders the embedded target note inside zap and reaction cards with
makeItShort, keeping the inner preview compact.
https://claude.ai/code/session_01LM3KTECMMAdNBHZfs1dANa
Opening the thread screen on a reaction, lightning zap, nutzap, or onchain
zap now shows that event as the root with only its reply subtree below it —
the post it targets stays visible through the card's embedded preview, but
the target's conversation no longer loads around it. Replies and comments
(kind 1 / 1111) keep the existing behavior: the full parent thread loads
and the screen lands on the clicked note. Covered by ThreadAssemblerTest.
The long-press reply shortcut on notification zap chips is removed: the
thread view's reply button is now the single reply entry point (and still
routes private zaps to the sender's DM room via routeReplyTo).
https://claude.ai/code/session_01LM3KTECMMAdNBHZfs1dANa
ScreenshotsStrip now renders each screenshot through ZoomableContentView,
so tapping one opens the standard fullscreen zoomable viewer with paging
across all of the app's screenshots. Tiles keep a fixed height and take
their width from the image's cached aspect ratio (portrait fallback
before first load).
https://claude.ai/code/session_01RJSke6d4dpSZgdtXAmGMmp
RenderLnZap, RenderNutzap, and RenderOnchainZap now show the post the zap
targets above the transfer card (via the zap note's replyTo, mirroring
RenderReaction), so the target is visible wherever a zap renders — the
thread master view, composer reply previews, and quoted embeds. Reactions
already embedded their target.
https://claude.ai/code/session_01LM3KTECMMAdNBHZfs1dANa
Clicking a zap, nutzap, or like chip in the notification galleries now
navigates to that event's own thread, where anyone can reply, boost, zap,
or share it — the sender's profile remains one tap away via their avatar
in the thread header. Boost chips keep navigating to the profile.
To make those threads render properly:
- The thread master view now dispatches ReactionEvent (kind 7) through
RenderReaction and NutzapEvent (kind 9321) through a new RenderNutzap
transfer card; NoteCompose gains the NutzapEvent branch as well.
- The master header shows the zap sender (from the embedded zap request)
instead of the lightning provider that signed the receipt, including
the avatar click target.
The private-zap reply-via-DM fallback moves from the chip long-press into
routeReplyTo, so every reply entry point — including the thread view's
reply button — routes private zaps to the sender's DM room when we hold
the decrypted sender, instead of a public composer that cannot tag them.
https://claude.ai/code/session_01LM3KTECMMAdNBHZfs1dANa
AppIcon used to render an empty bordered box when the icon url was
missing, still downloading, or failed to load. It now draws the app
name's first letter on a surfaceVariant background underneath the
image, so all three states show a meaningful placeholder.
https://claude.ai/code/session_01RJSke6d4dpSZgdtXAmGMmp
Feed cards now render the app's screenshots in a horizontal LazyRow at
200dp so the images are big enough to convey what the app is about.
The platform/license chips were removed from the card since they add
little at feed level (still shown on the detail screen).
https://claude.ai/code/session_01RJSke6d4dpSZgdtXAmGMmp
Replaces the standalone author row on the detail screen with a shared
AppAuthorLine ('by' + clickable profile picture + username) rendered
right under the app name, and adds the same line to the app feed cards.
https://claude.ai/code/session_01RJSke6d4dpSZgdtXAmGMmp
With WhileSubscribed, nothing held a strong reference to my own 31989
notes while no UI was collecting; under memory pressure the soft cache
could drop them, and the next recommendApp would rebuild that kind's
event from an empty snapshot, wiping previously recommended apps.
Eagerly keeps the observer registered (its result set strongly
references the notes), matching the sibling account states.
https://claude.ai/code/session_015dX5vWqvXUYD8rzPYX8vTB
Follows the Account state-class convention (InterestSetsState et al.):
the observed flow, the publish mutex, and recommendApp/unrecommendApp
now live in model/nip89AppHandlers/AppRecommendationsState, publishing
through account.sendMyPublicAndPrivateOutbox. Account just links it as
val appRecommendations = AppRecommendationsState(signer, cache, scope).
https://claude.ai/code/session_015dX5vWqvXUYD8rzPYX8vTB
- Add 12dp horizontal padding to all app detail sections (header,
screenshots, about, platforms, topics, links, releases) so content
no longer touches the screen edges like other screens
- Render comments with the reply-level indentation bars used by the
regular thread feed (drawReplyLevel + levelFlowForItem)
- Move the reactions row below the releases, matching the master-note
layout of thread screens, and follow it with a divider
- Drop the Comments section label since the reactions row + threaded
replies already follow the standard screen pattern
https://claude.ai/code/session_01RJSke6d4dpSZgdtXAmGMmp
Account.myAppRecommendations is now LocalCache.observeEvents (indexed
kind+author filter) stated in the account scope, seeded with a
synchronous scan so the editor's first frame still sorts correctly.
The editor and the Recommend button collect it instead of rescanning
the cache on every newEventBundles emission; isAppRecommended is gone.
The synchronous scan stays only as the seed and for the mutex-guarded
read-modify-write publishers, which must read current cache truth.
https://claude.ai/code/session_015dX5vWqvXUYD8rzPYX8vTB
Offers can be configured (via nmanage / ShockWallet) to require payer
fields; Lightning.Pub rejects requests missing them with the misleading
"Invalid Offer" (code 1) reply. A flag to attach payer_data makes such
offers testable from the CLI.
https://claude.ai/code/session_01Fh7MRv8477pJiAJZ7yF87r
Replaces TopFilter.GlobalRaw: TopFilter.Global itself now shows every
event that p-tags the user in Notifications (still minus hidden/reported
authors, muted threads, hidden DM content, and self), and the curated
per-kind relevance heuristics move to a new notifications-only
TopFilter.Selected mode.
Selected is offered only in the notifications top nav filter and is the
default for new users and new installs. Existing users who had Global
selected keep Global and therefore now see everything, intentionally.
https://claude.ai/code/session_013VDWpD8Dr6sBF7tBEUZGpg
Clicking an app in the recommendations editor (or any 31990 link)
navigated to Route.Note(event.id) — the per-id version note, which the
soft cache evicts and relays can't serve for replaceables — leaving the
thread stuck on 'Event is loading or can't be found'. Route by
addressTag() like the generic AddressableEvent branch already does.
https://claude.ai/code/session_015dX5vWqvXUYD8rzPYX8vTB
Searching for an app name (e.g. "Amethyst") was returning every event
published through that client, because the local-cache note search
matched the search term against all tag values including the NIP-89
["client", ...] tag. Skip the client tag when matching tag values in
findNotesStartingWith.
https://claude.ai/code/session_01YMs6aXuvs5NaYjzyPH6Zqj
The notifications mode previously labeled Global applied per-kind relevance
heuristics (tagsAnEventByUser) that drop reactions/reposts targeting other
people's notes, unrelated thread replies, etc. That mode is now shown as
"Selected" in the notifications spinner, and a new real Global mode
(TopFilter.GlobalRaw) shows every event that p-tags the user, filtered only
by the notification kind whitelist, hidden/reported authors, muted threads,
muted DM content, and self-authored events.
The split-notifications Everyone tab now pins to the raw Global mode.
Other feeds' Global filter is unchanged.
https://claude.ai/code/session_013VDWpD8Dr6sBF7tBEUZGpg
- New ByAuthorChip ('by <picture> <name>' pill, tappable to the profile,
with the following-checkmark overlay on the picture) shown under the
app name in the recommendations editor rows and on the app definition
card, so apps from known authors are distinguishable from impersonators.
- The editor's ordering pins now initialize from cached data instead of
empty sets, so the first frame after returning from an item sorts the
same as before leaving; previously the restored scroll anchor jumped
down the list when the recommended/follows tiers kicked in.
https://claude.ai/code/session_015dX5vWqvXUYD8rzPYX8vTB
- Account.kt: collapse three identical backend-not-configured Failure
constructions into one helper
- OnchainZapSendError: declare causeIsUserFacing on the enum so the
sender owns which failures carry a human-readable cause, instead of
the UI mapper hardcoding the list
- SendPaymentScreen: fee chip label is now a single format resource
instead of manual string concatenation
Source-identical values (Cashu, Lightning, On-chain, and per-locale
loanwords like Meditation/Yoga/Workouts in de) are intentionally left
to the English fallback since Crowdin strips them on export anyway.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Surfaces relays unresponsive for 7+ days across the user's NIP-65 (10002),
DM (10050), and Search (10007) relay lists. A non-modal banner appears
above feed columns (and above the single-pane content) whenever the
classifier finds anything; tapping it opens an anchored Popup with one
row per unhealthy relay and per-row Remove / Open Dashboard / Snooze 7d
actions plus a banner-level "Snooze all 7d".
Quartz
- RelayStat gains best-effort lastConnectAt + lastIncomingAt timestamps
(epoch seconds, 0 = never observed). RelayStats listener pushes them
on onConnected / onIncomingMessage. Durable per-relay history lives
outside quartz in the commons RelayHealthStore.
Commons (new commons/relays/health/ package)
- classifyRelayHealth() pure function with the v1 gates:
* first-run grace (don't flag for 7d after firstScanAt)
* offline grace (don't flag if no relay anywhere has responded)
* Tor-mode skip (relay timing is intentionally lossy through Tor)
* per-relay snooze (snoozedUntil > now)
* 10006 (blocked) excluded from detection but still part of the
multi-list Remove action
- RelayHealthStore (account-scoped, supervised scope, 5s debounced
persist, 60s ticker for snooze expiry).
- RelayHealthListener wires the quartz lifecycle into the store.
- RelayHealthPersistence interface (no expect/actual — single impl per
platform via injection).
- RelayListMutator interface + RelayRemovalResult sealed type.
- Shared UnhealthyRelayBanner (errorContainer @ 50% alpha) and
UnhealthyRelayRow (static outlined tag chips, no ripple) composables.
- 8 classifier unit tests covering each gate + multi-list membership.
Desktop wiring
- PreferencesRelayHealthPersistence (java.util.prefs.Preferences, per
account via 8-char pubkey prefix).
- DesktopRelayListMutator runs the 4 sign-and-broadcast jobs in
parallel via async/awaitAll so a slow NIP-46 bunker doesn't multiply
latency by 4.
- Banner placed in DeckColumnContainer + SinglePaneLayout, store +
listener + per-account scan trigger wired in Main.kt's MainContent.
Scope: Desktop only for v1. Android wiring is intentionally not in
this PR — the commons module is platform-neutral and ready for Android
to follow whenever someone wants to pick it up.
- App card: '+n' kinds overflow is now a chip with the same metrics as
KindChip so it centers with the others; Recommend button is compact
(32dp), sits flush with the banner's right edge, and gains breathing
room below the cover.
- Recommendations editor: hides unnamed kind 31990 events (kept only if
already recommended) and only opens an EventFinder subscription for
rows whose definition isn't in cache yet, so the list stops filling
with 'Unnamed app' rows that never resolve.
- Editor ordering: the follow list is now collected as it loads (it was
snapshotted once at screen entry, so the follows tier was empty when
the screen opened before kind 3 loaded) and freezes on first edit like
the recommended tier.
- NoteCompose/ThreadFeedView: render kind 31989 as a 'Recommended apps'
card with the target kind chip and tappable app chips.
- Search: collapse the per-kind 31989 fan-out to the newest event per
author so one user's recommendations don't flood the results.
https://claude.ai/code/session_015dX5vWqvXUYD8rzPYX8vTB
Redesign the three payment cards rendered in the middle of a post
(Lightning invoice, CLINK Offer, Cashu token) around a shared PaymentCard
scaffold that follows the wallet screens' Material3 idiom: tonal card,
icon + label header with a copy action, centered headline amount, and a
full-width themed Pay/Redeem button (no more hardcoded white text or
7sp mint lines).
Descriptions were not being rendered at all:
- BOLT-11: LnInvoiceUtil only decoded the amount from the HRP. Add
tagged-field parsing (description 'd', expiry 'x', timestamp) with
BOLT-11 spec-vector tests; the invoice card now shows the memo and
flags expired invoices (Pay disabled). Desktop card shows it too.
- Cashu: V3 'memo'/'unit' and V4 'd'/'u' were parsed then dropped.
CashuToken now carries them; the card shows the memo and no longer
mislabels non-sat units (usd/eur cents formatted as decimals).
- CLINK Offers: the card now shows who gets paid (avatar + name from
the pointer's pubkey, tappable to the profile).
https://claude.ai/code/session_019VuZ4y3ij6Ly4VVExE1W1W
Sort the management list as: my recommended apps, then apps authored
by my follows, then the rest (most recent first within each tier).
The ordering snapshot freezes at the first toggle so rows don't jump
mid-edit; deselected apps stay in place until the screen is reopened.
https://claude.ai/code/session_015dX5vWqvXUYD8rzPYX8vTB
- Bare-seal hosts (kind 13) carry no p tag, so the re-download filter's
p constraint silently matched nothing for them; the p filter is now
wrap-only (the ids filter is sufficient for seals)
- A just-sent private note has no relays until its self-wrap echoes
back from the DM relays; the fetch now falls back to the account's
own DM inbox relay set when note.relays is empty
Also pins the citation guarantee with RumorHostCitationTest: a rumor
note's nevent must encode the delivering wrap's id, never the private
rumor id, and public notes keep citing their own id.
https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo
- Profile 'Apps' section now mirrors the Badges component: header with
count and a Settings icon (own profile only) that opens a new
management screen at Route.ProfileAppRecommendations.
- New ProfileAppRecommendationsScreen lists known kind 31990 app
definitions (recommended first) with toggles that publish/remove the
per-kind 31989 recommendation events, backed by a new relay
subscription for the user's 31989s and recent 31990 candidates.
- Account gains recommendApp/unrecommendApp with mutex-serialized
read-modify-write per d-tag, mirroring the profile-badges flow.
- Profile recommendations render as logo+name pills instead of bare
35dp icons; in-post app definition cards now show platform
availability (web/android/ios), handled event kinds as chips, and a
Recommend/Recommended button.
- Quartz: AppDefinitionEvent.platformLinks() reader and
AppRecommendationEvent.buildFromTags() to rebuild a 31989 while
preserving other apps' tags; round-trip tests included.
https://claude.ai/code/session_015dX5vWqvXUYD8rzPYX8vTB
A memory audit found the global strong-reference RumorHosts index could
never be kept in sync with LocalCache.notes, which holds WeakReferences:
seven prune paths (pruneExpiredEvents — rumors inherit the seal's
expiration tag — hidden/old-message/replaceable/reaction/hidden-event
prunes, and cleanMemory) plus silent GC eviction dropped rumor notes
without clearing their entries, clear() had no callers (logout, account
removal, memory trim), and orphaned stubs accumulated unbounded.
The stub now lives on the Note (Note.rumorHost): whatever removes or
garbage-collects the note frees the stub, closing every leak path by
construction. Cost is one nullable reference per Note (~200-400 KB at a
50k-note steady state) versus the index's per-entry map overhead plus
unbounded orphan growth. All consumers already held the Note: toNEvent,
Account.broadcast, deleteEnvelopes, removeIfWrap, chat pruning, and the
ingestion pipeline. RumorHosts is deleted.
Also fixes the desktop regression the audit surfaced: the desktop
gift-wrap handler now records the wrap on the rumor note, so desktop
nevent citations of chat messages point at the wrap id again instead of
exposing the private rumor id.
https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo
Pay from, zap receipt, amount preset, and on-chain fee chips wrap with
the same 8dp row gap as the Receive-on pills. Material chips reserve a
48dp interactive height around their 32dp visual, inflating wrapped-row
gaps to ~24dp; the new shared ChipFlowRow drops that enforcement inside
the chip groups so the spacing matches.
- All chip FlowRows on the Send Payment screen (Receive on, Pay from,
zap types, amount presets, fee tiers) now declare an 8dp vertical
arrangement so wrapped lines get the same gap as the in-row spacing —
the custom Receive-on pills had no intrinsic padding and touched when
the row broke.
- The cashu rail chip and the cashu 'Pay from' wallet chip use the Cashu
vector mark (tinted with the chip content color) instead of the
generic wallet symbol.
Delivery metadata no longer lives on quartz event classes. The mutable
host var on @Immutable events (with its dual @Transient annotations) is
gone, and any event kind can now be a rumor without subclassing anything
— kind-14 chats and kind-1 private replies use one mechanism.
- commons RumorHosts: rumor id → delivering envelope (the kind-1059
wrap normally, a bare kind-13 seal otherwise), populated by the
gift-wrap ingestion pipeline from the publicNote threaded through the
handlers (the seal's host pointer was never needed)
- Note.toNEvent cites the envelope for ANY rumor — this also fixes
kind-1 private replies, whose nevent previously exposed the private
rumor id (kind-14s were already wrap-cited)
- Account: rumorHost() reads the index; relay computation refuses
seals, inner DM messages, and unsigned rumors explicitly
- LocalCache: deleteWraps → deleteEnvelopes (also removes the seal
layer the old host-chain walk missed); removeIfWrap and chat-history
pruning read the index; index entries are dropped with their rumor
- quartz: SealedRumorEvent and BaseDMGroupEvent extend Event directly;
GiftWrapEvent.unwrap no longer injects host stubs; WrappedEvent
deleted
https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo
The detail text under the Send Payment rail selector (e.g. the lightning
address, already visible in the recipient header) is gone. Instead every
chip carries its destination via long-press copy:
- Send Payment 'Receive on' chips copy the lightning address, the
noffer pointer, the on-chain destination (announced target address or
the pubkey-derived Taproot address — otherwise invisible), and the
shared cashu mint URL. The chips are now custom selectable pills since
M3 FilterChip has no long-press support.
- Profile rail chips gain the same copy values (clink noffer, derived
taproot address, mint URL) alongside the existing lightning and
payment-target copies.
- The announced bitcoin address moved from the detail line into the
on-chain receipt note so it stays visible when it differs from the
derived address.
The wallet-rail chips and the NIP-A3 payment-target chips were two
separately padded FlowRows, so the gap between the two rows was about
double the in-row spacing. The target chips now render inside the same
FlowRow as the rail chips, wrapping together with uniform 6dp spacing.
DisplayPaymentTargets is gone; PaymentTargetChip is rendered by
DisplayPaymentRailChips directly.
- Replace the NewWorkoutDialog with a Route.NewWorkout full screen
(NewGoalScreen pattern: NewWorkoutViewModel + PostingTopBar), which
also fixes the audit-found race where the dialog closed before the
signer finished — the screen now pops back only after a successful
sign/broadcast, keeping external-signer (Amber) flows alive.
- Move template building from Account.sendWorkout into the ViewModel.
- Render workout cards in thread view (ThreadFeedView fall-through gap).
- Snapshot parsed workout tags once per note (remember + WorkoutInfo)
instead of re-scanning the tag array on every recomposition.
- Fix Double.trimmed() Int overflow on absurd distances.
- Flatten the distance-unit chip layout.
https://claude.ai/code/session_01Kpx53UEeJqqR7CASzMu6GB
The Cashu vector is a monochrome black outline meant to be tinted like a
Material Symbol — with Color.Unspecified it rendered black and vanished
on dark backgrounds. Tint it with the chip color and lift the purple
from 0xFF7E57C2 to 0xFFA855F7 so the chip reads on both themes.
Replaces the lightning-address icon+text row with a chip and completes
the rail set, so every way to pay a profile reads as one chip row:
- Lightning chip shows the lud16 (long-press still copies it) and opens
the Send Payment screen on the Lightning rail.
- CLINK Offer chip moves into the same row (same look as before).
- New On-chain chip (when the chain backend is configured) and Cashu
chip (when the logged-in user's cashu wallet shares a mint the
recipient accepts), each opening their rail on the Send Payment
screen.
- All four render through a shared ProfilePaymentChip pill that matches
the NIP-A3 payment-target chips, replacing the old DisplayLNAddress
row and the one-off clink chip in DrawAdditionalInfo.
https://claude.ai/code/session_01UERRsbDoRPz46Qx5HCXgAa
Quartz: new experimental/fitness/workout package shaped like nip88Polls —
WorkoutRecordEvent with per-tag classes (exercise, duration, distance,
elevation, calories, steps, heart rate, splits, strength sets/reps/weight,
source, workout_start_time), TagArrayBuilder/TagArray extensions, lax
RUNSTR-dialect parsing (unit defaults, HH:MM:SS or raw seconds), and
EventFactory + LocalCache registration. Covered by fixture tests.
Amethyst: new Workouts feed (drawer entry, route, follow-list top bar,
per-relay filter assemblers mirroring the Pictures feed) with a + FAB
opening a manual workout composer that publishes canonical kind-1301
events. Workout cards render stats chips and also display inside threads
via NoteCompose. Adds fitness Material Symbols glyphs and regenerates the
subset font.
https://claude.ai/code/session_01Kpx53UEeJqqR7CASzMu6GB
Pinned chatrooms were stored local-only in encrypted SharedPreferences, so
they were lost on uninstall and never reached other devices. Move them into
AccountSyncedSettings as a new 'chats' group in the encrypted settings blob
(each room serialized as its member pubkeys sorted ascending), publishing a
new AppSpecificData event on every pin/unpin like the other synced settings.
Local persistence now comes from the existing latestAppSpecificData event
backup, so the dedicated pinned_chatrooms preference is removed (the local
format never shipped, so no migration is needed). Pins arriving from another
device flow through AccountSyncedSettings.updateFrom and re-sort the chat
list via the existing pinnedChatrooms feed invalidation collector.
https://claude.ai/code/session_0131YwG6bE3yH8Kk9MxjMA5i
All CLINK tests failed on iosSimulatorArm64 with IllegalArgumentException
because OptimizedJsonMapper on native dispatches through
KotlinSerializationMapper, whose fromJsonTo/toJson type lists did not
include the CLINK payload DTOs (Jackson handles them reflectively on
JVM/Android, which is why only iOS failed).
Adds hand-written kotlinx serializers for OfferRequest/OfferResponse/
OfferReceipt, DebitRequest/DebitResponse, and ManageRequest/ManageResponse,
mirroring Jackson behavior: ManageResponse.details coerces a lone object
into a one-element list (ACCEPT_SINGLE_VALUE_AS_ARRAY) and
OfferRequest.payer_data round-trips as a free-form JSON object.
Covered by a JVM test driving KotlinSerializationMapper directly and
cross-checking against Jackson, since the native path shares this code.
https://claude.ai/code/session_01SevV4fUCumKZ1UscSz85vS
Reading pinnedChatrooms.value in the UserRoomCompose body invalidated the
whole function scope on every pin toggle. Keep the single subscription but
read the set only inside the firstRow slot (pin icon) and the dropdown
menu-item text, so those two small scopes are the only ones that recompose.
https://claude.ai/code/session_0131YwG6bE3yH8Kk9MxjMA5i
The screen now shows which wallet the payment will come from and lets
the user switch before paying:
- Lightning and CLINK-offer rails list every configured wallet (NWC +
CLINK debit, via PaymentSourceResolver.all) plus an 'Another wallet
app' entry that hands the invoice to the system via intent. The
selection defaults to the account's default payment source and
re-resolves if the picked wallet is removed while the screen is open.
- On-chain and cashu rails show a fixed, disabled chip naming their
intrinsic wallet so the money's origin is always visible.
- payBolt11 now charges the picked source instead of silently using the
account default.
https://claude.ai/code/session_01UERRsbDoRPz46Qx5HCXgAa
Lightning payment targets already route into the Send Payment screen;
this extends the same treatment to bitcoin targets. Tapping a profile's
bitcoin payment-target chip (or its pay action in the wallet-button
dialog) now opens the Send Payment screen with the on-chain rail locked
to that announced address, paid directly from the user's NIP-BC Taproot
wallet — falling back to the external bitcoin: URI when the chain
backend is missing or the address isn't a payable native-segwit mainnet
address.
- quartz: SegwitAddress.scriptPubKeyFor/isPayableMainnetAddress;
OnchainZapBuilder.buildToScripts core shared by the pubkey paths.
- commons: OnchainZapSender.sendToAddress — plain wallet send with the
same fund-safety signing contract but no kind:8333 receipt (the
destination isn't pubkey-derived, so none is possible); the signing
block is now a single shared helper across send/sendSplit/sendToAddress
and Success.receiptEventId is nullable for receipt-less sends.
- amethyst: Account.sendOnchainToAddress; Route.SendPayment gains
btcAddressOverride; a shared inAppPaymentRouteFor() decides which
payment targets the user's wallets can pay in-app (used by both the
target chips and the payment-targets dialog).
- Send Payment screen: with an address override the on-chain rail shows
the target address, hides the message field (no receipt to carry it),
explains that no zap receipt is published, and dispatches the plain
address send.
https://claude.ai/code/session_01UERRsbDoRPz46Qx5HCXgAa
Brings DM-style broadcast to kind-1 private replies. Kind-14 chats carry
the kind-1059 host pointer on the event (WrappedEvent); other rumor
kinds can't, so LocalCache gains a rumorHosts index (rumor id → wrap
HostStub) populated when seals are unsealed and on seal replays after a
cache rebuild.
- Account.rumorHost(event): host from the event (WrappedEvent) or the
index (other rumor kinds)
- Account.broadcast: any rumor with a known host re-downloads the wrap
by id and republishes it — the unsigned rumor itself is never sent;
rumors with no known wrap stay non-broadcastable
- Broadcast menu rows reappear for rumors when the wrap is known
(AccountViewModel.canBroadcast), restoring the DM behavior the
earlier blanket isPrivateRumor gate had also hidden for kind-14s
https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo
Merge-readiness audit follow-ups:
- Account.report(note): reporting a private rumor now reports the AUTHOR
(p-tag only) instead of publishing a kind-1984 that e-tags the private
rumor id onto public relays (the one confirmed leak)
- Defense-in-depth guards at the model layer so the invariant no longer
relies on UI gating alone: RepostAction.repost returns null / throws
for empty-sig targets (covers Account.boost, createBoostEvent, and the
desktop call path), ReactionAction.reactTo (simple overload) throws,
and Account.broadcast no-ops for unsigned non-wrapped events — without
the guard it would disclose the rumor JSON to relays even though they
reject the signature
- Hide remaining actions that can't work on private rumors, per review:
share buttons (action row, both note menus) and all bookmark/playlist/
emoji-list rows (their lists reference an id other devices can't
resolve; public lists would also leak it)
- ZapCustomDialog: remember(accountViewModel, baseNote) so the
preselected zap type can't go stale on lazy-list slot reuse
Broadcast of the gift wrap itself (like DMs do via WrappedEvent.host)
needs host tracking for non-WrappedEvent rumor kinds in quartz — left
as a follow-up; the broadcast row stays hidden for kind-1 rumors.
https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo
Gift-wrapped un-react:
- NIP17Factory.createDeletionNIP17 wraps a NIP-09 deletion to explicit
recipients + self-copy, so the retracted rumor id never reaches public
relays; DeletionIndex keys by (id, pubkey) and rumor pubkeys are forced
to the seal's, so wrapped deletions are authenticated on receive
- Account.deletePrivately sends the wrapped deletion to the target
rumor's participants (author + tagged users)
- AccountViewModel.reactToOrDelete now partitions reactions: public ones
get a public NIP-09, rumor reactions get a wrapped one — un-react on
private notes and NIP-17 chats works instead of no-op
Force-private zaps on private rumors:
- AccountViewModel.zap forces ZapType.PRIVATE for empty-sig targets
(NONZAP kept: no receipt at all is even more private)
- ZapCustomDialog only offers Private/None for private targets
- Zap button re-enabled on private rumors; nutzap (public kind 9321) is
refused with an explanatory error and the onchain rail is hidden, as
both would e-tag the rumor id publicly
- Note: the LN provider's public 9735 receipt still carries the e-tag —
the private zap type protects sender identity and comment, not the
zapped id itself
Also verified: ReactionEvent consume counts empty-sig rumors (wasVerified
path) so wrapped reactions tally correctly.
https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo
Phase 3 of the private-notes plan: the composer's Notify row gains an
'+ Add' chip backed by the existing user-suggestion search, so users can
p-tag people who aren't cited in the text — for any post, public or
private. While the private toggle is ON the row is always visible,
relabeled 'Visible to' (the p-tags ARE the audience of the wrap), and an
empty list shows a 'only you will see this' hint for self-only notes.
https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo
Phase 2 of the private-notes plan: the short-note composer gains a
private (lock) toggle that gift-wraps the kind-1 to its p-tagged users
plus a self-copy instead of publishing it.
- NIP17Factory.createNoteNIP17: wraps a TextNoteEvent template to its
taggedUserIds + the sender (only the unsigned rumor form travels)
- Account.sendPrivateNote: signs, wraps, and routes each wrap to the
recipient's DM relays via the existing broadcastPrivately path
- ShortNotePostViewModel: wantsPrivateNote/privateNoteLocked state;
forced ON and locked when replying to an unsealed rumor (and when
reloading a drafted private reply); private wins over anonymous and
scheduled modes so a locked reply can never fall through to a public
publish path
- ShortNotePostScreen: lock toggle in the bottom action row; mutually
exclusive with polls; schedule and anonymous hidden while private
- ReactionsRow: reply re-enabled on private rumors now that the
composer locks privacy for them
Drafts stay enabled: TextNoteEvent does not implement ExposeInDraft, so
draft wrappers carry no anchor e-tags — the parent rumor id only exists
inside the NIP-44 encrypted draft content.
Verified by PrivateNoteFactoryTest: wraps cover p-tags + self, and the
recipient's unwrap yields a rumor with the same id and an empty sig
(the Note.isPrivateRumor() discriminator).
https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo
Unsealed NIP-59 rumors (private replies/posts arriving in gift wraps
from other clients) were indexed as ordinary notes: public reactions,
reposts, edits, pins, OTS timestamps, labels, public bookmarks, and
deletion requests could all e-tag the private rumor id onto public
relays.
- Note.isPrivateRumor(): empty-signature discriminator (rumors are the
only notes materialized with an empty sig; draft inners are never
indexed as standalone notes)
- ReactionAction: reactions inherit the target's privacy — empty-sig
targets get gift-wrapped kind-7s fanned to the rumor author, every
tagged user, and the sender's self-copy (add-only; un-react would
need a public NIP-09 deletion that leaks the rumor id)
- AccountViewModel.reactToOrDelete: never NIP-09-delete rumor reactions
(also fixes the same leak for existing NIP-17 chat reactions),
tracked-broadcast mode excluded for rumor targets
- ReactionsRow: hide reply/boost/zap on private rumors (each publishes
a public e-tag of the target); like stays, now wrapped
- DropDownMenu/NoteQuickActionMenu: hide broadcast, edit, timestamp,
pin, hashtag label, public bookmarks, deletion request for rumors;
private bookmarks and block/report stay available
- Lock badge in the note header (reuses existing Lock glyph, no font
regen needed)
Covered by ReactionActionTest (public vs rumor fan-out, jvmTest green).
Plan: commons/plans/2026-06-10-private-replies-reactions-posts.md
https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo
The profile_thumbnails_v2 dir was only created in ThumbnailDiskCache's
constructor, but Android can delete cache subdirectories while the app
runs (system cache trim under storage pressure, or the user tapping
Clear cache in Settings). After that, every generateFromFile call
failed with ENOENT on the temp-file write until process restart,
silently disabling thumbnail caching.
Recreate the dir right before the write, and add instrumented
regression tests covering the cleared-at-runtime path.
https://claude.ai/code/session_01RQinCw5QKpaYXqtyb4gf3h
Audit fixes for the unified payment screen:
- Re-peek cashu nutzap funding when the profile data refreshes so a
late-arriving kind:10019 doesn't keep the Cashu rail hidden, and read
the on-chain backend availability live instead of freezing it at first
composition.
- Seed the active CLINK offer only while unset so a kind:0 refresh
mid-flow can't discard an expired-or-moved redirect; drop the !!
derefs in the offer range check.
- Marshal payment-callback stage updates to the Main scope (matching the
app's progress-callback convention) and run the on-chain send off the
Main thread since the sender signs the PSBT on the calling thread.
- Launch the external-wallet intent from the Main scope instead of the
invoice fetcher's IO callback.
- Restore the old LN-address error affordance: payment failures now
offer 'Message the recipient about this', opening a DM prefilled with
the failure detail.
- Reuse the wallet sheet's FeeTier instead of a duplicated enum, memoize
the zap-type options, and hoist the lightning target-type set.
https://claude.ai/code/session_01UERRsbDoRPz46Qx5HCXgAa
Catalogs every Nostr kind the RUNSTR app publishes/consumes, the exact
1301 tag dialect, the Supabase-migration caveats, and a phased plan for
Quartz event classes and Amethyst fitness screens.
https://claude.ai/code/session_01Kpx53UEeJqqR7CASzMu6GB
- Rename Nip05Test backticked test name to drop parentheses, which are
illegal identifier characters on Kotlin/Native (iosSimulatorArm64).
- Resolve CLINK budget toast strings at composition time via stringRes
instead of context.getString inside the async callback, fixing the
LocalContextGetResourceValueCall lint errors in WalletScreen.
https://claude.ai/code/session_01UgP8ErzBbQYkTDtkJx5nrt
- Clamp the seeded kind:445 subscription since at wall-clock now: the
inner createdAt is sender-controlled, so a single future-dated message
could push since past the present and silently skip genuinely new
events on every restart. Covered by a new regression test.
- Drop the remember() around the group-list unread count: the chatroom's
message set can shrink without newestMessage or lastReadTime changing
(pruning, kind:5 deletion of an older message), which left the cached
count stale. The set is pruned to ~100 entries, so counting per
recomposition is cheap.
- Extract marmotGroupLastReadRoute(): the "MarmotGroup/<id>" last-read
key was inlined at three call sites; a prefix drift between the
mark-as-read side and the unread checks would silently reintroduce
the bug this branch fixes.
- Derive GROUP_EVENT_REFETCH_OVERLAP_SEC from TimeUtils.ONE_DAY instead
of re-deriving 24*60*60.
Restores the non-null zappedEvent on NutzapEvent.build and adds a
separate buildToUser builder (p tag only, no e/k tags) for nutzaps that
target a profile instead of an event — mirroring NIP-57's profile zap
convention. CashuWalletOps.sendNutzap dispatches between the two.
https://claude.ai/code/session_01UERRsbDoRPz46Qx5HCXgAa
Replaces the click-to-expand payment cards on the profile page with a
dedicated Send Payment screen that collects amount, optional message and
zap type, pays on the spot through the selected rail, and shows the
invoice-request + payment progress in the screen itself before closing.
- New Route.SendPayment(userHex, method, lnAddressOverride) with a
stateless SendPaymentContent (previews for editing, fixed-price clink,
in-progress, success and failure states).
- Rails offered per profile: Lightning (lud16/lud06 or a lightning
payment target), CLINK offer (kind-0 / NIP-05, with expired-or-moved
redirect), on-chain NIP-BC (fee tier selector), and NIP-61 cashu
nutzaps gated on a shared funded mint.
- Lightning rail keeps the Public/Private/Anonymous zap types and adds
the Non-Zap (plain payment) option; clink is a direct payment; cashu
and on-chain receipts are inherent to their protocols and noted as such.
- Paying from this screen skips the extra in-app wallet confirmation
dialog: the explicit amount + Pay tap is the confirmation.
- Profile LN-address row, CLINK chip, lightning payment-target chips and
the wallet button's pay action now navigate to the new screen; other
target types keep their external payto/URI behavior.
- NutzapEvent.build / CashuWalletState.sendNutzap now accept a null
zapped event so nutzaps can target a profile (p-tag only), and
AccountViewModel gains sendNutzapToUser + a zapType override on
sendSats.
https://claude.ai/code/session_01UERRsbDoRPz46Qx5HCXgAa
A public kind 1111 reply can never reach a private zapper: tagging the
decrypted sender would publicly expose them, so the composer correctly
refuses to — leaving the reply addressed to no one. Since only the zap
recipient can decrypt the sender, long-pressing a private-zap chip now
opens the DM room with that sender instead of the public comment
composer. Public and anonymous zaps keep the public reply path, and a
private zap we could not decrypt falls back to it as well.
https://claude.ai/code/session_01LM3KTECMMAdNBHZfs1dANa
Two bugs kept CLINK offer/debit round-trips from completing over the shared
account relay client:
- The offer relay was treated as a generic "new" relay, so with Tor on it
was dialed through the proxy and failed on services that block Tor exits.
Register the offer/debit relays as money-operation relays for the duration
of the round-trip; the subscribe()-triggered reconnect plus the
BasicRelayClient wrong-transport rebuild then move the socket to clearnet.
- The subscription id "clink-offer-<event id>" was 76 chars; relays cap REQ
subscription ids at 64 (NIP-01) and reject the over-long REQ outright, so
the reply never arrived. Use newSubId(); the reply is matched by request
id in the listener, not by subscription id.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Relay-socket Tor routing only had localhost/onion/DM/trusted/new buckets,
so a wallet or payment-service relay fell through to newRelaysViaTor and
got forced over Tor regardless of the "Money operations via Tor" toggle
(which previously governed only HTTP clients). On services that block Tor
exits this silently broke NIP-47 and CLINK payments.
Add a moneyOperationsViaTor field to TorRelaySettings and a moneyOpRelay
bucket to TorRelayEvaluation (taking precedence over DM/trusted/new, after
the onion reachability check). TorRelayState gains a persistent money-op
relay set — fed across all accounts from NIP-47 wallet relays and saved
CLINK debit relays via AccountsTorStateConnector — plus a reference-counted
ad-hoc registry for one-off payment relays (e.g. an noffer pointer). The
websocket builder resolves the per-relay decision from live source values
so ad-hoc registration takes effect on the next connect with no race.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
connectAndSyncFiltersIfDisconnected() bailed whenever a socket already
existed, so a still-connecting socket built for the wrong transport (e.g.
a relay whose Tor classification changed since the dial started) could
never be preempted — it blocked until the hung dial timed out. The
connected-relay path in RelayPool.reconnectIfNeedsTo already rebuilds
ready sockets via needsToReconnect(); this covers the connecting state it
cannot see (isConnectionStarted() true but isConnected() false).
Now: if a socket exists but reports needsReconnect() (transport/proxy
mismatch against the current builder decision), drop it and redial on the
correct transport; otherwise leave it. Disconnected relays still honor
their reconnect backoff.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Correctness fixes in GlobalMediaPlayer.kt
- snapshotFlow { hasMedia } collector for initial seek used `return@collect`
which only exits the lambda; the collector kept running and each
subsequent playVideo() call accumulated a live collector that would
re-fire a stale seekTo() on the wrong media. Replaced with `Flow.first`
which terminates the collection cleanly.
- playVideo()/playAudio() reset the public MediaPlaybackState to
volume=100/isMuted=false on a new URL, but the kdroidFilter player
retains its `volume` across openUri(); muting one track and starting a
new one left the engine silent while the UI showed unmuted. Reset
`player.volume = 1f` to match the public state.
- ensureVideoPlayer()/ensureAudioPlayer() called createVideoPlayerState()
synchronously from the Compose getter; if native init throws (missing
GStreamer on Linux, broken NativeLibraryLoader extraction) the whole
window would crash. Wrapped in runCatching and changed
activeVideoPlayerState to nullable. Consumers in DesktopVideoPlayer
and GlobalFullscreenOverlay handle the null path by rendering the
thumbnail / blank backdrop respectively; playVideo()/playAudio()
surface "Video playback unavailable" through the existing
errorReason -> PlaybackErrorMessage path.
Crash mitigation (kdroidFilter 0.10.0 UAF in MacVideoPlayerSurface)
- NowPlayingBar previously mounted a SECOND VideoPlayerSurface against
the same VideoPlayerState while the feed card was already mounting
one, doubling the draw rate against the shared frame bitmap and
widening the UAF window in MacVideoPlayerSurface's RasterFromBitmap
path. Mini-preview now renders the cached thumbnail (or the music
icon fallback). 0.10.1 contains an upstream fix
("recover video playback after composition removal") but is not yet
on Maven Central — single-surface mounting is the only mitigation
we can ship today.
VideoThumbnailCache.kt
- Truncated-download cache poisoning: when an origin ignored the
Range: header and returned HTTP 200 with the full body, we capped
the copy at MAX_THUMB_BYTES and persisted the truncated file
forever. Subsequent thumbnail attempts hit the broken cache file
and re-failed JCodec/ffmpeg every time. Tag download results with
whether the server actually returned 206; on 200, extract from the
temp file and delete it (no persistent cache hit).
- Tor bypass: replaced the bare OkHttpClient with
DesktopHttpClient.currentClient() so thumbnail fetches respect the
user's Tor preference (fail-closed when Tor is expected but
bootstrapping).
- ffmpeg version probe leaked the process on hang: now drains stdout
to DISCARD and calls destroyForcibly() on timeout.
- Frame-extract ffmpeg subprocess could deadlock on a chatty stderr
pipe: redirectError(DISCARD) so we never wait on stderr; a finally
block destroys the process if anything leaked through the timeout.
CI workflow cleanup
- Removed vlc-setup download cache + pre-fetch steps from
build.yml and smoke-test-desktop.yml. They were targeting an
ir.mahozad.vlc-setup plugin we no longer apply, so they wasted
~minutes of CI time per leg and tied the build to videolan.org
reachability for no reason.
- Trimmed create-release.yml's stale VLC-plugins justification on
the linuxdeploy-vs-appimagetool comment.
.gitignore + missing per-OS ffmpeg READMEs
- The pre-PR rules blanket-ignored desktopApp/src/jvmMain/appResources/{linux,macos,windows}/
so the LGPL FFmpeg drop-in slot READMEs created in 704f4f44e never
reached the commit. Refined the ignore rules to keep stale vlc/ workspace
trees out of git (still ignored) while explicitly tracking the
ffmpeg/README.md drop-in slot under each OS. The READMEs document the
recommended LGPL build source per OS for the bundled-FFmpeg packaging
path.
Verified on macOS arm64:
./gradlew :desktopApp:compileKotlin BUILD SUCCESSFUL
./gradlew :desktopApp:test BUILD SUCCESSFUL
./gradlew :desktopApp:spotlessApply clean
Refs PR #3175 review by @davotoula.
Long-pressing a private chat row in the messages tab now offers Pin to
top / Unpin. Pinned rooms sort above everything else in the known-chats
list (ties broken by the usual newest-first order) and show a small pin
icon next to the room name.
Pins are stored per account as a local-only setting (encrypted
SharedPreferences via AccountSettings.pinnedChatrooms) because there is
no standard NIP-51 list for pinned DMs; this can be migrated to a synced
list later if one is standardized.
https://claude.ai/code/session_0131YwG6bE3yH8Kk9MxjMA5i
LocalPreferences.deleteAccount() wiped the encrypted preference file but
left the in-memory cachedAccounts entry behind, so deleting and re-adding
the same account could resurrect stale settings from the cache.
https://claude.ai/code/session_0131YwG6bE3yH8Kk9MxjMA5i
The test lived in commons androidHostTest, but no CI workflow or
pre-push task runs :commons:testAndroidHostTest — and running it
manually fails before reaching any assertion: quartz's android
PlatformLog actual hits unmocked android.util.Log stubs
(NoSuchMethodError), since the source set is not configured with
returnDefaultValues. The end-to-end leave/rejoin coverage was
therefore never executed anywhere.
:commons:jvmTest runs in CI and in the pre-push hook, already has the
secp256k1 JVM bindings the test needs, and uses quartz's JVM logger.
Verified green there alongside MarmotManagerRestoreTest.
The user-only LnZapRequestEvent.create overload marked ANONYMOUS requests
with a blank-valued anon tag, which the signer treats as an unsigned
private zap: the message was encrypted to the recipient under the
throwaway key instead of staying public. Use the valueless anon tag, as
the event-targeted overload already does. Adds a regression test.
Also carries the nutzap note into the notification gallery chips so the
long-press reply-to-zap gesture works for NIP-61 nutzaps too — no extra
tagging needed there since nutzaps are signed by the sender.
https://claude.ai/code/session_01LM3KTECMMAdNBHZfs1dANa
Second audit pass over the remaining skills (amy-expert, auth-signers,
find-*, nostr-expert, quartz-integration, vendored technique skills),
verifying every concrete claim against the code:
- auth-signers: bunker login goes through NostrSignerRemote.fromBunkerUri
+ connect(), not the nonexistent RemoteSignerManager.connect(url)
- nostr-expert: NIP count 57 -> 80+; replace invented Nip44v2/Nip19
static APIs with the real Nip44 facade, ByteArray bech32 extensions,
entity create() helpers, and Nip19Parser.uriToRoute()?.entity
- nip-catalog: heading counts corrected to 87 standard + 23 experimental
packages with a ground-truth pointer
- quartz-integration: NIP-19 example rewritten for ParseReturn.entity;
Event Store is commonMain (all platforms), not Android-only, with the
real store.sqlite.EventStore import and suspend query<T> API
amy-expert, find-missing-translations, find-non-lambda-logs, the rest of
auth-signers, and the vendored technique skills audited clean.
https://claude.ai/code/session_01EC7LdXjatFTh1CJSP4qKRn
The Marmot subscription since, the processed-event dedup set, and the
application ratchet position (group state persists only at commits) are
all in-memory only. On restart, relays therefore redeliver the group's
entire kind:445 history and the rewound ratchet re-decrypts old
application messages as if they had just arrived — wasted decryption
work and, when a replay beats the disk restore, duplicate entries
appended to the persisted plaintext message log.
Two defenses:
- MarmotManager.restoreAll() now seeds each restored group's
subscription since from the newest persisted decrypted message, minus
a one-day overlap window for late/out-of-order publishes. Seeding
happens before syncWithGroupManager registers default entries, so
even the first filter set sent to relays carries it. The CLI is
unaffected: it builds group filters from its own persisted since.
- MarmotMessageStore appends are now explicitly idempotent (contract
was previously ambiguous and both real stores appended blindly):
the Android and CLI file stores skip an entry that is already in the
group's log, so replays inside the overlap window cannot grow it.
Covered by MarmotManagerRestoreTest in commons jvmTest — placed there
rather than androidHostTest because CI only runs :commons:jvmTest (the
androidHostTest task currently fails on android.util.Log stubs even
for the pre-existing Marmot test).
Zap receipts (kind 9735) are signed by the recipient's lightning provider,
not by the person who zapped, so both the reply tagging and the rendering
around replies-to-zaps need the sender resolved from the embedded kind 9734
zap request:
- Long-press on a zap chip in the notification galleries (MultiSetCard and
ZapUserSetCard) opens the NIP-22 comment composer targeting the zap
receipt, reusing the existing generic-comment fallback in routeReplyTo.
- CommentPostViewModel now p-tags the zap request author when replying to a
zap so the zapper actually gets notified (the receipt's own author tags
point at the custodian). Requests carrying an anon tag (anonymous or
private zaps) are skipped: the embedded key is ephemeral and tagging the
decrypted sender of a private zap would publicly expose them.
- The notifying chip row shows the zapper and removing the chip is
respected, including across draft reload.
- FirstUserInfoRow and the compact reply-to label now display the zap
sender (decrypted for private zaps, locally only) instead of the wallet
service when the note or the replied-to parent is a zap receipt.
- ZapAmountCommentNotification carries the receipt note so chips can act on
the zap itself.
- Adds LnZapRequestEvent.hasAnonTag() with tests covering public, anonymous
and private zap requests.
https://claude.ai/code/session_01LM3KTECMMAdNBHZfs1dANa
The unread dot for Marmot/MLS group rooms was driven by an in-memory
unreadCount on MarmotGroupChatroom. On restart the MLS group state is
restored from the last persisted commit, the kind:445 subscription
restarts with since=null, and the in-memory processed-event dedup set is
empty — so relays redeliver old group events, they re-decrypt as fresh
application messages, and the counter was re-bumped, resurrecting the
dot for chats already read.
Marking-as-read was already persisted: opening a group chat writes the
newest rendered message's createdAt to the MarmotGroup/<groupId> route
in lastReadPerRoute (saved to disk with account settings). Compute the
unread indicators from that timestamp instead — exactly how DM rooms
and public channels do it — in both the Messages screen row and the
Marmot group list row.
With no consumer left, drop the volatile counter and collapse the
addMessageSync/restoreMessageSync split (they only differed in the
counter bump).
Audit pass that verified every concrete claim in .claude/ against the
repository:
- account-state: Account.kt no longer exposes followListFlow-style
StateFlows; document the state-object pattern (kind3FollowList,
muteList, bookmarkState, ... each exposing .flow) and rewrite the
catalog reference from the real Account.kt
- feed-patterns: filter bases (FeedFilter, AdditiveFeedFilter,
ChangesFlowFilter, FeedContentState) moved to commons/ui/feeds;
ui/dal keeps AdditiveComplexFeedFilter/FilterByListParams plus
back-compat typealiases; fix recipe example signatures
- relay-client: add nip17Dm/, eoseManagers and subscriptions entries
to the layout tree
- gradle-expert: 4-module claim -> 10 modules; refresh compose/kotlin/
BOM versions; rewrite dependency graph with verified edges for cli,
geode, quic, nestsClient, quic-interop, benchmark
- desktop-expert: drop drifted Main.kt line numbers; sidebar is the
custom MainSidebar in DeckSidebar.kt, not a NavigationRail in
SinglePaneLayout.kt
- android-expert: compileSdk/targetSdk 36 -> 37, versionName via
generateVersionName()
- kotlin-expert: remove reference to nonexistent commit 258c4e011
- CLAUDE.md: add missing geode/benchmark/quic-interop modules
- desktop-run: packageRpm + correct binaries output path; extract.md:
drop duplicated find clause
- session-start.sh: /home/user/Amber fallback was a copy-paste from
another repo; fall back to CLAUDE_PROJECT_DIR
https://claude.ai/code/session_01EC7LdXjatFTh1CJSP4qKRn
StrictMode flagged the offer round-trip (ephemeral keygen, JSON serialization,
NIP-44 encrypt/decrypt, signing) running on the UI thread, because
ClinkOfferPreview launches it from a Compose (Main) scope. Wrap the heavy work
in withContext(Dispatchers.IO) in both ClinkOfferPayer.requestInvoice and
ClinkDebitPayer.payInvoice/requestBudget so the payers are main-safe regardless
of caller dispatcher.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Adds a ContentCopy IconButton at the right of the CLINK Offer card title that
copies the noffer string (the active pointer, after any moved-offer redirect) to
the clipboard with a confirmation toast.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Mirrors the app's NIP-05 .well-known clink_offer discovery fallback (kind-0
offers are already readable via 'amy profile show'). Reuses the Context's
nip05Client.loadClinkOffer and decodes the resolved noffer into its fields.
Adds a bad-nip05 validation case to the headless harness; 17/17 pass.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
amy zap printed the invoice but never paid it. With --with <ndebit> it now
settles the fetched BOLT-11 in-place through a CLINK debit pointer (kind-21002,
reusing DebitCommands.settle), mirroring how the app routes a zap through its
default payment source. Works for both single-recipient (zap user) and
split zaps (zap event) — each recipient reports paid + preimage (or pay_error).
Adds a --with validation case to the headless harness; 16/16 pass.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Brings amy's CLINK surface closer to the app's:
- profile edit --clink-offer <noffer|"">: set/clear the kind-0 clink_offer
(validated as a real noffer; "" clears). MetadataEvent already carried the field.
- offer request --follow: chase an 'Expired or Moved' (code 3) reply to its
'latest' pointer (bounded hops), mirroring the app; the error output now also
carries code/latest/range so a script can follow or correct manually.
- offer pay <noffer> --with <ndebit> [--amount]: end-to-end — fetch the invoice
(21001) and settle it through a debit pointer (21002), reusing DebitCommands.settle.
- Structured GFY detail (code, range, retry_after, delta) in debit/offer errors,
via a new Output.error(extra=) overload.
Adds local-validation cases to the headless harness (offer pay --with, profile
edit --clink-offer); 15/15 pass.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
The profile CLINK offer showed the full ClinkOfferPreview payment card up front.
Render it instead as a compact payment-target-style chip (Bolt icon + 'Lightning
Offer' label, matching the PaymentTargetChip look); tapping it expands the
payable card, collapsed by default — same expand-on-click idiom as the lightning
address row.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
- CLAUDE.md: drop the 5-step skill-approval workflow (skills auto-trigger
and the approval loop blocked autonomous sessions), condense Verify-Don't-
Guess to the repo-specific tooling pointers, remove references to the
uncommitted /bugfix and /investigate skills, and replace the mandated
emoji survey matrix with one-line guidance
- android-expert / desktop-expert: add missing YAML frontmatter so the
skills carry trigger descriptions and can actually auto-invoke
- extract.md: fix stale shared-ui/ module name -> commons/
- delete skills/quartz-kmp.md breadcrumb (migration long complete)
- gate the Stop spotlessApply hook on modified Kotlin files via
hooks/stop-spotless.sh so Q&A-only turns skip the Gradle run
- condense core-skills-plan.md to a historical changelog
https://claude.ai/code/session_01EC7LdXjatFTh1CJSP4qKRn
progressAllPayments was a non-atomic Float var incremented from the concurrent
mapNotNullAsync bodies AND the async response callbacks (NWC onResponse / the
CLINK launched coroutine), so parallel zap splits raced and could leave the
progress bar below 100%. Replace it with a shared PaymentProgress(AtomicInteger
over 2*N half-steps) used by both payViaNWC and payViaClinkDebit, which also
removes the duplicated half-step arithmetic.
Note: NWC's response half-step still won't fire if a wallet never replies within
its 60s window (sendZapPaymentRequestFor doesn't signal onResponse on timeout);
that progress-stall is pre-existing and separate from this race fix.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
From the audit of this session's changes:
- Error surfacing: the budget (WalletScreen) and offer/invoice card
(InvoicePaymentDispatcher) paths now use DebitResponse.failureDetail() like the
zap path, so a GFY code-5/code-4 surfaces its range/retry_after instead of just
the bare error string.
- NOffer.priceType is now non-null: decode already defaults an absent TLV 3 to
SPONTANEOUS, so the nullable type was misleading and the '?: SPONTANEOUS'
fallbacks in ClinkOfferPreview were dead. Drops them and the now-redundant
always-emit-TLV3 test (covered by the spontaneous round-trip).
- WalletViewModel.requestDebitBudget catches the budget-validation
IllegalArgumentException so a malformed frequency dismisses the dialog instead
of hanging the spinner.
- Document why ClinkDebitPayer signs with the persistent account key (stable
identity for budgets) while ClinkOfferPayer uses an ephemeral key.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Sweeps every typed kind: addressable kinds (30000..39999) must read
their d tag, plain replaceables (10000..19999, 0, 3) must ignore stray
ones — the invariant the kind-34235/34236 fix restores.
Advance half the per-payable progress on dispatch and the other half when the
async debit response arrives, exactly like payViaNWC, instead of jumping the
full share on dispatch.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Extracts the priority sort into rankPriorityFirst() and covers: priority
users move to the top, stable order within both groups, no injection of
non-matching priority keys, and untouched list when priority is empty.
The MLS/Marmot inner message kind was missing from isChatEvent, so a
chat message quoted inside an MLS chatroom message still rendered as the
default NoteCompose card instead of the chat reply design.
https://claude.ai/code/session_01DSQW7kku5cGEL36icXg6BC
payViaClinkDebit blocked the zap on the debit service's res:ok/GFY reply (up to
30s) before reporting a result. Mirror the NWC rail instead: dispatch the debit
on the account scope and report each payable paid optimistically so the zap UI
completes promptly; a GFY/failure (or no reply) surfaces asynchronously through
onError rather than blocking. The programmatic App Functions debit path is
unchanged (it still awaits the real result).
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Moves the Marmot composer's inline state (message TextFieldState,
reply state, upload state, @-mention suggestion wiring, send) into a
ViewModel mirroring ChatNewMessageViewModel / ChannelNewMessageViewModel /
NestNewMessageViewModel, so all four chat types share the same
init/load structure. No behavior change.
Renders a small chip on suggestion rows whose pubkey is in the
suggestion state's priorityPubkeys set, unless the caller supplies
its own trailingContent. Priority keys only reorder and label the
users that already matched the search — they never inject results.
The MLS/Marmot inner message kind was missing from isChatEvent, so a
chat message quoted inside an MLS chatroom message still rendered as the
default NoteCompose card instead of the chat reply design.
https://claude.ai/code/session_01DSQW7kku5cGEL36icXg6BC
- Move NewMessageTagger from the Marmot composer into
AccountViewModel.sendMarmotGroupMessage so every send path gets
mention rewriting + p-tagging, not just the chat composer.
- Pass the parent's MarmotGroupChatroom into the composer instead of
re-fetching it from the group list.
- Bound the public-channel participant scan with a one-month cutoff
(matches the recency-cutoff convention in ChannelObservers).
- Simplify the nests participant-set construction.
Follow-ups from the line-by-line spec audit, scoped to the consume-only client:
- NDebit.parse rejects a TLV-3 session id that isn't exactly 32 bytes (64 hex),
per clink-debits: a wrong-length k1 is a malformed session pointer.
- DebitClient.requestBudget validates frequency.unit is one of day/week/month
(DebitFrequency.VALID_UNITS) instead of sending a unit a node service will GFY.
- OfferClient caps the invoice description at 100 chars per clink-offers.
- DebitResponse.failureDetail() composes the GFY error with its actionable extra
(allowed range for code 5, retry_after for code 4); the debit zap path now
surfaces that instead of the bare error string.
Adds regression tests for each (malformed-k1 rejection, invalid-unit throw,
description truncation, failureDetail range/retry_after).
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
In MLS kind-9 chats Note.replyTo keeps both replies and quotes (e and q
tags), so a message quoting another chat message rendered it twice: once
in the reply row and once at its inline nostr: mention. Render the reply
row only when the target is not cited in the content; the inline quote
renderer already draws it in place.
https://claude.ai/code/session_01DSQW7kku5cGEL36icXg6BC
Adds an optional priorityPubkeys supplier to UserSuggestionState that
stable-sorts search results so the current conversation's participants
appear before network-wide matches (ranking only, never filters).
Wired per chat context:
- MLS/Marmot groups: live MLS member list
- NIP-17 DMs/groups: the room's users
- Public chats (NIP-28): authors who have posted in the channel
- Nests audio rooms: MeetingSpaceEvent participants + host
https://claude.ai/code/session_013NWdjCSegsf2FYSPPANX3n
The vector is the canonical @shocknet/clink-sdk example — both its MIT README
usage snippet and clink-demo's public-domain DEFAULT_NOFFER are the same string.
Confirmed the published npm tarball ships only build output (no test vectors), so
this is the one real codec vector the ecosystem exposes.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Adds ClinkWireShapeTest: the literal decrypted JSON payload bodies documented in
shocknet/CLINK/specs/clink-{offers,debits,manage}.md (public domain) must
deserialize into our DTOs with the right fields. Covers the encrypted-content
half the bech32 pointer vectors don't: offer request + success/error codes 1-5
(incl. code-3 latest, code-5 range) + receipts; debit direct/budget requests,
success, and GFY 1-6 (incl. delta, retry_after, range); manage nested
offer.fields requests and responses — including the single-object 'details'
coercing to a list, which exercises the Manage list/single interop fix.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Brings the MLS/Marmot message composer to parity with NIP-17 DMs:
typing @ shows the shared user-suggestion dropdown (local cache +
NIP-05 resolution), selecting a user inserts @npub…, and on send
NewMessageTagger rewrites mentions into nostr: URIs and collects
the referenced users as p-tags on the inner kind:9 rumor. Mentions
stay inside the MLS ciphertext; the outer kind:445 is unchanged.
Also applies MentionPreservingInputTransformation and
UrlUserTagOutputTransformation to the field so mentions render
highlighted while composing, matching the DM editor.
https://claude.ai/code/session_013NWdjCSegsf2FYSPPANX3n
The clinkme.dev demo (shocknet/clink-demo, public domain) hard-codes a live
default noffer. Adds it as an 8th cross-impl vector — a real-world spontaneous,
relay-bearing, no-price offer with a 64-char-hex offer-id — decoded and
round-tripped through our parser.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
The trailing characters after a bech mention are part of the surrounding
text, so InlineQuoteRenderer implementations no longer receive extraChars;
DisplayFullNote draws them after delegating the note itself.
https://claude.ai/code/session_01DSQW7kku5cGEL36icXg6BC
Interop review against the shocknet/CLINK ecosystem (Lightning.Pub, clink-sdk,
ShockWallet, Zeus, Stacker News, bridgelet, clinkme.dev) surfaced five fixes:
1. Manage `details` single-object responses now parse. Lightning.Pub returns a
bare OfferData object for create/update/get and an array only for list; enable
Jackson ACCEPT_SINGLE_VALUE_AS_ARRAY so both shapes coerce into the list field.
2. NOffer.encode() always emits the price-type TLV (3), even for spontaneous
offers — the reference SDK and bridgelet decoders throw on a missing TLV 3, so
an absent field made our pointers undecodable by every JS consumer. Decode now
defaults an absent/unknown price-type to SPONTANEOUS, per the spec.
3. Nip05Parser.parseClinkOffer accepts bridgelet's flat top-level
`"clink_offer":"noffer1…"` string in addition to the spec's per-name map.
4. Offer payment receipts: OfferEvent.createReceipt/decryptReceipt +
OfferClient.parseReceipt + OfferReceipt.isOk() make the post-settlement receipt
(the SDK's onReceipt) a parseable primitive instead of a dead DTO.
5. ClinkOfferPayer signs offer requests with an ephemeral key, like the SDK / Zeus
/ Stacker News, so paying an offer no longer reveals the user's Nostr identity
to the service. Debits keep the persistent account key (budgets need a stable
app identity).
Adds regression tests for each: always-emit TLV3, flat-string NIP-05 discovery,
and a receipt round-trip.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Inline nostr: quotes used to always render through NoteCompose's quoted-note
card, even when the quoted event was itself a chat message shown inside a
chat bubble. Introduce an InlineQuoteRenderer strategy behind a
CompositionLocal: both rich-text paths (plain and markdown) funnel through
DisplayFullNote, which now reads LocalInlineQuoteRenderer, defaulting to the
existing NoteCompose card.
ChatroomMessageCompose provides a chat-aware renderer for everything inside a
bubble, so quoted chat events (NIP-17/NIP-04 DMs, NIP-28 channel messages,
NIP-53 live chat, ephemeral chat) reuse ChatroomMessageCompose with
innerQuote = true — the same design as the reply row — including
scroll-to-message on tap for quotes within the same room. Non-chat events and
not-yet-loaded quotes keep the default card.
https://claude.ai/code/session_01DSQW7kku5cGEL36icXg6BC
The profile-header NIP-05 clink_offer cache stored the raw noffer string,
re-running ClinkPointerParser on every cache hit; it now stores the parsed
NOffer. The cache key is lowercased since NIP-05 identifiers are
case-insensitive, so casing variants no longer trigger duplicate fetches.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Append a 'Final implementation state' section to the CLINK plan capturing
what shipped across Phases 0-3 + the receive side + CLI, the audit findings
and their fixes, the three-level verification matrix, and the critical
spec-vs-SDK gotchas (offer 'latest' at GFY code 3 and ndebit k1 at TLV-3 are
spec-defined and must not be removed). Flip the doc status to implemented.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Local-only shell suite (no relay): asserts amy decodes the canonical CLINK
interop vectors (same fixtures as quartz ClinkInteropTest) to the right fields
for 'offer info' and 'debit info', plus the argument-error paths (bad pointer,
unknown budget frequency, missing --amount). The round-trip verbs need a live
service and stay out of scope.
12/12 assertions pass locally (amy init -> decode, no network).
Completes amy's CLINK coverage alongside 'amy offer', reusing the
Context.requestResponse round-trip primitive:
- debit info NDEBIT: local decode of an ndebit1… pointer (pubkey, relays,
pointer id, session flag), no network.
- debit pay NDEBIT BOLT11 [--amount SATS] [--timeout MS]: kind-21002 round
trip asking the wallet to pay the invoice; prints preimage or GFY error.
- debit budget NDEBIT --amount SATS [--frequency day|week|month] [--timeout MS]:
authorize a one-time or recurring spending budget.
Thin-assembly: ClinkPointerParser + DebitClient (quartz) do the protocol; the
command shares one roundTrip helper. Verified: 'debit info' decodes an interop
vector correctly (text + --json), and budget arg validation returns exit 1.
pay/budget need a live debit service to exercise fully.
Adds headless CLINK Offers support to amy for interop testing against real
offer services:
- offer info NOFFER: local decode of a noffer1… pointer (pubkey, relays,
pointer id, price type/amount), no network.
- offer request NOFFER [--amount SATS] [--timeout MS]: the kind-21001 round
trip — publishes the request to the pointer's relays and prints the returned
BOLT11, or the service's error.
Thin-assembly per the CLI contract: pointer decode + request/response events
live in quartz (ClinkPointerParser, OfferClient); the round-trip uses a new
Context.requestResponse primitive (publish then await the first matching live
reply — unlike drain, which returns at EOSE).
Verified: 'offer info' runs end-to-end against an interop vector (correct
pubkey/relays/price-type, text + --json modes, bad-pointer error contract +
exit 1). The request round-trip needs a live service to exercise fully.
Locks in the protocol-layer fixes that were previously only compile-checked:
- offerLargePriceRoundTripIsUnsigned: a price > Int.MAX_VALUE round-trips as a
positive Long (guards the unsigned-decode fix).
- cannotDecryptAuthoredEventMissingRecipient: an authored event with no p tag
can't be decrypted by anyone (guards the no-self-fallback conversationPeer).
- manageCreateRequestSerializesNested + manageFailureResponseParsesField: the
Manage request nests under offer.fields, payer_data is a string list, and the
failure response carries field (guards the 21003 shape fix).
All CLINK tests pass.
Two robustness fixes from the audit:
- OfferEvent/DebitEvent/ManageEvent: replace talkingWith() (which fell back to
self when an authored event lacked its p tag, deriving a NIP-44 key with
myself) with conversationPeer(), which returns null when I'm neither the
author nor the addressed recipient; decryptContent then fails cleanly with
UnauthorizedDecryptionException. canDecrypt() is now exactly 'a valid peer
exists'.
- OfferClient/DebitClient/ManageClient responseFilter now also requires
#p == my pubkey, so a service reply that e-tags my request but is addressed
to a different payer no longer matches my subscription.
Valid request/response round-trips are unchanged (CLINK tests pass).
From a spec/SDK audit (verified against the CLINK spec, not just SDK 1.5.5):
- NOffer.price: decode as UNSIGNED 4-byte big-endian (now Long) — the SDK reads
price via parseInt(hex); reading it signed turned prices >= 2^31 sats negative
and broke encode/decode idempotency for high-bit prices.
- Manage (21003) messages corrected to the nested spec shape: request nests offer
data under offer{id,fields}, payer_data is a string list (not a map), and the
response uses details + field (was offer/offers). Documented the single-object
details limitation (Manage is consume-unused).
- DisplayClinkOffer: cache NIP-05 .well-known clink_offer lookups (incl. negative
results) so profile visits / kind-0 refreshes don't refetch nostr.json.
Deliberately NOT changed: the offer 'latest' (code 3) field and ndebit k1 at
TLV-3 — both are SPEC-defined; the SDK 1.5.5 merely lags, as the code comments
already noted. CLINK tests pass; app compiles.
Resolves two review findings:
- App Functions (Assistant) pay path gated on hasWalletConnectSetup() and so
ignored a debit-only user's chosen default. payViaNwcOrNull is generalized to
payViaDefaultSourceOrNull, routing through account.settings.defaultPaymentSource()
(NWC wallet or CLINK debit), matching the rest of the app; NwcOutcome -> PayOutcome.
- AccountViewModel.payInvoiceViaClinkDebit now delivers onResult on Dispatchers.Main
(was Dispatchers.IO via launchSigner), consistent with requestDebitBudget and safe
for UI callbacks (toasts/dialogs).
:amethyst (play) compiles.
From a correctness review of the CLINK branch:
- ClinkOfferPayer/ClinkDebitPayer now catch decrypt/parse failures from
parseResponse and treat an undecryptable reply as no response (return null)
instead of throwing. An uncaught SerializationException/NIP-44 failure
escaped launchSigner (which only catches signer exceptions), hanging the UI:
the offer card stuck on 'Requesting…', the DVM status stuck, the budget toast
never shown, and a split zap silently cancelling sibling payments.
- ClinkOfferPreview now renders the active (possibly moved) offer's price/type,
not the original pointer's, after an Expired-or-Moved redirect.
- WalletViewModel.setDefaultWallet only updates local state when the persist
actually succeeds, so the default star can't diverge from the stored value.
:amethyst compiles.
Exposes the CLINK Debits budget capability (requestBudget) the spec describes:
- ClinkDebitPayer.requestBudget publishes the kind-21002 budget request and
awaits the reply; the publish/await machinery is factored out of payInvoice
into a shared sendAndAwait helper.
- DebitFrequency gains UNIT_DAY/WEEK/MONTH constants.
- WalletViewModel.requestDebitBudget resolves the debit pointer and runs it.
- A 'Budget' action on CLINK debit rows opens ClinkBudgetDialog (amount +
one-time/daily/weekly/monthly cadence); the result is surfaced as a toast.
:amethyst compiles. The 21002 budget round-trip is untested end-to-end.
Models the protocol-version tag the way other tags are modeled, instead of a
loose helper on the Clink object:
- New ClinkVersionTag (TAG_NAME/CURRENT/assemble/parse) under clink/tags, with
a clinkVersion() TagArrayBuilder DSL extension, reused by all three events.
- OfferEvent/DebitEvent/ManageEvent read version() via ClinkVersionTag::parse
and build via clinkVersion() in their templates.
- Retires the now-empty Clink object (its KDoc moved to the tag class).
Behavior-preserving: assemble() emits the identical ["clink_version", "1"]
tag in the same position. All CLINK tests pass.
Brings OfferEvent/DebitEvent/ManageEvent (21001-3) in line with the codebase
tag conventions, replacing raw inline tags:
- Build via eventTemplate(KIND, content) { pTag(...); eTag/add; alt(...) } and
signer.sign(template), instead of hand-rolled arrayOf("p"/"e", ...) + sign().
- Accessors use PTag.parseKey / ETag.parseId instead of matching "p"/"e" literals.
Behavior-preserving: PTag.assemble(x, null) yields the identical ["p", x] bytes
and tag order is unchanged, so signed events are byte-identical. All CLINK tests
pass (ClinkEventTest, ClinkClientServerTest, pointer/interop).
Note: these are NIP-44-encrypted request/response events, so create*() stays a
suspend factory that encrypts then signs the template — matching NIP-47; a pure
pre-signing template isn't possible without the signer.
Brings the kind-0 clink_offer field in line with the sibling fields' structure
instead of a raw string constant written to content only:
- New ClinkOfferTag (TAG_NAME/assemble/parse) under nip01Core/metadata/tags.
- clinkOffer() TagArrayBuilder DSL extension in TagArrayBuilderExt.
- MetadataEvent uses ClinkOfferTag.TAG_NAME and dual-writes it as a kind-0 tag
in updateOrDeleteTagNames (NIP-1770 pattern), like lud16/nip05; drops the
ad-hoc CLINK_OFFER_PROPERTY constant.
UpdateMetadataTest now also asserts the tag is emitted. quartz tests pass.
Paying a CLINK offer is already a direct payment to the recipient; wrapping it
in a NIP-57 zap request conflated two different things. Removes the zappable-
offers and post-level-zap behavior:
- ClinkOfferPreview no longer builds a zap request or takes authorPubKey/zapEvent;
it just pays the fetched invoice via the default source.
- ClinkOfferPayer.requestInvoice drops the zap param.
- Unwinds the zapEvent threading through RichTextViewer / ExpandableRichTextViewer
/ TranslatableRichTextViewer (both flavors) and reverts Text.kt.
Keeps the rest of the offer card (variable amount, moved-offer follow, default
payment-source dispatch) and the profile receive side intact. Both flavors compile.
Threads the note's Event through the rich-text render chain
(TranslatableRichTextViewer [play+fdroid] -> ExpandableRichTextViewer ->
RichTextViewer -> word renderers -> ClinkOfferPreview) as a default-null
zapEvent. The offer card now prefers an e-tag zap on the post when the event
is present, falling back to the author (p-tag) zap, then a plain invoice.
Also fixes coverage: the main note body (Text.kt) didn't pass author context
at all, so offer zaps previously only fired in chat. It now passes both
authorPubKey and the note event, so paying a noffer in a feed post lands as a
zap on that post.
Both flavors compile. Zap round-trip/receipt untested end-to-end.
When the offer service replies EXPIRED_OR_MOVED with a replacement noffer in
'latest', the card now parses it, swaps to the new pointer, and retries the
request once (paying the relocated offer) instead of dead-ending on an error.
Request handling is refactored into a single helper so the amount field and
zap-request attachment apply to the retry too.
:amethyst compiles; offer round-trip remains untested end-to-end.
Completes the receive side: a payable CLINK Offer card now appears on a
profile that advertises one, preferring the kind-0 clink_offer and falling
back to the NIP-05 .well-known clink_offer.
- Nip05Parser.parseClinkOffer + INip05Client.loadClinkOffer fetch/parse the
well-known clink_offer (keyed by local name, mirroring the names map; exact
shape isn't a finalized spec so a mismatch yields null). JVM-tested.
- DrawAdditionalInfo.DisplayClinkOffer resolves kind-0 first, else fetches
NIP-05 on IO, parses the noffer, and renders ClinkOfferPreview zapping the
profile.
quartz tests pass; :amethyst compiles. Network fetch + card render untested
end-to-end.
Adds a 'CLINK Offer (noffer)' field to the profile editor so users can advertise
a payment offer in their kind-0 metadata:
- UserMetadataState.sendNewUserMetadata threads clinkOffer into the kind-0 build.
- NewUserMetadataViewModel loads/saves/clears the clinkOffer field.
- NewUserMetadataScreen renders the input (placeholder noffer1…).
:amethyst compiles. The read side (NIP-05 clink_offer discovery + preferring it)
is the next step.
Adds the CLINK Offers discovery pointer to profile metadata, mirroring the
NIP-05 `clink_offer` key:
- UserMetadata.clinkOffer (@SerialName clink_offer) + clinkOffer() accessor,
with trim/blank cleanup alongside the other fields.
- MetadataEvent.createNew/updateFromPast gain a clinkOffer param written into
kind-0 content via the new CLINK_OFFER_PROPERTY key.
Covered by UpdateMetadataTest (write + parse round-trip) on JVM.
When a noffer is rendered in someone's note, paying it now attaches a NIP-57
zap request so the offer service issues a zappable invoice and publishes a zap
receipt — turning the payment into a real zap on the author instead of a silent
invoice:
- ClinkOfferPayer.requestInvoice forwards a serialized zap request via
OfferClient's existing zap field.
- ClinkOfferPreview builds the 9734 from the threaded authorPubKey using the
account's default zap type (skipped for NONZAP), at the resolved amount.
- RichTextViewer threads authorPubKey into the offer-segment renderers; the
markdown/secret paths default to null (plain invoice, no target).
Falls back to a plain invoice when there's no author or the user opted out of
zaps. :amethyst compiles; the round-trip and receipt remain untested end-to-end.
The offer card no longer assumes a fixed price:
- FIXED offers show their preset price (unchanged).
- SPONTANEOUS offers (and the spec default when the pointer omits a price
type) now render an amount field; Pay is disabled until a positive amount
is entered, and that amount is sent as amount_sats.
- An INVALID_AMOUNT (code 5) response reveals/refines the amount field and
shows the service's allowed range, so variable offers recover gracefully
even when the price type was ambiguous.
:amethyst compiles; the offer round-trip remains untested end-to-end.
fixMissingSpaces runs on the main thread once per rendered note. The scan
introduced in the previous commit re-tested every detected URL at every
character position (and allocated an iterator per position via firstOrNull),
i.e. O(N*U*L) for a note with U URLs — noticeable on large notes that carry
many links.
Bucket the URLs by their first character once up front and only attempt a
match at positions whose character can actually start a URL; every other
character now costs a single map lookup, keeping the pass linear in the text
length for typical content. Buckets stay longest-first so prefix URLs still
don't shadow longer ones, so the output is identical (verified against the
commons richtext JVM corpus).
In-post payment cards now route their 'Pay' button through the selected
default payment source instead of always opening an external wallet:
- New shared InvoicePaymentDispatcher: resolves defaultPaymentSource() for a
bolt11. External-wallet path fires the intent (the wallet app confirms);
NWC and CLINK-debit paths show a ConfirmPaymentDialog first, because a card
pay (unlike a deliberate small zap tap) can be a larger/variable amount.
- ClinkOfferPreview and InvoicePreview both drive it via a pending-invoice
state; InvoicePreview now takes accountViewModel.
NWC-only and intent-only users are unaffected. :amethyst compiles; the in-app
pay paths remain untested end-to-end.
RichTextParser.fixMissingSpaces used a Regex of the form
`([^ \n])?(urls)([^ \n])?` to insert spaces around URLs glued to
neighbouring text. Kotlin/Native's regex engine fails to backtrack the
optional `([^ \n])?` capture groups to zero width, so on iOS every URL was
corrupted (e.g. "https://x" became "h https://x"). That broke the
downstream segmenter, which is why :commons:iosSimulatorArm64Test reported
19 failures across the RichText/Gallery/Pdf/F4a parsers once the test binary
finally linked.
Replace the regex with a direct left-to-right scan that inserts a single
space wherever a detected URL touches a non-space/non-newline neighbour. The
scan is engine-independent, so it behaves identically on JVM and Native.
Verified equivalent to the old behaviour across the full commons richtext
JVM corpus, and the new FixMissingSpacesTest pins the cases on every target
(including iosSimulatorArm64).
Surfaces debits in the existing wallet list and add flow:
- WalletViewModel reads clinkDebitWallets and emits them as spend-only rows
(canShowBalance=false) in the unified walletInfoList; the default radio
spans both types via setDefaultPaymentSource; remove/rename route by type.
- WalletScreen renders debit rows with a Pay only badge instead of a balance
and disables the NWC-only detail navigation for them.
- AddWalletScreen offers a CLINK Debit type; AddClinkDebitWalletScreen pastes
or scans an ndebit1 pointer (no secret) and saves it as a payment source.
- New Route.WalletAddClinkDebit + AppNavigation registration.
:amethyst compiles. UI rendering and the end-to-end debit payout remain
untested on device.
ZapPaymentHandler now dispatches on account.settings.defaultPaymentSource():
a CLINK debit default pays each zap invoice via the new payViaClinkDebit
(kind-21002 round-trip through ClinkDebitPayer, surfacing the service's GFY
error text); an NWC default keeps the existing payViaNWC path; no configured
source falls back to the external wallet intent. NWC-only users are unaffected.
The two secondary single-invoice sites (profile LN-address pay, DVM pay) still
use NWC/intent and are left as follow-ups.
:amethyst compiles. The debit payout path is untested end-to-end — it needs a
live debit service to verify a real payment.
Wires the payment-source model into AccountSettings + LocalPreferences:
- AccountSettings gains clinkDebitWallets and add/remove/rename methods
mirroring the NWC ones, plus defaultPaymentSource() resolving the unified
default via PaymentSourceResolver.
- Renames defaultNwcWalletId -> defaultPaymentSourceId: one default id spans
both NWC wallets and CLINK debits. First configured source of any kind
auto-defaults; adding more never silently changes it; removing the default
falls back to the first remaining source.
- LocalPreferences persists clinkDebitWallets and defaultPaymentSourceId,
migrating the legacy defaultNwcWalletId key on load.
- NwcSignerState/WalletViewModel read the unified default; NWC zap routing is
unchanged for NWC-only users (falls back to first NWC wallet).
:amethyst compiles. The Wallet-screen rows/confirm dialog and routing the zap
button through ClinkDebitPayer are the next (compile-only) step.
Adds the verifiable core for using a CLINK debit pointer as a spend rail
alongside NWC:
- ClinkDebitWalletEntry (commons): a saved ndebit pointer, the spend-only
counterpart of NwcWalletEntry (no secret, no balance/history)
- PaymentSource + PaymentSourceResolver (commons): unifies NWC wallets and
CLINK debits into one list with a single default id spanning both types;
no explicit default falls back to first (NWC before debits), preserving
today's behavior. canShowBalance marks NWC vs debit honestly.
- ClinkDebitPayer (amethyst): publishes the kind-21002 pay request and awaits
the preimage via a one-shot subscription, mirroring ClinkOfferPayer.
Resolver logic covered by PaymentSourceResolverTest on JVM (7 cases incl.
cross-type default + stale-id fallback); amethyst compiles. Persisting the
new fields in AccountSettings and the Wallet-screen rows/confirm dialog are
the next (compile-only) step.
The :commons:linkDebugTestIosSimulatorArm64 CI step fails under Xcode 16.4
with 'Undefined symbols: _OBJC_CLASS_$_UIViewLayoutRegion'. The symbol is
referenced by the prebuilt Kotlin/Native cache of Compose Multiplatform's
org.jetbrains.compose.ui:ui-uikit (CMPLayoutRegion), which was built against
a newer simulator SDK (18.5) than the test binary is linked for (14.0).
Disable the native compiler cache for the iOS test binaries so ui-uikit
recompiles against the active SDK, where UIViewLayoutRegion resolves. The
DisableCacheInKotlinVersion guard re-surfaces the workaround once we move
past Kotlin 2.3.21 so it can be removed when the cache is fixed upstream.
Wires the ClinkOfferSegment into RichTextViewer with a ClinkOfferPreview
card (modeled on InvoicePreview): shows the offer + price and a Pay
button. Pay runs ClinkOfferPayer, which publishes the kind-21001 request
to the offer's relays and awaits the encrypted reply via a one-shot
subscription, then hands the returned bolt11 to the existing
payViaIntent wallet flow. Consume-only; Amethyst never answers offers.
Compiles (:amethyst:compilePlayDebugKotlin); visual rendering and a live
offer round-trip still need on-device verification.
Teaches the commons RichTextParser to recognize an inline noffer1...
token and emit a ClinkOfferSegment carrying the decoded NOffer, so a
GUI front end can render a 'Pay' card in the note body (the feed-offer
feature). Bare tokens only for now; nostr:/lightning: prefixed forms
fall through. Covered by ClinkOfferSegmentTest on JVM.
Backs out the reactive list-refresh plumbing added in the prior commit:
removes the `changes` SharedFlow from the shared `ChatroomList` (restoring
it to its original form) and reverts Desktop `ChatroomListState` to its
original 2s poll. Room assembly is expected to move to a LocalCache.observe
approach on both platforms later, which would supersede this.
Keeps the independent Desktop list improvements (per-room unread tracking
and the mute/acceptable filter), which don't depend on the flow.
https://claude.ai/code/session_01VEukNczAYxNLBjLnqVEoZd
Adds ClinkInteropTest with bech32 pointer strings generated by the
reference TypeScript SDK (clink-sdk 1.5.5) for noffer/ndebit/nmanage.
Asserts our parser decodes the SDK's bytes into the expected fields and
that re-encoding round-trips. TLV is order-independent on decode, so
interop is functional (not byte-identical: we emit fields ascending,
the SDK descending); the reverse direction (SDK decoding our output)
was verified out-of-band against decodeBech32.
Adds the high-level request/response orchestration over the CLINK
pointers and event kinds (experimental/clink):
- OfferClient / DebitClient / ManageClient: build the kind-21001/2/3
request from a decoded pointer, expose the relays to publish on, the
response filter (kind + author + #e=requestId), and the response parser
- ClinkServer: per-kind request filters (#p=service), 30s freshness
check, plus K1Tracker for single-use debit session enforcement
Filter construction, freshness window and k1 single-use covered by
ClinkClientServerTest on JVM; request-building encryption round-trips
will be added under androidDeviceTest (lazysodium constraint).
The test-quartz-ios job only compiled :commons production code for iOS
(compileKotlinIos*), never the commonTest source set, so a test using a
JVM-only API compiled fine on JVM/Android and shipped green while silently
breaking compileTestKotlinIosSimulatorArm64.
Mirror the quartz step: run :commons:iosSimulatorArm64Test (compiles +
runs the shared commonTest suite on the simulator) and
:commons:compileTestKotlinIosArm64 (device-only compile drift). This keeps
commonTest KMP-clean going forward.
Adds the three CLINK message kinds to quartz (experimental/clink):
- OfferEvent (21001), DebitEvent (21002), ManageEvent (21003), each
carrying both request and response over one kind, NIP-44 encrypted,
with p + clink_version tags and an e tag on responses
- Request/response DTOs per spec (offers, debits, manage) plus shared
SatRange/GfyDelta and GFY/offer error-code constants
- Registers all three kinds in EventFactory
Pure-logic + JSON (de)serialization covered by ClinkEventTest on JVM;
the NIP-44 encrypt/decrypt round-trip will live in androidDeviceTest
(lazysodium is unavailable in JVM unit tests).
Makes the Desktop DM client a first-class group participant and tightens
the shared/Desktop DM paths so they match Android behavior.
- commons ChatNewMessageState: actually attach the composed NIP-14 subject
to sent messages (the field was previously collected but dropped).
- commons ChatroomList: emit a `changes` SharedFlow on add/remove so list
UIs can refresh reactively; dedupe the User overloads onto the room ones.
- Desktop NewDmDialog: multi-recipient selection (chips + confirm button) so
a Desktop user can start a group, not only a 1:1.
- Desktop ChatPane/ChatroomHeader: show a group's NIP-14 subject in the
header and add a rename dialog that broadcasts a subject change to all
members.
- Desktop Main.kt DM ingest: route any ChatroomKeyable inner event into the
room (covers kind 14/15 and future variants) and store self-authored
NIP-37 drafts instead of dropping them.
- Desktop ChatroomListState: refresh reactively off ChatroomList.changes
(with a slower safety poll), track real per-room unread via a last-seen
mark, and hide rooms whose latest message isn't acceptable (mute/filter).
https://claude.ai/code/session_01VEukNczAYxNLBjLnqVEoZd
A kind:1 note carrying a `q` tag is a quote-repost of the quoted note,
but Amethyst's reaction-row repost counter reads `Note.boosts`, which
only collected kind:6/kind:16 reposts. Quote-reposts were treated purely
as inline citations (stripped by `tagsWithoutCitations()`), so they never
appeared in the quoted note's repost count.
LocalCache now adds a `q`-tagged note as a boost of each quoted note when
consuming text notes/comments, and detaches it on deletion. The quoted
note is deliberately kept out of `replyTo` so the quote still renders as a
root post in the home feed (`Note.isNewThread`). The same wiring is added
to DesktopLocalCache for parity, with tests pinning the behavior using the
exact event reported.
Implements the noffer/ndebit/nmanage pointers (CLINK Offers/Debits/Manage)
as standard-bech32 TLV codes, with a dedicated ClinkPointerParser kept
separate from NIP-19. Wire format (HRPs, TLV indices, single-byte priceType,
4-byte big-endian price) verified against @shocknet/clink-sdk 1.5.5.
Adds round-trip + dispatch + reject tests in commonTest.
Plan to implement CLINK (Offers 21001 / Debits 21002 / Manage 21003) on
Quartz (client + server) and Amethyst (consume-only), reusing NIP-44,
bech32/TLV, the NWC encrypted-event pattern, and ZapPaymentHandler.
Pointers parsed by a dedicated ClinkPointerParser (separate from NIP-19).
The commons commonTest source set is shared across all KMP targets,
including the iosArm64/iosSimulatorArm64 spike, but several tests still
reached for JVM-only APIs that don't resolve on Kotlin/Native:
- JUnit (`org.junit.*`, `junit.framework.TestCase`) → kotlin.test, with
message arguments moved from first (JUnit) to last (kotlin.test).
- `assertArrayEquals` → `assertContentEquals`.
- `@JvmStatic` on the `android.util.Log` test stub → removed (it only
affects JVM bytecode; companion calls work without it).
- `seg.javaClass.simpleName` → `seg::class.simpleName!!`.
- A test function name containing `()` (illegal on Native) → renamed.
- `String(CharArray, offset, count)` → `CharArray.concatToString`.
CliffDetectorTest exercises `computeStalledSpeakers`/`defaultCliffBackoffMs`,
which live in the jvmAndroid-only NestViewModel and are invisible to iOS,
so it moves to jvmTest alongside NestViewModelTest.
The in-stream loading marker spelled out "N relays" only for the fully-loaded
(done) chip; active frontiers showed a bare count ("Loading: ↓ 8"). Use the
relays plural for the count fallback on every state so a count chip always reads
as a sentence ("Loading: ↓ 8 relays"). 1–2 short host names still spell out.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BackwardRelayPager exposed five independently-updated StateFlows (exhausted,
relayCount, stalledCount, reachedBack, relayProgress) that are all recomputed
together on every page settle. Co-located consumers therefore paid up to five
separate recompositions per settle and could observe a torn read (e.g. an
updated relayCount against a still-stale relayProgress).
Combine them into one atomic PagingStatus snapshot, emitted by a single
publish(), collected once. updateStatus()/recomputeExhausted() merge into that
publish() (exhausted computed inline). loadingMore stays separate: its falling
edge is debounced on its own timer in PerRelayLoadTracker, decoupled from the
status recompute, so folding it in would miss that delayed transition.
Threaded through the 3 history managers and the 3 feed consumers
(ChatroomListFeedView, ChatroomView, LoadingReplyNote): 12 collectors -> 4 at
the heaviest views.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pre-existing desktopApp:UploadOrchestratorTest started failing
after the desktop image-compression feature landed:
- uploadCallsClientWithCorrectParameters (2x2 PNG, no quality set)
- uploadPassesAuthHeaderToClient (.txt)
- uploadPassesSameFileWhenNoStripExif (.txt)
- uploadComputesMetadata (.txt)
The orchestrator was unconditionally calling ImageReencoder.reencode,
which (a) reencoded PNGs to JPEG even when the caller did not opt into
compression and (b) threw UnsupportedFormat for any file the sniffer
could not classify (.txt, voice memos, video files, DM attachments —
the orchestrator is the upload path for everything, not just images).
Two changes restore the orchestrator's original "upload as-is"
behavior for callers that have not opted into compression:
1. UploadOrchestrator.upload's quality parameter is now nullable
(CompressionQuality? = null). Null means "do not reencode" —
matches the orchestrator's behavior before this feature, so the
Android, CLI, and any non-image upload path keeps working
unchanged. The desktop compose flow continues to pass a non-null
CompressionQuality so it still runs the reencoder.
2. ImageReencoder no longer throws UnsupportedFormat for
ImageFormat.Unknown — it returns PassThrough(NotAnImage) instead.
AVIF and HEIC still throw (those are recognized formats we
explicitly refuse). The new PassReason.NotAnImage is rendered in
the preview dialog as "Not an image · uploaded as-is" / "Metadata
preserved (non-image — no re-encode applies)".
My UploadOrchestratorTest.refusesAvifWithUnsupportedFormat is updated
to pass quality = MEDIUM explicitly so it still exercises the refuse
path under the new opt-in model.
In the lightbox/carousel:
- Hover over the image → Material3 PlainTooltip shows the full
Blossom URL above the image (TooltipAnchorPosition.Above, 8 dp
gap). Same TooltipBox pattern already used in
MediaServerSettings.
- Single-click on the image → copies the URL to the system
clipboard via AWT Toolkit, then surfaces a green snackbar
banner at the top: "Copied <url> to clipboard". The banner
slides in from above, sits below the download banner if both
fire simultaneously, and auto-dismisses after 2.5 s
(LaunchedEffect on the message state).
- Double-click still resets zoom — unchanged.
- The MoreOptionsMenu's "Copy URL" rows on both the image and
video paths now route through the same copyUrlToClipboard
helper so they also trigger the snackbar (previously they
copied silently with no user feedback).
ZoomableImage gains an `onTap: (() -> Unit)?` parameter; null
keeps the old "consume single tap" behavior, set means the caller
handles the click (lightbox uses it for the copy action).
"Cancel" implies the post is being abandoned. The actual behavior
is to return to the compose dialog with attachments still attached
so the user can adjust quality, swap files, or change copy before
re-triggering Preview. "Back" matches the semantic.
Reported via runtime crash dialog on the user's first PNG upload:
Exception in thread "AWT-EventQueue-0":
java.lang.IllegalStateException: Can't overwrite cause with
javax.imageio.IIOException: Bogus input colorspace
at java.lang.Throwable.initCause(Throwable.java:464)
at CompressionException.<init>(CompressionException.kt:39)
Two real bugs:
1. CompressionException constructor double-set the cause.
Exception(message, cause) super already wires the Throwable's
cause slot; the init block then called initCause(cause) AGAIN
which throws IllegalStateException by spec ("Can't overwrite
cause"). The init block was added per a code-review note that
was wrong about how Kotlin's primary constructor forwards
cause. Removed the init block; relying on super does the
right thing. Regression: encodeFailedWrapsCauseWithoutCrashing.
2. JPEG writer rejected non-RGB BufferedImages with "Bogus input
colorspace". TYPE_INT_ARGB (typical PNG decode), TYPE_BYTE_GRAY,
TYPE_CUSTOM (CMYK JPEGs, indexed PNGs) all blow up the stock
JPEGImageWriter. encodeJpeg now flattens via toRgbCanvas — draws
onto a fresh TYPE_INT_RGB canvas with white background for any
transparent pixels. White matches what every major image viewer
does for transparent PNGs over a light surface.
Regression: reencodesPngWithAlphaToJpeg.
Both regressions are covered by new tests so the patterns cannot
silently come back. Reencoder test count: 13 -> 15.
Reworked the per-row toggle in CompressionPreviewDialog to match the
user's actual intent. Previously the Switch meant "exclude this
attachment from the post entirely"; now it means "upload the original
bytes instead of the compressed version" — which is the only
meaningful per-row choice once you've already attached something.
Behavior:
- Toggle off the compression on a Reencoded row → orchestrator
uses bypassReencode=true (= upload original), the cached
compressed temp is deleted right before upload so it never
leaks.
- The Publish button no longer changes count or disables —
everything attached gets uploaded.
- Cancel still cleans up every cached compressed temp.
Layout fix the user called out:
- Only the compressed half of the row dims (thumbnail + arrow).
The original thumbnail stays full-color because that's what's
actually being uploaded when "use original" is on.
- The stats/savings line is replaced by "compression skipped —
original uploads as-is" when toggled.
- The metadata-strip sub-line now flips dynamically:
compressed → "All EXIF, GPS, camera tags stripped (re-encoded)"
original + strip ON + JPEG → "EXIF, GPS, camera tags stripped
from original before upload"
original + strip ON + non-JPEG → red warning: "Metadata
preserved — strip only runs
on JPEG; original is non-JPEG"
original + strip OFF → "Metadata preserved (EXIF strip off
in settings)"
Style fix: replaced the chunky Switch with a small TextButton —
"Use original" by default (muted color) → "Using original — undo"
when active (error color). Matches the rest of the dialog's
TextButton + DropdownMenu vocabulary; reads as a desktop action,
not a mobile preference.
The toggle is intentionally removed from PassThrough / Failed /
NonImage rows — those have no per-row choice (always-as-original
by design) and a control there would be deceptive.
API change: CompressionPreviewDialog.onPublish is now
(List<PreviewItem>, useOriginalPaths: Set<String>) -> Unit.
runPublish in ComposeNoteDialog routes Reencoded items in the
useOriginalPaths set through orchestrator.upload(bypassReencode =
true) and deletes the unused compressed temp inline.
Two manual-testing asks landed together — they share the same row
template inside CompressionPreviewDialog.
Per-row skip toggle:
- Every preview row gains a Switch labeled "Include" / "Skipped"
(the verb is shown so the user can't misread a bare switch).
- Skipped rows dim the thumbnail (0.4 alpha) and tone down the
surface, hide the "Click to compare" hint, and disable the
click-to-zoom.
- Publish button label now reflects the included count —
"Publish (4)" when nothing skipped, "Publish (3 of 5)" with
skips, "Nothing to publish" + disabled state when all skipped.
- On Publish, the dialog calls cleanupPreviewTemps(skippedItems)
so dropped re-encodes don't leak in ~/.amethyst/tmp/. The
included subset is handed off to UploadOrchestrator via the
preCompressed param as before.
- onPublish signature changed: (List<PreviewItem>) -> Unit, and
runPublish in ComposeNoteDialog now takes the filtered list
rather than reading pendingPreview directly.
Explicit metadata-strip status on every row:
- Reencoded rows: "All EXIF, GPS, camera tags stripped
(re-encoded to JPEG)" in the tertiary color. Re-encode wipes
metadata regardless of the strip-EXIF setting because we
don't preserve any metadata in the JPEG writer.
- PassThrough rows:
Animated → "Metadata preserved (animated — re-encode would
drop frames)"
Vector → "No raster metadata (SVG)"
Bypass → "Metadata preserved per your override"
- Failed rows (going to send original):
JPEG + strip on → "EXIF, GPS, camera tags stripped before
upload" in tertiary color
non-JPEG + strip on → "Metadata preserved — strip only runs
on JPEG; this is <Format>" in error
color (privacy warning)
strip off → "Metadata preserved (EXIF strip off in settings)"
- NonImage rows: "Metadata preserved — EXIF strip applies to
JPEG only"
The explicit per-row wording makes the strip-EXIF toggle's actual
behavior visible at the moment the user is deciding whether to
publish, rather than buried in the Settings panel.
When the post has image attachments, the Publish button now reads
"Preview" instead. Clicking it runs ImageReencoder on every
attachment eagerly, then opens CompressionPreviewDialog with one
row per file:
- Reencoded rows: original thumbnail → compressed thumbnail +
dims/sizes/savings % + chip showing the active quality preset.
Click the row to open a side-by-side ZoomCompareDialog with
420 dp images and a "Saves N%" header.
- PassThrough rows: original thumbnail + "Animated / Vector ·
uploaded as-is" assist chip — covers animated GIF, animated
WebP, SVG, and the bypass-by-user path.
- Failed rows: original thumbnail + red-bordered surface +
"Could not compress: <reason>" + the privacy hint
("EXIF will be stripped" for JPEG, "metadata may still be
present" for non-JPEG). User can still publish — original
bytes ship.
- NonImage rows: filename + extension badge + "uploaded as-is"
for any non-image attachment caught up in the batch.
The dialog's Publish button calls the same runPublish lambda the
main button uses. The lambda walks the preview items and tells the
orchestrator either:
- preCompressed = <cached temp> for Reencoded,
- bypassReencode = true for Failed,
- default flags for PassThrough / NonImage.
UploadOrchestrator.upload gains a `preCompressed: File?` param
so the dialog can hand off ownership of the cached temp; the
orchestrator deletes it after the actual upload in the same
finally block.
Cancel cleans up every cached temp via cleanupPreviewTemps so a
dismissed preview doesn't leak.
The standalone CompressionFailureDialog from Phase 7 is now
unreachable (all failures surface inline in the preview), so it
gets deleted. The shared `runPublish` lambda was hoisted out of
the Card into the composable's top scope so both the main button
and the preview's onPublish callback can call it.
Triggered by the user's manual-testing feedback: "shouldn't I
preview the compressed images before publishing the note?" — the
plan's deferred compare dialog became the natural publish gate.
The options row (Upload to / Quality / Post as) was wrapping
"Note" to two lines at the 600 dp dialog width — see the
screenshot the user surfaced during manual testing.
- Bumped the compose dialog from 600 dp to 780 dp and added
DialogProperties(usePlatformDefaultWidth = false) so the
explicit width is honored.
- Added maxLines=1 + softWrap=false to all three selector
TextButton labels (ServerSelector, QualitySelectorChip,
PostTypeSelector) so they can never wrap regardless of
future attachment count or label growth.
Also threads through a new preCompressed: File? param on
UploadOrchestrator.upload — landed early because the preview-
gate work needs it. When the upcoming CompressionPreviewDialog
hands off a pre-computed temp, the orchestrator skips reencode
+ stripExif and just uploads + cleans up.
Manual testing feedback from the UI:
- The Post-as Note/Picture FilterChip pair was overflowing the
options row and rendering "Picture" rotated 90°. Converted it
to the same Text + TextButton + DropdownMenu pattern as the
sibling Upload-to and Quality controls so the row stays
compact and visually consistent.
- QualitySelectorChip was likewise a FilterChip; rewritten as
Text + TextButton + DropdownMenu to match. Dropdown rows now
carry a two-line layout: bold preset label (e.g.
"Medium (640 px)") with a sub-line summary explaining the
tradeoff ("640 px · balanced size and quality").
- Restored the HIGH preset (640 px @ q=0.85, "visually lossless
on phones") between Medium and Desktop High. The four-preset
set is now Low / Medium / High / Desktop High — closer to the
Android-parity progression the brainstorm originally specced.
- CompressionQuality enum gains a `summary` field (one-line
description) plus a `chipLabel` convenience ("Medium (640 px)").
Settings panel and dropdown both consume `summary` so users
see what each preset actually does without trial-and-error.
ImageReencoderTest gains a HIGH-preset test and the monotonicity
test now asserts LOW < MEDIUM < HIGH for the same-dim subset.
Phase 7 of the desktop image compression plan.
Never silently downgrade — when ImageReencoder throws
CompressionException for one or more attachments (UnsupportedFormat,
InputTooLarge, EncodeFailed), the compose dialog now surfaces a
modal listing each failure and lets the user choose between Send
Original (uploads raw bytes, EXIF-stripped for JPEGs) or Cancel
post.
- UploadOrchestrator gains bypassReencode: Boolean = false. When
true, the orchestrator skips ImageReencoder and treats the
source as a PassThrough(BypassByUser), preserving the EXIF
strip + temp-cleanup semantics from the normal pass-through
path.
- ImageReencoder.PassReason gains BypassByUser.
- CompressionFailureDialog is built as Dialog { Surface } (not
AlertDialog) because:
* the body is a LazyColumn that grows with N failures —
AlertDialog's `text` slot has fixed-width constraints,
* LaunchedEffect / produceState don't fire inside
AlertDialog.text (see custom-feeds-alertdialog.md memory),
leaving room for future per-row actions (e.g. per-file
retry).
Each row shows: filename, "Could not compress: <reason>" in
error color, original byte count, and a privacy hint that
differs by source format ("EXIF will be stripped" for JPEG
bypass, "metadata may still be present" for PNG/HEIC bypass).
- ComposeNoteDialog send loop now wraps each upload in try/catch
(CompressionException), collects failures, and after the loop
awaits the user's choice via CompletableDeferred<FailureUserChoice>.
On SendOriginal: re-uploads each failure with
bypassReencode=true. On Cancel: aborts the post.
Phase 6 of the desktop image compression plan.
ClipboardPasteHandler previously wrote clipboard_*.png to /tmp with
deleteOnExit, leaking across long-running JVM sessions (the
existing leak documented in docs/temp-file-cleanup-analysis.md
under "Desktop temp files (out of scope for this change)").
Now lands under AmethystTempDir as
amethyst_paste_YYYYMMDD-HHMMSS_<rand>.png
so:
- the boot-time orphan sweep recovers it if the JVM crashes
before the upload pipeline consumes it,
- the directory mode (0700 on POSIX) keeps it out of reach of
other local users on shared systems,
- the filename surfaces the paste timestamp for diagnostics.
The PNG roundtrip is unavoidable: Java's clipboard image flavor
only exposes BufferedImage, so PNG is the lossless container we
materialize before the orchestrator's ImageReencoder decides to
re-encode at the active quality preset.
Phases 4 + 5 of the desktop image compression plan, landed together
because they touched the same send-loop / options-row code.
- New QualitySelectorChip composable (FilterChip + anchored
DropdownMenu) shows "Quality: <displayName>" and visually
highlights when the user has overridden the global default.
Reset row appears only after the user has chosen an override.
- ComposeNoteDialog gains:
* defaultQuality / stripExifSetting read from
ImageCompressionStore via .collectAsState() so changing the
Media settings panel updates the compose dialog live,
* perPostQualityOverride: CompressionQuality? for one-off
overrides scoped to the current post,
* activeQuality = override ?: default,
* QualitySelectorChip wired into the existing options row
(when attachments include images), next to the
ServerSelector and PostTypeSelector,
* the upload loop now passes both stripExif and quality
through to UploadOrchestrator.upload(),
* inline batch progress via the existing tracker fileName
slot — "1/3: foo.jpg", "2/3: bar.jpg", "3/3: baz.jpg" —
no new tracker state class needed,
* perPostQualityOverride resets after a successful send so
the next post starts from the saved default again.
Phase 3 of the desktop image compression plan.
ImageCompressionSettings composable in
desktopApp/.../ui/settings/, modeled on the existing
MediaServerSettings shape:
- Section header "Image Compression"
- Default quality as SingleChoiceSegmentedButtonRow (Low / Medium
/ Desktop High), matching the codebase convention from
TorSettingsSection / FeedBuilderDialog (NOT a DropdownMenu).
- Per-preset hint text underneath the segmented row, refreshed
reactively as the user clicks.
- Strip-metadata Switch with hint "Removes camera, GPS, and
timestamp data from uploaded photos."
State flows from ImageCompressionStore via .collectAsState() — uses
the JVM-portable Compose API (collectAsStateWithLifecycle is
Android-only and is not used anywhere in desktopApp).
Integrated into Main.kt:1791 just below MediaServerSettings,
surrounded by the standard HorizontalDivider + Spacer rhythm.
Per-post override (compose dialog) will read from the same store
in Phase 4.
Phase 2 of the desktop image compression plan.
- DesktopPreferences gains two raw prefs:
KEY_IMAGE_QUALITY (default "DESKTOP_HIGH") and
KEY_IMAGE_STRIP_EXIF (default true). Marked internal — callers
should go through ImageCompressionStore, not the raw prefs.
- ImageCompressionStore mirrors SearchHistoryStore: object
singleton, init seeds StateFlow from prefs, setters write
through to both StateFlow and prefs in one shot. Exposes
quality: StateFlow<CompressionQuality> and stripExif:
StateFlow<Boolean> for Compose reactivity.
Per-post override state lives in ComposeNoteDialog (Phase 4), not
here — this store carries only the default that the override falls
back to.
Phase 1 part C — the integration that makes the new compression
pipeline actually run on every upload.
- UploadOrchestrator.upload gains an optional quality parameter
(default CompressionQuality.DESKTOP_HIGH). The orchestrator now:
1. Runs ImageReencoder.reencode — branches on Reencoded vs
PassThrough.
2. For PassThrough + stripExif=true + JPEG source, runs
MediaCompressor.stripExif so animated/SVG passes still get
EXIF stripped where applicable.
3. Computes metadata on the bytes that will actually leave
the machine.
4. Eager-cleans up every intermediate in a finally{} block
wrapped in NonCancellable so user-cancelled uploads don't
leak temps.
Throws CompressionException for the fail-loud dialog path.
- MediaCompressor.stripExif drops deleteOnExit (long-running
desktop process was leaking stripped_*.jpg per
docs/temp-file-cleanup-analysis.md). Temp files now land under
AmethystTempDir with the amethyst_stripped_ prefix; caller owns
cleanup.
- AmethystTempDir lazy-initializer now triggers sweepOrphans() on
first access, recovering any amethyst_* files > 24h old left
behind by JVM crashes that skipped shutdown hooks.
- BlossomClient: open class + open upload methods so
UploadOrchestratorTest can substitute a FakeBlossomClient that
captures the uploaded file's bytes.
- jvmTest now pulls secp256k1.kmp.jni.jvm so end-to-end signer-
based tests (Blossom auth event signing) can run.
5 new orchestrator tests cover: JPEG re-encode path (uploaded file
is the AmethystTempDir temp, smaller than source, hash matches
captured bytes), animated GIF pass-through (uploaded == original),
AVIF refused with UnsupportedFormat (client.upload never called),
temp cleanup on success, and temp cleanup on upload failure.
Test totals across commons jvmTest upload package: 44 / 44 green
(4 smoke + 23 sniffer + 12 reencoder + 5 orchestrator).
Phase 1 part B. The core re-encode + downscale pipeline:
- AmethystTempDir resolves ~/.amethyst/tmp/ at mode 0700 with a
boot-time sweep of amethyst_* files > 24h. Defends against
/tmp tmpfs OOM on Linux VMs and against multi-user temp races
on shared systems. Overridable via -Damethyst.tmp.dir=.
- ImageReencoder.reencode(File, CompressionQuality) returns a
sealed ReencodeResult (Reencoded(file) | PassThrough(reason))
and throws CompressionException for fatal cases.
* Format sniffer first → pass-through for animated GIF /
animated WebP / SVG; refuse AVIF / HEIC with UnsupportedFormat.
* Pre-decode pixel guard: stream header dims via
ImageReader.getWidth(0)/getHeight(0), refuse > 50 MP before
any pixel buffer is allocated.
* Subsampled decode (floor stride) so a 4032×3024 source decodes
to ~2016×1512 in heap before Thumbnailator's final resize.
* Never upscale: Thumbnails.of(...).size() is only called when
the source actually exceeds the target box.
* CPU-bound work runs on Dispatchers.Default.limitedParallelism(1)
with ensureActive() between stages.
* Cleanup on cancellation/failure runs in NonCancellable.
12 unit tests cover preset routing, never-upscale, InputTooLarge,
AVIF/HEIC refused, animated-GIF pass-through, SVG pass-through,
JPEG SOI verification, and temp-file placement under
AmethystTempDir.
ICC profile preservation and the wide-gamut warning path land in
the next commit alongside UploadOrchestrator wiring.
Per .claude/CLAUDE.md: per-module plans live in the owning module's
plans/ folder. The global docs/plans/ is frozen. The image compression
feature is desktop-driven (commons gets the backing pipeline), so the
owning module is desktopApp.
Phase 1 part A of desktop image compression. Pure data types — no
external dependencies yet beyond Thumbnailator already on classpath.
- CompressionQuality enum (Low / Medium / Desktop High) with
JPEG quality values tuned for 2026 displays (0.65 / 0.75 / 0.90)
rather than the obsolete 2014-era Android values.
- ImageFormat sealed class covering the 9 formats the orchestrator
must distinguish (JPEG/PNG/BMP/TIFF re-encoded, animated GIF and
animated WebP byte-identical pass-through, SVG pass-through,
AVIF and HEIC refused for lack of a pure-Java decoder).
- ImageFormatSniffer with magic-byte detection + RFC 9649 VP8X
animation-flag check + GIF NETSCAPE2.0 application-extension
scan + ISO BMFF ftyp brand match for AVIF / HEIC variants.
- CompressionException sealed hierarchy (UnsupportedFormat /
InputTooLarge / EncodeFailed) with initCause for chain
preservation through logging.
23 sniffer unit tests cover each format including JPEG-via-file,
empty input, missing file, and both WebP animation paths.
Phase 0 of the desktop image compression plan
(docs/plans/2026-06-08-feat-desktop-image-compression-plan.md).
- commons jvmMain gains net.coobird:thumbnailator:0.4.21 (pure-Java,
MIT) — to be consumed by the new ImageReencoder in Phase 1.
- amy CLI now sets -Djava.awt.headless=true via three paths so any
transitive ImageIO/AWT touch never spawns a GUI thread:
* applicationDefaultJvmArgs in cli/build.gradle.kts (covers the
installDist startup scripts and any future jpackage launcher),
* the amyImage custom Unix launcher in cli/build.gradle.kts,
* System.setProperty as the first line of cli Main.kt — belt-
and-braces for invocations that bypass the launcher scripts.
- commons:jvmTest forces -Djava.awt.headless=true for the same
reason during test runs.
Smoke tests (CompressionSmokeTest.kt) document Thumbnailator's
upscale-by-default behavior — ImageReencoder must gate the resize
itself in Phase 1.
Plan for adding JPEG-only image re-encode + downscale pipeline to
desktop (and the Amy CLI), with EXIF strip, per-post quality override,
clipboard-paste integration, and a fail-loud failure dialog.
WebP encoding and HEIC input deferred to a JNI follow-up: no usable
pure-Java implementations exist in 2026 (sejda webp-imageio is JNI +
abandoned, TwelveMonkeys has no HEIC plugin per issue #976).
The equation image is taller than a text line, and the paragraph FlowRow
top-aligned its items, so equations hung below the baseline. Center items
on the cross axis (itemVerticalAlignment) so the equation sits centered
on the line; no-op for the common all-text row where every item is the
same height.
Some sources (e.g. the math-academy posts) over-escape their LaTeX, so
the content carries `\\ldots` / `\\cdots` instead of `\ldots` / `\cdots`.
JLaTeXMath reads the `\\` as a forced TeX line break — splitting every
inline equation across two lines — and renders the trailing command name
as the literal letters "ldots"/"cdots", so the ellipsis symbol is lost.
Collapse doubled backslashes (`\\cmd` -> `\cmd`) before rendering inline
math; display math is left untouched since `\\` can be a genuine line
break there. No-op when the content isn't over-escaped.
The "where the live tail ends and paged history begins" boundary (one week)
was copy-pasted across the gift-wrap + NIP-04 live tails, the backward history
pager floor, and the prune's recent/old split — easy to drift out of lockstep
(overlap = double-load, gap = missed messages).
- Add DmHistoryTuning: one place for liveTailSeconds + recentKeepCount, read by
AccountGiftWrapsEoseManager, both NIP-04 SubAssemblers, BackwardRelayPager,
and Chatroom.pruneMessagesToTheLatestOnly. Drops the duplicated
LIVE_TAIL_SECONDS / DEFAULT_LIVE_TAIL_SECONDS constants.
- Log the prune-time window realignment under DMPagination, per scope
([giftwrap] / [rooms.nip04] / [convo.nip04]): relay count + newest pruned
timestamp, so the rewind is observable in logcat.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pins that a MathSegment covers only its $-delimited span (not the
paragraph), that space-separated hashtags, URLs and images next to math
stay independently detected, that `$x$.` keeps its trailing period while
a following hashtag remains its own segment, and that currency `$5`
doesn't pair with a later equation. Also documents the glued (no-space)
edge cases.
Audio files have no video dimensions, so the player collapsed to a thin
strip and the playback controls were cramped. Size the inline-feed player
square (full width, height capped at 400dp) for every audio style except
voice notes, which keep their seek-bar strip. Full-screen still fills the
screen.
- AudioPlayerSquare: pure shouldSquareAudioPlayer decision + audioSquareSide
math + a Modifier.audioSquare layout modifier (guards zero-width/unbounded
first-layout passes), with unit tests.
- Detect audio via imeta mimeType (up front, no resize) plus a shared
rememberIsAudioTrack composable (runtime fallback for bare-URL audio),
extracted from AudioPlayingAnimation so the visualizer and the container
share one signal.
- CLASSIC keeps its compact 48dp wave centered inside the square; the
spectrum styles fill it via the existing audioVisualizerHeight policy.
Memory pruning drops DM messages out of the cache but left the per-relay
paging cursors untouched, so a relay still claimed to have delivered the
dropped band (reachedUntil deep, or done) and the demand-driven loader
never re-requested it — a silent hole until app restart.
- Prune NIP-17 too: pruneMessagesToTheLatestOnly now reaps both NIP-04
(PrivateDmEvent) and NIP-17 (WrappedEvent rumors) on one merged top-N
cut, so a conversation is cut at a single time point (no NIP-04-without
-NIP-17 holes). NIP-17 is the actual memory-pressure driver.
- HostStub carries the host's createdAt, so a decrypted rumor self-
describes its outer gift-wrap time (the time the cursor pages by; the
rumor's own time is the message time, not the wrap time).
- RelayLoadingCursors.rewindTo() pulls a relay's reached cursor up past
the pruned band, clears done, and un-arms it (demand-driven re-fetch);
advance() now resumes from the rewound reached point instead of the
floor.
- LocalCache.pruneOldMessages accumulates the newest pruned created_at
per relay (outer-wrap time for gift wraps, event time for NIP-04),
filtered below each cursor's floor, then rewinds giftWrapHistory +
rooms-list nip04History (account-wide) and the per-conversation
nip04History.
The gift-wrap window is account-global, so pruning one room rewinds the
shared sweep; the interference is bounded (already-held wraps short-
circuit in consumeRegularEvent, re-fetch is demand-gated).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- RadialRenderer: guard minDim <= 0 so a transient 0-size canvas never builds Brush.radialGradient(radius = 0f), which throws.
- AuroraRenderer: clamp y with coerceIn(half, maxOf(half, h - half))
- PcmTapRegistry: never evict a flow that a composable is still collecting (subscriptionCount > 0)
- Aurora/Waves: build the palette-only gradient brushes once via remember(palette)
- audioVisualizerHeight: clamp the fallback strip to min(fallback, maxHeight)
- normalizeToPeakInPlace gains a fromIndex param
- Remove unused silentSpectrum()
feat(audio): wire audio-visualizer settings into navigation and menu
feat(audio): add audio-visualizer settings screen with live previews
feat(audio): add audio-visualizer settings strings
fix(audio): move visualizer setting to Account section; render at feed size
feat(audio): add Classic (default) and Static visualiser styles
feat(audio): show selected live visualiser for audio notes
feat(audio): add change/read accessors for audio-visualizer preference
feat(audio): expose synced audio-visualizer preference flow
feat(audio): add media prefs to synced-settings internal model
fix(audio): thread-safe tap registry, reset spectrum on track reuse
feat(audio): tap decoded PCM via TeeAudioProcessor in pooled players
test(audio): unit-test PCM sink with synthetic sine waves
feat(audio): add PCM-tap registry and FFT audio-buffer sink
fix(audio): continuous viz clock, safe peak-normalize, OFF layout, palette guards
feat(audio): add AudioVisualizer dispatcher composable
feat(audio): add renderer interface, canvas scaffold, registry, and all five styles
feat(audio): add deterministic synthetic spectrum for previews
feat(audio): add VisualizerStyle enum and palette
refactor(audio): drop Visualizer FFT helper, add peak normalization
feat(audio): add Hann windowing + PCM-to-float conversion
feat(audio): add pure-Kotlin radix-2 FFT for the visualiser
The first cut special-cased the whole per-line loop with an
`if (mightContainMath)` branch that duplicated the word-split +
wordIdentifier logic and filtered empty words inconsistently with the
non-math path (which preserves them to keep double-spaces).
Reframe MathParser.split as a drop-in replacement for `line.split(' ')`
that returns typed Word|Math tokens, keeping math spans whole instead of
tearing them at internal spaces. For a math-free line it yields exactly
the same words (empties included), so RichTextParser collapses to a
single uniform map with an exhaustive when — no branch, no duplication.
Also fixes end-of-sentence math: a span glued to trailing punctuation
(`$x$.`) now carries that punctuation as a `trailing` field rendered
adjacent to the equation, mirroring HashTagSegment's extras, instead of
being dropped to a plain word.
https://claude.ai/code/session_01N8ZhVv9912DLGNJiErVTR4
Posts that use the common dollar-delimiter convention (e.g. the
math-academy "Linear Independence" note) now render their formulas
as real equations instead of raw LaTeX text.
- commons: new MathParser tokenizes a line into atomic math spans
(kept whole, since they contain spaces) interleaved with plain text,
following the pandoc/remark-math dollar rules so currency like
"$5 and $10" and escaped "\$" don't false-fire. New MathSegment
carries the inner LaTeX + display flag through the rich-text pipeline.
- RichTextParser splits math out before the whitespace word-splitter
when a line might contain math; non-math lines keep the existing path.
- amethyst: LatexEquation renders a MathSegment via JLaTeXMath, tinted
to the current text color and sized to the font, with a raw-text
fallback when the formula fails to parse. Wired into both the
preview and no-preview render paths of RichTextViewer.
Scope: dollar delimiters only, regular (non-markdown) render path.
https://claude.ai/code/session_01N8ZhVv9912DLGNJiErVTR4
- Expose a nullable descriptor
- Log the JSON element kind instead of the raw, network-sourced value.
- drop birthday happy-path tests duplicated by UpdateMetadataTest
- Make birdex_species_preview_more a <plurals> keyed on the remaining count
- Bound the species preview with maxLines=2
- Hoist the joined-names remember out of the conditional (stable slot).
- Drop the unused accountViewModel parameter
- BirdexEvent.speciesCount() derives from speciesNames().size instead of re-scanning tags
- remember() the joined species-name string so it is not rebuilt on every recomposition.
The single-active BackwardRelayPager applies forwarded relay callbacks to
whichever scope is currently bound. Its doc already states this is "safe as long
as the caller only advances the bound scope", but a subscription for a
*just-backgrounded* scope (conversation navigation overlap, account switch, a
second pane) can still deliver a late onEvent/onEose/onClosed — which would move
the newly-bound scope's cursors instead. Now that those cursors persist on the
Chatroom/ChatroomList model, that corruption would stick.
Add BackwardRelayPager.isBoundTo(cursors) (cursor identity == scope identity) and
gate each manager's forwarded callbacks on it, so a stray callback from a
non-bound scope is dropped, not mis-applied. The framework's own newEose
bookkeeping still runs. No-op on the happy single-scope path.
PerRelayLoadTracker silenced (→ stalled) any in-flight relay after 15 s of total
cohort dead air. Over Tor, REQs queue on a not-yet-connected socket and circuits
routinely take 20–80 s to come up, so relays — including the user's primary —
were being flagged "stalled" before they ever connected (visible in the Messages
trace: vitor's history REQ went out at +0 s, was silenced at +15 s, and only
actually hit the wire at +77 s). Bump the window to 60 s. lastActivityMs is
global, so any relay delivering keeps it fresh for the whole cohort — this only
fires on total dead air, and a genuinely dead relay still settles via CLOSED /
cannot-connect, not this watchdog.
Two correctness bugs in `RemoteSignerManager` (NIP-46) and its NIP-55
sibling `IntentRequestManager`:
1. **Double-resume crash** — `awaitingRequests.get(id)?.resume(value)`
was non-atomic. Multi-relay delivery, bunker echo/retry, and
late-after-timeout responses could call `resume` twice for the same
continuation, throwing `IllegalStateException: Already resumed` on a
`Dispatchers.Default` worker.
2. **Retry id-reuse → wrong data** (NIP-46 only) —
`launchWaitAndParse` built the request and event once, then re-used
the same `request.id` across retry attempts. A late response from
attempt N could resume attempt N+1's continuation with stale data.
Replace the cached-`Continuation` map with the in-house Channel-per-request
correlation pattern already used in `quartz/.../accessories/NostrClientPublishExt.kt`
(`LargeCache<id, Channel<Response>(capacity=1)>` + atomic `remove` +
`trySend` + `withTimeoutOrNull { receive() }`). Each retry attempt now
builds a fresh request with a new id; the builder is still called only
once. `finally`-block cleanup removes the cache entry on every path,
incidentally fixing a slow leak on the success path.
Adds three regression tests:
- duplicate responses → no crash + single resume (fails on \`main\`
with \`IllegalStateException\`)
- late response after timeout → silently discarded
- late attempt-1 response does not corrupt attempt-2 result (fails on
\`main\`: the two attempts share an id)
Design + review notes: \`quartz/plans/2026-06-03-fix-nip46-bunker-double-resume-plan.md\`
Two reported chatroom-screen issues.
Bug 1 — the NIP-04 card's `⋯` paused state showed a bare protocol tag with no
relay count, even though tapping it listed 5 relays. historySubtitle only
counted relays that were *in-flight* (relayCount) or *stalled*; a relay that
returned a page and parked (the paused state) is neither, so it fell through to
the bare tag. The card now derives a "reaching" count from relayProgress (not
done && not stalled) — covering both fetching and parked relays — so the
subtitle reads "N relays · back to <date>", matching the popup.
Bug 2 — the in-stream "Relay sync: ✓ 5" divider was a non-interactive dead end
and didn't say which protocol it meant (it mixes NIP-17 + NIP-04). Give each
RelayReachCursor a protocol tag, make RelayReachMarkers tap-through (optional
onShowDetail callback), and add RelayReachDetailDialog listing the relays at
that point in the stream with protocol · state glyph · reach-back date. The
conversation view hoists the dialog state and wires the tap; the marker stays a
passive divider wherever onShowDetail isn't supplied (rooms list unchanged).
Compiles: commons (JVM) + amethyst. iOS not buildable in this sandbox (toolchain
download blocked) but avoids the destructuring-in-composable pattern the file
guards against.
On the short new note screen, polls and zap polls kept their option text in
two separate maps (`pollOptions` vs `zapPollOptions`). Switching the poll type
only toggled the `wantsPoll`/`wantsZapPoll` flags, so the text typed for one
type was hidden and never reused for the other.
Make both poll types read and write the same `pollOptions` text fields, so
switching between zap and non-zap polls preserves whatever the user already
typed. The zap-specific settings (deadline, vote value range, consensus
threshold) remain independent.
Two follow-ups to the reply-context PR.
1) Parent-author metadata wasn't reaching the embed / "Replying to @X"
label, so they rendered the truncated hex indefinitely.
- FeedScreen.missingNoteIds: also fetch the immediate parent EVENT
for visible replies (was only repost originals + bech32 quotes).
- FeedScreen.missingAuthorPubkeys: also include the parent AUTHOR
hex, extracted DIRECTLY from each reply's tags
(CommentEvent.replyAuthor() for NIP-22; taggedUsers().lastOrNull()
for NIP-10) so the kind 0 request fires even before the parent
event itself arrives in cache.
- NoteCard.QuotedNoteEmbed + FeedScreen.rememberReplyContext:
produceState observation of the parent author's
metadata().flow so the embed and label recompose to display name
+ avatar once kind 0 lands.
2) Embedded parent appeared clickable but did nothing — the outer
NoteCard's OutlinedCard onClick was catching the click and
re-navigating to the reply's own thread (the current view). Make
the wrapping Box itself clickable, route it to the parent thread,
and drop the inner OutlinedCard's onClick so there's a single
explicit click surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RichTextParser splits each paragraph on ' ' so every segment is one
space-delimited token; the source space lives BETWEEN segments, not
within them. When a paragraph contains only RegularTextSegments the
parser collapses them back to one segment rejoined with " ". When the
paragraph also contains a mention/hashtag/link the segments stay split
and DesktopRichTextViewer rendered them in a FlowRow with no horizontal
gap — every word glued together.
Set the FlowRow's horizontalArrangement to Arrangement.spacedBy(4.dp)
(the same constant the file already uses for ImageGalleryParagraph),
preserving the RTL alignment.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Replies tab predicate used `!note.isNewThread()`, which returns true
whenever Note.replyTo is non-empty. The cache populates replyTo from
event.tagsWithoutCitations(), and that includes unmarked positional
NIP-10 e-tags — which modern clients use for QUOTES and MENTIONS, not
replies. Posts that merely quoted another note were therefore appearing
in the Replies tab.
Tighten the signal: a reply is now either a NIP-22 CommentEvent, or a
NIP-10 TextNoteEvent carrying an explicit `reply`/`root` marker tag
(`markedReply()` / `markedRoot()`). Unmarked e-tags no longer qualify.
Adds 6 regression tests including the unmarked-e-tag false-positive
case the user reported.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a dedicated "Replies" tab between Notes and Reads on the desktop
profile screen so the reply-context rendering can be eyeballed on a
specific user's profile without scroll-hunting for an organic reply.
- DesktopProfileFeedFilter gains a repliesOnly: Boolean = false ctor
param. Default keeps Notes-tab behavior unchanged; when true, the
predicate becomes `event is TextNoteEvent && !note.isNewThread()`
(excludes reposts and chat-message kinds in one check).
- UserProfileScreen: second DesktopFeedViewModel for the replies feed,
new tab at index 1, body branch mirroring the Notes Loading/Empty/
Error/Loaded states. Reads/Gallery/Highlights indices shift by 1.
NIP-22 kind 1111 deferred — most replies today are kind 1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Detect NIP-10 / NIP-22 replies in the desktop feed pipeline and render an
embedded parent card plus a "Replying to @displayName" label above the
reply body, matching Android's home-feed behavior. Extracts the shared
ReplyToLabel composable + ReplyContext data class to commons so Android
switches over to the shared version.
- commons/.../ui/note/ReplyContext.kt: data class + from(event, cache)
detection. NIP-10 + NIP-22 unified via BaseThreadedEvent polymorphism.
- commons/.../ui/note/ReplyToLabel.kt: shared composable.
- commons/strings.xml: new "Notes & Replies" section + replying_to key.
- desktopApp NoteCard: replyContext param + render branch (bordered
QuotedNoteEmbed + ReplyToLabel). Recursion impossible because
QuotedNoteEmbed's inner NoteCard call doesn't pass replyContext.
- desktopApp FeedScreen: rememberReplyContext() observes parent
metadata flow so embed/label pop in once the parent arrives via
relay subscription. Wired into both regular and reposted-inner paths.
- amethyst ReplyInformation.kt: removed local ReplyToLabel definition.
- amethyst Text.kt: calls shared commons ReplyToLabel; resolves author
display name at the call site.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Agora (a crowdfunding client on the Ditto stack) publishes fundraising
campaigns as kind 33863 — an app-specific addressable kind with no NIP.
Amethyst had no parser or renderer, so it hit the "Event Not Supported"
path and was dropped; reposts of one rendered as a permanently blank card.
Add first-class support, modelled on NIP-99 Classifieds (title/image/body)
plus NIP-75 zap goals (goal/deadline/progress).
The design doc still described the pre-keyless engine ("BackwardRelayPager<K>...
owns the cursors... in quartz"). Update it to current reality:
- the engine paragraph: keyless single-active orchestrator that does NOT hold
the cursors (RelayLoadingCursors live on the Chatroom / ChatroomList), binds
per scope.
- the component map: split into "Paging primitives (quartz commonMain)" —
RelayLoadingCursors, RelayPagingProgress — and "Paging orchestrators (commons
relayClient/paging, jvmAndroid)" — BackwardRelayPager, PerRelayLoadTracker,
WindowLoadTracker. Fixed stale test notes (the deleted silence test;
BackwardRelayPagerTest now in commons jvmTest; diagnostics are amethyst-side).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per commons/ARCHITECTURE.md, quartz is protocol/NIPs/crypto/relay framing while
commons owns the relay-subscription client and StateFlow state holders. The
paging *orchestrators* are exactly that — StateFlow-backed, subscription-loading
state — so they belong in commons, not quartz:
- BackwardRelayPager, PerRelayLoadTracker, WindowLoadTracker (+ trackingListener)
-> commons/relayClient/paging (jvmAndroid source set, same as before).
- BackwardRelayPagerTest -> commons jvmTest.
The pure protocol-paging primitives stay in quartz commonMain:
- RelayLoadingCursors (the until+limit cursor mechanics) and RelayPagingProgress.
They had no upward deps, so the move is downhill (commons -> quartz): the
orchestrators now import RelayLoadingCursors / RelayPagingProgress from quartz.
Consumers (the six DM managers/assemblers + WindowLoadTrackerIdleTest) repoint
their imports to the commons package. The quartz geode wire test keeps testing
the relay contract; its lone BackwardRelayPager KDoc link is demoted to a
backtick (no longer reachable from quartz).
No behaviour change. DM suite green (26/26); quartz + commons compile on iOS.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Same principle as RelayLoadingCursors: BackwardRelayPager, PerRelayLoadTracker,
WindowLoadTracker and RelayPagingProgress are reusable quartz classes, so their
docs shouldn't lean on Amethyst's Chatroom / ChatroomList / feeds / loading card
/ on-screen markers / sentinels / "decrypted into rooms" / invalidateFilters().
Reworded to generic library terms ("the caller", "the bound scope", "a
demand-driven loader", "a per-relay progress display", "the owning object").
Comment-only. (The DMPagination log tag stays — it's an established log key,
not doc prose.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
It's a reusable quartz class, so it shouldn't document itself in terms of
Amethyst's Chatroom / ChatroomList / feeds / on-screen markers / sentinels /
invalidateFilters(). Reword generically: "the caller holds one instance per
scope on whatever object owns it", "a demand-driven loader", "the loaded-back-to
point", "the owner re-issues the relay's REQ". BackwardRelayPager demoted to a
backtick mention (it's jvmAndroid, so the KDoc link can't resolve from
commonMain anyway).
Comment-only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Same content, ~⅓ shorter: fold the per-relay/on-demand intro into the
asked-vs-delivered framing, compress the time-window rationale and the
thread-safety note, keep the two-cursor explanation and the short-page caveat.
Comment-only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After the class moved to commonMain and was renamed, two doc nits remained:
- [BackwardRelayPager] is a jvmAndroid type, so the KDoc link can't resolve
from commonMain — demote it to a plain mention.
- the bare [done] links pointed at the private RelayCursor.done, not a member
of this class — repoint them to the public [isDone].
Also reword "Not internally synchronized" (it leans on the thread-safe
LargeCache + serialized per-relay callbacks) to not read as a contradiction.
Comment-only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Once the pager was split into the orchestrator (BackwardRelayPager) and the
pure per-relay cursor state that lives on the model, "UntilLimitPager" no longer
described the latter — it pages nothing, it just records how far each relay has
loaded. Rename it (and its test) to RelayLoadingCursors.
The geode wire-contract test keeps its name (UntilLimitPagingRelayTest): it
pins the relay-side `until`+`limit` paging behaviour, not the class.
Pure rename — no behaviour change. Design doc updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The DM history widgets extracted into commons were never compiled for the
commons iOS target, which hid two Kotlin/Native-only breaks:
- RelayReachMarker: `toSortedMap(compareBy { it.ordinal })` + a destructured
`(state, list)` Map.Entry inside an inline @Composable lambda don't type-infer
on Native. Rewrite as `.entries.sortedBy { it.key.ordinal }` with explicit
`entry.key` / `entry.value`.
- DmHistoryLoadingCard referenced RelayPagingProgress, which sat in quartz's
jvmAndroid source set — visible to commonMain only when building JVM/Android,
not iOS. It's a pure data class, so move it to quartz commonMain.
commons:compileKotlinIosArm64 now succeeds; JVM/Android unaffected and the DM
test suite is still green (26/26).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The history pagers were keyed by account / (account, conversation) inside the
quartz engine, with all per-key state in inner hashmaps + an activeKey +
activate() machinery. But the loaders are per-account-VM and the on-screen
scope is single-active, so the key was redundant indirection.
Move the per-relay cursor *state* onto the domain object whose lifetime it
should share:
- UntilLimitPager is now keyless (per-relay cursors + a pinned floor only) and
lives in commonMain (LargeCache<NormalizedRelayUrl, RelayCursor> — keyed only
by relay url, which is Comparable + equals-consistent, so the sorted cache is
safe; kotlin.concurrent.Volatile for the fields). It is stored on:
* Chatroom.nip04History (per conversation)
* ChatroomList.giftWrapHistory (account NIP-17)
* ChatroomList.nip04History (account rooms-list NIP-04)
The LocalCache object graph is now the partition; cursors are dropped exactly
when the cached messages they describe are pruned, and survive an account
switch (no re-page on switch-back).
- BackwardRelayPager is now a keyless single-active orchestrator: it owns only
the transient bits (in-flight tracker, stalled set, display flows) and binds
to the active scope's cursors via bind(cursors, scope, relaysFor). Removed
activeKey / activate() / the per-key exhausted+floor+stalled maps. Safe as
single-active because history relays only arm while their markers are
on-screen, so a backgrounded scope emits no callbacks.
The three assemblers resolve the scope's cursors from the account's
chatroomList and bind on newSub; the redundant `user` arg dropped from the
account-level advance/advanceAll (callers updated).
Behaviour change: switching between two conversations no longer keeps both
rooms' cursors live in one engine — each room's cursors persist on its own
Chatroom instead, so reopening a room restores its progress (strictly better).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The relay-paging trackers measured elapsed wall-clock with
System.currentTimeMillis() directly. Introduce a multiplatform millisecond
clock (currentTimeMillis expect/actual across jvm/android/ios/macos/linux,
mirroring the existing currentTimeSeconds) exposed as TimeUtils.nowMillis(),
and route PerRelayLoadTracker + WindowLoadTracker through it.
TimeUtils.now() is seconds, so it can't be used for the ms-scale silence /
idle / linger timers — nowMillis() is the correct primitive. No behavior
change (same underlying clock on JVM/Android).
Note: the two trackers stay in jvmAndroid for now — @Synchronized has no
commonMain equivalent. ConcurrentHashMap is kept deliberately: LargeCache is
a sorted ConcurrentSkipListMap (Comparable, compareTo-identity keys) and the
pager keys (ConvoKey not Comparable; ChatroomKey.compareTo is hashCode-based)
don't satisfy that, so ConcurrentHashMap is the correct structure here.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two naming families described the same concept — a relay's position in its
backward history walk — and the UI name overloaded the heavily-used "window"
and REQ "limit" terms. Collapse onto one vocabulary ("Reach"):
- RelayWindowLimit -> RelayReachCursor
- RelayWindowLimitMarkers -> RelayReachMarkers
- RelayWindowLimitSentinels -> RelayReachSentinels
And rename the NIP-04 per-relay routing map so it reads as a map, not a list:
- Nip04DmRelays (class) / nip04DMRelays (factory) -> Nip04DmRelayRouting / nip04DmRelayRouting
Pure rename — no behavior change. Design doc updated to match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- BackwardRelayPager.floorFor was public but only ever called inside the
pager (and its same-module test) — narrow to internal.
- RelayReachMarker composable was public but only rendered by
RelayWindowLimitMarkers in the same file; the public entry points are
RelayWindowLimitMarkers/Sentinels — make it private.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Removes machinery that ships but is unreachable from any live path:
- WindowLoadTracker's REQ-aware backstops (silence + connect-grace). All
three live-tail managers construct it with the default tracksReqSends =
false, so onReqSent/reqSentAt/silencedOut/connectStalled and the
onAbandoned reporting could never fire. Only WindowLoadTrackerSilenceTest
exercised them, so it goes too. accountedFor collapses to "settled".
- trackingListener's onEachEvent param — no caller ever passed it.
- UntilLimitPager.isArmed() / activeRelays() — only the unit test called
them; the pager uses armedRelays() in production.
- Orphaned string chats_load_entire_history, left behind when the
"load entire history" button was removed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move isRenderableRepost() (and its test) from amethyst ui/dal into
commons/ui/feeds so both platforms use one implementation, then point
desktop's isFeedNote() at it.
The DM trail showed REQ + CLOSED/NOTICE/OK-fail but was blind to the entire
NIP-42 handshake — incoming AUTH challenges hit `else -> {}`, the AUTH send is
not a REQ so onSent skipped it, and the auth OK(true) was filtered out by the
OK(fail)-only guard. So a CLOSED 'auth-required' followed by a re-REQ was
unattributable: you couldn't tell whether the relay challenged, whether we
signed+sent AUTH, whether it was accepted, and whether the re-REQ was the
post-auth syncFilters or just a blind retry.
Add three lines under the DMPagination tag (DM relays only, debug):
- `AUTH challenge <- relay '<challenge>'` (incoming AuthMessage)
- `AUTH -> relay (id ...)` (our AuthCmd reply; id remembered)
- `AUTH accepted|REJECTED <- relay '...'` (the OK matched to that auth id)
With these, the post-CLOSED sequence reads end to end: challenge → AUTH → accept
→ re-REQ (→ events/EOSE), so an auth-walled DM relay's load can be confirmed or
pinned to the exact link that breaks.
Adds WindowLoadTrackerIdleTest, the one WindowLoadTracker backstop that had no
coverage (silence and connect-grace are already pinned by
WindowLoadTrackerSilenceTest):
- idle completes a window when a relay streams stored events but never EOSEs,
once the stream goes quiet for idleTimeout (the "every relay settled" path can
never finish such a relay).
- the idle gate holds the window open while a still-pending relay has never been
heard from, so a slow connect isn't mistaken for a quiet stream; once that
relay delivers anything and goes quiet, idle then completes it.
Real-time tests with a short idleTimeout, matching the silence suite's style.
Audit follow-up.
- Remove the 12 history-card strings (chats_history_older / all_caught_up /
reached_start / relay_sync / subtitle{,_no_date} / waiting / relays_title /
relay_back / incomplete{,_sub} + the chats_history_relays plural) from
amethyst's default strings.xml: they moved to commons composeResources with
the card and have zero remaining amethyst references. They were branch-new and
not yet translated, so removing the default key is a clean, orphan-free delete.
Kept chats_history_proto_* (still the card's protocolName) and chats_reply_*.
- Extract the "does this relay's reached cursor fall in this gap" check, which
was duplicated (with off-by-one-prone >/<= boundaries) between marker placement
(RelayWindowLimitMarkers) and the paging driver (RelayWindowLimitSentinels),
into a single pure reachedFallsInGap(); both now call it so they can't disagree
about which gap a cursor lives in. Add RelayReachMarkerTest pinning every
boundary (newer strictly >, older inclusive <=, null ends).
- Fix a garbled comment in BackwardRelayPager.onSilenced.
The relay-reach markers and the history status card now live in commons/ui/feeds
(shared Android + Desktop), with the card taking an injected date formatter.
Update the component map's UI section accordingly.
Phase 2 (UI), stage 2. Move DmHistoryLoadingCard + its tap-through per-relay
dialog + the historySubtitle/incompleteSubtitle helpers out of amethyst into
commons/commonMain (com.vitorpamplona.amethyst.commons.ui.feeds), so the same
"older history / all caught up / some relays didn't respond" boundary card can
back any per-relay BackwardRelayPager feed on Android and Desktop.
The card's localized date formatting (SimpleDateFormat/Locale) can't live in
commons commonMain (it targets iOS/linux/macOS, no java.*), so it's injected as
a formatReachDate: (epochSeconds) -> String lambda — the platform that renders
the card supplies its native formatter, no i18n regression. Android passes
formatHistoryReachDate (new HistoryDateFormat.kt). The dialog's per-relay reach
now uses that same month-precision formatter (was "MMM d, yyyy", now "MMM yyyy")
— a negligible cosmetic change.
Its ~11 strings move to commons composeResources, including the module's first
<plurals> (chats_history_relays). The three Android call sites (ChatroomView,
ChatroomListFeedView, LoadingReplyNote) now import the shared card/helpers and
pass the formatter; no behaviour change. The old amethyst string copies are left
in place (harmless, separate resource namespace) for a later cleanup pass.
Phase 2 (UI), stage 1. Move the per-relay reach markers — RelayReachState,
RelayReach, RelayWindowLimit, RelayWindowLimitSentinels (the hoisted,
visibility-driven paging driver), RelayWindowLimitMarkers, and RelayReachMarker
— out of amethyst into commons/commonMain (com.vitorpamplona.amethyst.commons.ui.feeds)
so the Desktop chats UI (and any feed) can render the same in-stream paging
progress, not just Android.
De-Android-ified: the one Android string (chats_history_relay_sync) becomes a
CMP composeResources string in commons; the two theme constants (DividerThickness,
HalfPadding) are inlined (0.25.dp / padding(5.dp)) so the shared component carries
no app-theme dependency. Logic is otherwise byte-identical. The Android
conversation + rooms-list views now import the shared version; no behaviour change.
The per-relay paging primitives moved to quartz (nip01Core/relay/client/paging,
jvmAndroid source set) and the three history managers now delegate their shared
bookkeeping to BackwardRelayPager. Update the architecture note and the
component map (new toolkit location + the two new quartz tests) accordingly.
Step 3+tests of the pagination generalization. The gift-wrap, conversation
NIP-04, and rooms-list NIP-04 history managers each reimplemented the same
per-relay cursor / in-flight / stall / exhausted / display-flow bookkeeping;
they now delegate all of it to the shared BackwardRelayPager and keep only
what is genuinely theirs: building the protocol's REQ filters, the relaysFor
lookup, and forwarding subscription callbacks. ~550 lines of duplicated logic
removed; the public API (loadingMore/exhausted/relayCount/stalledCount/
reachedBack/relayProgress, advance/advanceAll) is unchanged, so the UI is
untouched. Behaviour-preserving.
Tests (quartz jvmAndroidTest):
- BackwardRelayPagerTest drives the engine's callbacks directly and pins the
logic that backed the bugs in this branch: empty page -> done -> caught up;
a CLOSED / cannot-connect relay -> stalled -> exhausted-but-INCOMPLETE
(stalledCount > 0, not "all caught up"); re-advance clears a stall; the
reached cursor is the deepest across relays; a done relay won't re-advance;
advanceAll arms only not-done relays; switching the active key repoints the
display flows and restores a backgrounded key's terminal state.
- UntilLimitPagingRelayTest drives a real NostrClient against the in-process
relay (geode) to pin the wire contract the design rests on: a backward
until+limit walk returns each event exactly once (no re-download), newest
first, capped at the limit, with an empty page + EOSE as the gap-proof stop.
Step 1+2 of generalizing the DM history pagination into a reusable toolkit.
Move the four transport-agnostic primitives out of the amethyst module into
quartz's jvmAndroid source set, new package
`nip01Core.relay.client.paging`: UntilLimitPager, PerRelayLoadTracker,
WindowLoadTracker, RelayPagingProgress. They were already pure Kotlin (no
Android deps); jvmAndroid keeps their java.util.concurrent / @Synchronized
concurrency without a KMP-atomics rewrite, while making them visible to
amethyst, desktop, and quartz's jvmAndroidTest (geode in-process relay) for
the integration tests to come.
Add BackwardRelayPager<K>: the generic per-relay backward-pagination engine
that collapses the ~80%-identical pager+tracker+status+exhausted bookkeeping
the three DM history loaders each reimplement. It owns the cursors, in-flight
+ silence tracking, stalled set, pinned floor, and the display StateFlows
(relayProgress / exhausted / reachedBack / relayCount / stalledCount); the
caller supplies only the filter builder, the subscription wiring, and a
relaysFor(key) lookup. Not yet wired into the managers — that swap is step 3.
Pure relocation + new component; no behavior change. UntilLimitPagerTest and
WindowLoadTrackerSilenceTest stay in amethyst (they use JUnit) with explicit
imports added, and both still pass against the relocated classes.
The doc had drifted from the implementation on two material points:
- "Update 3" claimed the rooms-list and gift-wrap *history* managers still use
the round model. They were both migrated to the per-relay until+limit model
(commits 60b8629a, 9f0ecd54); all history paging is now per-relay. The round
model (WindowLoadTracker) survives only as the live-tail completion barrier.
- The rooms-list "stall-gate" it described was removed (commit 98fb8720); the
rooms list now pages to exhaustion off marker visibility like the convo.
Restructured so the current architecture is authoritative and up front (two
layers, the two completion models and where each lives, the marker/sentinel
driver, per-relay NIP-04 filter scoping, terminal-state split, reply
placeholder, diagnostics, Tor self-heal), with a component map and review
notes. The superseded time-slice and round-model history are kept clearly
labelled under "Design evolution (historical)".
The terminal state of both DM history cards equated two very different things:
a relay that genuinely bottomed out (empty page = done) and one that merely
stalled (auth CLOSE / offline / 15s silent). `exhausted` flips true when every
relay is done OR stalled, and the card rendered that unconditionally as "All
caught up" — so a chat whose messages sit on a stalled relay claimed completion
while a lot of history was still unfetched. The reply placeholder had the same
flaw and, worse, collapsed to a bare 👀 (post_not_found_short) that told the
user nothing.
Split the terminal state by stalledCount:
- caughtUp (all done): keeps "All caught up" and the lingering collapse.
- incomplete (>=1 stalled): "Some relays didn't respond · N unreachable",
error-coloured glyph, stays put (no collapse).
Both cards are tappable into the existing per-relay popup, so the user can see
exactly which relays stalled (… in error colour) vs reached the bottom (✓).
The reply placeholder now says "Couldn't find this message" with an honest
subtitle — "N relays unreachable · tap to see which" when stalled, or "Searched
every relay · tap to see" when it genuinely isn't in history — instead of 👀.
The card summarized progress but hid the per-relay detail that relayProgress
already carries. Make the card tappable: it opens a popup listing every relay
with its state (✓ done · … stalled · ↓ reaching) and how far back it has paged
('back to <date>'), deepest-reaching first. Passes each loader's relayProgress
through to the card; empty progress keeps the card non-interactive.
The card's count was loadTracker.count() = relays in-flight, which drops to 0
the moment they all stall — so historySubtitle hit its relayCount<=0 guard and
showed just the bare protocol tag ('NIP-17' / 'NIP-04'), with no hint of how
many relays it was waiting on.
Expose a stalledCount (not-done relays that can't be reached right now) from all
three history loaders and render it on the paused card as 'waiting on N relays'
when nothing is in flight. Active fetching still shows 'N relays'; a parked-but-
reachable protocol still shows just the tag (it isn't waiting on anything —
it resumes on scroll).
The demand-driven rewrite dropped the round model's 'load done: all relays'
completion line, leaving no aggregate signal for 'this is as far as history
goes right now'. Log one per protocol on the exhausted false->true edge, with
the done vs stalled relay breakdown, restoring that diagnostic.
The per-relay window-limit sentinel placed its advance() effect inside the
gap row that currently hosts the marker, so its identity rode that row. Any
feed reorder (a live DM bumping a room, or a slow relay dribbling a history
page) moved the gap to a different row, tore the keyed LaunchedEffect down and
recreated it, and re-fired advance() on a static screen — re-arming stalled/
auth relays into a 15s silence-watchdog storm and risking an unprompted
page-back for delivering relays.
Hoist the driver above the list: RelayWindowLimitSentinels now holds one
stable effect per non-done limit (keyed by lim.key) that watches the
LazyListState and fires advance() only when the marker's gap is among the
currently visible rows AND it just scrolled into view OR its reached cursor
moved (a page landed). A reorder that keeps the marker on the same side of the
fold changes neither, so it no longer re-pages. RelayWindowLimitMarkers is now
pure UI. Wired in the rooms list (inline) and the conversation (via a new
sentinels slot on ChatFeedView).
Verified on device: static rooms list drops from a perpetual ~15s re-fire/
silence storm to 2 silence events and only legit cursor-moved advances, while
demand-driven paging (page back while the marker is visible) is preserved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PerRelayLoadTracker dropped the loading flag the instant in-flight emptied, so
a relay paging page-after-page (each page settles, then the marker fires the
next) flipped loading true->false->true every page — and the card's icon hard-
cut spinner -> paused-glyph -> spinner, a visible blink per page.
Let loading=false linger ~600ms after the last page settles; cancel the linger
the moment the next page starts. Back-to-back pages now show a steady spinner;
the card still flips to paused/done once paging genuinely stops.
The floor (now - 7d) was recomputed on every call, so it drifted forward
over time. For a relay that hadn't delivered yet (reachedUntil == null),
reachedUntilFor returned that drifting floor, so its marker's reached cursor
kept changing, the sentinel's LaunchedEffect(reachedUntil) key changed, and
it re-fired advance() — observed as rooms.nip04.history re-advancing a silent
vitor.nostr1.com every ~15s, with the retry's 'until' moving NEWER each time.
Pin the floor once per account/conversation for the session (matching the
pre-rewrite behavior) in all three history loaders, so an un-delivered relay
parks at a stable cursor and its sentinel fires once.
Also add diagnostics to catch this class of bug quickly: one log line per
marker sentinel fire (key + reachedUntil — a loop repeats the same key, a
drift shows a moving cursor) and one per advanceAll (empty-feed bootstrap).
The per-relay sentinel keyed its LaunchedEffect on (reachedUntil, state).
The state component meant every REACHING<->STALLED transition restarted the
effect and re-issued advance(), so a flaky or auth-walled relay's reconnect/
timeout churn re-paged the window on a completely static screen (observed:
vitor.nostr1.com + auth.nostr1.com re-REQing on every ping timeout / auth
CLOSE with no user interaction).
Key the sentinel on reachedUntil ALONE — a landed page is the only thing
that should pull the next one. A stalled relay now parks until its cursor
moves or its marker is scrolled back into view (one retry, not a loop).
Also: the empty-feed bootstrap now triggers on FeedState.Empty only (not the
transient Loading that navigation flashes through) and is debounced, so
re-opening Messages / a conversation that already has content no longer kicks
a one-round advanceAll across every relay.
Audit follow-ups to the demand-driven paging change:
- UntilLimitPager.onEose: require the reached cursor to move strictly
older each page. A misbehaving relay that returns events but none older
than already reached would otherwise pin the cursor and the on-screen
sentinel would re-request the same window forever; treat it as the bottom.
- updateStatus (gift-wrap + rooms-list NIP-04): only write the shared
display StateFlows for the foreground account, mirroring the existing
exhausted guard, so a background account's late EOSE can't clobber the
on-screen relay count / reached-back / per-relay markers.
- Add UntilLimitPagerTest covering the requested/reached split, park-on-
EOSE, done-on-empty, the strict-older guard, and per-relay independence.
Replace the proactive walk-to-exhaustion with on-demand, per-relay paging
driven by on-screen window-limit markers, so history loads only while the
user is looking at a relay's frontier and a spam-dense relay never floods.
UntilLimitPager: split the cursor into 'requested' (drives the REQ; moves
ONLY on advance()) and 'reached' (oldest delivered; for markers). Leaving
requested untouched on EOSE is what parks a relay — no auto-continuation.
PerRelayLoadTracker (new): tracks which relays have a page in flight (for
the spinner) with a silence watchdog to park relays that accept a REQ then
go quiet. Replaces the window/round bookkeeping for the history loaders.
All three history loaders (gift-wrap account-wide, rooms-list NIP-04,
conversation NIP-04) rewritten: onEose parks instead of self-continuing;
advance(relay) steps one relay one page; advanceAll() bootstraps the
empty/initial boundary; exhausted = every relay done or stalled.
UI: RelayWindowLimitMarkers renders each relay's marker at its reached
depth AND acts as its load sentinel — while the marker is composed
(on/near screen) it pulls that relay's next page (LaunchedEffect keyed on
the reached cursor, so it keeps going page after page while visible) and
stops when the marker scrolls off or the page fills the screen. Wired into
both the conversation (gap markers) and the rooms list (interleaved
between rooms); a BootstrapHistoryWhenEmpty drives advanceAll while the
feed has nothing to scroll.
Removes the round/give-up machinery's last users: drop loadMore/
loadEverything and the scroll-driven WidenHistoryWhen.
Three names didn't describe their behavior:
- removeFromCache → unlinkAndRemove: the method's main job is unlinking the
note from every referrer (parents, channels, the report/card/status/poll
indexes), not just evicting it from the map; the old name only captured
the last step.
- removeAllChildNotes → clearChildLinks: it clears only THIS note's forward
child collections and returns them — it does not touch the children's
replyTo and does not remove anything from the cache. The old name sounded
more aggressive than detachFromChildren(), which is actually the
both-directions op.
- Note.removeOnchainZap(source) → removeOnchainZapBySource(source): too easy
to confuse with removeOnchainZapForSource(txid, pubkey), which is the
verification-verdict removal with anti-spoof guards. The new name matches
its inner helper (innerRemoveOnchainZapBySource) and disambiguates the two.
Pure rename: no behavior change. Test names/comments updated to match.
https://claude.ai/code/session_01RqJPYzmjb1pR3NBeH2yY3s
The gift-wrap history loader exposed only relayCount + reachedBack, so the
conversation's in-stream marker trail showed NIP-04 relays paging back but
never NIP-17 ones. Give AccountGiftWrapsHistoryEoseManager the same
relayProgress map (per-relay reachedUntil / done / stalled) the
per-conversation NIP-04 loader publishes, refreshed on every page, stall,
CLOSE, and cannot-connect.
Move the shared RelayPagingProgress data class out of the NIP-04 UI-package
assembler into service/relayClient/eoseManagers so the gift-wrap manager
can produce it without the service layer depending on a UI package.
ChatroomView now merges both protocols' progress into the gap markers, each
contributing only while it is still paging; a relay that serves both (the
DM inbox relays) collapses to one marker. Markers hide once both protocols
are exhausted.
Convert ChatroomListNip04HistorySubAssembler from the round-based model to
the per-relay-independent one, matching the per-conversation NIP-04 loader
and the gift-wrap history loader. A single loadMore opens one window
spanning the whole walk; each relay continues itself on its own non-empty
EOSE (beginRound([relay]) + invalidateFilters), and silent/unreachable
relays are marked stalled (kept open) with exhaustion coming from the
WindowLoadTracker's silence + connect-grace backstops (tracksReqSends=true).
The rooms list now pages both protocols identically.
This was the last user of the round-tally + give-up machinery, so remove it
from UntilLimitPager: roundEventCount(), onClosed()/giveUp() and the
givenUp/closedStreak cursor state + GIVE_UP_AFTER_CLOSES. activeRelays now
excludes only done relays. Delete UntilLimitPagerGiveUpTest (tested the
removed give-up path; abandonment is now the WindowLoadTracker's job, which
its own test covers).
Also drop the now-dead loadEverything/autoLoadAll and the no-progress retry
(no callers; with independent paging one loadMore already walks each relay
to its bottom, and the tracker's backstops cover cold starts).
The gift-wrap history loader was round-based: loadMore asked every active
relay together and the next page only went out after the whole round
settled, so one slow or auth-walled relay throttled the cadence and fast
relays idled until the laggards finished. The per-conversation NIP-04
loader already pages each relay independently; this brings NIP-17 to the
same model.
Now a single loadMore opens one window spanning the whole walk, and each
relay continues itself the instant it EOSEs a non-empty page
(pager.beginRound([relay]) + invalidateFilters, which the sub layer diffs
so only the advanced relay re-REQs). Fast relays race to the bottom while
slow ones catch up in the background. The WindowLoadTracker switches to
tracksReqSends=true with an onAbandoned handler that marks silent/
unreachable relays stalled (kept open, still trying) rather than giving up
on them - exhaustion comes from the window settling via the tracker's
silence + connect-grace backstops, which also removes the need for the old
no-progress retry loop.
Drop the now-dead loadEverything/autoLoadAll (no callers; with independent
paging one loadMore already walks each relay to its bottom). Pin the
history floor per window to keep un-advanced relays' filters stable across
the per-EOSE invalidateFilters, and keep reachedBack monotonic over all
relays.
deleteNote() (NIP-09) and removeFromCache() (prune) had drifted into two
near-duplicate "detach a note from the cache" routines. Removal really has
two halves — (1) unlink the note from everything that points AT it, and
(2) handle the note's OWN children — and only the second half differs
between the paths. removeFromCache() already implemented half (1)
completely, so deleteNote() now delegates to it and keeps only its two
delete-specific responsibilities: tearing down gift-wrap hosts and
severing (but keeping) its children via detachFromChildren().
This also fixes a real leak the duplication was hiding. computeReplyTo()
has no ReportEvent branch, so a report note's replyTo is empty and the
report→target link lives only in the explicit reported* index handling.
The old deleteNote() only undid reportedAuthor(), so deleting an
event-level report (reportedPost / reportedAddresses) left the reported
note's `.reports` map holding the removed report note — a partial deletion
that leaked the shell and risked a duplicate Note for the same id.
Delegating to removeFromCache() (author + post + addresses, all
idempotent) closes that gap.
Net behavior change is the report-leak fix only; the redundant
TorrentCommentEvent case is dropped because the torrent target is already
in replyTo (and removed via removeNote), and its @Suppress("DEPRECATION")
goes with it. Adds KDoc to both methods documenting the two-halves model.
https://claude.ai/code/session_01RqJPYzmjb1pR3NBeH2yY3s
Reframe from a forward-looking plan diff to the current architecture, and fix
the onAuthenticated signature to (event): Boolean (pubKey was dropped). All
structural elements already matched the merged code.
The unloaded-reply loader matched the history card's chrome but dropped
its status line. Surface the same detail: which protocol, how many relays
it's still asking, and how far back it has paged - reusing the card's
historySubtitle so the inline loader and the oldest-end card read
identically.
The auth set is already a per-RelaySession instance field (one session per
connect(), fresh policy per connection), so two connections never share auth
state. Add a regression test: authenticate different pubkeys on two connections
of the same server and assert each scope holds only its own — no union leak.
deleteNote() removed the target from its parents, gatherers, and the cache
map, but never cleared its own child collections nor dropped itself from
its children's replyTo. That left a partial deletion: every child kept the
removed shell alive through replyTo (a leak), and a reply resolved later
via computeReplyTo would getOrCreateNote a *second* Note for the same id —
breaking the one-Note-per-id invariant.
Adds Note.detachFromChildren(), which clears the note's forward child
collections (via removeAllChildNotes) and severs this note from each
child's replyTo (keeping any other parents). deleteNote() now calls it
before notes.remove(), so once the note leaves the map nothing points at
the dead shell. Orphaned replies become roots, which is correct once their
parent is hard-deleted from the cache.
Adds detachFromChildren coverage to NotePruningReferenceTest.
https://claude.ai/code/session_01RqJPYzmjb1pR3NBeH2yY3s
pubKey was always event.pubKey (the NIP-42 signer is the authenticated
identity), so the parameter was pure redundancy. onAuthenticated(event) and
authorize(event) now read event.pubKey directly; the engine still commits
cmd.event.pubKey. Removes the now-unused HexKey imports from IRelayPolicy and
PolicyStack.
Authentication state (who is logged in on a connection) is connection scope,
not a policy decision. It was stored on FullAuthPolicy, which forced the
AuthScopedPolicy marker, the PolicyStack union, and a downcast in
RequestContext just to route it back out as scope.
Now the engine owns it: RelaySession holds the authenticatedUsers set behind
the (now public) requestContext; the data plane and policies read it through
RequestContext. The policy stays pure decision —
- onConnect(scope, send): a per-connection policy captures the read-only scope
to gate on; shared singletons ignore it.
- onAuthenticated(): Boolean: the policy's vote on whether to record the
verified pubkey (default false, so blind-accept policies never record an
unverified identity). FullAuthPolicy runs authorize() then votes true.
- RelaySession.handleAuth performs the single, engine-side commit after the
whole chain approves and a verifying policy votes to record.
FullAuthPolicy keeps all auth logic (challenge, accept(AuthCmd), gating,
authorize) and gains a protected authenticatedUsers accessor over the scope for
subclasses (restricted content / filter rewrite). Deletes AuthScopedPolicy,
PolicyStack.authenticatedUsers, and the RequestContext downcast.
removeFromCache() relied solely on note.inGatherers to detach a pruned
note from its channels, while deleteNote() additionally resolved the
channel via getAnyChannel() and removed the note there too. inGatherers
is normally authoritative (Channel.addNote always calls addGatherer), so
this is defensive rather than a confirmed live leak — but it closes the
divergence so both removal paths detach channels identically. Guards
against any future consume path that adds a note to a getAnyChannel-
resolvable channel without the gatherer link: otherwise the note would
linger in the channel's notes map after leaving the cache, leaking it and
letting a relay echo mint a duplicate Note with the same id.
https://claude.ai/code/session_01RqJPYzmjb1pR3NBeH2yY3s
Give LoadingReplyNote the same chrome as DmHistoryLoadingCard - rounded
translucent surface, spinner-in-a-box, status line - so an unloaded reply
reads as the same 'reaching back into history' state, just inline in the
quote instead of at the oldest end.
Reply quotes inside a conversation rendered the same 'post not found'
BlankNote as the main feeds when the target message had not been paged in
yet. But a reply target in a DM is not missing - it is simply older than
the loaded window, and for NIP-17 the inner rumor id is not even queryable
on relays (only the outer gift-wrap id is), so the only way to surface it
is to keep paging gift-wrap history until the wrap carrying it decrypts.
Add LoadingReplyNote: a custom inner-quote placeholder that shows a small
spinner + 'looking for the original message' and drives the matching
history pager's loadMore in a loop (gated on its own loadingMore/exhausted)
until the target decrypts - at which point WatchNoteEvent crossfades the
real message in and disposes the loader - or the protocol's history runs
dry, settling into the terminal 'not found' text. It runs regardless of
scroll position so opening a thread pulls an off-screen reply target in on
its own; the loop is idempotent so multiple loaders and the scroll loader
coalesce onto one paging window.
Wire it in via a new optional onBlank slot on ChatroomMessageCompose,
chosen by the parent message's protocol (gift-wraps vs NIP-04). Public
chats and marmot groups keep the default blank.
LocalCache must hold a single Note per event id/address and must never
remove a Note from the cache map while another Note still strongly
references it — a dangling reference both leaks the shell and lets a
relay echo mint a second Note with the same id.
Note.onchainZaps (NIP-BC) and Note.nutzaps (NIP-61) were added after the
removal/migration routines were written and were never wired into them,
so a pruned zap-source Note leaked through its target's maps:
- removeNote() only detached reply/boost/reaction/zap/zapPayment/report/
label, leaving the target's onchainZaps/nutzaps entry dangling when the
source note was pruned. Now also calls removeNutzap + a new
source-keyed removeOnchainZap (unconditional cache removal, distinct
from the verdict-respecting removeOnchainZapForSource).
- removeAllChildNotes() cleared onchainZaps but never returned the source
notes for removal from the cache map (asymmetric with nutzaps), so they
lingered orphaned. Now included.
- moveAllReferencesTo() dropped labels, zapPayments, and onchainZaps when
a replaceable's old version was superseded — silent data loss plus
orphaned onchain sources. Now migrated and cleared like the rest.
Adds NotePruningReferenceTest covering all three paths.
https://claude.ai/code/session_01RqJPYzmjb1pR3NBeH2yY3s
Keep the universal policy interface free of NIP-42: instead of a default
authenticatedUsers on IRelayPolicy, add an opt-in AuthScopedPolicy mixin that
only FullAuthPolicy (and PolicyStack, which unions its auth-tracking members)
implements. RequestContext.authenticatedUsers resolves it via an `as?
AuthScopedPolicy` downcast, defaulting to empty — so non-auth relays carry no
auth concept, and the accessor now earns its keep by encapsulating that cast
(ctx.policy is IRelayPolicy and no longer exposes the set directly).
Non-storage relays answer a REQ purely from filters: EventSource.events()
got no session/auth context, even though the connection's policy already
knows the authenticated pubkey(s). That walled the auth state off from the
code that produces events, forcing a shared mutable holder + hand-wired
RelaySession to build any caller-aware relay (NIP-50 search scored from the
viewer, DM-style restricted content, paid/allow-listed sets, per-connection
tenancy).
Introduce RequestContext (connectionId, authenticatedUsers, policy) and pass
it through the read path: RelaySession -> SessionBackend.query/count/
countResult -> EventSourceBackend -> EventSource. authenticatedUsers is now
exposed on IRelayPolicy (default empty, overridden by FullAuthPolicy and
unioned by PolicyStack) and read live, so a REQ after AUTH sees the freshly
authenticated pubkey(s). For richer per-connection state, downcast ctx.policy.
EventSourceServer.serve { } is now usable for auth-scoped relays without a
side channel. Adds a test proving ctx.authenticatedUsers reaches the source
after a NIP-42 handshake; updates RELAY.md.
The additive predicate in ChatroomListNewFeedFilter required
room.senderIntersects(followingKeySet) to be true, the exact inverse of
the full feed() rebuild, which includes a room only when the sender is
NOT followed. So new gift wraps / NIP-04 DMs from strangers streaming in
through the additive update path were all rejected, and the New Requests
list never grew past whatever feed() had computed at screen-open time.
On a heavy account this showed as the list freezing at a handful of rooms
while tens of thousands of events decrypted and history exhausted -
'loading but not changing the screen'. Negate the senderIntersects term
so the additive path matches feed().
The round-model history cards computed reachedBack = deepestUntil over
the ACTIVE relays only. When the deepest relay finished paging and
dropped out of the active set, the min jumped to the next-active (newer)
relay's cursor, so 'back to X' lurched FORWARD to a more recent date —
un-reaching history it had already loaded. Visible in the giftwrap trace:
the deepest relays reach ~2023, then once they finish and only the
shallow inbox.nostr.wine (~70d) remains, deepestUntil(active) snaps back
to ~70d.
Compute it over ALL relays (including finished ones, which keep their
deep cursor), matching the convo path. Now it only ever moves older.
Applied to both round-model managers (giftwrap + rooms NIP-04).
Note: a large *forward* jump (e.g. ~3 years in one step) is still
expected and correct — a dense relay can return a full 10k-event page
spanning years, so the oldest-loaded date legitimately leaps. No
messages are skipped; each relay pages contiguously.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
On a flaky network Arti records circuit failures past the first hop as
"indeterminate" and, once a guard's indeterminate ratio crosses 0.7,
permanently disables it. Disabled guards are never re-enabled nor removed
from the sample (60-day lifetime), and the sample is capped at 60. Arti
normally refills usable guards when they fall below 20, but a full sample
of unusable guards leaves no room — replenishment wedges and every circuit
returns AllGuardsDown. The state persists in guards.json and bootstrap
still "succeeds", so no existing self-heal path fires: Tor stays broken
across restarts. This is the long-standing, hard-to-reproduce production
"can't connect to Tor" bug.
On init, scan guards.json and, if any non-empty guard selection has zero
usable guards (disabled or unlisted_since set), wipe Arti state so the
next bootstrap rebuilds a fresh sample. A single usable guard is enough to
build circuits, so recovery only triggers at the last resort to preserve
guard-set stability (anonymity) and avoid pointless churn on bad networks.
Verified on emulator: poisoned 60/60 -> wipe -> fresh 20/20 usable sample
-> .onion OnOpen, zero AllGuardsDown.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the no-progress retry: in this trace vitor/mom/nos.lol never
connect at all (only damus gets a REQ — the other three cannot-connect
for both live and history). The retry correctly re-attempts, but it can't
reach relays that won't connect, so every round is 0 events, no relay is
ever 'done', exhausted never completes, and it retries forever — a cold,
empty feed stays on the spinner.
The round-model history had no give-up for repeated cannot-connect (only
for CLOSE), unlike the convo path. Route onCannotConnect through the same
pager give-up as onClosed, so a relay that's unreachable for a few rounds
is abandoned, exhaustion completes, and the screen resolves (to the
loaded rooms, or empty + retry) instead of spinning forever. The streak
resets on any contact, so a merely slow relay that connects within a few
attempts isn't dropped. Applied to both round-model managers.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
On a cold start with no cached rooms and no recent (<7d) DMs, the rooms
list relies entirely on history paging. If a round settles via
cannot-connect / CLOSE (relays dropped during a connect storm) instead of
a clean empty-EOSE, no relay is marked done, exhausted stays false, and
the no-progress guard then refuses to retry — recovery would only come if
the relays happened to reconnect AND re-EOSE the open subscription on
their own. Meanwhile the empty feed shows LoadingFeed() forever (it needs
BOTH protocols exhausted to show 'no conversations').
When a round makes no progress but isn't exhausted, actively retry after
a 5s backoff (clearing the no-progress gate) instead of waiting
passively. Paced so a fast-CLOSE / rate-limited relay isn't hammered, and
it stops once the protocol exhausts or a round makes progress. Applied to
both round-model history managers (giftwrap + rooms NIP-04).
Not caused by the stall-gate drop — the empty-feed search path is
unchanged by that commit; this is a pre-existing fragility the severe
76s connect storm in this cold start exposed.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
The SPI name leaned on "Req" (the NIP-01 command), which isn't self-explanatory.
Rename to read as what it is — a source of events for a query:
- ReqResponder -> EventSource (method respond() -> events())
- ReqResponderBackend -> EventSourceBackend (param responder -> source)
- ReqResponderServer -> EventSourceServer
- *Test + RELAY.md + KDoc references updated to match.
Also drop the JwtAuthPolicy snippet from RELAY.md (it was only an illustrative
doc example, never a real class); the surrounding prose still documents the
`authorize` bridge hook.
Pure rename + doc edit, no behavior change; full suite green.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
The stall-gate stopped widening once a load surfaced older messages but
no new conversation row, leaving the boundary card in a confusing paused
state (not loading, not caught-up). It existed to brake the OLD
pagination model, where every widen re-downloaded the whole window; the
per-relay until+limit paging doesn't re-download, so the brake is
obsolete — and a visible boundary card means the user is waiting for
more, so pausing there made no sense.
Now while the boundary is in view each protocol pages round after round
until genuinely exhausted (empty page), then shows 'all caught up'. The
card is only ever loading or caught-up, never paused. Removes the
autoFillRoomMark mark plumbing and the getMark/setMark gate.
Tradeoff: a user with few conversations but deep message history pages
that history to the end on reaching the bottom — bounded and efficient
now (no re-download), terminated by exhaustion + the no-progress guard.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Cleanup pass (4 review angles), behaviour-preserving, full suite green:
- Extract RelayServerBase: NostrServer and ReqResponderServer duplicated
connect/serve/buildPolicy/activeConnections and the connection scope verbatim
(ConnectionRegistry only unified the bookkeeping). They now share one engine
base and contribute only their backend + teardown; ReqResponderServer is ~10
lines, NostrServer just its ingest/store wiring.
- HyperLogLog.leadingZeroBits: replace the hand-rolled per-byte bit loop with
stdlib Int.countLeadingZeroBits() (- 24 for the 0..255 byte).
- LimitsPolicy.clampLimits: drop the `var changed` + throwaway-list map for a
`none{} -> map{}` that's simpler and allocates nothing when no filter is
clamped (the common case once maxLimit is set).
- PolicyStack: collapse the two first-non-null hook loops to firstNotNullOfOrNull.
- RelaySession.handleAuth: use OkMessage.rejected(MachineReadablePrefix.ERROR, …)
instead of hand-writing the "error:" prefix, matching the COUNT path.
Skipped: rewriting FullAuthPolicy's pre-existing auth-required:/invalid: reason
strings to MachineReadablePrefix — unchanged context lines, out of this diff's
scope.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
The bare glyph markers (✓ 8 · ↓ 1) had no context. Prefix each with a
translatable 'Relay sync:' label and add '·' separators between states,
so a marker reads e.g. 'Relay sync: ✓ 8 · ↓ 1' or 'Relay sync: ↓ nostr.wine'.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
The pendingNewlyAdded field was a rollback token faked through instance state:
FullAuthPolicy.accept(AuthCmd) committed the pubkey eagerly, so a later
rejection (composed policy or a throwing hook) had to be undone, and the field
existed only to avoid dropping a pubkey that was already authenticated.
Fix the root cause — the eager commit. accept(AuthCmd) now only validates; the
pubkey is recorded in FullAuthPolicy.onAuthenticated (made final), which the
engine calls only after accept AND the whole policy chain approve the AUTH.
External-auth bridges override a new open `authorize` hook that runs before the
commit; throwing rejects the login with nothing committed to undo.
This deletes pendingNewlyAdded, IRelayPolicy.onAuthenticationFailed, its
PolicyStack override, and both rollback call-sites in RelaySession.handleAuth —
and makes the prior compose-after-reject / failed-re-AUTH cases correct by
construction (no rollback to get wrong). Tests updated to override `authorize`;
the two regression tests pass unchanged.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
The status card's icon slot only handled two states — caught-up (✓) and
loading (spinner) — leaving it blank in the third: not exhausted but not
actively loading (the rooms-list auto-fill stops short of exhaustion via
the no-new-rooms stall-gate, or between round-model pages). The card then
read 'Older … messages · N relays' with nothing on the left. Fill that
paused state with a static '⋯' glyph so the slot is never empty.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Behaviour-preserving readability improvements (full suite green):
- LimitsPolicy: drop the <T : Command> generic reject helpers (which forced
rejectSubId<ReqCmd>(...) call-site type args). Each check is now a
"rejection reason or null" function (eventRejection / subscriptionRejection)
and the accept overloads just wrap a non-null reason — the three overloads
read almost identically.
- Extract ConnectionRegistry: NostrServer and ReqResponderServer duplicated
the connection bookkeeping (stable-id keying, active gauge, once-only
teardown accounting). That subtle logic now lives in one named class both
servers delegate to; their connect()/close() shrink to the parts that
actually differ (the backend and what else teardown closes).
- HyperLogLog.addPubKey: rename `ri` -> `registerIndex`.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
Final-review findings on the relay tooling:
- Auth bypass (security): FullAuthPolicy.accept(AuthCmd) commits the pubkey,
but handleAuth only rolled back on the hook-throw path — a policy composed
AFTER FullAuthPolicy that rejects the AuthCmd left the connection
authenticated behind an OK false. handleAuth now also rolls back on the
reject path, preserving OK-true-iff-authenticated for any composition order.
- Failed re-AUTH no longer drops a prior valid auth: onAuthenticationFailed
removes only the pubkey THIS AUTH newly added (tracked via Set.add's return),
not one already authenticated earlier on the connection.
- Rename the server-side RelayConnectionListener -> RelayServerListener to
avoid colliding with the existing client-side
relay.client.listeners.RelayConnectionListener (published-API clarity).
- RelayLimits.toNip11Limitation clamps created_at bounds to Int range so a
post-2038 epoch second can't wrap negative in the NIP-11 document.
- Add regression tests for both auth cases (reject-after-FullAuth; failed
re-AUTH keeps prior auth).
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
Nip11RelayInformation modeled `icon` but not `banner` (NIP-11's wide
promotional image, distinct from the square icon), so relays advertising a
banner couldn't round-trip it. Add the field + a round-trip test.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
Previously max_message_length and max_subscriptions were hard-coded in
RelaySession behind a parallel `limits` param, while the per-command limits
went through LimitsPolicy — two mechanisms, and a custom policy couldn't
influence the session-level ones.
Unify them: add two default-noop hooks to IRelayPolicy —
acceptMessage(raw) (pre-parse) and acceptSubscription(subId, openCount) —
chained through PolicyStack so they compose across multiple policies.
LimitsPolicy now implements all limit checks; RelaySession just invokes the
hooks and no longer takes a `limits` param. Servers compose LimitsPolicy
whenever `limits` is set and keep `limits` only to advertise via NIP-11.
Behaviour is unchanged (oversized -> NOTICE invalid:, sub cap -> CLOSED
rate-limited:); the enforcement now lives in the policy layer. Adds direct
hook unit tests; existing end-to-end limit tests still pass.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
Two UI papercuts in the per-relay DM history surface:
- RelayReachMarker listed every relay name comma-joined per state, so a
gap shared by many relays (e.g. all nine clustered at the live-tail
floor on first open) overflowed into an unreadable line. Now each state
shows the relay's host name only when it is the sole one of its state at
that depth (the usual converged case); otherwise just a count, with
maxLines/ellipsis as a backstop.
- The history status card briefly read 'loading from 0 relays': the count
populated a beat after loadingMore flipped true (and again as the last
relay settled). Set the relay count before raising the spinner in
loadMore, and defensively drop the relay clause from the card subtitle
when the count is 0.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
A relay author no longer hand-rolls limit policies or wires NIP-11 twice.
- RelayLimits: one config that is both enforced and advertised. Pass it to
NostrServer/ReqResponderServer and every limit is applied; toNip11Limitation()
renders the same numbers into the NIP-11 limitation block so they can't drift.
- LimitsPolicy: per-command enforcement (max_content_length, max_event_tags,
created_at bounds reject EVENT; max_filters / max_subid_length reject
REQ/COUNT; max_limit clamps, default_limit fills) with invalid: prefixes.
Servers prepend it automatically when limits declares command caps.
- RelaySession: session-level caps that a policy can't see — max_message_length
(NOTICE before parse) and max_subscriptions (rate-limited: CLOSED on new sub).
- NIP-11 serving: Nip11RelayInformation.toJson() + CONTENT_TYPE
(application/nostr+json); the existing model was parse-only.
- Tests for the policy, the session-level caps end-to-end, and the
limits->NIP-11 round trip; RELAY.md Limits + Serving NIP-11 sections.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
The HLL aggregation side (estimate/merge/encode) existed but the construction
side did not, and the Jackson wire (de)serializer silently dropped the `hll`
field — so a JVM/Android relay could not actually answer COUNT with HLL.
- HyperLogLog.addPubKey(): the NIP-45 construction (register index = pubkey
byte at the filter offset; value = leading-zero-bits from offset+1, +1),
KMP-safe. Plus HyperLogLog.builderFor(filter) and an HllBuilder that streams
event pubkeys into registers and yields an approximate CountResult.
- Plumb CountResult through the count path: SessionBackend.countResult /
ReqResponder.countResult (default = exact count(); override for approximate/
hll); RelaySession sends the returned CountResult.
- Fix CountResultSerializer/Deserializer (jvmAndroid) to write/read the `hll`
hex field, matching the kotlinx serializer the native targets already use.
- Tests for construction (index/value/merge-idempotence) and the wire path;
RELAY.md Approximate COUNT section.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
Gives relay operators metrics/logging hooks without patching the engine, and
removes the hashCode()-keyed connection registry the audit flagged.
- RelaySession gains a stable, process-unique `id` (monotonic counter).
- RelayConnectionListener (onConnect/onDisconnect, no-op default) is accepted
by NostrServer and ReqResponderServer; both now key their connection
registry by `id` instead of hashCode() (no more identity-collision hole).
- Both servers expose a live `activeConnections` gauge. Teardown accounting is
idempotent (double close counted once) and onDisconnect fires for any
connections still open at server close.
- Tests + RELAY.md Observability section.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
Add cs-rCZ, de-rDE, sv-rSE and pt-rBR translations for the new settings
search field (placeholder + no-results) and the share-to-DM flow strings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Audit finding: FullAuthPolicy.accept(AuthCmd) added the pubkey to the
authenticated set before onAuthenticated ran, so a bridge that threw from
onAuthenticated (its whole point — reject when e.g. a JWT exchange fails)
produced an OK false while the connection stayed authenticated server-side.
Subsequent REQ/EVENT/COUNT were then allowed despite the failed login — an
auth bypass.
- Add IRelayPolicy.onAuthenticationFailed(pubKey) (default no-op), forwarded
by PolicyStack and overridden by FullAuthPolicy to drop the pubkey.
- RelaySession.handleAuth calls it when onAuthenticated throws, restoring the
invariant that a client treated as authenticated is exactly one that got
OK true. The rollback is itself guarded so a misbehaving policy can't also
swallow the failing OK.
- Tests: failed-hook now asserts the connection is NOT authenticated and that
a follow-up REQ is rejected with auth-required.
- RELAY.md: note that throwing from onAuthenticated rolls auth back.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
startUntil() is 'now - 1week' and was recomputed on every updateFilter,
so it drifted forward in real time. A relay that hadn't advanced its
cursor (first page in flight, or empty) has until = that floor, so its
filter changed every time ANY other relay's EOSE triggered
invalidateFilters — the subscription saw a 'new' filter and re-REQed it.
The trace showed nostr.oxtr.dev asked twice (until 1779903206 then
...207, +1s) and reaching the bottom (done) twice, and it fed extra
ditto REQ->CLOSE churn and rate-limiting on the busy relays.
Pin the floor once when the window starts and reuse it for the window's
life (which ends in ~2s once every relay is done or stalled), so an
un-advanced relay's filter stays stable and only a relay whose cursor
genuinely advanced is re-REQed.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Lets non-storage relays (search, redirector, computed/projected data) answer
REQs without implementing the heavy IEventStore or hand-writing the
readFrame -> parse -> policy -> EVENT/EOSE loop.
- ReqResponder: the public Flow<Event> SPI — respond(filters): Flow<Event>
(+ a count() default). EOSE is sent when the flow completes.
- SessionBackend: the seam RelaySession now depends on (query/count/submit/
negentropy-snapshot). submit + snapshot default to reject / empty so a
responder only implements the read path. LiveEventStore implements it
(storage path unchanged); ReqResponderBackend adapts a ReqResponder.
- ReqResponderServer: storage-free dispatch engine mirroring NostrServer's
connect/serve/close, reusing RelaySession for the full wire protocol.
- RelaySession now frames backend failures as CLOSED error: <msg> (REQ) and
count failures likewise, instead of dropping the coroutine — useful for
responders doing network I/O.
- RELAY.md: Non-Storage Relays section + engine/source-map updates.
Storage path (NostrServer + IEventStore, live tail, negentropy) is unchanged;
existing server/auth/negentropy tests pass alongside the new responder tests.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
Review prep for the DM pagination branch:
- Delete commons TimeWindowPagination + its test: the early since-based
time-window approach, referenced only by its own test and fully
superseded by UntilLimitPager (until+limit, gap-proof). 212 lines a
reviewer would otherwise study for nothing.
- Bring the design doc up to the final architecture: NIP-04 per-relay
filter scoping, per-relay independent paging (no rounds) + in-stream
markers for the convo, the round model still used by rooms/gift-wrap,
the WindowLoadTracker backstops and tracksReqSends gating, the
loadingMore-starts-false fix, and the DMPagination diagnostics map.
Marks the obsolete time-slice section as superseded.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Addresses the self-contained, low-risk items from the relay-ergonomics
request:
- NIP-50: add SearchQuery to parse Filter.search into free-text terms and
the typed key:value extensions (domain/language/sentiment/nsfw/include),
preserving unknown extensions and offering a canonical toSearchString().
- NIP-42: add a suspend IRelayPolicy.onAuthenticated(pubKey, event) hook
(chained through PolicyStack) so external-auth bridges (e.g. JWT exchange)
can live inside FullAuthPolicy instead of leaking into transport code.
RelaySession invokes it after the AUTH passes; a throw becomes OK false.
- Ergonomics: Command.fromJson/toJson and Message.fromJson/toJson mirroring
Event, plus MachineReadablePrefix + OkMessage/ClosedMessage factories for
standardized OK/CLOSED reason prefixes.
- Docs: RELAY.md sections for the external-auth bridge, NIP-50 search, and
the wire helpers.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
loadingMore was wired straight to windowLoad.loading, which starts true
(the tracker assumes a load is in flight from construction). On the
first conversation open — before any paging window has run — that true
wedged the scroll-driven loader: its gate is '!loading', so loadMore
never fired, and even if it had, the 'if (!windowLoad.loading.value)
startLoading' guard would have skipped startLoading (value was the
construction-time true), leaving no watchdog to ever settle it. Result:
permanent spinner, 0 relays. Earlier opens only worked because a prior
conversation had left the shared tracker at false.
Expose a _loadingMore that starts false and is mirrored from the window
by the done collector, and track windowActive ourselves so the first
loadMore actually starts the window (and a re-entrant loadMore mid-page
doesn't reset it and forget finished relays).
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Kind 34551 (CommunityRulesEvent) was missing from EventFactory.create, so
signing a community-rules template produced a generic Event. Returning it
as CommunityRulesEvent in Account.sendCommunityRules threw a
ClassCastException when publishing community rules.
Register the kind in the factory and add a regression test.
- Mark all *_search_keywords translatable="false" (English concept/protocol
index; stops Crowdin translating protocol terms and breaking locale search) [#1]
- Collapse ~23 symbol+nav rows via a local symEntry() helper [#3]
- Reword keywordsRes KDoc to match the actual word-prefix tokenization [#6]
- Add search keywords to the Legal rows (privacy_policy, child_safety) [#7]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add keyword blobs so settings resolve by concept/protocol name, not just
title — e.g. "blossom" -> Media Servers, "audio rooms" -> Nests Servers,
"negentropy" -> Event Sync, "nsec" -> Backup Keys. NIP numbers omitted by
preference.
- Collapse filterSettings' two identical lookup lambdas into one stringLookup
- Rebuild via SettingsCategory.copy() so new fields aren't silently dropped
- Memoize buildSettingsCatalog with remember(hasPrivateKey, nav, uriHandler);
onResetMarmot reads isResettingMarmot via rememberUpdatedState to avoid a
stale-capture, eliminating ~60 allocations per keystroke
feat(settings): add search box that filters settings rows by title + keywords
refactor(settings): expose legal section as legalSettingsCategory factory
feat(settings): add search placeholder, empty-state, and keyword strings
test(settings): cover filterSettings (blank/title/keyword/category/empty/danger)
refactor(settings): match category title in search; use data classes
Two related polish fixes on the collapsed sidebar:
1. The hover/active highlight on each nav item used to span the full
sidebar width (minus 8dp outer padding), producing ~12dp of empty
highlight either side of the 24dp icon. Now the highlight clips to
a 40dp square centered on the icon (24dp icon + 8dp padding on each
side), so the ripple sits tight against the glyph.
2. When the sidebar is collapsed, the label was already supplied as
`contentDescription` for screen readers but had no visual
affordance. Added a `TooltipArea` that surfaces the label on hover
(Surface + inverseSurface tonal style, matching the existing
TorStatusIndicator tooltip pattern), so mouse users can also see
what each icon means without expanding the sidebar.
Applied to both `SidebarNavItem` and `SidebarFeedItem` since both
suffer the same issue. Expanded behaviour is unchanged.
Two bugs that together caused HomeFeed to always open on Following:
1. FeedScreen was reading feedRepo.pinnedFeeds.value as the source of
truth for the first pinned feed. That's a stateIn-derived flow with
initial value persistentListOf(); the underlying _feeds StateFlow
IS loaded synchronously by FeedDefinitionRepository on construction,
but the derived pinnedFeeds doesn't reflect it until the first flow
emission propagates — which is too late for `remember` to see.
Fixed by reading feedRepo.feeds.value directly and filtering /
sorting by pinOrder ourselves.
2. DeckColumnContainer was passing initialFeedMode = FeedMode.FOLLOWING
when rendering DeckColumnType.HomeFeed, which overrode FeedScreen's
first-pinned logic entirely. Removed the hardcode so the deck's
home column inherits FeedScreen's default.
With both fixed, a user who has only Global pinned now opens to Global
on launch instead of Following.
If the user has pinned only Global (or only a custom feed), the app
should open to that on launch instead of showing Following just
because DesktopPreferences.feedMode happened to be saved as
Following. The "pinned feeds" list is the user's stated ordering;
the first item should drive the initial tab.
Resolution order (most specific wins):
1. explicit customFeedSource/customFeedId from the caller
2. explicit initialFeedMode from the caller
3. first pinned feed in feedRepo.pinnedFeeds (NEW)
4. DesktopPreferences.feedMode (last-saved, previous default)
For a pinned Filter feed, this also seeds activeFeedId and
activeFeedSource so the feed mounts in CUSTOM mode with the right
source.
Real root cause of the "stale feed on launch" perception bug: when
fresh events prepend to the desktop home feed, Compose's stable-key
diff (`items(loadedState.list, key = { it.idHex })`) preserves the
visual anchor on whatever item was already visible. The user's
previously-visible top item — once at index 0 — silently shifts to
index N as N new items are inserted above the viewport. From the
user's perspective the feed looks frozen on stale items even though
the underlying state HAS updated; switching screens unmounts
FeedScreen, recreates lazyListState at index 0, and on remount paints
from the now-current top.
Android already handles this with StickToTopOnPrepend
(amethyst/.../WatchScrollToTop.kt:133-152), but the helper lived in
the Android module and Desktop had no equivalent.
Changes:
- New commons/.../ui/feeds/StickToTopOnPrepend.kt with the same
observer + snapshotFlow trick, ported to use plain `collectAsState`
(replacing the Android-only `collectAsStateWithLifecycle` — the
effect's lifecycle is already bound to composition via
LaunchedEffect). Provides the same overloads:
* StickToTopOnPrepend(LazyListState, firstItemKey)
* StickToTopOnPrepend(LazyGridState, firstItemKey)
* StickToTopOnPrepend(FeedContentState, LazyListState)
* StickToTopOnPrepend(FeedContentState, LazyGridState)
- FeedScreen wires StickToTopOnPrepend(viewModel.feedState,
homeFeedLazyListState) at the same scope as the hoisted lazy list
state and the NewPostsChip.
Mutually exclusive with the NewPostsChip: the chip's visibility
predicate fires when isAtTop is false, the auto-snap fires when
isAtTop is true. Together they cover both cases:
* user at top → events arrive → auto-snap shows them
* user scrolled down → events arrive → chip announces them
The Android version in amethyst/.../WatchScrollToTop.kt is left in
place to avoid a wider refactor; it can be reduced to a thin delegate
in a follow-up.
Both loading splashes (the Tor-connect gate and the account-loading
screen between Tor active and LoginScreen) now show the Amethyst
icon tinted to the theme primary, anchored below the status text.
Layout pattern (status-forward, both splashes):
spinner → status text → Amethyst logo (96.dp, primary tint)
Brief research summary backing the choice:
- Apple HIG argues against splash branding, but its model assumes
near-instant launch — not applicable here where the Tor gate
can block for seconds.
- Material Design 2's branded-launch-screen pattern endorses
logo + brand color while a placeholder UI loads.
- The status-forward order keeps the dynamic info (what we're
waiting on) leading and the brand as the anchor below — the
right call when the wait is non-trivial.
Audit follow-up on the per-relay paging work:
- Restore DmRelayLog in the convo history loadMore (every other nip04 /
giftwrap assembler logs it) and add per-relay milestone logs: which
relay reached the bottom, which stalled and why, plus a one-line
done/still-trying breakdown when the window settles — the snapshot to
reach for when a chat doesn't load.
- Extract markStalled() (dedupes onClosed/onCannotConnect, logs once per
relay) and relaysFor() (dedupes the active-convo relay lookup).
- relayCount now counts the relays still being paged (done ones drop out)
instead of staying frozen at the total.
- Drop the unused loadEverything().
- Fix WindowLoadTracker docs/log that claimed it 'gives up' on silent
relays: it only stops waiting and reports them; the owner decides (the
convo keeps them open and retries). Fix a dangling KDoc link in
RelayReachMarker.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Visualises the per-relay-independent engine: each relay gets a thin
marker in the message stream at the depth (createdAt) it has paged down
to, sitting just below the oldest message it has loaded. As a relay
pages older its reached-back cursor drops and the marker slides down; a
relay that races ahead leaves its marker deep while slower relays' trail
higher and converge as they catch up — ✓ done (empty-EOSE), … stalled
(auth CLOSE / unreachable, still trying), ↓ reaching. Hidden once the
conversation is fully converged (every relay done or stalled).
Adds an optional markersInGap slot to the shared chat feed view
(no-op for public-chat callers) invoked per message gap with its
createdAt bounds; ChatroomView renders the markers from the
relayProgress flow.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Replaces the lock-step round model (every relay advanced one page per
global round, gated by the slowest) with per-relay continuous paging:
each relay continues to its next page the instant it EOSEs, off its own
cursor. The subscription layer diffs per relay, so re-issuing only
re-REQs the relay whose cursor moved — others' in-flight REQs are
untouched. Fast relays race to the bottom in back-to-back pages while
slow / auth-walled relays catch up at their own pace; none are
abandoned (this reverts the give-up behaviour — slow relays keep their
subscription open and keep trying), so every relay converges on the same
window.
A relay is done on an empty page; one that won't answer (auth CLOSE,
unreachable, silent) is marked stalled but keeps trying. loadingMore
clears once every relay is done or stalled. Exposes per-relay
RelayPagingProgress (reached-back / done / stalled) for the upcoming
in-stream progress markers.
Splits the convo widen loop so each protocol pages on its own loader
state — NIP-04's continuous loading no longer starves gift-wrap paging.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
The silence + connect-grace backstops are only meaningful when the owner
feeds onReqSent, which only the convo NIP-04 path does. But connectStalled
keyed off the ABSENCE of a recorded REQ, so for giftwrap/rooms (which never
call onReqSent) every relay looked connect-stalled after connectGrace —
the window completed at ~15s before its REQs had even gone out during a
slow connect storm ('giftwrap.live load done: settled/silent' 45s before
the REQ), prematurely declaring an empty round done and tripping the
no-progress guard, so giftwraps stopped loading.
Gate both REQ-aware backstops behind a tracksReqSends flag that only the
convo manager sets; everyone else keeps the plain settle / idle / cap
behavior. Adds a regression test that a non-tracking tracker keeps
blocking a never-heard-from relay until it actually settles.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
After the connect-grace fix the convo history no longer hangs, but it
could linger forever showing 'N relays' with no progress bar and
exhausted=false: a correspondent's auth-walled relay (ditto, which only
ever CLOSEs 'all authors must be authenticated') never reaches the
pager's done/given-up state, and the no-progress guard merely *skipped*
re-issuing the round — stopping the loop without ever reflecting that
we're finished.
When the guard trips (same active relays, zero events two rounds
running) give up on those relays and recompute exhaustion, so the
conversation reports as fully loaded and the relay count clears. My own
reachable relays empty-EOSE to 'done' and never reach this branch, so
only genuinely stuck correspondent relays are dropped.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
The convo history spinner could sit for the full 5-minute absoluteCap
('load done: cap' in the logs). A correspondent relay (ditto) dropped
after its auth-required CLOSEs and got stuck reconnecting on a flaky
network, so its round-2 REQ was never delivered. It therefore reached
neither a terminal signal (no CLOSE without a REQ) nor the silence
backstop (which measures from REQ-delivery), and blocked the round until
the cap.
Add a connect-grace backstop: a relay that has been expected past
connectGrace (15s) without even receiving its REQ — i.e. stuck
connecting — stops blocking the round. Unlike the silence backstop it
does NOT give the relay up (it may be a genuinely slow connect), so the
owner keeps it and retries it next round; only relays that accepted a
REQ and then went silent are abandoned.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
The convo NIP-04 history spinner stayed up for minutes against 9 relays
even when the conversation was fully loaded. Auth-walled relays (ditto,
nostr.wine, …) accept the REQ (success=true) and then send nothing — no
event, no EOSE, no CLOSED. WindowLoadTracker only completed once every
relay reached a terminal signal, an idle gate that required hearing from
*all* relays, or the 5-minute cap; a silent relay armed none of those,
so only the cap freed the spinner. The pager likewise kept the silent
relay 'active' every round, so the count never dropped and exhaustion
never completed.
Add a silence backstop keyed off onSubscriptionStarted (REQ delivered,
post-connect — so a slow connect isn't mistaken for a dead relay): a
relay that received its REQ but stays silent past silenceTimeout (10s)
no longer blocks completion and is reported via onAbandoned, which the
convo assembler uses to giveUp() the relay in its pager so it leaves the
active set and lets exhaustion finish. finish() applies the give-up
before flipping loading, so the round collector recomputes exhaustion
after the silent relays are dropped.
Tests cover the pager give-up/exhaustion and the tracker's silence +
connection-gap behavior.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Nip04DmRelays held two flat relay sets and every filter named the whole
conversation group, so a relay that belongs to only one counterpart was
still asked about all of them (e.g. {authors:[bob,charlie]} sent to a
relay that is only charlie's). Restructure it into per-relay key maps so
each relay sees exactly the keys it owns:
fromMe: my outbox -> {authors:[me], #p:[whole group]}
each counterpart inbox -> {authors:[me], #p:[keys reading there]}
toMe: my inbox -> {authors:[whole group], #p:[me]}
each counterpart outbox-> {authors:[keys publishing there], #p:[me]}
Relays shared across roles union their key sets, so my own relays still
carry the full group while a counterpart's relay only ever names that
counterpart.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
A conversation's NIP-04 relay set folded the correspondent's inbox
(read) relays into the from-me filter set, so we sent {authors:[me]} to
relays that belong to the other party (e.g. ditto). Those relays have no
reason to hold my authored messages and auth-walled ones reject the
filter outright ("all authors must be authenticated"), stalling the
load. Scope filters to relay owners: my outbox carries my messages; the
correspondent's outbox (plus my own inbox as a legacy safety net)
carries theirs. Drops groupInbox from the from-me set.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Adds DmRelayLog, a diagnostic that prints — per DM subscription — the
account whose relays are being used and breaks the relay set down by the
source list each relay comes from (NIP-65 inbox/outbox, DM-relay-list,
private-storage outbox, local relays). Wired into all six DM assemblers
(NIP-17 live/history, NIP-04 rooms live/history, NIP-04 convo live/history).
The existing REQ lines now also print the resolved relay URLs (split into
fromMe/outbox and toMe/inbox for the NIP-04 paths), so an unexpected relay
— e.g. a write-only NIP-65 relay that only the NIP-04 home+dm path queries —
can be traced back to the list it leaks in from.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
With "exhausted only when every relay empty-EOSEs", a relay that demands auth we
can't satisfy — e.g. relay.ditto.pub answering "auth-required: all authors must
be authenticated" for a correspondent's pubkey we can't authenticate as — CLOSEs
every round, never finishes, and the conversation loads forever.
UntilLimitPager now tracks a per-relay CLOSED streak; after GIVE_UP_AFTER_CLOSES
(3) consecutive CLOSEDs with no answer in between (so the pool's auth handshake +
a retry have already failed), the relay is marked "given up" and excluded from
activeRelays. It is NOT counted as done (it didn't empty-EOSE — we just can't
read it), but it no longer blocks exhaustion. The streak resets on any event or
EOSE, so a relay whose auth succeeds is never abandoned. onClosed wires into the
pager and re-checks exhaustion when a relay tips into given-up.
NIP-17 only queries the user's own DM relays (auth = self), so it rarely hits
this; it's the NIP-04 conversation fan-out to the correspondent's relays that
trips author-auth-required relays.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Exhaustion was decided by "the round returned zero events," but a round can
return zero because relays CLOSED (auth) or never answered — not because they
reached the end. So "All caught up" appeared before slow/auth relays had
actually finished.
A relay is finished only when it returns an empty page followed by EOSE — the
pager already records exactly that in its per-relay `done` flag (CLOSED /
cannot-connect deliberately don't set it). So the chat is now exhausted only
when every relay is done (activeRelays is empty), per the rule "all relays must
return that EOSE." A post-auth empty EOSE that lands after the round already
settled on the earlier CLOSED now flips exhausted immediately too
(markExhaustedIfAllDone in onEose), not just at the next round boundary.
Because a chat is no longer "done" while a relay keeps CLOSING, the conversation
auto-fill (no stall-gate) would otherwise re-issue identical rounds and hammer
that relay. A no-progress guard skips re-issuing a round whose relay set and
zero-event result are unchanged; the pool re-auths on the open subscription and
its EOSE clears the guard and finishes the relay. All the new single-valued
state resets on account/conversation switch alongside the existing display flows.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Replaces the "pubkey/listId" string key with a small ConvoKey data class holding
the account pubkey and the ChatroomKey. ChatroomKey is a data class over the
participant set, so it's a collision-free key — unlike listId, which is its
32-bit hashCode as a string and can collide. Including the account keeps the two
accounts' views of the same correspondent on separate cursors (the manager is a
singleton shared across logged-in accounts).
Also lighter on allocation than the string it replaces: the per-relay-event hot
path captures the key once per subscription (no per-event construction), and the
remaining call sites build one small object instead of concatenating + hashing a
string.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
The DM managers live in a single shared coordinator (Amethyst.instance.sources),
so every logged-in account uses the same instances. The per-account paging state
(pager cursors, started set, accounts map) was keyed by pubkey and fine, but the
single display flows — exhausted, relayCount, reachedBack, autoFillRoomMark —
were not. Switching from an account that had exhausted its history to another
left exhausted=true, so the second account's auto-fill was gated shut and its
chats never paged in.
Each history manager now tracks the active account/conversation and repoints its
display flows on switch: exhausted is restored per-account (kept in a small
exhaustedByUser/exhaustedByList map so an already-finished account shows "all
caught up" rather than re-paging), and the cosmetic flows + stall mark reset.
Paging cursors stay in the per-account pager, so progress is preserved.
Also scopes the conversation history pager key by account pubkey: a ChatroomKey
(hence listId) is identical for the same correspondent across accounts, so two
logged-in users viewing the same person would otherwise share one cursor.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Replaces the bare spinner + "Load entire history" link with a status card that
tells the user what the app is actually reaching for: per protocol, it shows
"Older <encrypted|legacy> messages" with a subtitle of "<NIP-17|NIP-04> · N
relays · back to <month>" while it pages. When that protocol runs dry the card
doesn't just vanish — it crossfades to "All caught up · Reached the start of
your <…> messages", holds for a beat, then collapses away.
The history managers now surface the live status the card needs: relayCount
(relays the current page is asking) and reachedBack (oldest point paged to, from
the deepest per-relay cursor), added to all three history managers and computed
via UntilLimitPager.deepestUntil. The "load entire history" action is dropped —
scroll-driven paging already walks to exhaustion, so the link was redundant.
Each protocol's card sits at its own oldest-loaded boundary (rooms list) or both
stack at the conversation's oldest end, so the two protocols' loading is shown
independently at their real depths. New strings use a <plurals> for the relay
count.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
The rooms list had a single auto-fill trigger and a single loading boundary,
both pinned to the oldest private room of EITHER protocol. When NIP-04 history
ran far deeper than NIP-17 (e.g. NIP-04 back to 2023, NIP-17 shallow), that
oldest room was a 2023 NIP-04 row at the very bottom, so gift-wrap loadMore only
fired when the user scrolled all the way down to it — NIP-17 never paged on the
way, and the loading indicator was only visible at the bottom.
Split the trigger and the boundary per protocol. Each protocol now widens on its
OWN oldest-loaded room (gated only on its own loader and its own room-count
stall-gate) and shows its OWN loading indicator at its own depth, so NIP-17 and
NIP-04 page independently as the user scrolls — matching their very different
histories. WidenPrivateWindowWhen is generalized to a per-protocol WidenHistoryWhen
called once per protocol (and once per protocol for the empty-feed hunt). Each
history manager carries its own auto-fill stall mark (autoFillRoomMark).
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Fixes the perceptual "stale feed on launch" bug: on cold launch the
desktop feed paints with whatever local cache had (up to 7 days old)
before relays catch up. The live updateFeedWith() path already prepends
fresh events silently, but users had no signal that fresh content
arrived unless they were already at the top of the feed (auto-snap via
StickToTopOnPrepend).
This adds a Twitter/Mastodon-style floating pill chip that slides down
from above the search header when fresh events have prepended AND the
user is scrolled below position 0. Tapping it smooth-scrolls to top
and slides the chip back up off-screen. Scrolling to top manually
also dismisses it.
Implementation:
- NewPostsChip + rememberNewPostsChipState in commons/commonMain so any
future feed surface (incl. Android, iOS) can adopt it. Desktop wires
it today; Android continues with the existing auto-stick + bottom-nav
dot pattern.
- Visibility predicate is pure-function and unit-tested (5 cases).
- Predicate mirrors the inverse of StickToTopOnPrepend's "at top" check
so the two systems are mutually exclusive — auto-snap when at top,
chip when not.
- Chip placement: floating Alignment.TopCenter inside FeedScreen's outer
Box, offset by the animated headerSpacerHeight (60.dp normal,
300.dp when search is expanded) so it tracks the header card.
- Hoisted lazyListState + headerSpacerHeight one level so the chip can
share scroll state with the LazyColumn. Existing viewport-aware
metadata loading is unchanged (same lazyListState reference).
- Animation: slideInVertically(tween(280, FastOutSlowInEasing)) + fadeIn
for enter; slideOutVertically(tween(220, FastOutLinearInEasing)) +
fadeOut for exit. Initial/target offset of -fullHeight-16 guarantees
the chip is fully off-screen above its rest position.
- Per-column scope by construction: each FeedScreen instance has its
own chip state (deck mode shows one chip per column).
- Resets cleanly on feed mode switch (Following ↔ Global ↔ Custom)
because rememberNewPostsChipState is keyed on FeedContentState,
which is recreated when viewModel = remember(feedMode, activeFeedId)
recomposes.
Plan: docs/plans/2026-06-02-feat-new-posts-chip-desktop-feed-plan.md
5 issues from davotoula's review on PR #3124:
- #3 (protocol): inline reply emitted a minimal e/p tag set instead of
NIP-10. Extract `commons/actions/ReplyActions.replyTo` wrapping
`TextNoteEvent.build(replyingTo=)` (which already encodes root marker,
reply marker, parent root-e-tag carry) + carry parent's p-tag chain via
`notify(...)`. Replies to deep-thread notes now thread correctly in
Damus/Primal/Coracle. Covered by `ReplyActionsTest`.
- #4 (architecture): reaction/follow/reply each inlined
`localCache.consume + relayManager.broadcastToAll` in 5 sites with
inconsistent ordering. Extract `desktopApp/cache/dispatch(...)` —
canonical local-first order — and route all 5 sites through it.
- #1 (UX): related-content section scanned the cache once via
`DisposableEffect(noteId)` and never refreshed. Switch to `produceState`
collecting `DesktopLocalCache.eventStream.newEventBundles`; re-scan only
when an arriving bundle contains a candidate (matching hashtag or
author). `LargeCache.notes` is a ConcurrentSkipListMap (weakly consistent
iterator) so the scan stays safe on the composition coroutine.
- #2 (UX): `DeckColumnContainer` re-requested focus on every
`currentOverlay` change, stealing focus from sibling columns whenever
any column mutated overlay state. Drop to `LaunchedEffect(Unit)` and
wrap the column in `key(column.id)` in `DeckLayout` so the one-shot
effect survives column reordering.
- #5 (consistency): zap totals bypassed the shared `ZapFormatter`. Wire
`RelatedContentRow`, `CommentItem`, and `NoteActions` to
`commons/util/ZapFormatter.{showAmount,toZapAmount}`; delete
`formatZapAmount` and `formatSats` desktop-local helpers.
`WalletColumnScreen.formatSats` intentionally kept — locale-aware full
precision for wallet balance is by design.
Plan: docs/plans/2026-06-02-fix-desktop-feed-review-findings-plan.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1. LocalRelayStore: use batchInsert() with per-row savepoints instead of
manual transaction — UNIQUE constraint violations skip that row instead
of failing the whole batch
2. Robohash empty hex: guard blank input in CachedRobohash.get() with a
fallback all-zeros hex key instead of passing empty string to assembler
3. GiftWrapEvent decrypt: downgrade from WARN to DEBUG — expected when
gift wraps from local relay cache aren't addressed to current user
(subscription filter is correct, but hydration doesn't filter by p-tag)
4. Relay URL %20: decode percent-encoded spaces before rejection check in
RelayUrlNormalizer.fix() — wss://relay.example.com/%20 now normalizes
to wss://relay.example.com/ instead of being rejected
5. NIP19 Parser: downgrade from ERROR/WARN to DEBUG — malformed bech32
from relay content is expected in the wild, catch+log is correct
6. VLC macOS: add --avcodec-hw=none (disables VideoToolbox that causes
CVPN chroma failures) and --reset-plugins-cache (rebuilds stale cache
on startup instead of logging hundreds of stale-cache errors)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The time-slice history bounded re-downloads but couldn't tell "this relay is
empty" from "this is a gap" — an empty time slice can sit above older messages,
so the only stop was the 10-year maxLookback, and a wide late slice could pull a
20k-event firehose in one request.
History now pages backward by until+limit, per relay (UntilLimitPager). Each
round asks every not-yet-empty relay for up to 10000 events older than its own
cursor, no since, so gaps are skipped: an empty page + EOSE is a gap-proof
"nothing older on this relay" signal. A relay returning fewer than the limit is
treated as its own cap, not exhaustion — only an empty page ends it. A relay
answering CLOSED isn't "empty" (it may answer after the auth handshake), so the
global exhausted flag flips only when a whole round advances no relay at all,
which also stops the loop on a relay that keeps CLOSing. limit caps per-request
volume too.
Both NIP-04 history managers now paginate themselves (per relay, scoped) instead
of following the gift-wrap slice; loadEverything pages to the end by auto-issuing
the next round until exhausted. The live tail and the rooms-list stall-gate are
unchanged. Filter builders gained an optional limit; the conversation NIP-04
helper exposes its outbox relay set + a per-relay until builder.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
The conversation auto-fill only fired when the thread overflowed the screen, so
a one-message room sat at its load-more boundary without ever advancing — you
were at the start of the chat but it wouldn't reach for older messages. That
overflow guard was added back when each widen re-downloaded the whole window
(to stop a short thread auto-walking the gift-wrap firehose); now that history
loads in bounded, non-re-downloading slices that reason is gone.
Drop the overflow requirement: load the next slice whenever the oldest end is in
view, including a thread too short to scroll. A one-message room now walks
history back to its real beginning (or until the window is exhausted), one
bounded slice at a time, gated on both loaders being idle.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Every widen re-requested the whole DM window (the filters carried `since` only,
no `until`), so a relay re-streamed the entire history from the new floor — a few
pixels of scroll walked the window to the 10-year backstop, re-downloading
exponentially more each step (589 → 1486 → 2609 events in one session). This
splits each DM protocol into two responsibilities:
- Live tail (existing managers, now fixed): a one-week floor with no `until`,
always open to the future. Never widens, so new messages keep arriving.
- History slices (new managers): load the past in bounded `since`+`until`
one-shot slices. Widening fetches only the new band `[newFloor, prevFloor]`;
consecutive slices are disjoint so advancing the filter never re-streams an
earlier slice — they live in the cache. The NIP-17 2-day wrapper-timestamp
margin is applied to the slice `since`, overlapping adjacent slices so a
randomized outer timestamp can't open a gap. NIP-04 (exact timestamps) needs
no margin.
New: AccountGiftWrapsHistoryEoseManager owns the geometric window and the
bounded slices; ChatroomListNip04HistorySubAssembler / ChatroomNip04History-
SubAssembler follow its slice bounds so both protocols page to the same depth.
The live managers (AccountGiftWrapsEoseManager and the NIP-04 followers) are
reduced to the fixed one-week tail.
Also adds the rooms-list stall-gate: the auto-fill remembers the private-room
count at the last widen (on the history manager, so it survives reopening the
screen) and stops widening once a step brings in no new private room — widening
pulls older messages, not rooms, so a few busy correspondents would otherwise
flood events without ever filling the list. "Fill until full OR nothing new
found", instead of walking to the 10-year backstop.
Design: amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
The wire-level diagnostics logger only recognized gift-wrap kinds (1059/21059),
so kind:4 NIP-04 REQs never produced a `REQ -> wss://…` line and their relays'
connect/CLOSED/NOTICE lines were filtered out — even though the kind:4 REQs are
issued (the `[rooms.nip04] REQ` manager logs show them going out). Broaden the
match to the whole DM path (1059/21059/4) so the wire trail covers both
protocols, and rename the gift-wrap-specific identifiers to dm-path.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
The load summary's "before floor" count compared incoming gift wraps against the
un-margined window floor (window.since), but filterGiftWrapsToPubkey actually
asks relays for `since = window.since - 2 days` to catch wraps whose randomized
outer timestamp dips below the real message time. So the deliberate 2-day margin
band showed up as "before floor" (a boot reported "6 before floor" that were all
legitimate margin-band wraps), conflating the intended margin with a relay that
ignores `since`. Compare against window.since - twoDays() so only a relay that
under-shoots the floor we actually requested is flagged.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
A boot trace reported `[giftwrap] load summary: 1 event(s)` for a 7-day window
that actually holds ~100, because the DM relays connect over a ~35s spread: one
fast relay delivered a single event at +7s, the next 3s were quiet only because
the other four relays were still mid-connect, and the idle heuristic mistook
that gap for "done". The rest streamed in afterwards, past the load boundary.
The clean "all relays answered" completion was also unreachable: relays that
answer `CLOSED auth-required` (and unreachable relays) never produced an EOSE,
and WindowLoadTracker ignored onClosed/onCannotConnect entirely — so the only
completion path was the too-eager idle timer firing in a connection gap.
Completion is now per-relay terminal-state based. A relay is "settled" once it
sends a terminal signal — EOSE, CLOSED, or cannot-connect — and the load is done
when every targeted relay has settled. This is fast when relays are fast
(everyone EOSEs in a couple seconds) and correctly patient when they are not
(waits for the slowest relay), and it cannot trip in a connection-stagger gap.
The idle timer is kept only as a backstop for a relay that streams without ever
EOSE'ing, gated behind "every relay has been heard from" so it too can't fire in
a gap; the absolute cap still bounds a relay that connects then hangs forever.
WindowLoadTracker.trackingListener now wires onClosed and onCannotConnect into
the tracker, and a live event no longer settles a relay (its preceding EOSE
does); forward (newEose) semantics are unchanged.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
When composing an anonymous post (tap pfp to go anon on the short-note
or comment screens), media uploads still authorized against the Blossom /
NIP-96 server with the real account's signer. The server echoes that
pubkey back in the returned media URL (e.g. Blossom's `as=<pubkey>`),
linking the real identity to the supposedly anonymous post.
Thread an optional `forcedSigner` through the upload chain
(MultiOrchestrator -> UploadOrchestrator -> NIP-96/Blossom auth). Both
ShortNotePostViewModel and CommentPostViewModel now hold a single
ephemeral signer per compose session, reused for every photo/voice
upload and for the final anonymous broadcast, so the upload auth event
and the post share one throwaway key. signAnonymouslyAndBroadcast accepts
that signer so the media author matches the post author. Non-anonymous
callers are unaffected (forcedSigner defaults to null).
The signer is reset in cancel() so each new compose session gets a fresh
anonymous identity.
A cold boot trace showed `[giftwrap] load done: idle` firing with 0 events at
+3s while the DM relays had not even connected yet (nos.lol first connected at
+14s). The idle watchdog could not tell "quiet because the relays answered"
from "quiet because nothing has connected", so a slow boot looked finished with
an empty result — and with the Messages screen open that false "done, 0 events"
would trip the auto-fill into widening the window over and over.
WindowLoadTracker now only arms the idle path after the first event or EOSE
(sawActivity). Before that first sign of life, the load can only end via a
generous no-response bound (30s) or the absolute cap, so a still-connecting
boot stays "loading" instead of falsely completing empty. The clean paths are
unchanged: relays that EOSE complete via "all relays", and a stream that starts
then quiets still completes via "idle".
Also makes the gift-wrap load-summary collector a singleton: the tracker is
shared across accounts, so launching it per newSub double-logged every summary
when a second account was logged in. Per-load counters are now reset
synchronously at load start (beginWindowLoad) rather than on the collector's
rising edge, so no in-flight event is counted against the wrong load.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Removes the EVENT <- and AUTH <- per-message lines from the DM diagnostics
logger (too busy, and auth is not relevant to the pagination trail), and adds
a per-load tally to the gift-wrap manager so a relay that ignores `since` and
re-streams the whole history every widen becomes visible.
Each load now counts the gift-wrap events the relays push and, separately,
those whose outer created_at falls before the floor the REQ asked for. A
collector on the window-load flag resets the counters when a load starts and
logs `[giftwrap] load summary: N event(s), X before floor (since=…)` when it
finishes. A total that keeps growing across widens (with a large out-of-window
share) is the fingerprint of "getting all the events over and over again".
The WindowLoadTracker.trackingListener gains an optional onEachEvent hook so
the manager can observe every event (stored or live) for this instrumentation
without changing the EOSE forwarding path.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Add a focused, low-noise log trail (tag "DMPagination") so DM windowing and
auto-fill can be observed while opening/closing rooms and scrolling. Per-event
noise stays off (onActivity is silent).
- WindowLoadTracker: takes a name ("giftwrap" / "rooms.nip04" / "convo.nip04")
and logs "load start" and "load done: <reason>" where reason is one of
all relays / idle / cap / no relays — the key signal for whether a load is
stuck or looping.
- AccountGiftWrapsEoseManager: logs window open, REQ floor (since + days back),
loadMore (from→to days, exhausted), loadEverything.
- NIP-04 followers: log their REQ floor and reload.
- Rooms list: logs OPEN/CLOSE and each widen with its trigger (empty/scroll).
- Conversation: logs room OPEN/CLOSE, each scroll-driven widen, and the
"Load entire history" tap.
The gap-free display floor was hiding the whole thread: until `coveredSince`
settled the floor was Long.MAX_VALUE (reveal nothing), but a thread clipped to
empty made the auto-fill fire (total == 0), which kept the loaders busy so
coveredSince never settled — a vicious cycle that walked the account-wide
gift-wrap window to exhaustion ("loads everything") while the thread stayed
blank ("loading sign and no message whatsoever").
- Remove the display-floor clipping (ChatFeedView no longer takes
oldestVisibleTime; ChatroomView drops rememberConversationDisplayFloor). The
thread renders the cached messages directly again, like before.
- Bound the conversation auto-fill: only load the next older window when the
thread already overflows the screen AND the user has scrolled near the oldest
loaded message, so a short thread is never auto-walked to the start of
history. The oldest-end boundary still offers an explicit "Load entire
history".
The kept-it-honest gap-free guarantee wasn't worth a blank inbox; the transient
NIP-04-before-NIP-17 ordering is the lesser evil. The loading spinner + load-all
boundary added last commit stays.
Replace the literal "CashuWalletState.start() not called" duplicated across
9 call sites (8 check guards + the publish default lambda) with a single
private companion constant.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- fix(dm-share): kotlin-review fixes (alias dot-boundary match + transient feed doc)
- fix(dm-share): address code-review findings (intent consume, media helper, manifest sync)
- fix(dm-share): make the picker one-shot so backing out doesn't duplicate drafts
- fix(dm): avoid duplicate drafts on abort by rotating draft tag after the async save
The conversation showed a perpetual spinner at the oldest end (it was tied to
`!exhausted`, not to actual loading) and had no "load all" escape, while the
rooms list showed a spinner only while loading plus a "Load entire history"
button. Make them consistent and share one component.
- Extract DmLoadMoreIndicator (spinner while loadingMore + "Load entire
history" button while not exhausted), used by both screens.
- ChatFeedView: replace the loadingOlder: Boolean flag with an opt-in
olderBoundary slot rendered at the oldest end; public-chat / channel callers
pass null (unchanged).
- ChatroomView: supply that boundary — spinner only when a window is actually
loading (gift wraps OR this room's NIP-04), button while there's older
history to reach, nothing once exhausted.
- Rooms list: drop its local PrivateChatsLoadMoreFooter in favor of the shared
one.
TagArrayBuilder.addUniqueValueIfNew had an inverted guard:
if (tag.has(1) || tag[0].isEmpty() || tag[1].isEmpty()) return this
Since has(index) == size > index, `tag.has(1)` is true for every
well-formed tag with a value, so the function returned early and never
added it. addUniqueValueIfNew / addAllUniqueValueIfNew are used only by
the quote() / quotes() builders, so every `q` tag (naddr, nevent, note,
nembed, npub, nprofile) has been silently dropped since this file was
introduced. Restore the missing `!` and add a regression test covering an
addressable (naddr) quote plus the guard's accept/skip/dedupe semantics.
https://claude.ai/code/session_01NMavNzJ7VRLhoD3hboCCC7
Collapse the DM windowing system to a single source of truth and remove the
accumulated duplication.
- One window: the gift-wrap (NIP-17) loader owns the only TimeWindowPagination.
The rooms-list NIP-04 loader no longer keeps its own window advanced "in
lockstep" — like the conversation loader, it now follows the gift-wrap
window's `windowSince` and re-requests via `reload()`. Removes its window,
loadMore, loadEverything and exhausted; the rooms screen drives
giftWraps.loadMore() + nip04.reload() and reads giftWraps.exhausted alone.
- Shared listener: extract WindowLoadTracker.trackingListener(forward) — the
one place that feeds onActivity/onRelayResponded — replacing three copies of
subscription-listener boilerplate and two newEose overrides.
- Drop the cold-boot instrumentation (bootStartMs/bootEventCount/bootEoseLogged
+ verbose per-call logs) from the gift-wrap manager; it was development
scaffolding. (The debug-gated DmRelayDiagnosticsLogger stays.)
- Renames for clarity: DMsFromUserFilterSubAssembler -> ChatroomListNip04SubAssembler,
ChatroomFilterSubAssembler -> ChatroomNip04SubAssembler, field nip04Dms -> nip04.
Behavior is unchanged: same auto-fill/prefetch, same all-relays-or-idle gating,
same gap-free conversation reveal. ~250 fewer lines and no more lockstep concept.
A thread renders the LocalCache union as events land, and NIP-04 (one
decrypt) paints faster than NIP-17 (gift-wrap unwrap = two NIP-44 decrypts).
So even with both windows requested to the same depth, a thread could
transiently show kind:4 messages with the kind:1059 messages that belong
between them still missing — and a user could read it as complete.
Introduce a per-conversation display floor: only reveal messages at or newer
than the deepest gift-wrap floor at which BOTH protocols have finished
loading, plus a 2-day margin (NIP-17 randomizes the gift wrap's outer
created_at up to 2 days, and relays filter on that outer time, so fetching
outer >= F only guarantees holding every inner time >= F+2d). The floor is
monotonic (revealed history never retracts) and a "loading older" boundary
shows at the oldest end until the window is exhausted, so incompleteness is
always visible rather than mistaken for "done".
- ChatroomFilterSubAssembler gains a WindowLoadTracker (loadingMore) and a
reload(), so the conversation knows when NIP-04 has covered the floor; the
scroll widen now gates on both protocols and calls reload() instead of a
bare invalidate.
- ChatFeedView gains opt-in oldestVisibleTime (clip) + loadingOlder
(boundary) params; public-chat / channel callers default to no-op.
- ChatroomView computes the floor from both loaders' idle state + windowSince
and passes it down.
Honest limits: a relay that withholds data can't be conjured (we never hide
incompleteness, floor only descends); a sender backdating the gift-wrap outer
timestamp beyond the 2-day spec can still plant a late message, defended only
by "Load entire history". The rooms list is intentionally not clipped this
way — there, hiding a known conversation is worse than a row reordering.
- Fix like: read replyNote.event inside lambda (not captured val)
to avoid stale null reference. Consume reaction into local cache.
- Wire zap on comments: uses zapNote (now internal) with 21 sats default
via NWC connection, same flow as main action row
- Wire like/zap in both FeedScreen (inline expansion) and ThreadScreen
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Wire onLike on CommentItem: ReactionAction.reactTo + broadcast
- Related content clicks use overlay navigation (ThreadScreen) since
related notes may not be in the feed LazyColumn
- Add onNavigateToThreadOverlay param to ExpandedNoteContent
- Zap from comments deferred (requires full NWC flow)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Observe note.flow().replies so replyNotes recomputes when replies arrive
- Use loadMetadataBatched with explicit author pubkeys from reply events
- DisposableEffect for proper flow cleanup
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add expandedNoteId state to FeedScreen — clicking a card expands it
in-place instead of navigating to separate ThreadScreen
- AnimatedVisibility(expandVertically + fadeIn) for smooth expansion
- ExpandedNoteContent composable renders CommentsCard + RelatedContentSection
below the expanded card within the same LazyColumn item
- Auto-scroll expanded card to top of viewport
- Thread reply subscriptions start on expand, cancel on collapse
- Only one card expanded at a time — clicking another collapses current
- Search bar stays visible (floating header above LazyColumn)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix follow pill layout: author row uses weight(1f) so pill has room
(was invisible due to SpaceBetween squeezing)
- Fix comment metadata: observe metadataState so author info recomposes
when kind:0 arrives from relay
- Wire "View all" on related content to navigate to author profile
- Wire reply button on CommentItem to open reply compose dialog
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Create CompactNoteData @Immutable data class in commons for reuse
- Create RelatedContentSection composable with horizontal LazyRow
- Scan LocalCache for hashtag-matching + same-author notes
- Compact cards (160dp) with title, author, zap count
- Wire into ThreadScreen below reply notes
- Hidden when no related content found
- Subscriptions cancel on dispose
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Refactor ColumnNavigationState to use mutableStateListOf with direction tracking
- Add pushWithCap(maxDepth=2) — replaces top entry when cap reached
- Replace instant Surface overlay with AnimatedContent slide transitions (200ms)
- Add Esc key handler (onPreviewKeyEvent) for back navigation
- Add FocusRequester for keyboard nav to work after slide
- Apply to both DeckColumnContainer and SinglePaneLayout
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add bottomContent slot to NoteCard for actions to render inside card boundary
- Move NoteActionsRow into the slot in FeedNoteCard (both regular and repost paths)
- Add muted parameter to SidebarNavItem; mute Home when feed tabs are visible
- Resolves feedback: actions clearly belong to their card, sidebar doesn't
compete with feed tab active state
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Update DEFAULT_ELECTRUMX_SERVERS table to the 6 clearnet entries
actually shipped (testls.space + nmc2.bitcoins.sk + IP peer +
relay.testls.bit + IP peer + electrum.nmc.ethicnology.com); remove
ulrichard.ch and nmc2.lelux.fi which were retired.
- Update TOR_ELECTRUMX_SERVERS table to the current 7 entries
including the relay.testls.bit hidden service on port 50001.
- Document the Namecoin Core RPC backend (NamecoinCoreRpcClient,
NamecoinCoreRpcConfig, StartOS / umbrel / onion URL paths).
- Document CompositeNamecoinBackend + NamecoinFallbackPolicy chain
(primary -> custom ElectrumX -> default ElectrumX) and the
short-circuit-on-NameNotFound rule.
- Document the ifa-0001 `import` resolver (NamecoinImportResolver),
including the bare-string / array short-hand forms and the
default depth-4 / cycle-safe recursion.
- Document TOFU cert pinning (PINNED_ELECTRUMX_CERTS plus the
user-supplied PEM store on Android and Desktop) and the Test
Connection diagnostic returning ServerTestResult.
- Document name expiry enforcement on both backends and the
NamecoinResolveOutcome sealed type used by resolveDetailed().
- Add a Commons module section covering NamecoinSettings (the shared
serializable config) and NamecoinResolveState (UI state model).
- Add a Desktop section covering DesktopNamecoinNameService,
DesktopNamecoinPreferences, LocalNamecoin lazy init, and the
desktop NamecoinSettingsSection.
- Rename trustAllCerts -> usePinnedTrustStore throughout the doc,
matching the rename in code.
- Update the Quartz paths from `nip05/namecoin/` to
`nip05DnsIdentifiers/namecoin/` (the actual package layout) and
refresh the file list to match what is on main today.
- Refresh the testing section: add backend-picker, on-chain zap,
and NameNotFound cases; update tcpdump port set to cover 50001 /
57002 / 8336; replace stale single IP example with reference to
DEFAULT_ELECTRUMX_SERVERS.
- Architecture diagram now shows CompositeNamecoinBackend,
NamecoinImportResolver, and NamecoinCoreRpcClient alongside
ElectrumXClient.
No code changes.
The Step 2.5 git "sync-timestamp" heuristic (skip keys added before the
last Crowdin export commit) produced false negatives: a key added shortly
before an export that translators hadn't reached yet is genuinely missing,
but the filter classified it as "Crowdin already decided" and dropped real
work. Replace it with the raw on-disk diff reconciled against the Crowdin
web UI's untranslated count; source-identical entries are skipped by
inspection instead.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Catches up four locales on the missing strings/plurals for the new
Cashu (NIP-60) wallet, mint top-up/reload, NIP-61 nutzaps, NIP-32
hashtag labels, podcasts, and (pt-BR) music tracks/playlists & NIP-82
software releases. pt-BR was furthest behind (199 keys); cs/de/sv each
add 123 strings + 5 plurals. Czech plurals carry the full one/few/many/
other CLDR set. Notification-channel id calendar_reminder_channel_id
left untranslated by design.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Messages list mixes private DMs (windowed) with public, ephemeral and
marmot-group rooms, which are membership-based — every room you're in shows
regardless of age, loaded by their own always-on loaders, not time-windowed.
The auto-fill was using whole-list geometry (lastVisible >= total/2), so an
old public chat at the bottom either stalled private paging (it inflated the
item count) or, with an oldest-item rule, would have dragged the private
window back years.
Now the widen trigger ignores non-private rows: it fires as the user
approaches the oldest LOADED private chat (event is ChatroomKeyable) within a
small prefetch margin, or when no private chat is loaded yet. The loading
spinner / "Load entire history" footer moves to that private boundary —
between the last loaded private chat and the older public rooms below it —
instead of sitting at the absolute bottom under unrelated old channels.
Windowing all chat types together was considered but rejected: it would hide
followed-but-inactive public channels, which must always appear.
Follow-up to the link-preview move: HtmlParser + HtmlCharsetParser were
stuck in jvmAndroid only because they spoke java.nio.charset.Charset.
There is no common Charset type in the Kotlin stdlib, so this reshapes
the API to speak IANA charset *names* (String) and pushes the single
genuinely-platform operation — byte->String decode — behind expect/actual.
- Move HtmlParser + HtmlCharsetParser to commonMain. Charset detection
(meta-tag sniff + BOM sniff) is pure string/byte work; BOM detection no
longer needs okio (manual leading-byte compare).
- Add `expect fun decodeBytes(bytes, charsetName)`:
* jvmAndroid actual -> java.nio.charset (all JRE charsets, UTF-8 fallback)
* iosMain actual -> NSStringEncoding for the common web charsets
(UTF-8/16/32, Latin-1, CP1252, ASCII), UTF-8 fallback for the rest.
- UrlPreview (stays jvmAndroid; needs OkHttp) now reads response.body.bytes()
and passes mimeType.charset()?.name().
Verified: commons compiles for JVM AND iosSimulatorArm64, verifyKmpPurity
passes, commons jvmTest passes, amethyst play + fdroid compile.
Add a dedicated TopUpMintScreen + TopUpMintViewModel for funding a
specific mint outside the zap flow. Each mint row on the wallet screen
gets an add-funds icon that opens the screen with that mint as the fixed
target.
The screen reuses the same funding primitives as the zap-driven Reload
screen — CashuWalletState.rebalance for a mint-to-mint move and
startMintFromLightning/completeMintFromLightning for an LN top-up (NWC
or external invoice) — but drops all zap machinery: no recipient, no
shared-mint intersection, no fixed send amount/shortfall, no terminal
nutzap, no fund-then-send atomicity. This keeps the double-spend-
sensitive zap pipeline untouched.
Shares SectionHeader/SourceRow/shortMint/sats from ReloadMintScreen
(widened to internal) instead of duplicating them.
Third slice of the amethyst→commons migration. UrlPreview (OpenGraph
link-preview fetcher) and HtmlParser already wrapped the extracted
commons preview parsers (MetaTagsParser/OpenGraphParser/HtmlCharsetParser);
this consolidates the whole link-preview concern in commons.
- Move service/previews/{UrlPreview,HtmlParser} into commons jvmAndroid
service preview package. They land in jvmAndroid (not commonMain)
because UrlPreview uses OkHttp and HtmlParser uses java.nio.charset —
both JVM-only. No Android-framework or keystone coupling: the caller
injects the OkHttpClient as a lambda.
- Add explicit okhttp + okhttp-coroutines deps to commons jvmAndroid
(previously only present transitively via coil-okhttp).
- Re-point the single caller (model/UrlCachedPreviewer).
commons JVM compile + verifyKmpPurity pass; amethyst play + fdroid compile.
Previously a conversation loaded its NIP-04 (kind:4) history in full while
NIP-17 was windowed, so a thread could reach deeper on one protocol than the
other. Now both follow a single floor: the account-wide gift-wrap window.
- AccountGiftWrapsEoseManager exposes windowSince(user) — the current window
floor.
- The conversation NIP-04 loader (ChatroomFilterSubAssembler / filterNip04DMs)
requests kind:4 from that same floor instead of the EOSE cursor, so it never
reaches further back than NIP-17. The gift-wrap manager is plumbed in via
ChatroomFilterAssembler from RelaySubscriptionsCoordinator.
- The conversation scroll handler now advances both: it widens the gift-wrap
window (NIP-17) and re-invalidates the chatroom sub so NIP-04 re-requests at
the new, wider floor. Gated by the gift-wrap loadingMore so it steps once at
a time, stopping at exhaustion.
Because the floor is shared (not an independent per-room window), the two
protocols stay aligned even when the rooms list has already widened the
window. Display still reads from LocalCache, so any messages already cached
(e.g. from a prior full load) keep showing regardless of the request floor.
Second slice of the amethyst→commons migration. BroadcastTracker +
BroadcastEvent/RelayResult/BroadcastStatus are platform-agnostic relay
event-broadcast logic (no keystone coupling, no Android) that Desktop and
the CLI can reuse.
- Move service/broadcast/{BroadcastModels,BroadcastTracker} into
commons commonMain service/broadcast.
- Replace the two commonMain purity-gate violations:
System.currentTimeMillis() -> TimeUtils.now() (startedAt is only used
to sort the active-broadcast list) and java.util.UUID.randomUUID() ->
RandomInstance.randomChars(16) for the tracking id.
- Re-point the 4 Android callers (AccountViewModel + broadcast UI).
verifyKmpPurity passes; amethyst play + fdroid both compile.
Replace the eager full gift-wrap load on conversation open with the same
scroll-driven widening the rooms list uses. As the user scrolls a thread
toward older messages (reverse-laid-out, so older = higher indices), the
account-wide gift-wrap window widens one step at a time — prefetching at the
midpoint so older messages land before the top is reached — and stops once
the window is exhausted. A thread that already fills the viewport doesn't
load anything extra until you actually scroll back.
The shared chat feed (used by public channels, ephemeral chats, live
activities, marmot groups too) stays generic: it gains an opt-in
listStateObserver slot, and only the private-DM screen attaches the
gift-wrap loader through it. NIP-04 in a conversation is still loaded in
full (it was already, and a single room's kind:4 is cheap), so only the
windowed NIP-17 side is scroll-driven; the thread is time-sorted so the two
merge without reordering. loadEverything stays for the rooms-list button.
Display spendable sats per mint in the wallet screen's Mint section and
remove the thumbs-up recommend button from each mint row — mint
recommendations are managed from Cashu Wallet Settings.
- Add reactive CashuWalletState.mintBalances (StateFlow<Map<String, Long>>)
derived from token entries, mirroring balanceSats.
- Expose it through CashuWalletViewModel and render the per-mint total in
MintRow using the existing wallet_sats label.
- Remove the per-row recommend (ThumbUp) action and its wiring;
recommendMint() stays for the Settings screen.
The conversation screen issues its own unbounded NIP-04 REQ (full room
history) but has no NIP-17 fetch of its own — it relies on the account-wide
gift-wrap loader, which is windowed and only driven by the rooms list. So a
thread could show a deep NIP-04 history but only the NIP-17 messages inside
the current (possibly 7-day) window, silently hiding older gift-wrapped
messages.
Gift wraps are addressed to us, not the partner, so a relay can't filter
them per-room; the only lever is the shared account window. On opening a
conversation, ask the gift-wrap loader to pull everything (loadEverything).
It's idempotent via the isExhausted guard, so only the first conversation
opened in a session pays the cost; reopening threads is a no-op.
- Remove the Taproot address block from the onchain card (Copy/Send
actions still operate on the address; it's just no longer displayed).
- Drop the redundant leading spacer above the NWC wallet list so the
gap below the onchain card no longer doubles up.
- Render the Cashu logo in a circular chip mirroring the Bitcoin chip
so both payment rails read as the same kind of object.
- Remove NWC wallet drag-to-reorder: order was purely cosmetic since
a default wallet is always set, so setDefault is the real selector.
Drops moveWallet/moveNwcWallet and the unused wallet_reorder string.
https://claude.ai/code/session_014zaY9EVdxgtdssyVTaeEZd
First low-friction slice of the amethyst→commons migration
(commons/plans/2026-05-30-amethyst-to-commons-migration.md): the model
nipNN state holders are all blocked by the LocalCache/Note/Account
keystone (Phase A), so start with the genuinely Android-free utilities.
- Delete amethyst service/IterableExt.kt — exact duplicate of the existing
commons util/IterableUtils.kt (Iterable.replace); re-point 4 callers.
- Move retryIfException (CoroutinesExt.kt) into commons util/CoroutinesUtils.kt.
- Move togglePresenceInSet (SetExt.kt) into commons util/SetUtils.kt.
All commonMain-safe (verifyKmpPurity passes). amethyst play + fdroid both
compile against the relocated helpers.
Audit follow-up — close three concurrency holes exposed by the auto-fill
loop, which calls into the loaders from the UI thread while the bundled
invalidation runs updateFilter on Dispatchers.IO:
- windows map: was a plain HashMap mutated from both the UI thread
(loadMore/loadEverything) and Dispatchers.IO (updateFilter). Concurrent
getOrPut can corrupt the table. Switch to ConcurrentHashMap.computeIfAbsent.
- WindowLoadTracker watchdog: a stale watchdog waking from delay just as a
new startLoading ran could complete the *new* window (flip loading=false
and cancel the new watchdog), leaving it stuck. Guard each poll with a
generation token so a superseded watchdog bows out.
- scope field: written on IO (newSub), read on the UI thread (loadMore);
marked @Volatile for visibility.
- cold-boot diagnostic maps (bootStartMs/bootEventCount/bootEoseLogged) are
written from the concurrent relay reader callbacks during the boot flood;
switch to ConcurrentHashMap + an atomic merge so they can't corrupt or
hang under that load.
The fixed 15s window-load timeout fired mid-flood on accounts with a large
DM history: a relay streaming thousands of stored gift wraps never EOSE'd
within 15s, so the window was declared "loaded" while events were still
pouring in and before they were decrypted into rooms. The rooms list still
looked empty, so auto-fill widened again — re-issuing an ever-wider REQ that
re-downloaded the whole history, over and over, every 15s.
WindowLoadTracker now completes a window on activity quiescence instead of a
wall clock: it stays loading until every expected relay EOSEs, or the event
stream goes quiet for a few seconds. Every event (stored backfill included)
bumps the idle timer via onActivity, so a relay mid-flood is never mistaken
for a finished window; an absolute cap bounds pathological dribble. Both DM
loaders feed event activity in (the NIP-04 loader now uses a custom listener
so it sees stored events, not just live ones).
Also add a "Load entire history" button to the rooms-list footer: it jumps
the window straight to the max lookback (TimeWindowPagination.loadAll) so a
single REQ pulls everything — the pre-windowing behavior — and marks the
window exhausted so the auto-fill loop stops.
Pure data class (only @Stable) — extract to
commons/model/nip51Lists/interestSets so Desktop/CLI/iOS can reuse the
interest-set model. Re-points the four interest-set UI files and the
sibling InterestSetsState.
https://claude.ai/code/session_01JFbYZdVV4QmDC4eQYvEccb
Pure data class (only @Stable + quartz bookmark tags) — extract to
commons/model/nip51Lists/labeledBookmarkLists so Desktop/CLI/iOS can
reuse the bookmark-group model. Re-points the six bookmark-group UI
files and the sibling LabeledBookmarkListsState.
https://claude.ai/code/session_01JFbYZdVV4QmDC4eQYvEccb
Extract the three remaining keystone-free quartz-only decryption caches
(MuteListDecryptionCache, PeopleListDecryptionCache,
CommunityListDecryptionCache) into commons/model so Desktop/CLI/iOS can
reuse them. Re-points Account, FeedDecryptionCaches, and the sibling
state holders (MuteListState, PeopleListsState, BlockPeopleListState,
CommunityListState).
The remaining relay-list decryption caches depend on
GenericRelayListCache -> amethyst.model.Note (the keystone), so they
stay until Phase A extracts Note/LocalCache.
https://claude.ai/code/session_01JFbYZdVV4QmDC4eQYvEccb
Replace the fire-once scroll detector with a viewport-fill + prefetch loop
so the messages screen stays ahead of the user instead of stranding a
near-empty list.
One condition drives three behaviors: widen the DM time windows when the
feed is empty, or when the last visible row crosses the midpoint of what's
loaded. While the list is short everything is visible, so the midpoint is
always crossed and it keeps widening until the list overflows the viewport
with a buffer below the fold; once full it only fires again as the user
scrolls past the new midpoint, so a fresh chunk lands well before the end.
It stops only when the window is exhausted (reached the 10-year lookback —
nothing older exists), which also gives the empty-account case a real
terminating condition instead of the old runaway cascade.
- TimeWindowPagination: optional geometric step growth + a hard max-lookback
floor with isExhausted(), so a sparse / single-person history converges in
~10 requests. Default stays linear/unbounded; existing callers unchanged.
- WindowLoadTracker: a window counts as loaded only once ALL of its relays
have answered (EOSE / live event) or a timeout fires — not on the first
EOSE. This stops a fast, near-empty relay from clearing the gate and
letting the fill loop outrun the slow relay that holds the conversations.
- Both DM loaders (NIP-17 gift wraps + NIP-04) expose loadingMore (= window
still loading) and exhausted, advance in lockstep, and gate each widen on
the tracker. The rooms screen shows a spinner until history is exhausted
rather than flashing the empty state while older windows are still in
flight.
Pure quartz-only decryption cache — extract to
commons/model/nip51Lists/favoriteAlgoFeedsLists so Desktop/CLI/iOS can
reuse it (mirrors the TrustProviderListDecryptionCache move). Re-points
Account and the sibling FavoriteAlgoFeedsListState.
https://claude.ai/code/session_01JFbYZdVV4QmDC4eQYvEccb
Pure data class (only quartz + a UUID id generator) — extract to
commons so Desktop/CLI/iOS can reuse the NWC wallet entry model.
Swaps java.util.UUID for the multiplatform kotlin.uuid.Uuid (matching
the quartz convention) and re-points the three callers
(LocalPreferences, AccountSettings, WalletViewModel).
https://claude.ai/code/session_01JFbYZdVV4QmDC4eQYvEccb
Replace the labeledEventId/relay/author trio with a single
EventHintBundle<out Event>, building the e-tag via the standard
EventHintBundle.toETag() idiom. The Account caller now passes the
note's event hint directly.
https://claude.ai/code/session_019gc3FipVBcndF9fmqCCfVX
Re-key the rooms list edge-detector on listState only, instead of
(listState, itemCount). Keying on item count re-armed the detector on
every widen: loadMore pulled older conversations, the list grew,
LaunchedEffect restarted, the edge-detector reset, and it fired again —
walking the window 7->14->21->...->112 days back in a few seconds on a
slow connection. Now distinctUntilChanged fires once per reach-the-end
gesture and does not re-fire while parked at the end.
Also surface initialLoadInFlight from both DM loaders (gift wraps +
NIP-04) and keep a spinner up on the rooms screen until the first relay
answers, so cold boot no longer flashes the empty state before the DMs
land.
The kind-1985 label subscription for the hashtag feed now follows the
NIP-65 outbox model: instead of querying the flat hashtag relay set for
all labels and filtering to follows locally, it queries each follow's
outbox relays for that follow's own label events (authors restricted per
relay via account.followsPerRelay), tagged with the hashtag. This ensures
a follow's labels are picked up even when they never reach the hashtag's
own relays.
https://claude.ai/code/session_019gc3FipVBcndF9fmqCCfVX
amethyst/service/ByteFormatter duplicates
commons/util/countToHumanReadableBytes. Adopt the commons version as
canonical (it appends a " B" unit suffix for sub-1000 byte counts;
the amethyst copy returned a bare number) and re-point the three
callers.
https://claude.ai/code/session_01H66WwvUYm5KtAWBLgUMcod
The rooms list merges NIP-04 (kind 4) and NIP-17 (gift wrap) conversations into
one time-sorted list, but only the gift-wrap loader was windowed — NIP-04
(`DMsFromUserFilterSubAssembler`) still used an EOSE-only `since` with no limit,
so it loaded all kind-4 history at boot.
That asymmetry broke scroll-to-load-more: gift wraps filled only the recent top
of the list while NIP-04 filled the whole tail, so reaching the list end (deep in
the NIP-04 tail) fired `giftWraps.loadMore()`, and the newly fetched 7-14d gift
wraps inserted in the *middle* of the feed instead of extending the end — and
could re-fire step after step while the user sat in the NIP-04 tail.
Apply the same TimeWindowPagination to the NIP-04 rooms-list loader and advance
both windows together from the scroll handler, so the merged list is bounded
uniformly and reaching the end extends the actual end. The loading footer now
reflects either protocol still loading.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
amethyst/service/CountFormatter.countToHumanReadable(counter, str)
duplicates the identical 2-arg overloads already in
commons/util/NumberFormatters. Delete the duplicate and re-point the
two relay-row callers.
https://claude.ai/code/session_01H66WwvUYm5KtAWBLgUMcod
amethyst/service/EmojiUtils duplicates commons/util/EmojiUtils
(identical logic; commons uses the KMP codepoint helpers instead of
JVM Character calls). Delete the duplicate and re-point callers.
https://claude.ai/code/session_01H66WwvUYm5KtAWBLgUMcod
CashuToken and Proof are pure data classes (only @Immutable +
kotlinx.serialization). Extract to commons so Desktop/CLI/iOS can reuse
them, and re-point all callers.
https://claude.ai/code/session_01H66WwvUYm5KtAWBLgUMcod
Let users tag any post with a hashtag via a NIP-32 kind 1985 label event
(using the `#t` tag-association namespace), and surface follow-labeled
posts in the hashtag feed.
quartz:
- LabelEvent.buildHashtagLabel() + HASHTAG_NAMESPACE ("#t") and
hashtagAssociations() to build/extract hashtag-association labels.
commons:
- Note now carries a `labels` reverse-reference map (hashtag -> labeler
notes) with addLabel/removeLabel and a NoteFlowSet.labels flow,
mirroring reactions/reports.
amethyst:
- LocalCache consumes LabelEvent, attaching hashtag labels to their
target notes and re-notifying feed observers for already-cached
targets.
- Account.createLabelHashtagEvent/labelHashtag/consumeLabelEvent and
AccountViewModel.labelWithHashtag (tracked + direct broadcast).
- Overflow "⋯" menu gains an "Add hashtag" action backed by a new
AddHashtagLabelDialog.
- HashtagFeedFilter also accepts posts a followed user labeled with the
hashtag; a new label sub-assembler subscribes to kind 1985 by `#l`
and fetches missing label targets.
- Hashtag feed shows an attribution banner ("#tag added by @user") above
follow-labeled posts via a custom RefresheableFeedView onLoaded.
https://claude.ai/code/session_019gc3FipVBcndF9fmqCCfVX
Extract the pure (quartz-only) decryption cache into commons. Converge
the commons trustedAssertions package onto the quartz NIP slug
'nip85TrustedAssertions' (per migration plan §10.2), moving the existing
TrustProviderListState interface + UserCardsCache with it and
re-pointing all callers.
https://claude.ai/code/session_01H66WwvUYm5KtAWBLgUMcod
Pure data class (only quartz + @Stable deps) — extract to
commons/model/nip30CustomEmojis so Desktop/CLI/iOS can reuse it.
Moves the unit test to commons commonTest and re-points callers.
https://claude.ai/code/session_01H66WwvUYm5KtAWBLgUMcod
ProcessBuilder("git", …) inherited the daemon's working directory, which
in a git worktree (or when the Gradle daemon was started elsewhere) is
not the project root. git printed "fatal: not a git repository"; with
redirectErrorStream that landed in stdout, got cleaned to dashes, and
truncated to exactly "fatal--not-a-git-rep" — visible as the version
suffix in installed builds.
Pass rootDir to ProcessBuilder.directory() so git always runs at the
worktree root, and check the exit code so any future failure falls back
to "unknown" instead of leaking stderr into the version name.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the architecture end-state (ports exist, impls are forked 3 ways =>
unify), the concrete destination package tree for the migrated common
objects (cache engine, decomposed Account into model/account state +
actions + facade, per-NIP state holders, feeds, keystorage, service split),
and the package-naming decisions behind it (model/nipNN raw state vs
model/account composed view; state/ stays generic; actions=verbs; quartz
slug normalization; LocalCache class+delegating object). Also drops a stray
markup line at the end of the doc.
https://claude.ai/code/session_01KXLzsvx9Gyrm3Yz4Rims55
Survey of amethyst/{model,service,ui} (1817 files) classifying what should
move to commons vs stay Android-native, with per-area matrices, the real
cross-cutting blockers (account state is the keystone; R.string is solved by
Compose Resources, not a new StringProvider; Coil is already KMP), a
dedup/reconciliation backlog, and a phased roadmap with a concrete first PR.
https://claude.ai/code/session_01KXLzsvx9Gyrm3Yz4Rims55
The diagnostics logger tagged a subscription as gift-wrap when its raw REQ
string merely *contained* "1059"/"1060" — which matches incidentally inside a
pubkey hex or a since/limit number on unrelated feed REQs. Those feed subs then
leaked their EOSEs (and some connect/auth lines) into the DMPagination tag.
Match the filter's `kinds` array exactly against the real gift-wrap kinds
(1059 + 21059) instead. Also drop the per-relay EOSE line entirely — it's
redundant with the "cold boot: … initial load complete" summary that already
reports the first EOSE and gift-wrap count.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Make the feature-UI vs cross-cutting-UI rule consistent (feature-first):
- ui/nip53LiveActivities -> nip53LiveActivities/ui
- ui/article + ui/editor -> new nip23LongContent/ui (article reader + editor)
ui/ now holds only cross-cutting composables (theme, components, layouts,
elements, markdown, signing, thread, feeds, notifications, screens, state,
text). Tighten ARCHITECTURE.md with the deciding test ('could a second
unrelated feature reuse this as-is?') and reconcile the NIP-second-axis
section so a single-NIP feature owns its UI under <feature>/ui rather than
ui/nipNN.
https://claude.ai/code/session_01KXLzsvx9Gyrm3Yz4Rims55
Rename the two single-NIP feature packages to mirror their quartz
counterparts for 1:1 traceability:
- chess -> nip64Chess
- call -> nipACWebRtcCalls
marmot and nip53LiveActivities already match quartz and are unchanged.
Document the rule in commons/ARCHITECTURE.md: layer is the primary axis,
NIP is the secondary axis (nipNN<slug> matching quartz), and commons is
deliberately NOT reorganized NIP-first at the top level. Also remove
stray markup that leaked into the end of the doc.
https://claude.ai/code/session_01KXLzsvx9Gyrm3Yz4Rims55
fix(sonar-s1871): merge PayInvoice/Nwc error arms via IErrorResponseLike
fix(sonar-s1871): drop redundant is CommentEvent branch in ThreadFeedView
fix(sonar-s1871): merge NPub/NProfile route arms via IPubKeyEntity
fix(sonar-s1871): merge Error/Notice debug-message arms via IRelayDebugMessageText
fix(sonar-s1871): merge LiveActivities/MeetingRoom arms via LiveStreamLike
fix(sonar-s1871): merge channel-message/metadata arms via IsInPublicChatChannel
fix(sonar-s1871): collapse channel-draft reply handling via BaseThreadedEvent
fix(sonar-s1871): drop redundant is CommentEvent arm in NoteCompose
fix(sonar-s1871): merge Failed/Error arms in NIP-05 badge
fix(sonar-s1871): merge Verifying/NotStarted arms in NIP-05 badge
fix(sonar-s1871): merge note-backed RenderOption arms via NoteBackedName
fix(sonar-s1871): drop redundant is PrivateDmEvent arm in RouteMaker
fix(sonar-s1871): merge GiftWrap/SealedRumor arms in RouteMaker via HasInnerEvent
fix(sonar-s1871): merge Connecting/Connected arms in CallSession state collector
fix(sonar-s1871): collapse playback-state when to if/else in CurrentPlayPositionCacher
fix(sonar-s1871): drop redundant is CommentEvent arm in sendPublicReply
fix(sonar-s1871): merge addressable-filter arms via AddressableTopFilter
fix(sonar-s1871): merge repost branches in LocalCache via BaseRepostEvent
fix(sonar-s1871): merge badge-set branches in LocalCache referenced-notes when
The connection listener fires for every relay the app dials (hundreds, under
the outbox model), so logging connect/auth/notice unconditionally drowned the
DMPagination tag in unrelated relay traffic.
Restrict connect / disconnect / cannotConnect / AUTH / NOTICE / OK(fail) lines
to relays on the gift-wrap path — learned the first time we send a kind:1059/1060
REQ to a relay or receive a gift wrap from it. EVENT/EOSE/CLOSED were already
scoped by kind/subId.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Document the commons module's purpose, source-set layout, and the CLI-safe vs
UI boundary in commons/ARCHITECTURE.md, then clean up the clearest package
overlaps that had accumulated:
- merge duplicate util/utils -> util (all source sets)
- unify service/services -> service (jvmAndroid)
- move data/UserMetadataCache -> model/cache
- fold compose/ into ui/ (ui/article, editor, elements, layouts, markdown,
nip53LiveActivities, and Compose helpers in ui/state + ui/text)
- move ProfileBroadcastBanner composable into profile/ui
All changes are whole-file/whole-package moves with import rewrites; no logic
changed. The chess logic/UI split is documented as deferred debt (it needs
file-level surgery, not moves). Marks docs/shared-ui-analysis.md superseded.
https://claude.ai/code/session_01KXLzsvx9Gyrm3Yz4Rims55
The reported symptom — messages inside the 7-day window never appear, and the
first EOSE takes ~136s on a single-event account — points at the relay/connection
path, not the time filter. Nothing currently logs where that time goes or whether
a relay is silently rejecting the query (CLOSED "auth-required"/"restricted").
Add DmRelayDiagnosticsLogger, a debug-only RelayConnectionListener that folds the
gift-wrap loading timeline into the DMPagination tag with elapsed-time prefixes:
- connecting / connected (ping) / disconnected / cannotConnect per relay
- REQ sent for gift-wrap subscriptions (kind:1059/1060), with the command
- AUTH challenge, NOTICE, and CLOSED (for gift-wrap subs) — the silent-failure tells
- gift-wrap EVENT arrivals (relay, sub, createdAt) and their EOSE
Wired in AppModules next to the other debug loggers.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Previously only the boot window open and per-assembly filter were logged; the
completion of the initial cold-boot load was effectively invisible (the EOSE
log was gated behind the scroll-only loadingMore flag, and newEose can't tell a
real EOSE from a live event because the base listener funnels both into it).
Install a custom SubscriptionListener in newSub so we can distinguish a real
EOSE and count arriving gift wraps:
- "cold boot: … opening gift-wrap subscription, starting to load messages"
- "cold boot: … initial load complete — first EOSE from <relay> after Nms,
M gift wrap(s) received so far"
Boot timing/count state is reset per subscription and cleared on endSub.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Adds Log.d("DMPagination") tracing so the boot window and scroll-driven
backfill can be watched live in logcat:
- initial window opened per account (with depth in days)
- each updateFilter assembly (window `since` + depth + relays)
- loadMore widening the window (old -> new floor, depth before/after)
- EOSE clearing the loadingMore flag (transition only, not every event)
- the rooms list reaching its end (triggered vs skipped-already-loading)
Filter logcat by tag `DMPagination` to follow the whole flow.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
The always-on gift-wrap subscription (AccountGiftWrapsEoseManager) had no
lower bound on first boot: `since` came purely from the per-relay EOSE
cursor, which is null on a cold start, so every DM relay dumped the account's
entire NIP-17 history at once — all of which then had to be unwrapped and
NIP-44 decrypted before the messages list felt usable.
Replace that with a per-account time window:
- New `TimeWindowPagination` primitive (commons): tracks a moving `since`
floor, opens a small window at boot, widens backward one step per
`loadMore()`. The subscription stays open so live messages still stream in
regardless of the window.
- `AccountGiftWrapsEoseManager` now requests gift wraps from the window floor
instead of the EOSE cursor, exposes `loadMore(user)` and a `loadingMore`
flag, and clears the flag on EOSE.
- The rooms list (`ChatroomListFeedView`) widens the window when scrolled near
the end and shows a loading footer while the next window loads. It
re-evaluates as the list grows so a near-empty first screen keeps filling.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
R2 — sendNutzap gains an optional preferredMintUrl; the Top-up screen passes the
just-funded selectedTarget so the nutzap spends from THAT mint instead of whichever
shared mint holds the most (which could leave the top-up sitting idle). Falls back
to the best-balance pick when the preferred mint isn't a valid shared target.
R5 — meltToLightning gains skipScrub; rebalance() (which already scrubbed the
source for its coverage check) passes it to drop the redundant second NUT-07
/checkstate round-trip. The Top-up screen's wallet collector now projects to the
per-mint balance map + distinctUntilChanged, so unrelated wallet activity (an
inbound redeem, a scrub, a token for another mint) no longer re-runs the whole
balances/targets/sources rebuild on every global tokenEntries emission.
R7 — replace ReloadMintScreen's hand-rolled copyToClipboard with the shared
Clipboard.setText helper (drops the android ClipData/ClipboardManager imports);
document the keys-only observed values in the reactive railCapability block so a
future reader doesn't delete them as "unused" and silently break the live rail
loading + relay fetch.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
addAmount() allowed adding the same amount twice; two preset chips then share the
same key(amount) (Compose duplicate-key hazard) and the drag-reorder's
indexOf(amount) resolves to the wrong chip. De-dupe on add.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
Audit found the toppedUp checkpoint was still too coarse: it was set only AFTER
rebalance() / the LN mint fully returned. But funds leave the wallet mid-flow —
the source melt (rebalance) and the invoice payment (Lightning) both happen before
the poll/completeMintFromLightning steps that can throw. A failure there left
toppedUp=false, so "Try again" re-ran the whole move and spent a second time.
- CashuWalletState.rebalance gains an onFundsMoved callback fired immediately after
the melt succeeds; the VM sets toppedUp there.
- The Lightning path sets toppedUp the moment the invoice is confirmed paid, before
issuing ecash.
Either way, once money has moved a retry can only re-send / resume — it can never
move funds again. (The paid-but-unissued quote remains recoverable via the pending
quote banner.)
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
The zap popup computed rail availability once from a synchronous snapshot, so a
recipient whose lnAddress (kind:0) or nutzap info (kind:10019) hadn't loaded yet —
or before our own cashu wallet finished loading — showed no Lightning/cashu logo
and never updated.
Now it observes those inputs and recomputes railCapability as they arrive:
- observeUserInfo(author) → the Lightning logo appears when the lnAddress loads.
- observeNoteEvent<NutzapInfoEvent>(author.nutzapInfoNote) + the cashu wallet's
mints/tokenEntries flows → the cashu logo appears when the recipient's kind:10019
and our proofs load.
The observers also trigger the relay fetch, so a not-yet-seen lnAddress / kind:10019
gets pulled in while the popup is open.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
- Drag-and-drop now works: pointerInput was nested inside the graphicsLayer
translation, so the layer moving under the finger corrupted the per-frame drag
deltas. Moved the gesture outside the transform.
- Preset chip regrouped to match the popup: outlined track, default rail + amount
in a highlighted thumb, alternatives as quiet mono icons, then the X — instead
of everything mashed together.
- Top-up screen header now shows the zap amount between the cashu symbol and the
arrow (you → cashu · N sats → recipient).
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
Bug: a top-up that moved funds but then failed at the nutzap (the freshly minted
proofs hadn't landed in local state yet → "No proofs available") left a Failed
state; tapping "Try again" re-ran the WHOLE pipeline and moved the funds a second
time — two transfers for one zap.
- Add a `toppedUp` checkpoint set the moment funds land at the target (after
rebalance / completeMintFromLightning). confirm()/retry now skips the move
entirely once topped up and only (re)sends the zap — funds can never move twice.
- awaitTargetFunded(): briefly poll the target balance after topping up so the
follow-up nutzap sees the new proofs and succeeds on the first try instead of
needing a manual retry. Best-effort with a timeout; the checkpoint guarantees no
double-move even if it falls through.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
- Toggle border eased from `outline` to `outlineVariant` — present but no longer
heavy.
- Selected cashu (and the reload variant) now tints with the same BitcoinOrange
as the Lightning/on-chain rails instead of the purple accent, so the active
rail colour is consistent across all three.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
Reverts the per-segment selected outline. The real ask was a clearer mark for the
entire 3-rail toggle: give the whole component a 1dp `outline` border so it reads
as one control. The selected segment keeps its subtle primaryContainer thumb.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
- The new monochrome cashu outline read too small; bump it from 0.72× to 0.86×
the symbol size so it matches the bolt/bitcoin marks optically.
- The selected segment's container fill sat too close to the track to read as
"selected", so add an animated primary outline around the active segment — the
state is now unmistakable (outline + fill + the amount label all land on it).
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
Replaces the "tap a rail icon = instant send" model (which read like a toggle but
moved money) with an explicit two-step control, so switching rails never sends:
- The rails are one connected segmented pill (shared surfaceVariant track) — they
visibly belong together. A primary-container "thumb" animates to the selected
segment.
- Only the selected segment shows the amount (+ a send arrow); it expands in on the
chosen rail and shrinks away on the previous one, so the amount reads as
travelling to the icon you tapped.
- Tapping an unselected rail only selects it (no payment). Tapping the selected,
labelled segment is the single thing that sends. Selection starts on the
amount-tier default, so the common case is still one tap. Long-press still edits
the presets.
Removes the old leading-icon + trailing circular-button layout and the now-unused
RailButton.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
- When a chip has no alternative rails, match the right inset to the left so the
icon+amount pill stays symmetric (was cramped against the right edge).
- Alternative-rail circles shrunk toward the amount's font height (28→22dp) with
a smaller icon inside (ZapRailIcon gained a size param; alternatives render at
14dp), so they stay quiet next to the bigger coloured preferred logo.
- The amount text is now neutral (onSurface) instead of taking the rail's brand
colour — only the leading logo carries colour, so amounts don't shout across
the feed. Dropped the now-unused zapRailAccent helper.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
Replaces the per-relay backoff-token approach (opaque WebsocketBuilder
config token + BasicRelayClient bookkeeping) with a much smaller check at
the source.
The connector already holds the two OkHttp clients, and those are rebuilt
only when something connection-relevant changes (Tor's SOCKS port appears,
wifi<->cellular switch). Everything else it wakes on — Tor bootstrap status
churn, connectivity blips, self-heal restarts — leaves the clients
untouched. So instead of threading a config token through quartz, the
connector now forces a backoff-skipping reconnect (ignoreRetryDelays=true)
only when an OkHttp client instance actually changed; otherwise it lets
each relay's exponential backoff decide. That stops the
reconnect-fail-reconnect loop while Tor boots, and still reconnects every
relay (Tor and clearnet alike) the instant the transport changes.
Reverts the quartz/OkHttpWebSocket changes from the previous commit and
wires the connector to take the StateFlows it actually consumes (the two
client flows + connectivity/tor status) instead of the manager objects,
which also decouples it from DualHttpClientManagerForRelays/
ConnectivityManager/TorManager.
https://claude.ai/code/session_01SCz8kdYs2FwesEyzbhmRPY
Bump the pixel sunglasses scale (1.45 -> 1.70) so the shades read bolder and
overhang the slimmed, rounded cashew body more prominently. Body outline and
1.2 stroke weight unchanged.
Long-press a preset chip to pick it up and drag it to a new position in the
FlowRow; release to drop. Hand-rolled (no new dependency):
- VM gains moveAmount(from, to) to reorder the preset list.
- Each chip records its bounds (boundsInParent) and is wrapped in key(amount) so
reordering doesn't restart the in-flight drag gesture; the dragged chip follows
the finger via graphicsLayer translation and lifts with zIndex.
- The drop target is the chip whose bounds contain the pointer; on cross-over the
list reorders and the drag offset is rebased so the chip stays under the finger.
Identity (drag state, bounds map, target) is keyed by the amount value, not the
slot index, to avoid stale-index bugs across reorders.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
The Nostr Wallet Connect section (connect button, paste, QR, manual pubkey/relay/
secret) is removed from the zap-amount settings content. NWC setup now lives only
in the wallet area:
- The `nostr+walletconnect` connect deep link (`dlnwc`) now opens Wallet → Add NWC
with the URI prefilled (Route.WalletAddNwc gained an optional nip47 arg), instead
of the old shared NIP-47 setup screen.
- UpdateZapAmountContent loses its nip47uri parameter and the whole wallet-connect
block; both callers (zap settings + NIP-47 setup) updated. The NIP-47 setup
screen keeps its Lightning-address and payment-targets sections (both also
reachable from profile edit / EditPaymentTargets) and is no longer the deep-link
target.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
Replace the blocky pixel-stepped cashew silhouette with a smooth rounded
outline (corner-cut and emitted as a compact Bézier spline) so the nut reads
as a clean curved shape instead of a staircase. Use a round stroke cap/join at
the same 1.2 weight; the pixel "deal-with-it" sunglasses stay a solid fill.
Quick-zap preset chips now render like the real feed chip instead of a plain
"⚡ N" InputChip: the amount with the rails a preset of that size could use —
the amount-tier default rail in colour on the left, the alternatives (cashu /
Lightning / on-chain) in monochrome — with an X to remove. The bolt emoji is
gone; Lightning is the Material Symbols bolt, matching the feed.
Refactor: ZapRail, ZapRailIcon, the tier thresholds, and a new
previewRailsFor / previewPreferredRail / zapRailAccent are now internal so both
the feed chip and the settings preview share one source of truth for rail
choice, ordering, colouring, and the default highlight.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
Squeeze the cashew outline horizontally (~0.72) and scale the pixel sunglasses
up (~1.45) so the shades overhang the body for a bolder, more recognizable
mark. Stroke weight stays at 1.2 to match the shared Zap outline icon.
- Header is now a visual: your avatar → cashu zap glyph → arrow → the recipient's
avatar, centered, replacing the explanatory subtitle text.
- Multiple Lightning wallets: each configured NWC wallet is its own "Funds from"
option (by name), defaulting to the configured default wallet; the chosen
wallet's URI is used to auto-pay. Falls back to a single external-invoice option
when no NWC wallet is set up.
- Separated the two amounts: the send (zap) amount is fixed; the editable field is
now the top-up amount, defaulting to the shortfall and adjustable upward.
Funding mints/moves the top-up; the nutzap always sends the fixed amount.
- Muted the selected source styling — the radio button carries the signal, so the
card uses a faint primary wash + a thin low-opacity border instead of a heavy
2dp outline and filled container.
- Bottom summary now reads "Top up X sats to <mint> and zap <name> Y sats · fee ≈ Z
sats" (and "Zap <name> Y sats from <mint>" when no top-up is needed), using the
recipient's display name.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
Convert the multi-tone "deez nuts" Cashu/nutzap logo into a single-color,
tintable outline icon so it behaves like a Material Symbol glyph: the cashew
body is a hollow stroke (1.2 weight, matching the shared Zap outline icon) and
the pixel sunglasses stay a solid fill so they read at small sizes.
Drop the `tint = Color.Unspecified` overrides at every call site (zap chips,
nutzap rows/gallery, redeem, wallet screens) so the icon now tints with the
surrounding content colour instead of being locked to the brand browns, and
remove the imports/comments that only existed to preserve the old multi-tone
rendering.
Before Tor is ready, relays were disconnecting and immediately
reconnecting, ignoring BasicRelayClient's exponential backoff.
RelayProxyClientConnector calls reconnect(ignoreRetryDelays = true) on
every infrastructure change. That flag flows pool-wide into
connectAndSyncFiltersIfDisconnected and unconditionally bypassed the
backoff gate. While Tor is still bootstrapping its SOCKS port isn't
listening, so each unrelated infra event (connectivity transitions,
self-heal restarts, Tor status churn) forced an immediate reconnect that
failed again — the backoff was computed (delay kept doubling) but never
consulted.
Make the bypass per-relay and conditional on the relay's transport
config actually changing since its last attempt:
- WebsocketBuilder gains connectionConfig(url): an opaque,
value-comparable token of the transport config (proxy + timeouts).
Default null = untracked, preserving legacy always-bypass behavior for
in-process/standalone/test/desktop builders.
- BasicRelayClient records the token at each attempt and only lets a
forced reconnect skip the backoff when the token changed. A Tor relay
whose SOCKS config is unchanged keeps honoring its backoff; the moment
Tor flips to active (proxy port appears) the token changes and it
reconnects immediately. Clearnet relays still retry immediately
whenever their own client changes.
- OkHttpWebSocket.Builder reports the proxy+timeout fingerprint, reused
by needsReconnect() to avoid drift.
Adds BasicRelayClientBackoffTest covering the honored/bypassed/untracked
cases.
https://claude.ai/code/session_01SCz8kdYs2FwesEyzbhmRPY
- The default action is now one tap target: the preferred rail's logo hugs the
amount (no separate button), and a tap anywhere on icon+amount fires that rail.
- Alternative rails render as distinct circular buttons (filled surfaceVariant
circle) to their right, so it's clear they're individually tappable.
- The amount takes the preferred rail's accent colour (BitcoinOrange for
Lightning/on-chain, primary for reload; neutral for the multi-tone cashu logo,
which has no single brand colour).
- Reworked the preview to exercise every rail combination across the amount tiers
(5 / 100 / 5k / 100k sats): all-rails, cashu-only, lightning-only, and a
funds-split reload scenario.
Refactor: split the per-rail logo (ZapRailIcon, click-free) from the tap target
so it can be reused both inside the default area and inside RailButton.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
- Default rail is now amount-tiered: under 10 sats prefers cashu (Lightning
min/fees make tiny zaps awkward), over 10k sats prefers an on-chain
transaction, and the middle defaults to Lightning — each falling back through
the remaining rails when the tier's rail isn't available. The tap action and
the colored leftmost logo both follow this single `preferred` choice.
- The preferred rail's logo now sits to the LEFT of the amount (in colour); the
alternative rails follow on the right in monochrome.
- Shrank the cashu logo to 15dp (~17%) so it no longer dwarfs the Material
symbol rails it sits beside; the reload "+" badge scaled to match.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
Top-up (formerly "Reload Mint") screen:
- Renamed throughout to "Top up mint" / "Top up & send zap".
- Amount is now editable (numeric field, seeded from the tapped preset). The VM
recomputes shortfall/sources/fees on every edit; if the chosen amount already
fits the target mint, the screen drops the funding step and just sends.
- Selected funding source is now obvious: radio button + 2dp primary border +
primaryContainer fill, instead of a faint 12%-alpha tint.
- Each source's balance/description moved to its own line under the title, so
the Lightning explanation wraps instead of clipping.
- Removed the redundant "Your balances" list (it duplicated the per-mint balance
the Funds-from rows already show); the target's current balance now sits in the
destination card.
- Summary reads "Send AAA sats from XXX to YYY, fee Z sats" (or "Send AAA sats to
YYY" when no top-up is needed).
- Balances are now reactive: the VM observes the wallet's tokenEntries/mints
flows and rebuilds as proofs arrive from relays, instead of a one-time snapshot
that could show only the first-loaded mint.
Zap chip:
- The preferred rail (what a plain tap triggers) is rendered first and in full
colour; the alternative rails follow to its right in monochrome.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
The NEEDS_RELOAD affordance rendered two full 18dp icons side by side (cashu +
"+"), making the chip nearly twice as wide as the other rails. Overlay the "+"
as a small 11dp corner badge on the dimmed cashu logo so it occupies a single
logo's footprint like Lightning/on-chain.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
RenderPodcastMetadata and RenderPodcastEpisode rendered their description
tag as a plain Text. Both now use TranslatableRichTextViewer (rich text +
translation), matching the podcast screen header. The metadata card finally
uses its previously-ignored canPreview/backgroundColor params; the episode
card gives its short description a distinct id so it doesn't share remember
state with the markdown body below it.
- The show description on the podcast screen now renders through
TranslatableRichTextViewer (rich text + translation), matching how a
profile's About is shown, instead of a plain Text. PodcastHeader takes a
nav for the rich content's links.
- Audio playback now shows a decorative per-episode waveform when the
source has none, instead of a flat strip — same behaviour as music
tracks. Extracted MusicTrack's private syntheticWaveformFor into a shared
wavefront/SyntheticWaveform.kt used by both music and podcast players; the
podcast player still prefers a real IMeta waveform when one is present.
Follow-up to dropping .sorted() from mergeZapAmounts: update ZapAmountMergeTest
to lock in first-seen order (Lightning list, then on-chain extras) rather than
ascending, so a user's saved ordering is provably preserved.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
Correctness:
- ReloadMintViewModel.confirm() now guards against re-entry (only starts from
Configuring/Failed and flips to Working synchronously), so a double-tap or a
Failed-state Retry can't launch two pipelines that double-spend the source /
double-mint the target.
- sendNutzapAndFinish awaits the real CashuWalletState.sendNutzap (throws on
failure) and reports Done only on success — a send that fails after a
successful reload now surfaces as Failed instead of popping the screen on a
premature "done" and silently stranding the moved funds.
- The reload pipeline runs on the long-lived AccountViewModel scope; the VM now
holds its Job and cancels it in onCleared(), so leaving the screen stops the
(up to 3-minute) Lightning poll instead of hammering the mint unobserved.
- rebalance() poll budget widened to a steady ~60s so a merely-slow mint no
longer strands funds that already left the source.
- Mint a small headroom buffer (RELOAD_FEE_BUFFER_SATS) above the bare shortfall
so the follow-up nutzap's own swap fee doesn't leave the target a sat short;
the source-feasibility gate accounts for it.
Regressions from the settings merge:
- mergeZapAmounts / the picker no longer .sorted() the amounts — a user's saved
preset order is preserved instead of being silently reordered ascending.
- The on-chain send dialog falls back to DEFAULT_ONCHAIN_ZAP_SATS when the
unified list has nothing above the on-chain minimum, restoring the guaranteed
quick-pick preset.
- zapClick's one-tap (single-amount) path now checks rail capability and opens
the picker when the recipient can't receive Lightning, instead of firing a
doomed Lightning zap.
Cleanup:
- Centralized the mint-quote "settled" predicate as MintQuoteBolt11ResponseDto
.isSettled(); rebalance, ReloadMintViewModel and CashuWalletViewModel now
share it instead of three copies of paid==true || PAID || ISSUED.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
Audit follow-ups on the podcast screen:
- Extract the duplicated GetMediaItem -> GetVideoController -> RenderVoicePlayer
block (and the FLAT_WAVEFORM constants) from both PodcastEpisode and
PodcastEpisodeListItem into one PodcastEpisodeAudioPlayer composable. The
border shape stays a parameter (bottom-rounded under the cover vs. fully
rounded in the list).
- Fix the list row clipping the 75dp play button: the row hard-coded 72dp,
below the voice player's 80dp minimum. Height now lives in the shared
player so both call sites stay identical.
- Header no longer flashes "0 episodes" while the relay request is in flight:
episodeCount is nullable and the count/divider only render once loaded.
- Drop the odd uppercasing of the episode date (looked wrong on relative
values like "3d").
When the cashu logo on an amount chip is NEEDS_RELOAD (you hold enough cashu,
but not in the recipient's mint), it now shows a dimmed logo with a "+" badge
that opens a full Reload Mint screen instead of silently falling back to
Lightning.
The screen tells the whole story and auto-sends the nutzap on success:
- Destination ("mint to reload") with its shortfall; selectable when the
recipient accepts several mints we hold.
- All cashu balances for context.
- Source ("funds from"): another mint (mint-to-mint rebalance, no new sats) or
Lightning. Mint rows are disabled when they can't cover shortfall + fee.
Funding paths:
- Rebalance: CashuWalletState.rebalance moves cashu between mints.
- Lightning: mint a fresh quote at the destination, auto-pay via NWC when a
wallet is configured (else show the invoice to copy), poll, then issue.
Wiring: new Route.ReloadMint + AppNavigation entry; a ReloadMintRequest handed
off via an LruCache on AccountViewModel (mirrors the manual-zap pattern);
onReloadNutzap threaded through the zap popup overloads and the chip; callers
(ReusableZapButton, ReactionsRow, NestActionBar) navigate to the screen.
IMPOSSIBLE (not enough cashu anywhere) keeps falling back to Lightning — the
reload screen is only for moving existing cashu.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
Each item in the Podcasts feed (kind 10154 show metadata) now opens a
dedicated podcast screen built around the show's pubkey — per NIP-F4 a
podcast is its own keypair, so its metadata and every episode (kind 54)
share one author.
- New Route.Podcast(pubkey) + AppNavigation binding.
- PodcastScreen: modern hero header (cover art, title, websites, tappable
expand/collapse description, "N episodes" section) followed by every
episode in a LazyColumn.
- PodcastEpisodeListItem: compact "show list" row (date, title, summary)
with an inline audio player so episodes play without leaving the list.
- OnePodcast datasource (per-user EOSE manager) fetches the show metadata
and episodes from the podcast key's outbox relays.
- OnePodcastEpisodesFeedFilter + OnePodcastFeedViewModel scope LocalCache
to a single podcast's episodes.
- Podcasts feed metadata card is now clickable with a "View episodes"
affordance.
Foundation for the Reload Mint screen (NEEDS_RELOAD funding path):
- peekMintBalances(): per-mint spendable balance from in-memory proofs
(synchronous; feeds the balances list and source-feasibility checks).
- recipientSharedMints(): the recipient's accepted mints we also hold —
the candidate reload destinations.
- rebalance(source, target, sats): mints a fresh quote at the target and
pays its invoice by melting at the source, moving cashu between mints with
no new sats. Verifies the source covers amount + fee before spending,
abandons the unpaid quote otherwise, and leaves a resumable kind:7374 if the
target hasn't confirmed yet — money is never lost on failure.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
The on-chain rail no longer has its own editable preset list. There is now
one zap-amount list used by every rail; on-chain simply filters it by the
shared MIN_ONCHAIN_ZAP_SATS floor (1000 sat), and the unified chip already
gates each logo per amount.
- Settings: drop the public onchainZapAmountChoices StateFlow and its editor
section in the Update Zap Amount dialog. The serialized field is kept for
backward/cross-client (NIP-78) compatibility and migration: on load the two
saved lists are unioned into zapAmountChoices (mergeZapAmounts), and on save
the on-chain-eligible subset is written back so older clients still get
sensible on-chain presets. The round-trip is idempotent.
- updateZapAmounts / changeOnchainZapAmounts lose the separate on-chain
parameter throughout (Account, AccountViewModel, UpdateZapAmountViewModel).
- OnchainZapSendDialog draws its quick presets from the single list filtered
by the minimum.
- MIN_ONCHAIN_ZAP_SATS is now a single shared constant in the model, replacing
the copies previously private to OnchainZapSendDialog and ReactionsRow.
- Remove the now-unused on-chain amount strings; clarify the zap-amounts
explainer to note amounts apply to all rails.
Adds ZapAmountMergeTest covering the union/dedup/idempotent-round-trip.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
Replace the three parallel chip rows (cashu / lightning / on-chain) in the
zap amount popup with a single pill per amount that shows a tappable logo
for each rail that can actually pay it. Tapping the amount fires the
cheapest/fastest available rail (cashu funded -> lightning -> on-chain);
tapping a specific logo forces that rail.
Rail gating:
- Cashu: precise per-amount status (FUNDED / NEEDS_RELOAD / IMPOSSIBLE /
UNAVAILABLE) computed from in-memory proofs — the one balance that is free
to read synchronously. Only FUNDED is offered for now.
- Lightning: optimistic — an external wallet can pay any invoice, so it's
offered whenever the recipient can receive, with no sender-balance fetch.
- On-chain: offered when a backend is configured and the amount clears the
on-chain minimum; UTXO sufficiency stays a send-time check.
Also fixes the original "No proofs available at <mint>" failure: nutzap mint
selection now picks the shared mint where we hold the most balance instead of
the first one the recipient lists (peekNutzapFunding), so a zap no longer
fails when funds sit in a different shared mint. Cashu capability is now
resolved against the note author (who sendNutzap actually pays) rather than
split recipients.
Amount presets from the (still separate) lightning and on-chain settings are
merged into one sorted set for display.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
The cashu zap chip previously fired its async send and closed the
popup with no visible motion until the kind:9321 round-tripped
back (1-2 seconds of "did the tap register?" silence). Wire the
same zappingProgress channel the lightning path uses so the bar
visibly animates from tap to completion.
Pieces:
- CashuWalletOps.sendNutzap: optional `onProgress: (Float) -> Unit`
emitting at the network checkpoints — 0.55 after the swap (the
long mint round-trip), 0.80 after the kind:9321 publish (the
recipient can see it), 0.95 after keep + delete, 1.0 after the
kind:7376 history.
- CashuWalletState.sendNutzap: forwards onProgress to ops and
also emits 0.20 after scrubLocallyStaleProofs so the bar moves
before the slow swap call begins.
- AccountViewModel.sendNutzap: accepts an `onProgress: (Float) -> Unit`
parameter (default no-op so existing callers compile unchanged)
and threads it through to CashuWalletState.
- ReactionsRow.onNutzap callback: emits 0.05 immediately on tap
for instant click feedback, then passes the same `onProgress`
channel the lightning chip uses so zappingProgress drives the
same progress indicator. The existing onError reset already
zeroes the bar on failure.
Resulting cadence at the UI:
tap → 0.05 (instant)
→ 0.20 (scrub done)
→ 0.55 (mint swap done)
→ 0.80 (kind:9321 out)
→ 0.95 (token rollover + NIP-09 out)
→ 1.00 (history out, bar hides).
The grace-period unsubscribe in LifecycleAwareKeyDataSourceSubscription ran
on the composition scope from rememberCoroutineScope(), whose dispatcher is
coupled to the UI frame clock. When the app is backgrounded the frame clock
stops ticking, so the pending unsubscribe could be starved and never fire.
Because closing the REQ is what drives the relay disconnect (via desiredRelays
-> RelayPool.updatePool), the connection could linger indefinitely. This is
most visible on the relay feed, whose dedicated one-off relay is kept alive by
nothing else.
Drive the grace timer from Lifecycle.currentStateFlow on a dedicated
Dispatchers.Default scope instead. collectLatest cancels the pending delay
automatically when the lifecycle returns to STARTED, preserving the 30s
app-switch grace while ensuring the timer fires reliably in the background.
https://claude.ai/code/session_01SesftJphLwvLtn1fJB5zx8
Opens the App Drawer (same as Cmd+K) for quick access to all
available screen types. Positioned between main nav and FEEDS section.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Revert spacer change (back to 60dp)
- Add top = 16.dp to LazyColumn contentPadding so first card has
extra spacing and slides nicely under the floating search header
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add AccountState.Loading as initial state (was LoggedOut)
- Show centered "Amethyst" + spinner while accounts load from storage
- After loadSavedAccount(): transition to LoggedIn or LoggedOut
- No more 0.5s flash of login screen when account exists
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- COLLAPSED_WIDTH 56dp → 64dp so icons aren't truncated
- Tor connected: use Security icon (filled shield) instead of Shield
- Tor off/connecting/error: keep outlined Shield icon
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Replace compact icon Row at bottom with SidebarNavItem-style items
- Tor: shows "Tor: Off/Connecting/Connected/Error" with Shield icon
- Bunker: shows "Bunker: OK" with Favorite icon (only when connected)
- Both use same shape, hover, and label pattern as other sidebar items
- Collapsed mode: icon-only with tooltip, same as nav items
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add LocalOpenFullSearch CompositionLocal (navigates to Search column)
- Provided at Main.kt level alongside LocalFeedSearchActive
- FeedScreen reads it directly — no param threading needed
- "Open full search" link in expanded header now actually opens Search column
- Removed unused onOpenSearch param from DeckColumnContainer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- SearchPill: move hoverHighlight() inside Surface content Row so it's
clipped to the pill's 36dp height (was drawing on parent Row height)
- AccountSwitcherDropdown: reduce IconButton from 48dp to 40dp so ripple
circle fits within collapsed sidebar width
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- SidebarNavItem/SidebarFeedItem: .clip().clickable().background()
— ripple now clipped to RoundedCornerShape(8dp) bounds
- ColumnHeader: .padding() before .pointerInput() — gesture detection
respects horizontal padding
- SearchPill: .clip(pill shape) before .hoverHighlight() — hover
drawBehind rect clipped to pill shape, not parent rectangle
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sidebar onNavigate callback now sets searchActiveState.value = false,
clearing the feed search expansion and sidebar dim overlay when the
user clicks any nav item (Home, Messages, Settings, etc.)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Relay query debounce stays at 300ms (AdvancedSearchBarState default)
- Separate 1s debounce on searchText: when user stops typing for 1s,
save the query to SearchHistoryStore (assumes intent confirmed)
- LaunchedEffect(searchText.text) auto-cancels on each keystroke,
so only fires after 1s of inactivity
- No duplicates: addToHistory() deduplicates by serialized query
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Increase relay query debounce from 300ms to 1000ms for inline search
(reduces unnecessary relay load while typing)
- Save query to SearchHistoryStore when search collapses (if non-empty)
- SearchHistoryStore.addToHistory() already deduplicates by serialized query
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- LinearProgressIndicator at top of expanded card (animated in/out)
- Loading state: centered icon + "Searching N relays..."
- Empty state: "No results found" / "No search relays configured"
- Results stream in incrementally from relays
- 1s debounce for relay queries, save to search history (no dupes)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Show "Searching N relays..." with spinner while waiting for results
- Show "No search relays configured" if searchRelays is empty
- Observe isSearching, peopleResults, noteResults directly for state
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
FeedTabsHeader now creates actual NIP-50 relay subscriptions when
typing in the inline search, matching the full SearchScreen wiring:
- Collect debouncedQuery (300ms) from AdvancedSearchBarState
- Access searchRelays from LocalRelayCategories
- rememberSubscription for people search (MetadataEvent kind 0)
- rememberSubscription for note search (SearchFilterFactory filters)
- Results flow into SearchResultsList (reused from search/ package)
- 10s timeout for silent relays
- Subscriptions auto-cleanup on collapse via DisposableEffect
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. AnimatedVisibility for search card expand/collapse (expandVertically
+ fadeIn 200ms, shrinkVertically + fadeOut 150ms)
2. Feed tab clicks now collapse the search (onSearchExpandedChange(false)
before switching feed)
3. When typing in search: reuses SearchResultsList composable from
search/ package with AdvancedSearchBarState (same as full SearchScreen)
- Empty input shows search history (recent + saved)
- Typing shows live people + note results grouped by kind
- "Open full search" link at bottom for advanced filtering
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Header card pulled OUT of ReadingColumn into outer Box layer 3
(rendered after scrim, so it floats visually above it)
- Feed content in layer 1 with spacer for header height
- Scrim in layer 2 covers only feed content
- Sidebar gets its own scrim overlay via Box wrapper in Main.kt
- Search card stays sharp/visible while everything else dims
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- FeedTabsHeader expands to full-width search card when active
- Feed tabs hidden, search input fills entire header width
- History section (recent + saved) shown below input
- "Open full search" link at bottom
- Scrim overlay dims feed content when search is active
- Cmd+F toggles search via LocalFeedSearchActive CompositionLocal
- MutableState<Boolean> provided at Window level
- FeedScreen reads it directly, no param threading needed
- SearchPill simplified back to clickable-only (no inline expansion)
- Escape or click scrim dismisses search
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- SearchPill: fix collapsed state not rendering — removed nested
Box/Surface/matchParentSize, replaced with simple if/else on
expanded state within single Surface+Row
- Sidebar: add "+ Add Feed" item below custom feeds in FEEDS section
- Dropdown menu moves outside Surface to render properly
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- FeedTabsHeader wrapped in Surface card (border + rounded corners)
- Feed tabs compact with spacedBy(4dp), removed "+ More" (in sidebar)
- SearchPill centered with weight(1f), expands inline to BasicTextField
with DropdownMenu showing recent + saved searches
- Removed Dialog-based SearchSpotlight (wrong UX — created separate AWT window)
- Cmd+F now navigates to Search column via navigateToScreen callback
- All feed tab types (Following/Global/Custom) properly handled in onClick
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- SearchSpotlight: global overlay with scrim, auto-focused input, recent/saved
searches, hashtag quick-jump, and "Open full search" link
- SearchPill: compact reusable pill component with hover highlight and shortcut hint
- Cmd+F keyboard shortcut wired in MenuBar to open/close spotlight
- SearchPill added to FeedTabsHeader (Home/Feeds column headers)
- Result selection dispatches to deckState (deck mode) or singlePaneState (single-pane)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Rename DeckSidebar -> MainSidebar (it's not deck-specific anymore)
- Hoist MainSidebar from DECK-only to shared Row in Main.kt
- Remove inline 80dp NavigationRail from SinglePaneLayout
- SinglePaneLayout now only renders content pane (sidebar is external)
- onNavigate dispatches to singlePaneState.navigate() or deckState.addColumn()
based on active layoutMode
- Active item highlighting works in both modes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two parallel gaps to the cashu work, surfaced once the cashu side
was wired correctly:
1. The orange bolt highlight on the reaction row never lit up for
onchain zaps. Note.isZappedBy checked LN zaps, NWC payments,
and (since Phase 1) nutzaps — but never onchainZaps. And the
fast-path gate in ObserveZapIconState shared the same blind
spot. Add isOnchainZappedBy parallel to isNutzappedBy (same
shape: any onchainZaps entry whose source.author matches the
user and whose source event is newer than afterTimeInSeconds),
and extend the gate with onchainZaps?.isNotEmpty().
2. The reaction-row counter included CONFIRMED onchain amounts
via updateZapTotal (verifiedSats only, per NIP-BC) but not
the signed-in user's OWN pending/unverified outgoing zaps.
That created a UX mismatch: the gallery shows the user's own
UNVERIFIED entry with its claimed sat amount immediately
(the user knows what they sent), but the counter stays at 0
until the chain catches up. Add
Note.extraOwnPendingOnchainSats(loggedInPubKey) that sums
claimedSats from non-CONFIRMED onchainZaps whose source.author
matches the logged-in pubkey, and add it on top of zapsAmount
in both AccountViewModel.calculateZapAmount paths and
ObserveZapAmountText's no-zapPayments fast path. Other senders'
non-confirmed entries still contribute 0, preserving the
anti-spoof posture for incoming zaps.
StickToTopOnPrepend hardcoded `wasAtTop = true` on remember, so when the
user scrolled down, navigated to a post and came back,
rememberForeverLazyListState restored a non-zero offset but the helper
still believed the user was at the top. As soon as the head-of-feed key
emitted (first observation after recomposition), the LaunchedEffect
scrolled them back to 0.
Seed wasAtTop from the actual restored scroll position via an
initialAtTop lambda. The sampler's gesture-guarded false→true protection
against keyed-item shifts is unchanged.
Regression from #3088.
The module-level mutable SimpleDateFormat formatters in these files
were read concurrently — UI composition on the main thread, and
LocalCache.justVerify calling dateFormatter() from background event-
verification coroutines for failed-signature log lines. SimpleDateFormat
is not thread-safe (mutable internal Calendar), and updateFormattersIfNeeded
reassigned the field mid-format. Race produced corrupted timestamp
strings and occasionally NumberFormatException inside format().
Replace the shared-var pattern with a small LocaleAwareFormatter that
wraps a ThreadLocal<Pair<Locale, SimpleDateFormat>>. Each thread caches
its own instance and rebuilds lazily when Locale.getDefault() changes —
no locks, no contention, same allocation profile after warm-up.
Apply the same pattern to CalendarTimeFormat for consistency; today its
callers are all main-thread but the structure was identical.
The reaction-row icon turns orange when the logged-in user has
zapped a note. Phase 1 extended Note.isZappedBy to detect cashu
zaps, but ObserveZapIconState had a fast-path gate that skipped
the whole calculation when the note had no LN zap data:
val hasZapData =
zapsState?.note?.zapPayments?.isNotEmpty() == true ||
zapsState?.note?.zaps?.isNotEmpty() == true
A cashu-only zap leaves both `zaps` and `zapPayments` empty, so
hasZapData was false, wasZapped was hard-coded false, and the
new isZappedBy logic never ran. The amount counter still updated
because that path (Note.zapsAmount) was already extended in
Phase 1 — only the icon gate was missing the third source.
Add `nutzaps?.isNotEmpty()` to the gate.
Two UI follow-ups after seeing the rails in the running app:
1. ReactionDetailGallery cashu row was Size25dp, matching the
lightning-bolt convention from OnchainZapGallery. The
multi-tone cashu glyph reads bigger than a thin bolt at the
same nominal dp, so the row felt visually heavier than the
lightning row above it. Drop to Size20dp to match the cashu
chip in MultiSetCompose.RenderNutzapGallery and the boost /
like rows that sit below.
2. sendNutzap's success toast is now redundant. The earlier
Phase 1 work attaches the kind:9321 to the target Note via
addNutzap, so the reaction-row counter and the
you-already-zapped icon highlight both update on their own
the moment the kind:9321 round-trips through the cache. A
"Cashu zap sent" toast on top of that visible state change
is just noise. Drop the toast and the now-unused
nutzap_sent_title / nutzap_sent_amount strings.
The Phase 0 work was an artifact of the order I implemented the
phases — feedback first while the counter was still LN-only, then
the foundation made the toast moot. Removing it now keeps the
final UX clean.
The post-refactor `bestPattern(skeleton)` helper allocated a fresh
SimpleDateFormat — plus ran the ICU lookup in `getBestDateTimePattern`
— on every call. That's hot in the calendar grid: 7 weekday-header
calls per render and up to 42 `formatLongDate` cells per month render.
Restore the old caching pattern (module-level vars, rebuild on locale
change via `refreshFormattersIfLocaleChanged`), now keyed off the
locale-aware skeleton patterns. Time format still re-fetched per call
via `DateFormat.getTimeFormat(context)` since the user's 24-hour
override can change without a locale change.
Zulu's CDN (cdn.azul.com) has been returning HTTP 520 for several
hours, breaking JDK 21 setup across multiple PRs (#3075, #3095) on
every Linux runner. actions/setup-java's internal retry budget isn't
enough to ride through it.
Temurin (Adoptium) publishes its JDKs as GitHub Releases assets, so
the download path uses GitHub's own CDN — completely independent
infrastructure. Java 21 is the same upstream OpenJDK build either way,
so Gradle, Compose Multiplatform, and jpackage behave identically.
Applied to all setup-java steps in build.yml, smoke-test-desktop.yml,
and create-release.yml.
Phase 4 of the nutzap UX integration. The expanded reaction view
on a note now shows a cashu row between the lightning row and the
onchain row, matching the rail visual hierarchy used elsewhere
(zap chip popup → notifications card → reaction-detail gallery).
New `NutzapGallery.kt` modelled on `OnchainZapGallery.kt`:
- `WatchNutzapsAndRenderGallery` subscribes to the same per-note
zap flow the lightning + onchain galleries already use. Memoizes
on the nutzaps map reference (immutable per mutation) so a
lightning zap arrival on the same note doesn't recompose this
row, and renders only when at least one entry exists.
- Cashu icon (CustomHashTagIcons.Cashu, tint=Unspecified) followed
by a FlowRow of sender avatars with the claimed sat amount
overlay. Unlike onchain, there's no UNVERIFIED/PENDING gating —
every entry shows at full opacity because nutzap verification
is the recipient wallet's job at redeem time (the
reaction-detail gallery is just showing "who sent what to this
note").
- Tapping an entry navigates to the sender's profile, same as the
other galleries.
Wired into `ReactionsRow.ReactionDetailGallery` between
`WatchZapAndRenderGallery` and `WatchOnchainZapsAndRenderGallery`,
so the three zap rails read top-to-bottom in the order they tend
to surface for a given recipient (LN cheapest/easiest, cashu
mint-mediated, onchain final-settlement).
That closes the 4-phase nutzap UX work:
- Phase 0: success toast on send (sendNutzap previously silent)
- Phase 1: Note.nutzaps + LocalCache wiring; reaction-row counter
and icon highlight come along for free via zapsAmount and
isZappedBy extension.
- Phase 3: notifications — NotificationFeedFilter kind add,
NutzapUserSetCard for per-sender aggregates, cashu rail in
MultiSetCard, NutzapUserSetCompose card renderer.
- Phase 4 (this): dedicated cashu row in the expanded reaction
view.
Phase 3 of the nutzap UX integration. Inbound NIP-61 nutzaps
(kind 9321) now appear in the notifications feed alongside
lightning zaps, boosts, likes, and onchain zaps — but with the
cashu icon so the rail is visible at a glance.
Pieces:
- NotificationFeedFilter.NOTIFICATION_KINDS now includes
NutzapEvent.KIND. The existing tagsAnEventByUser logic falls
through to its `return true` default for nutzap (it's not a
BaseNoteEvent / ReactionEvent / Repost / Git / Highlight), and
the kind:9321's `p` tag carries the recipient so isTaggedUser
matches.
- CardFeedContentState gains a parallel nutzap grouping pass.
Nutzaps targeting a specific note feed into nutzapsPerEvent
and roll into the per-note MultiSetCard; nutzaps without an
e-tag target (recipient-only) feed into nutzapsPerUser and
surface as a NutzapUserSetCard per sender per day.
- MultiSetCard extended with `nutzapEvents: ImmutableList<Note>`,
and its min/max createdAt cover the cashu rail too. Adding a
new field at the end with a `persistentListOf()` default keeps
the existing constructor sites compatible.
- New NutzapUserSetCard for the per-sender aggregate. Wraps raw
kind:9321 Notes (no request/response pair like LN), keyed by
pubkey+createdAt with an "N" suffix so it never collides with
the LN ZapUserSetCard.
- MultiSetCompose: new RenderNutzapGallery row renders the cashu
icon (CustomHashTagIcons.Cashu, tint=Unspecified to preserve
the brand colour) followed by the same AuthorGalleryZaps the
lightning rail uses. Each kind:9321 is mapped to a
ZapAmountCommentNotification with the claimed sat total and
the event content as the comment.
- New NutzapUserSetCompose modelled on ZapUserSetCompose, also
wired into CardFeedView's card switch.
The user-facing result: the notifications screen now shows a
"X sent you Y sats via cashu" card with the cashu icon, exactly
the same shape as the lightning version, and the per-note
multi-card grows a cashu rail when the note has cashu zaps.
Phase 4 next: the dedicated cashu line in ReactionDetailGallery,
modelled on the existing onchain row.
Re-add the nostr-protocol/nips reference (dropped in the cleanup) as a
one-liner in the overview, tied to the /nip command so it points at the
exact spec file rather than the bare index.
Hardcoded date/time patterns ignored the user's Locale (date order:
dd/mm/yyyy vs mm/dd/yyyy vs yyyy-mm-dd) and the system 12/24-hour
override. Replace them with locale-aware formatters that resolve order
from the active Locale via DateFormat.getBestDateTimePattern() and pick
the time-of-day pattern via DateFormat.is24HourFormat(context).
- TimeAgoFormatter (amethyst + commons): build SimpleDateFormat from
Unicode LDML skeletons (yMMMd / MMMd / yMMM) so "May 28, 2026" in
en-US becomes "28 May 2026" in en-GB, "28.05.2026" in de-DE, etc.
- CalendarTimeFormat: same skeleton approach for date pieces; time
uses DateFormat.getTimeFormat(context) so a 24-hour Android user
sees 14:32 even on a 12-hour locale.
- New LocalizedDateTimeFormat helper with formatMonthDayTime,
formatMediumDate, formatMediumDateTime — used by wallet, vanish,
attestation, namecoin, eventsync screens to replace inline
SimpleDateFormat("MMM d, HH:mm") / ("MMM dd, yyyy hh:mm a") etc.
- Material3 TimePicker callers (calendar/nest/poll/zap-poll/expiration
date pickers + vanish request) now pass is24Hour from the system
setting instead of hardcoding false.
- Desktop article/reads/highlights screens use
java.text.DateFormat.getDateInstance(MEDIUM, locale).
- Drop dead formattedDateTime() in RelayCompose (was unused).
Intentionally left alone: notification feed bucket keys ("yyyy-MM-dd"
used as Map keys), TakePicture file naming (Locale.US), iCalendar
RFC 5545 stamps, NIP-52 ISO date storage, internal logging, and
ThreadLevelCalculator sort keys — none are user-facing.
Phase 0 (small fix): sendNutzap was async-launched with no success
callback, so after tapping the teal cashu chip in the zap picker
the popup vanished and the user saw no feedback for the 1-2 seconds
it took the swap + publish to complete. Add a "Cashu zap sent —
Sent N sat(s) via cashu" toast on success, matching the lightning
zap's progress feedback in spirit.
Phase 1 (foundation): NIP-61 nutzaps attach to their target note
the same way LN zaps and onchain zaps do, contributing to the
reaction-row total and the "you-already-zapped" icon highlight
without any UI-layer change.
Pieces:
- NutzapEvent.claimedSatsTotal() in quartz parses the sender-
claimed sat sum from the proof tags once, leniently (a single
malformed proof contributes 0 rather than throwing). The
recipient wallet still verifies proofs against the mint at redeem
time; this is the trusted-claim total for display.
- Note.nutzaps: Map<HexKey, NutzapEntry> on the canonical commons
Note, parallel to onchainZaps. NutzapEntry carries the source
kind:9321 note (sender = source.author) and the pre-parsed
claimedSats. Volatile because writes happen on applicationIOScope
and reads happen on the Compose main thread.
- updateZapTotal() now sums nutzap claimedSats into zapsAmount, so
the existing ObserveZapAmountText composable in ReactionsRow
picks up cashu without code change.
- hasZapped() and the suspend isZappedBy() extended to detect
nutzaps from a given user. ReactionsRow's calculateIfNoteWasZap-
pedByAccount path therefore highlights the bolt orange for cashu
zaps the same way it does for lightning.
- LocalCache previously routed NutzapEvent through
consumeRegularEvent, which would add it as a *reply* to the
e-tagged note via computeReplyTo. computeReplyTo gains a
NutzapEvent case returning the linked event ids, and a dedicated
consume(NutzapEvent) function attaches via addNutzap instead of
addReply.
The "list" merge across LN + cashu + onchain that the user floated
is deferred — three separate collections with different shapes
(zap pair, onchain entry, nutzap entry) are kept; only the
aggregates and queries are unified. That's enough for the
reaction-row UX and avoids touching every iteration site at the
call layer.
Coming next: notifications (NotificationFeedFilter + a cashu-icon
variant of ZapUserSetCard) and the dedicated cashu row in
ReactionDetailGallery modeled on OnchainZapGallery.
Addresses 10 audit findings from the post-build review:
CRITICAL — non-functional without this:
- LocalCache.justConsume had no branches for kind 54 / 10054 / 10064 / 10154,
so every podcast event fell through to "Event Not Supported" and was
silently dropped. Added the four explicit branches (regular event for
PodcastEpisode; replaceable for the other three).
HIGH — silent invisibility / broken tap-through:
- Home, profile (newthreads + mutual), hashtag, geohash, follow-pack, and
notification feed filters didn't recognize PodcastEpisodeEvent /
PodcastMetadataEvent. Episodes were invisible everywhere outside the
dedicated tab; reactions/zaps on episodes were dropped from the
notifications feed.
- ThreadFeedView's renderer dispatch had no podcast branch, so tapping a
feed card opened a plain text-note view. Added explicit cases that call
the new RenderPodcastEpisode / RenderPodcastMetadata composables.
- The hashtag / geohash / relay / search REQ kind lists didn't include
podcast kinds, so discovery surfaces returned nothing for them.
- RelayInformationScreen kind→label map gained podcast entries +
4 new string resources (Podcast Episode, Podcast Show, Authored
Podcasts, Favorite Podcasts).
- HomeNewThreadFeedFilter.ADDRESSABLE_KINDS gained PodcastMetadataEvent so
shows surface alongside music/wiki/long-form on the home feed.
HIGH — privacy leak in Quartz:
- FavoritePodcastsListEvent.add(isPrivate=true) was passing
earlierVersion.tags through untouched, so toggling a previously-public
favorite to private left the public p-tag intact. Made both branches
symmetric: each removes the entry from the other half before adding to
its own. Two regression tests cover the round-trip.
MEDIUM — data hygiene:
- AuthorTag.parse used to accept ANY non-empty slot-2 string as a role
(rendering a stray relay-hint URL as "Role: wss://relay…"). Now
validates against the spec-defined {host, cohost, editor} allowlist;
unknown values resolve to role=null, preserving the pubkey association.
PERF:
- PodcastEpisode renderer was allocating a fresh 96-element WaveformData
and rebuilding the cover Modifier chain per visible card. Hoisted both
to top-level constants (FLAT_WAVEFORM, COVER_IMAGE_MODIFIER,
PLAYER_BORDER_MODIFIER) so the whole feed shares one instance.
CODE QUALITY:
- Extracted PodcastCoverCard as a shared composable used by both renderers
(was duplicated byte-identical across PodcastEpisode + PodcastMetadata).
- Extracted PodcastFeedLoaded so the Episodes screen and Shows screen
share one feed body (was duplicated byte-identical).
- Dropped the misleading `group = listOf(singleAssembler)` wrapper in the
two FilterAssembler files.
- Replaced `mapNotNull { … }.flatten()` with `flatMap { … }` in the
Communities sub-assembly (the lambda never returns null).
DOCUMENTED:
- PODCAST_KINDS "Following" resolution still goes through kind:3 follows,
but per NIP-F4 podcasts use their own keypairs tracked via kind:10054.
Added an inline comment naming the deferred work — proper fix needs
Account-level 10054 integration which is a separate scope.
- Add a standing instruction to test hypotheses before diagnosing
(state guesses as guesses, reproduce-first, predict-then-run).
- Remove content duplicated by the harness-injected skill list (skills
tables, Commands section) and generic expect/actual examples.
- Condense the Feature Workflow (removed the duplicated share/keep-native
tables and the hardcoded grep block) and the skill-handshake example.
- Fix stale facts: drop pinned tool versions (now point to
libs.versions.toml) and correct the nestsClient tree comment to match
the overview (production runs on moq-lite).
The intraword `_` rule (CommonMark §6.2, implemented in the
previous commit) is sufficient on its own to keep base64url
cashu payloads out of the markdown renderer — every `_` inside
such a token is intraword and gets skipped. The upfront
`contains("cashuA"/"cashuB")` scan was a safety net for the
mixed-content case ("**enjoy** cashuB..."), but keeping it broke
markdown rendering for any post that legitimately had both
markdown formatting AND a cashu token. Drop it: mixed posts
render markdown correctly, cashu-only posts still skip markdown
via the intraword rule.
Convert both kind 30078 and the new kind 78 from the legacy
`suspend create(... signer)` shape to the now-standard
`build(...) -> EventTemplate` shape used across recent quartz events
(NIP-34, NIP-66, etc):
- Use `eventTemplate<T>(KIND, content, createdAt) { ... }` and lean
on the shared `alt()` and `dTag()` TagArrayBuilder extensions
instead of hand-rolling the `d`/`alt` injection.
- Callers now do `signer.sign(AppSpecificDataEvent.build(...))`.
For kind 78 the `d` tag is optional (it's a grouping key, not an
addressing key), so we keep it nullable and assemble it via
`DTag.assemble` — the typed `dTag()` extension is constrained to
addressable events, which is correct.
Update the lone caller (`AppSpecificState.saveNewAppSpecificData`).
Adds the audio-track imeta properties from NIP-71 PR #2255 so video
events can advertise external audio tracks (multi-language, alternate
bitrates) alongside video variants:
- New imeta properties: bitrate, duration (float seconds), waveform,
and l <code> <standard> [ov] for language with an original-version flag
- Extends VideoMeta with bitrate, duration, waveform, language fields
plus isAudio/isVideo helpers
- VideoEvent exposes audioTracks()/videoTracks() so players can prefer
separate audio tracks over in-video audio while switching resolution
- Round-trip test against the PR's spec example
The markdown detector now applies CommonMark §6.2 flanking rules:
- `_` skips intraword positions (preceded by a letter, digit, or
another `_`). This is the same "snake_case" carve-out that lets
identifiers like `foo_bar_baz` and `snake_case_identifier`
render literally — and it incidentally protects base64url
payloads from misclassifying, since every `_` inside a cashuB
token sits between word chars.
- `*` keeps intraword behavior (CommonMark allows
`foo*bar*baz`) but now requires non-whitespace on both sides of
the run, so `5 * 3 = 15` and `5 * 3 * 7 = 105` no longer
false-fire as italic.
The upfront `contains("cashuA"/"cashuB")` shortcut is kept as a
safety net for the mixed-content case (a chat that has BOTH a
cashu token AND real markdown like "**enjoy** cashuB..."), where
routing through the markdown renderer would lose the cashu card
because RenderContentAsMarkdown has no CashuSegment support. For
cashu-only messages, the intraword rule alone is sufficient — a
new test (`arbitraryBase64UrlBlobIsNotMarkdown`) confirms that
by feeding the detector a base64url blob without the cashuB
prefix.
New regression tests cover:
- snake_case_identifier
- foo__init__bar (intraword `__`)
- 0v______ trailing-underscore run
- foo*bar*baz (intraword `*`, expected markdown per spec)
- 5 * 3 * 7 = 105 (whitespace-flanked `*`)
- __init__ surrounded by whitespace (markdown per spec; Python
dunders collide here — users escape with backticks)
- user_name@example.com
- arbitrary base64url blob
NIP-78 was updated (nostr-protocol/nips#2292) to define a second
event kind alongside the existing addressable kind 30078:
- Kind 78: normal event, for apps that need to store and query
multiple events of the same type. Recommended to use unique tags
(including `d` tags) for grouping related events; the `d` tag here
is a grouping key only, not an addressing key.
Add `AppDataEvent` (kind 78) extending `Event`, mirroring the
ergonomics of `AppSpecificDataEvent` (kind 30078): optional `d` tag
hoisted into `tags`, NIP-31 `alt` tag injected when absent, and a
`signer.sign(...)` factory. Register it in `EventFactory` so
incoming kind-78 events deserialize into the typed class.
The existing kind-30078 implementation remains compliant with the
updated spec.
Swap computeIsMarkdown for a single-pass character scan gated by a
preallocated trigger-char table (BooleanArray[128]). Drops the
chain of `String.contains` calls in favor of one O(n) walk with
O(1) per-char dispatch. Also broadens coverage to ordered lists,
unordered lists (-/*/+), tables (|), strikethrough (~~), code
spans (single backtick), setext underlines (=== / ---), and
markdown links with URL validation.
Cashu exemption kept up front — the new algorithm would still
false-positive on cashuB tokens because `_` is in the trigger
table and `__` returns true immediately.
Two refinements on top of the proposed algorithm to pass the
new test suite:
- Setext heading now requires the underline line to be
homogeneous (only `=` or only `-` plus whitespace). Without
this, an ordinary sentence ending in `-` would be promoted
to a heading underline at the next newline.
- Markdown link detection now validates that the URL portion
(between `(` and `)`) doesn't contain a newline, so
`[link](url\nbroken)` no longer matches.
Test suite expanded to ~50 individual cases plus a parameterised
`testMarkdown()` that mirrors the user-provided case list with
pass/fail diagnostics to stdout. One case in the original list
(`"Standard divider line\n=========="`) was flipped from false
to true — the case name self-identified as "Valid Setext" and
CommonMark gives no length cap that would distinguish a long
underline from a heading.
Swaps PlayCircle / AudioFile (generic) for the canonical Material Symbols
podcast iconography — `headphones` (U+F01F) on the Episodes feed and
`podcasts` (U+F048, the mic + signal-waves glyph) on the Shows feed.
Both codepoints added to MaterialSymbols.kt and the subset font
regenerated via tools/material-symbols-subset/subset.sh.
A cashuB token pasted into a chat rendered as raw base64 instead
of the redeem card, because the message was routed through the
markdown renderer rather than the rich-text renderer.
Root cause: computeIsMarkdown treats any content containing `__`
as markdown (bold). cashuB is base64url-encoded CBOR, and the
base64url alphabet uses `_`; a token whose CBOR ends with the
indefinite-length break marker (0xff) easily produces a trailing
run like `______`. The user's actual token did. The markdown
renderer has no CashuSegment support, so it just dumped the raw
text. The previous truncation patch was unrelated — the cutoff
guard already returned content.length for this 398-char token, so
truncation never fired.
Fix: when content contains `cashuA` or `cashuB` (case-insensitive),
short-circuit isMarkdown to false. The trade-off is that a chat
message mixing markdown formatting AND a cashu token will lose
the markdown rendering, but the cashu card showing up matters
more — and pure-markdown messages are unaffected.
Tests cover the exact user-reported token, the same token
embedded in a longer message, a synthetic cashuA, and a control
case proving plain `__bold__` is still classified as markdown.
Mirrors the existing Music/Playlists screen pair end-to-end so podcast
events flow through the standard feed pipeline:
- AccountSettings/Account/LocalPreferences gain the two new follow-list
selectors (defaultPodcastEpisodesFollowList, defaultPodcastsFollowList)
plus their derived liveX/liveXPerRelay flows.
- AccountFeedContentStates wires podcastEpisodesFeed (kind 54 from
LocalCache.notes) and podcastsFeed (kind 10154 from addressables) into
updateFeedsWith/deleteNotes.
- RelaySubscriptionsCoordinator + BottomBarFeedPreloaders register the
two new filter assemblers, each with its own EOSE/since cursor.
- Routes/AppNavigation/NavBarItem add the two destinations; both go in
DrawerFeedsItems with PlayCircle and AudioFile icons (existing subset
glyphs, no font regeneration required).
- New NoteCompose dispatch cases call RenderPodcastEpisode (cover +
audio player via the shared GetMediaItem/GetVideoController/
RenderVoicePlayer chain + description + markdown content) and
RenderPodcastMetadata (cover + title + description + website chips).
Skipped (separate PRs): authoring flows (NewPodcast/NewEpisode) and the
kind:10054 favorites toggle sheet — podcast publishers typically don't
hold their podcast keypair in Amethyst, and favorites need a
PrivateTagArrayEventCache hookup in Account that's larger than the read
path alone.
A cashuB token pasted into a DM didn't render the redeem card —
the user saw the raw base64 + a useless "Show more" button.
Root cause was in ExpandableTextCutOffCalculator. The user's
token was ~480 chars with no whitespace anywhere. The calculator
saw `min == content.length > TOO_FAR_SEARCH_THE_OTHER_WAY (450)`,
fell into the backward-search branch, found no space or newline
in the first SHORT_TEXT_LENGTH (350) chars either, and returned
350 — slicing the token mid-base64.
The truncated string still started with "cashuB", so the parser
matched a CashuSegment, but CashuPreview's base64 decode failed
on the corrupt body and it fell back to rendering the raw text.
Fix: when there's no whitespace boundary anywhere in the first
SHORT_TEXT_LENGTH chars during backward search, return
content.length — the entire content is one indivisible atom
(cashuA/cashuB, base64 data: URI, lnbc, single huge URL), so
cutting it can only corrupt the segment.
The pre-existing testImage was locking in the same bug for a
~11k-char data: URI (truncated to 350 → broken image segment);
updated it to assert image.length and added two new regression
tests around the user's exact cashuB token and a "preamble +
long token" case where the cut should land cleanly at the
boundary before the token. Also added a CashuTokenParserTest
covering the parser side (which was already correct) so any
future change that breaks cashuB detection is caught.
Implements the four event kinds defined by NIP-F4 so Quartz can parse and
build native Nostr podcasts: kind:10154 show metadata, kind:10064 author
counter-claim, kind:54 episode, and kind:10054 favorite-podcasts list. All
four are registered in EventFactory so the existing JSON deserialization
pipeline returns typed instances. Tag classes mirror the per-event package
layout used by the experimental music module.
Each Resync click was re-deriving the same UNSPENT proofs from the
NUT-13 seed and publishing them as a fresh kind:7375, inflating the
displayed balance (which sums proofs across every kind:7375) by the
unspent total on every click. The mint won't honor the duplicates,
but the local count drifts upward until the user tries to spend.
Two changes:
1. CashuWalletOps.restoreFromMint now takes existingSecrets: Set<String>
and filters recovered proofs whose NUT-00 secret is already present
locally. The wrapper in CashuWalletState collects the set from
_tokenEntries before each Resync, so subsequent runs are no-ops.
2. CashuWalletState.cleanupDuplicateProofs scans held kind:7375 events
and NIP-09 deletes any whose secret set is a (non-strict) subset of
another's, keeping one canonical survivor per equivalence class
(oldest createdAt, smallest id as tiebreaker). Called automatically
before each Resync so the click also heals balance damage from
prior Resync clicks that ran without the dedup. removeEvents fires
inline so the de-duplicated balance is visible immediately.
Removes ~700 lines of code added across ~10 prior commits trying to dodge
the ART JIT crash from the wrong angle. With the real root cause fixed
upstream (uLtInline inline-expansion), none of this is needed.
Deleted:
- BdhkeScratchpad.kt + 3 platform actuals (apple/jvmAndroid/linux). The
thread-local Fe4/MutablePoint pool was added under the belief that
per-call allocation density was triggering an ART escape-analysis
bug. It wasn't. The original Bdhke functions allocated ~5-10 small
objects per call — well under any TLAB pressure threshold.
- Bdhke.warmup() and the 2048-cycle blind+unblind loop it ran. The
warmup was justified by "force the JIT compile to happen during init
where a crash isn't user-facing" — except the warmup itself was what
triggered the crash. ~4 seconds of wasted startup CPU.
- MintApiSerializerWarmup.kt (kotlinx.serialization decoder warmup).
Same wrong theory, same wasted startup work.
- The `scope.launch(Dispatchers.Default) { Bdhke.warmup(); ... }` block
in CashuWalletState.start() that called both warmups.
Reverted:
- Bdhke.kt to its pre-scratchpad shape. Drops `hashToCurveInto`,
`parseAffinePointInto`, `computeNegRkInto`, `negateInto`,
`toUncompressedOrNullScratch`, `compressedToUncompressedScratch`,
`toCompressedScratch`, `@Volatile warmupDone`, `fun warmup()`, and
the `JIT_WARMUP_ITERATIONS` constant. Restores the simple
fresh-allocations-per-call form of `hashToCurve`, `blind`,
`unblind`, `verifyDleq`, `addRTimesA`.
Stripped from CashuMintOperations.kt:
- Four `Log.i("CashuTrace") { ... }` diagnostic lines in the restore
loop, added to chase the wrong hypothesis.
- The `import com.vitorpamplona.quartz.utils.Log` they were the only
user of.
- Five "Bdhke uses a thread-local scratchpad internally" comments that
referenced the now-deleted scratchpad.
- The "easier on the ART JIT" rationale in the per-counter dedup
comment, replaced with the actual NUT-09 §2 echo-semantics
explanation. The dedup itself is a real algorithmic win (~378 → ~6
unblinds per batch), kept.
Cleaned in CashuPreferences.kt:
- "ART JIT crash on Android 15+" example in the durability rationale,
replaced with generic "OOM, signer dialog dismiss, unexpected
process death." The durability point stands regardless of crash
source.
Net: -704 / +117 lines.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The hand-rolled `parseRestoreResponse` was added under the (wrong) belief
that kotlinx.serialization's generated `RestoreResponseDto.serializer`
decode body was the ART JIT crash trigger. Real cause was `uLtInline`
inline-expansion downstream in `Bdhke.unblind` (see two commits back);
the decoder was never on the crash path.
The hand-roll is also actively worse than the generated path:
`runCatching { ... }.getOrNull() ?: empty` everywhere means a malformed
mint response that the generated decoder would have rejected with a
clean exception silently returns an empty `RestoreResponseDto`. The
restore driver reads that as "no matches in this batch → bump
empty-streak counter → terminate." Net effect: a misbehaving mint can
silently truncate your NUT-09 wallet restore. Fail-loud is the correct
posture for an untrusted endpoint.
Drops 6 kotlinx.serialization.json imports and the entire
`postWithManualResponseDecode` + `parseRestoreResponse` block (-110
lines). `restore` becomes a one-liner again, identical to every other
mint endpoint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restores the per-signature `Bdhke.verifyDleq` check in
`CashuMintOperations.unblindOne` that was removed earlier on this branch
under the (wrong) belief that `verifyDleq`'s allocation density was
triggering the ART JIT crash. The real bug was `uLtInline`
inline-expansion in U256/ScalarN/FieldP (see preceding commit); with
that fixed, `verifyDleq` runs at 2048 iterations/process in the
regression suite (`r_verifyDleq_2048`) with no JIT trouble.
The skip-DLEQ commit argued "a malicious mint can just refuse the
request" so the check buys "fail fast vs fail-at-next-spend, not actual
security." That undersells NUT-12: a key-substituting mint produces
*unspendable* proofs, and pre-emptive DLEQ catches that at mint-receive
time, before the user considers the operation done. Without the check,
the failure surfaces at the next swap — by which point a sender has
already considered the payment complete and any sent token is dead.
Third-party proof verification (incoming cashu tokens, nutzap redeems)
continues to go through `verifyDleqCarol` / `verifyTokenDleq` —
unchanged, still the harder untrust boundary.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root-cause fix for a deterministic SIGSEGV in the ART JIT compile thread
on Android 16, fault address 0x48, inside
`art::HBasicBlock::RemoveInstruction +32` →
`art::InstructionSimplifierVisitor::Run` → `OptimizingCompiler::JitCompile`.
`uLtInline` was `internal inline`, so its body
`(a xor MIN_VALUE) < (b xor MIN_VALUE)` was duplicated at every call site.
With 199 sites across U256, ScalarN, FieldP, FieldMul*, and 12 sequential
`if (uLtInline(...)) 1L else 0L` patterns inside `ScalarN.reduceWideTo`
alone, ART's `InstructionSimplifier::VisitAnd` / `VisitBooleanNot` tried
to fold them as a group and null-deref'd while removing HIR nodes.
Threshold for the crash on this Android 16 emulator is ~48 invocations
of any function whose call tree reaches `ECPoint.mul` (NUT-09 restore,
swap, ECDH, NIP-44 conversation-key derivation — basically every Cashu
op).
Drop `inline`. The function-call boundary at each site hides the
xor+lt+Select(0L,1L) chain behind an HInvokeStatic returning bool, so
the simplifier no longer sees the group-foldable shape.
Cost: ~80 ns per call (vs zero inlined). ~12 calls per `reduceWideTo`,
~4 `reduceWideTo`s per `splitScalarInto`, ~1 `splitScalarInto` per
`ECPoint.mul` — order of microseconds per unblind. NUT-09 restore of a
typical wallet adds ~1 ms vs the network round-trip. Negligible.
Also adds BdhkeJitCrashTest.kt, an instrumented regression suite that
exercises every Cashu-reachable cryptographic primitive at 2048 calls
each, with byte-for-byte cross-validation against fr.acinq.secp256k1
JNI where applicable (catches both crashes and silent miscompiles):
- blind/unblind workload (a, b, e, f)
- hashToCurve, blind, sign, secp256k1 pubkeyCreate isolations (g..j)
- acinq pubKeyTweakMul control (l)
- ECDH x-only vs acinq (n)
- Schnorr sign byte-match vs acinq (o)
- Schnorr verify (p)
- privKeyTweakAdd byte-match vs acinq (q)
- NUT-12 mint-side DLEQ accept+reject (r)
- NUT-12 Carol-side DLEQ roundtrip (covers addRTimesA) (s)
- NIP-44 v2 cipher encrypt-decrypt roundtrip (t)
All 16 tests pass on the Android 16 emulator that previously
deterministically reproduced the crash.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three pre-existing breakages on this branch that block the new
BdhkeJitCrashTest regression suite from dexing:
- NotificationFeedFilterModeOverrideTest, ThreadDualAxisChartAssemblerTest:
add the cashuWalletFilterAssembler / cashuMintDirectoryFilterAssembler /
okHttpClientForMoney parameters that Account() now requires.
- TorBootstrapInstrumentedTest: rename three backticked test methods to
underscored identifiers. D8 rejects spaces in inner-class names prior
to DEX version 040 (minSdk 35); Kotlin generates `Lambda$<methodName>`
inner classes from backticked function names, so any space in the
method name kills the dex step on the project's minSdk 26 target.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LaunchedEffect runs on Dispatchers.Main by default. The first call to
LanguageTranslatorService triggers its class init, which loads a Properties
file from inside the play-services AAR via ZipFile/RandomAccessFile and
trips StrictMode's DiskReadViolation on the UI thread.
Wrap the translateAndCache call in withContext(Dispatchers.IO) so MLKit's
first-touch init happens off the UI dispatcher.
The last reproducer crashed AFTER the hand-rolled restore parser
moved past kotlinx.serialization: batches 1-3 unblinded fine, batch
4 SIGSEGV'd in the JIT thread between `deduped` and `batch unblinded`.
Now the JIT is choking on Bdhke.unblind itself at tier-1 (optimizing
compile threshold ~21 invocations on Android 15+).
Bump warmup from 32 → 2048 iterations of blind+unblind. At ~1 ms
per cycle on a mid-range Android 15 device that's ~4 s of background
warmup at app start (Dispatchers.Default coroutine, UI stays
responsive). Crossing tier-1 inside that window means the production
restore loop hits already-optimized code instead of triggering the
compiler mid-operation.
Also bump MintApiSerializerWarmup element count 32 → 128 for the
swap / mint / melt endpoints that still go through generated
deserializers (restore now uses the tree-API hand-roll).
The diagnostic logs confirmed the JIT crash hypothesis: three NUT-09
batches deserialize fine through the generated
RestoreResponseDto.serializer().deserialize(...), then the 4th batch
crosses ART's tier-1 (optimizing) compile threshold for the
generated decoder body, the optimizer crashes (SIGSEGV at offset
0x48 in Jit thread pool), and the process dies before the 4th
batch's "decoded" log can fire.
Trace shape on every reproducer:
Bdhke.warmup begin / end ← warmup OK
MintApiSerializerWarmup begin / end ← warmup OK
restore POST batch 1, decoded, deduped, unblinded
restore POST batch 2, decoded, deduped, unblinded
restore POST batch 3, decoded, deduped, unblinded
restore POST batch 4
<SIGSEGV — never reaches "decoded">
Replace the generated decode path for /v1/restore only. The encode
side stays through the generated serializer (request payload is
small + cold). For the response: use Json.parseToJsonElement (the
JSON-tree API) and walk the tree by hand, extracting fields into the
existing DTOs. The tree API is one parser routine, completely
separate code from the per-class generated deserializers — much
smaller bytecode, no escape-analysis target shape, no JIT crash.
Defensive against missing fields (returns empty lists / null dleq)
so a misbehaving mint can't trip the hand-roll. Other endpoints
(swap, mint, melt, checkstate) keep their generated deserializers
since they don't go through the multi-batch tier-1 threshold.
JIT crash still recurs on Android 15+ even with ThreadLocal Bdhke
scratchpad + at-most-once warmups + serializer warmup. To isolate
whether the crash is:
(a) the warmup never running,
(b) kotlinx.serialization deserializing the restore response, or
(c) downstream unblind work,
add four focused log points:
- Bdhke.warmup begin / end
- MintApiSerializerWarmup.warmup begin / end
- restore: POST /v1/restore (req=N outputs)
- restore: decoded sigs=N echoes=N ← reached IFF deserialize OK
- restore: deduped to N unique counter(s)
- restore: batch unblinded
After the next crash, the last surviving log line says which phase
the JIT was compiling. If "decoded" never appears, (b) is confirmed
and we hand-roll the JSON parser for the restore endpoint.
The find-missing-translations skill only diffed <string name=, so missing
<plurals> resources slipped through. Updated Steps 2, 2.5, 3 to diff
<plurals> independently and added 3 missing music playlist plurals
across cs/de/sv with correct CLDR category coverage (Czech: one/few/many/other).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two issues surfaced once the @Volatile import fix (daa9bbff3) let the
iOS compiler proceed:
1. CashuDeterministic.bytesToLowercaseHex used `String(CharArray)`,
which is `DeprecationLevel.ERROR` on Kotlin/Native. Swap for
`CharArray.concatToString()` — identical semantics on all targets,
and the only KMP-portable form.
2. MintExceptionTest lived in commonTest but referenced
MintHttpException / MintProtocolException, which are defined in
jvmAndroid/MintHttpClient.kt (HTTP-layer concerns, not portable).
Move the test to jvmAndroidTest where its dependencies actually
exist. No coverage change — these classes are JVM/Android-only.
Verified locally with `./gradlew :quartz:iosSimulatorArm64Test
:quartz:compileTestKotlinIosArm64`.
`@Volatile` in commonMain resolves to `kotlin.jvm.Volatile` by default,
which doesn't exist on iOS targets. Two new NIP-60 Cashu files use
`@Volatile` without an explicit import, breaking
`:quartz:compileKotlinIosSimulatorArm64`:
Bdhke.kt:577:6 Unresolved reference 'Volatile'.
MintApiSerializerWarmup.kt:74:6 Unresolved reference 'Volatile'.
Add `import kotlin.concurrent.Volatile` to both files. Same pattern as
6f1292bfc (Note.kt) — semantics unchanged on JVM/Android, now also
resolves on iOS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The trailing icon used MaterialSymbols.Lock in both branches of the
visibility conditional, making it a no-op. Switch to Visibility /
VisibilityOff to match the AccountBackupScreen convention.
Bdhke's allocation-free hot path used to expose `BdhkeScratchpad` as
an explicit parameter on every public function (`blind(secret, r, scratch)`,
`unblind(..., scratch)`, `verifyDleq(..., scratch)`, …). Every caller
in CashuMintOperations had to remember to allocate a scratchpad per
loop and thread it through.
Switch to a thread-local pool. New expect/actual:
internal expect fun bdhkeScratchpad(): BdhkeScratchpad
- jvmAndroid: ThreadLocal.withInitial { BdhkeScratchpad() }
- apple / linux: fresh allocation per call (no Cashu in prod)
Each public Bdhke function pulls the scratchpad internally with one
`val scratch = bdhkeScratchpad()` at the top. Every thread that ever
touches Bdhke gets one scratchpad allocated lazily on first use and
reuses it across every subsequent call on that thread — same JIT-bug
mitigation, much cleaner API.
Removes:
- 0-arg and N+1-arg overloads on blind / unblind / verifyDleq /
verifyDleqCarol / hashToCurveCompressed
- `scratch` parameter on private addRTimesA / unblindOne /
unblindAll
- All `val scratch = BdhkeScratchpad()` boilerplate in
CashuMintOperations.restore / meltToLightning / verifyTokenDleq /
checkStates / secretOutputsFor
Also strips the diagnostic Log.i("CashuTrace") / Log.i("BdhkeTrace")
lines added during the JIT-bug investigation — the at-most-once
warmup + ThreadLocal pooling should resolve the crash, and the
traces were polluting logcat at info level.
Nested calls (verifyDleqCarol → blind + addRTimesA + verifyDleq)
all grab the same thread-local scratchpad; the holder field sets
are disjoint by design so nested use is safe.
BdhkeTest still 17/17 green.
The previous warmup change introduced a startup crash: each account's
CashuWalletState.start() spawned a coroutine on Dispatchers.Default
that ran Bdhke.warmup() (32 blind+unblind cycles) AND
MintApiSerializerWarmup.warmup() (decode 32-element synthetic
RestoreResponseDto). With two accounts, that's 128 BDHKE calls + 64
deserializations all in flight at once — far worse JIT pressure than
the original problem, since the warmup explicitly tries to make
methods hot.
The trace showed it clearly — multiple unblind/blind logs from
different threads interleaving mid-call ("unblind: parseAffinePointInto k"
appearing without a preceding "unblind: parseAffinePointInto cTick"
from the same logical call). ART's optimizer then crashed on the
flood.
Add a @Volatile flag to both warmups. First caller does the work;
every subsequent caller returns immediately. Plain volatile (not
atomic CAS or synchronized) because:
- The race window is tiny (microseconds between read and write)
- Two extra 32-cycle warmups in the worst case isn't a correctness
or performance issue
- Stays commonMain-portable without expect/actual or atomicfu
Pulls StickToTopOnPrepend out of every per-feed callsite and into
SaveableFeedContentState, SaveableGridFeedContentState, SaveableFeedState,
and SaveableGridFeedState — the same wrappers that already own
WatchScrollToTop. A new FeedContentState-keyed overload derives the head
key from feedContent → Loaded.feed → list.firstOrNull()?.idHex, so the
wrappers can wire auto-stick without any per-feed plumbing.
Removes the explicit StickToTopOnPrepend calls from FeedLoaded,
PictureFeedLoaded, ArticlesFeedLoaded, NestsFeedLoaded,
WebBookmarksFeedLoaded, GalleryFeedLoaded, DiscoverFeedLoaded,
DiscoverFeedColumnsLoaded, and ChatroomListFeedView — they all consume
listStates created by one of the four wrappers above.
Kept as explicit calls:
- UserFeedView (custom listState, no wrapper)
- CardFeedView (CardFeedContentState — different type)
- TabNotesNewThreads (custom listState, no wrapper)
- BrowseEmojiSetsScreen (doesn't use SaveableGridFeedContentState)
Also extracts the list/grid bodies to a private stickToTopOnPrepend
core that takes the state object as the LaunchedEffect key plus
lambdas for sampling / scroll, and switches the cached flag from
mutableStateOf to a plain BooleanArray holder (read only from effects,
never composition — no snapshot tracking needed). Adds the missing
"why isScrollInProgress gating is safe" line to the KDoc.
The trace from the last crash showed the restore unblind loop running
378 iterations per batch — minibits returns one signature per
(amount, bTick) we probed (63 denominations per counter × 6 unique
counters). Most iterations hit the recoveredCounters.add(...) continue
path. That's 60× more loop pressure than needed AND it's exactly the
kind of wide hot loop the ART JIT keeps trying to compile.
Changes:
1. Dedup BEFORE the unblind loop. Walk response.signatures once,
keep one (counter -> first signature) entry per unique counter
into a LinkedHashMap, then iterate that. Restore's per-batch
inner loop drops from 378 to ~6 iterations and stops being a
JIT compile target.
2. Pre-warm hot paths during CashuWalletState.start() on a background
coroutine, so the synchronous JIT compile pause happens at app
init (low pressure, no user waiting) instead of mid-restore where
we observed a 13ms gap on the first Bdhke.blind of a new batch
followed by a crash:
- Bdhke.warmup() runs 32 blind+unblind cycles with synthetic
keypair so the secp256k1 / hashToCurve / addPoints / toCompressed
hot path tier-1 compiles once.
- MintApiSerializerWarmup.warmup() decodes a synthetic 32-element
RestoreResponseDto (with nested DleqProofDtos) plus a SwapResponseDto.
kotlinx.serialization's generated decoder for BlindSignatureDto
becomes JIT-compiled during init — that decoder is otherwise
the heaviest allocation density we hit (~1.5k data-class
instances + ~5k Strings per real restore response, ART optimizer
bait on Android 15+).
If the JIT crash recurs, the next move is hand-rolling the JSON
parser for RestoreResponseDto / SwapResponseDto to bypass
kotlinx.serialization on the hot path entirely.
The previous round of scratchpadding still left two Fe4 allocations
per call inside toCompressed — the final point→bytes step at the
tail of every blind / unblind / addRTimesA. Across an NUT-09 restore
sweep that's ~250+ Fe4 allocations the ART JIT optimizer still gets
to chew on. After ART inlines toCompressed back into the outer
crypto bodies, those allocations end up in the same hot method
bodies the scratchpad was supposed to clear out.
Adds a [toCompressedScratch] variant that reuses two new holders on
[BdhkeScratchpad], and routes every hot-path caller through it
(unblind, blind, addRTimesA, hashToCurveCompressed). The plain
[toCompressed] stays for the cold paths (sign, signFull, others
only used by tests).
Also adds verbose BdhkeTrace / CashuTrace markers in:
- Bdhke.blind: secKeyVerify → hashToCurveInto → mulG → addPoints → toCompressedScratch
- Bdhke.unblind: parseAffinePoint cTick → parseAffinePoint k → computeNegRk → addPoints → toCompressedScratch
- restore inner loop: per-counter (CashuDeterministic → Bdhke.blind → build dtos), HTTP, per-signature unblind begin/end
So when the next JIT crash happens (if it does), the last log line
points at the exact sub-step ART was compiling. Logs are at INFO
level; cheap enough to leave on while diagnosing, easy to strip
afterward.
Final pass on the Bdhke JIT-crash mitigation. Three more Bdhke
functions on the production hot path still allocated per-call holders
and could trigger the Android 15+ ART JIT escape-analysis crash:
verifyDleq ~17 short-lived Fe4 / MutablePoint per call
addRTimesA ~6 short-lived holders per call
hashToCurveCompressed ~3 per iter (plus ~3 inside hashToCurve)
verifyDleqCarol orchestrates blind + addRTimesA + verifyDleq, so the
Carol path (used by every inbound nutzap / cashu-token redeem) was
allocating ~40 short-lived holders per proof. Same shape that crashes
the JIT in swap output handling.
Adds scratchpadded overloads:
verifyDleq(..., scratch)
verifyDleqCarol(..., scratch)
hashToCurveCompressed(x, scratch)
addRTimesA's scratchpad variant is the only signature (it's private)
Plus three helper -Into variants for the negate / toUncompressedOrNull /
compressedToUncompressed steps verifyDleq inlines.
Wired:
verifyTokenDleq → one scratch per call, reused across every per-proof
Carol verification
checkStates → one scratch per call, reused across the per-proof
hashToCurveCompressed pre-image computation
Tests: 17/17 BdhkeTest still pass.
After this commit, every Bdhke function on the production hot path
runs allocation-free. Functions only used by tests / mint emulation
(sign, signFull, verify) are intentionally not refactored — they're
never invoked at runtime in the app. The remaining 2-allocation
helpers (toCompressed, toCompressedOrNull) are well below the JIT
crash threshold.
Latest crash log showed no CashuTrace markers between the mint HTTP
response and the SIGSEGV — the tracing was only around swapToLocked
and unblindAll. The crash is in the NUT-09 restore loop, which calls
Bdhke.blind hundreds of times per batch (one per counter slot) and
each blind call still allocated ~5 short-lived Fe4 / MutablePoint
holders inside hashToCurve + the blind step itself.
Same JIT-bug shape as unblind had. Same fix: pre-allocate the holders
inside BdhkeScratchpad, pass it to blind / hashToCurveInto so the
methods write through caller-owned holders instead of allocating
fresh ones. Adds 6 fields to the scratchpad (kept distinct from the
unblind ones so future nesting can't silently overwrite live state).
Wired:
- CashuMintOperations.restore: one batchScratch per HTTP batch,
reused across all per-counter Bdhke.blind calls in that batch.
- secretOutputsFor: one scratch per call, reused across the
mapIndexed loop.
- Pre-existing scratchpads in unblindAll, melt-change loop, NUT-09
restore unblind loop unchanged.
Added a `restore: batch counter=N size=M denoms=K` trace line so
the next failure (if any) points at restore unambiguously.
Tests: 17/17 in BdhkeTest still pass (spec vector + round-trips).
The earlier "split into smaller methods" mitigation didn't hold —
ART's JIT inliner re-merged them at compile time and the same
SIGSEGV at 0x48 in "Jit thread pool" still hit on Android 15+. The
crash was visible in NUT-09 restore (hundreds of unblinds in a
loop), in zap-send, and in send-token.
Root cause is ART 15+'s escape-analysis scalarization pass choking
on the allocation density inside Bdhke.unblind — about 10 short-
lived Fe4 / MutablePoint holders per call. Splitting doesn't help
because once the JIT inlines, the holders are back in one body.
This commit eliminates the allocations entirely. Bdhke.unblind
gains an overload that takes a [BdhkeScratchpad] holding pre-
allocated holders; the internal helpers (parseAffinePointInto,
computeNegRkInto) write through them instead of constructing new
ones. The JIT sees objects coming from outside the method, marks
them as escaping, and the buggy scalarization pass never runs.
unblindAll, the melt-change loop, and the NUT-09 restore loop each
allocate ONE scratchpad and reuse it across every iteration —
1 allocation per loop instead of ~10 per output. Big perf win for
restore on top of dodging the crash.
The scratchpad-less unblind(blindSignature, r, mintPubKey) overload
still exists for tests and one-off callers; it just delegates to
the scratchpad variant with a fresh allocation.
If this still crashes, the trace logging now points at the
swap-level frames (swapToLocked / fetchKeyset / POST swap / swap
response / unblindAll begin / end). If it survives, the JIT had
nothing left in the hot path to choke on and the underlying ART
bug is no longer reachable from our code.
CashuTrace showed the Android 15+ JIT compiler crash (SIGSEGV 0x48
in "Jit thread pool") firing while ART was compiling Bdhke.unblind:
unblindOne[0/2] begin amount=2
unblindOne: Bdhke.unblind begin
>>> Fatal signal 11 (SIGSEGV) <<< in Jit thread pool
unblindOne: Bdhke.unblind end ← app thread keeps running
unblindOne[0/2] end
unblindOne[1/2] begin amount=8
unblindOne: Bdhke.unblind begin
unblindOne: Bdhke.unblind end
unblindOne[1/2] end
[…300ms of other work, then process death tombstone…]
The unblind itself runs fine to completion (twice). The crash is the
JIT compiler thread choking on Bdhke.unblind's bytecode — specifically
the escape-analysis pass on the 10 short-lived Fe4 / MutablePoint
allocations packed into one ~30-line method body. ART decides the
process is unstable a few hundred ms later and tears it down.
Split the body into three: an orchestrator + parseAffinePoint() +
computeNegRk(). Same semantics, three smaller bytecode bodies for
the JIT to compile, each well under the threshold that triggers the
bad optimizer path.
If this still crashes, the trace will tell us which of the three
methods the JIT was compiling and we move to the next mitigation
(reusable per-thread buffer pools to drop allocations further).
Adds StickToTopOnPrepend, a Compose helper that auto-scrolls back to
index 0 whenever new items land at the head of a feed — but only if the
user was already at the very top right before the update. Wired into
every FeedLoaded variant: Home/Hashtag (FeedLoaded), Notifications
(CardFeedView), Pictures, Articles, Discover (list + grid),
WebBookmarks, ProfileGallery, BrowseEmojiSets, Chatroom list,
UserFeed, NestsFeed, and TabNotesNewThreads.
Why this was broken: every feed uses stable key = item.idHex, so when N
items prepend Compose preserves the user's visual anchor by shifting
firstVisibleItemIndex from 0 to N. The existing
WatchScrollToTop only fires on explicit tab-bar taps, and the
LaunchedEffect(items.firstOrNull()) { if (firstVisibleItemIndex <= 1) }
pattern (used in ChatFeedView and PublicChatsFeedLoaded) breaks the
moment more than one item arrives in the same batch.
How the helper avoids the race: it tracks "was at top" continuously via
snapshotFlow but only flips it true → false when isScrollInProgress is
true. Data-driven index shifts happen with isScrollInProgress == false,
so they never poison the cached value. When firstItemKey changes and
the cached value is still true, we snap back to 0 with an instant
(non-animated) scroll so the prepend appears as in-place growth instead
of a visible jump-then-scroll.
ChatFeedView is left alone — it already works because reverseLayout
masks the prepend shift.
CashuPreview parses tokens locally via CachedCashuParser — no network,
no link unfurl, just CBOR decode of the cashuB string and a small
card. The canPreview = false gate is there to suppress network
previews (images, link unfurls, lightning invoice lookups); cashu
tokens have no such cost.
Suppressing it was the reason a cashuB pasted into a DM rendered as
a wall of base64 even though the same token in the Redeem button
worked fine. Flip the no-preview branch to render the same CashuPreview.
Also add CashuTrace logging around the entire zap path (sendNutzap,
swapToLocked, unblindAll, unblindOne, secretOutputsFor, Bdhke.unblind,
signer.sign + publish for nutzap/keep/delete/history events). The
JIT crash on Android 15+ keeps surfacing in different code paths
even after the DLEQ-on-our-outputs skip; the trace lets us see the
last frame before the SIGSEGV so we can target the right hot spot.
"Mint error HTTP 400: outputs already signed" started hitting every
send-token / send-LN after the wallet had crashed once. Root cause:
AccountSettings.reserveCashuCounters wrote the counter advance to
the same MutableStateFlow that drives the global settings save —
debounced by 1000 ms before the disk write fires.
The race window is exactly the time between "we ask the mint to
sign" and "the mint replies": ~200 ms. Any crash in that window
(ART JIT crash on Android 15+, signer dialog dismiss, OOM) loses
the counter advance even though the mint has already persisted its
side. Next reservation pulls the same slot, derives the same
deterministic blinded message, mint rejects with 10002.
Move the counter to a dedicated CashuPreferences SharedPreferences
file per account, written with `commit = true` so every reserve()
is durable BEFORE returning. AccountSettings.reserveCashuCounters
now delegates; a one-time migration on first read seeds the new
store from the legacy cashuKeysetCounters map so users on the old
build don't reset to zero. Plain (non-encrypted) prefs because
counters aren't secret — they don't carry value and aren't the seed.
Per Vitor's suggestion: the file is sized for the broader "Cashu
state that needs its own store" idea; today it only holds counters,
but the structure is in place for the kind:17375 / kind:10019 backups
to move out of the debounced settings path too if we later want.
Android 15 ART crashes (SIGSEGV at 0x48 in "Jit thread pool") when
the post-swap unblind loop runs Bdhke.verifyDleq once per signed
denomination in tight sequence. The pure-Kotlin elliptic curve
math allocates many short-lived MutablePoint / Fe4 holder objects;
ART 15+'s JIT compiler hits a bug compiling that pattern under load
and takes the whole process down right after a swap (or mint, or
swap-to-locked) returns. User reported it on auto-redeem first; now
hits send-token too, exactly 3 ms after the swap response — the
unblind loop barely started before the JIT crashed.
unblindOne / unblindAll only ever processes outputs WE asked the
mint to sign. A malicious mint can just refuse the request or
silently substitute a key — both are caught at next-spend when the
mint rejects our proofs, so the pre-emptive DLEQ check buys "fail
fast" vs. "fail at next op", not actual security. CDK and nutshell
wallets skip this check for the same reason.
Drop the DLEQ block from unblindOne. Third-party proof verification
(incoming cashu tokens, nutzap redeems) goes through the separate
verifyTokenDleq / verifyDleqCarol path and is unchanged — that's
where the untrust boundary actually is.
Rework the NIP-82 Software Applications feed item so it scans cleanly at
list density and split the detail content into its own route.
Feed card (RenderSoftwareApplication): icon + name + summary, full
description (3 lines), platforms / license chips, and a latest-version
chip resolved from LocalCache. Drops the screenshots strip, website /
repo link rows, and #topic chips at feed scale. The card is tappable
and the standard ReactionsRow now hosts replies, boosts, likes, zaps,
and share underneath each card.
New SoftwareAppDetailScreen (Route.SoftwareAppDetail) reached via the
card tap, the routeFor() dispatch, and naddr deep links. Layout: 72dp
header, screenshots carousel, About, Platforms, Topics (#tag chips
clickable through to Route.Hashtag), Links, ReactionsRow, latest
release with bundled assets, a collapsible "show older releases"
section, and the NIP-22 comment thread inline (driven off the app's
address tag through ThreadFeedViewModel + ThreadFilterAssembler).
Reworks the add-cashu-wallet mint-discovery flow per UI feedback.
Was: two surfaces — the mint URL text field + a separate "Browse"
button that opened a bottom sheet (MintPickerSheet) listing
recommended mints. Each row showed two chips ("X from people you
follow" + "Y recommendations") which read as a count but didn't say
*who*.
Now: one surface. The Browse button + the bottom sheet are gone.
The autocomplete under the URL field always shows the directory:
"Popular mints" header when the field is empty (the old Browse
content, ranked by follows-then-total), "Matching mints" header
when typed. Both states render the same MintDirectoryRow.
MintDirectoryRow replaces the two chips with a single line —
"Recommended by [avatar gallery of up to 6 follows] +N more" —
where +N rolls in remaining followers and every non-follow
recommender. Falls back to "Recommended by N others" when no
follow has signed off, or "No recommendations yet" otherwise.
Rows are visually discrete via OutlinedCard + HorizontalDivider,
addressing the "need a more visible border between two items"
note.
Data plumbing:
- CashuMintDirectoryEntry gains followsRecommenderPubkeys (capped
at MAX_FOLLOWS_RECOMMENDER_AVATARS=6 so the gallery doesn't
blow up on broadly-recommended mints — the total is still in
followsRecommendationCount).
- CashuMintDirectoryState.search(query, limit) — substring filter
ranked by the same comparator as entries; empty query returns
the top mints, which is exactly what the inline-popular state
needs.
MintPickerSheet.kt deleted; the only caller was the now-removed
Browse button. cashu_browse_mints + cashu_mint_picker_* strings
removed alongside it. New strings use <plurals> for the count-
bearing forms per the project's plural-handling rule.
Future Step Bob's request mentioned: the autocomplete in
CashuWalletSettingsScreen could be migrated to the same row in a
follow-up — same shape, different host.
Mint failure "Signature amount 8 != output amount 4" hit on the
resume-pending-invoice flow because some mints (observed on
minibits) match their internal restore table by the blind point
alone — when the wallet probes (B_=X, amount=4) and (B_=X, amount=8),
those mints return ONE entry with echo.amount=4 (the first matching
output we sent) and sig.amount=8 (the actually-signed denomination).
The strict amount-equality check in unblindOne then trips.
The cryptographic source of truth is the signature, not the echo:
the mint can only sign one denomination per blind point because the
keys are per-denomination. Switch the restore loop to look up
counter materials by bTick alone, then reconstruct the BlindOutput
with the signature's amount before unblinding.
Side benefits: the per-counter map shrinks from N_denoms entries
to 1 entry, and a HashSet<Long> dedups counters in case a tolerant
mint echoes the same blind point under multiple amounts.
The "Issuing proofs" dialog hung at 80% for 30 seconds and the mint
returned "List should have at most 1000 items after validation, not
6300" on the resume-pending-invoice flow. Two compounding causes:
1. CashuMintOperations.restore packs `batchSize * denominations.size`
blinded outputs into each /v1/restore body. The default
batchSize=100 against a typical keyset with the full power-of-2
denomination set (~63 amounts) hits 6300 outputs per request —
nutshell / CDK / minibits all enforce a 1000-item Pydantic cap
and reject the body.
Auto-clamp the effective batch size so total outputs stay under
MAX_RESTORE_REQUEST_ITEMS (500, leaving headroom for the
response doubling — the mint echoes outputs alongside signatures).
Honours the caller's batchSize when it already fits.
2. recoverPreviouslyIssuedProofs (the "outputs already signed"
fallback) was scanning every keyset denomination at every counter
in the rewind window. But the prior /v1/mint/bolt11 reserved only
`splitAmountIntoDenominations(amountSats).size` counters and the
mint signed exactly one output per slot — so the relevant denoms
are a handful, not 63.
Pass `amounts = splitAmountIntoDenominations(amountSats)` plus a
single-batch sweep (`batchSize = rewind`, `emptyBatchesToStop = 1`)
so the recovery makes one HTTP call against ~3-6 denominations
instead of 32 batches of 63.
Some NWC relays (notably relay-nwc.rizful.com) replay cached kind-23195
events every time a REQ filter mutates. Because we previously left each
reqId in the filter for a full 60s after sending its request, every new
NWC call added a fresh reqId to a filter that still listed several
stale ones, and the relay would re-deliver every matching cached
response. That arrived here as a flurry of NO_MATCH events, wasted work
on each new send, and made the transactions screen feel stuck.
Cleanup is now driven by the response itself:
- NwcSignerState cancels the 60s safety-net job on response and asks
the assembler to drop the filter through unsubscribeSoon, which is
debounced 1.5s so a burst of responses produces one relay-side
filter update instead of one per response.
- subscribeAndFlush drains any pending unsubscribes synchronously
before adding the new query, so the relay sees a direct {old}->{new}
transition instead of the {old}->{old,new}->{new} that triggered
Rizful's replay path.
- The 60s timeout remains as a safety net for wallets that never reply.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two bugs in the receive-LN path that together produced
"Mint error HTTP 400: outputs already signed" after paying the
invoice, with the old balance + pending banner stuck on screen.
1. Race in the 3s polling loop. checkAndCompleteMint did its
paid-check before flipping _mintState to Completing, so two
polls 3s apart both saw AwaitingPayment and both called
completeMintFromLightning. Poll 1 spent the mint quote and
poll 2 hit code 10002. Same hazard on a double-tapped
"resume pending quote" banner.
Fix: atomic compareAndSet to Completing before any await,
in both checkAndCompleteMint and resumeMintQuote. A
concurrent caller sees the flipped state and bails. If the
paid check returns "not yet", we roll the state back to the
previous AwaitingPayment so the poll resumes.
2. No recovery when the mint already issued. If the prior
attempt's /v1/mint/bolt11 succeeded at the mint but we
failed to publish the kind:7375 locally (signer dialog
dismissed, app killed, etc.), the mint considers the
quote consumed and rejects every retry with "outputs
already signed" — the wallet sat stuck forever.
Fix: catch that exact failure mode in
completeMintFromLightning and fall back to NUT-09 — re-
derive the deterministic blinded outputs from the wallet
seed (scanning a 32-counter rewind window, then forward
via the standard gap-limit heuristic), ask the mint
/v1/restore which it has signed, checkstate the results
to filter spent ones, unblind into proofs, and publish
them as kind:7375 + kind:7376 + kind:5 of the quote
exactly like a fresh mint. End state matches the
original happy path.
Threading: CashuWalletOps gains three optional callbacks
(seedForRestore, peekCashuCounter, reserveCashuCounters)
alongside the existing seedWarmer and DeterministicSecretFactory
wiring; CashuWalletState supplies them from ensureSeed() and
AccountSettings.{peekCashuCounter,reserveCashuCounters}.
The framing: Amy gains every cashu action the Amethyst UI exposes, each
one reusing the exact same quartz + commons code the Android wallet
runs in production. The deliverable is a shell harness under
cli/tests/cashu/ that walks two Amy accounts through the full wallet
lifecycle against a production mint, so regressions in the cashu code
path fail on the JVM in CI without an emulator.
Plan covers:
- Three extractions before any verb lands: cashu token parsers to
quartz, CashuWalletOps to commons, CashuWalletReader projection
helpers to commons.
- One storage addition: ~/.amy/<account>/cashu.json for NUT-13 keyset
counters (deterministic secrets need durable counter state across
invocations).
- Command surface under amy cashu …: wallet, mint, balance, receive,
send, mint-rec, maintenance — mirrors every action the Android
wallet exposes.
- Stable --json shape per verb.
- 9-PR sequencing: extractions first, then verbs grouped by user
intent, then the 10-scenario interop harness.
- Acceptance criteria: harness passes against mint.minibits.cash and
the on-relay events are byte-equivalent to what Amethyst would
produce.
Brings Amethyst Desktop to feature parity with Android for the Namecoin
resolution backend stack landed in #3056 / #3068. Three pieces:
- DesktopNamecoinPreferences now persists backend, namecoinCoreRpc,
fallbackToCustomElectrumx and fallbackToDefaultElectrumx (KEY_BACKEND
/ KEY_CORE_RPC / KEY_FALLBACK_*), mirroring NamecoinSharedPreferences.
Mutators are non-suspend because java.util.prefs is synchronous,
unlike Android's coroutine-backed DataStore. The Jackson mapper now
rejects unknown properties on read so kotlinx `@Serializable`
computed getters (e.g. NamecoinCoreRpcConfig.isUsable) round-trip
cleanly through java.util.prefs.
- DesktopNamecoinNameService takes an optional OkHttpClient provider,
lazily constructs a NamecoinCoreRpcClient when supplied, and builds
a fresh CompositeNamecoinBackend per lookup based on current
NamecoinSettings. Same shape as AppModules#buildNamecoinBackend.
Exposes the underlying RPC client (rpcClient) and a probeCoreRpc(cfg)
helper for the Settings Test RPC button.
- NamecoinSettingsSection gains a backend radio selector, a Core RPC
subform (URL / username / password / Save / Test RPC) with a TOFU
cert-pin AlertDialog mirroring Android's NamecoinCoreRpcSection, and
a fallback toggles section. The same KEY_PINNED_CERTS list is shared
with both ElectrumXClient and NamecoinCoreRpcClient via
setDynamicCerts(...), matching Android's behaviour where both
backends consume one trust store.
- Main.kt wires DesktopHttpClient.currentClient() in as the Core RPC
HTTP provider so .onion RPC URLs flow through the existing Tor
routing without extra plumbing, and propagates the new mutators to
the Settings UI.
- Extends DesktopNamecoinPreferencesTest with 8 new cases covering
default state, backend round-trip, Core RPC URL/user/pass/pin-flag
round-trip, fallback toggles, reset clearing, and a full
multi-field round-trip across a fresh preferences instance.
Verification on the canonical workspace clone:
- ./gradlew :commons:jvmTest --tests *Namecoin* — BUILD SUCCESSFUL
- ./gradlew :amethyst:compilePlayDebugKotlin — BUILD SUCCESSFUL
- ./gradlew :desktopApp:compileKotlin :desktopApp:test — BUILD SUCCESSFUL
- ./gradlew :amethyst:testPlayDebugUnitTest --tests *Namecoin* — BUILD SUCCESSFUL
- ./gradlew :amethyst:spotlessCheck :commons:spotlessCheck
:desktopApp:spotlessCheck — BUILD SUCCESSFUL
Two bugs in the pending-quote resume path:
1. resumeMintQuote was hard-coding amountSats=0 because kind:7374 doesn't
carry the amount and the mint quote status DTO doesn't echo it. When
the user paid the invoice in an external wallet and then tapped the
pending banner, the 3s polling loop would fire checkAndCompleteMint
with amountSats=0 — completeMintFromLightning couldn't mint zero
proofs and the mint would either reject or, more commonly, the next
/v1/mint/quote/bolt11/{quote} call would already say "quote not found"
because issuance had been GC'd.
Fix: decode the amount from the bolt11 invoice in status.request via
LnInvoiceUtil.getAmountInSats. If the mint reports paid AND we have a
valid amount, complete the mint inline; otherwise drop to
AwaitingPayment with the recovered amount so the polling loop has
something real to drive.
2. checkAndCompleteMint and resumeMintQuote treated "quote not found"
like any other error — the local kind:7374 stayed alive and the
pending banner kept pointing at dead state. NIP-09 delete the quote
event when the mint says it's gone (HTTP 404, or 400 with detail
containing "quote … not found / does not exist / unknown"), and
reset to Idle.
isQuoteGoneError matches by substring across common mint phrasings —
the cashu spec doesn't pin the error message and minibits/nutshell/cdk
all word it slightly differently.
The DragIndicator sat between the wallet name column and the balance
column with verticalAlignment=CenterVertically, so it visually centered
between the balance amount and the "sats" subtitle — landing in dead
space between two lines.
Move it to the start of the row, matching the relay-reorder convention
(BasicRelaySetupInfoClickableRow). Same icon, same modifier, same
RelayDragState wiring; just promoted from middle to leading slot.
Mirrors Android's NamecoinSharedPreferences pinned-cert API on Desktop so
user-accepted TLS pins survive process restart. Same JSON-list shape, same
distinct-append semantics, same wipe-on-reset behaviour.
What's new
- DesktopNamecoinPreferences gains addPinnedCert / loadPinnedCerts /
clearPinnedCerts (sync rather than suspend, since java.util.prefs is
synchronous). reset() now clears pinned certs too, matching Android.
- DesktopNamecoinNameService accepts a pinnedCertsProvider and pushes the
loaded list into ElectrumXClient.setDynamicCerts at init, mirroring
Android's AppModules.kt wiring. Exposes the underlying client so the
Settings UI can call testServer() and re-apply pins live.
- Desktop NamecoinSettingsSection grows an optional Test Connection + TOFU
pin sub-section: runs ElectrumXClient.testServer per active server,
collects PEM + SHA-256 fingerprint from successful TLS handshakes, and
prompts the user to pin each new cert via AlertDialog. UI hidden when
no service is wired (so existing call sites stay valid).
- Main.kt wires both halves together and updates the freshly-pinned cert
list into the live client without waiting for restart.
Persistence is plain java.util.prefs (same backing store as the rest of
DesktopNamecoinPreferences) — explicitly NOT EncryptedSharedPreferences.
Pinned cert PEMs are public material; no secrets stored.
Tests
- DesktopNamecoinPreferencesTest: +6 cases covering empty default,
persistence + reload, dedup, blank input ignored, reset wipes, and
independence from settings copies.
Verification
- ./gradlew :desktopApp:compileKotlin — BUILD SUCCESSFUL
- ./gradlew :desktopApp:test — BUILD SUCCESSFUL (16 tests, 0 failures)
- ./gradlew :amethyst:compilePlayDebugKotlin — BUILD SUCCESSFUL
- ./gradlew :amethyst:spotlessCheck :commons:spotlessCheck :desktopApp:spotlessCheck — BUILD SUCCESSFUL
Stack note
Stacked behind #3072 (merged 2026-05-27). Next: PR-C for the full
Namecoin Core RPC backend + composite fallback persistence + UI.
Two issues with the send flow on the new-music-track screen:
1. Cover + audio uploaded sequentially. As cover finished, its picker
reverted to the placeholder while audio was still going, making the
user think the operation was done.
2. The coroutine ran on the screen's viewModelScope, so leaving the
screen cancelled it.
Rework the upload pipeline:
* Snapshot the form into an immutable SendSnapshot before launching, so
user edits to the form during the in-flight upload don't poison what
gets published, and the coroutine sees stable inputs even after the
per-screen VM is cleared.
* Run cover + audio uploads in parallel via async/awaitAll. Either
side failing throws an UploadException that the outer catch routes
to the global toast manager.
* Launch through accountViewModel.launchSigner so the work runs on the
AccountViewModel.viewModelScope — survives screen leave. Errors and
success both surface as global toasts.
* Keep coverMedia/audioMedia/pickedAudioName populated through the
ENTIRE upload+publish, not just per-phase. Only clear them on
success, so each picker keeps showing the user the file it's still
uploading.
* Single isSending flag replaces the separate isUploading/isPublishing
booleans; drives an inline progress banner + disables the Send
button + dims the pickers to read-only while in flight.
* One-shot completionEvents SharedFlow that the screen subscribes to.
If the user is still on the screen when the operation finishes, we
popBack; if they've already left, no one is listening and we don't
pop someone else's stack frame.
Also tighten the playback Row height in MusicTrack from 100dp to 80dp.
With a 75dp play button the previous 100dp container left a ~12.5dp
empty band top + bottom that the user flagged as too tall.
The route hint set by newKey() (Route.ImportFollowsSelectUser) was stored
on AccountState.LoggedIn and never cleared. LoggedInPage re-assigned
accountViewModel.firstRoute = route on every recomposition, so any
Activity recreation (rotation, dark-mode toggle, returning from
background) made the screen pop up again even after the user dismissed
it via popBack.
Consume the route once via remember(state) in AccountScreen, and gate the
firstRoute assignment behind LaunchedEffect(Unit) so it only fires once
per composition lifetime.
AddCashuWalletScreen, AddNwcWalletScreen, and CashuWalletSettingsScreen
host their OutlinedTextFields directly in the Scaffold body — mint URL,
wallet name, NWC URI, mint-recommendation add row — so the keyboard
covers the field when the user taps to type.
Apply the codebase's canonical insets recipe (matching AllMediaServersScreen):
.padding(padding)
.consumeWindowInsets(padding)
.imePadding()
The other new wallet screens are untouched on purpose: AddWalletScreen
has no text inputs; WalletScreen + CashuWalletScreen route every input
through AlertDialog (which handles its own IME); MintPickerSheet rides
ModalBottomSheet which moves with the keyboard.
Five changes:
1. Drop the audio-URL text field from the composer. Upload tile at the
top is the single source of truth; the ViewModel still threads the
existing url through MusicTrackEvent.edit so edit mode keeps it.
2. Auto-fill title / artist / album / duration from MediaMetadataRetriever
when an audio file is picked. Only fills empty fields — anything the
user already typed wins. Duration is the exception: derived numbers
overwrite, since they're not opinions.
3. Rename the SavingTopBar 'Save' button to 'Send' for this screen
(introduces SendingTopBar + R.string.send so other screens can reuse).
4. Fix silent data corruption in AddToMusicPlaylistViewModel.init:
the previous `if account already set, return` guard kept the FIRST
track address the VM ever saw. Subsequent invocations with a
different trackAddress (process recreation, navigation reusing the
back-stack entry) routed toggle() at the stale track, adding the
wrong track to playlists. Now refreshes trackAddress on every call
and restarts the scan job when it changes.
5. Inset modifier order on NewMusicTrackScreen tightened in the prior
commit, kept here for continuity.
Three fixes in one:
1. MediaSaverToDisk crash when saving a music URL. The fall-through
branch routed every non-image, non-PDF MIME to
MediaStore.Video.EXTERNAL_CONTENT_URI; an audio/mpeg blob inserted
into the Video collection fails with IllegalArgumentException. Add a
dedicated audio/* branch that writes to MediaStore.Audio with
DIRECTORY_MUSIC.
2. Drop the redundant cover-URL text field from the new-music-track
composer. The upload tile at the top is the source of truth for the
cover — having a second field at the bottom asked the user to set
the same thing twice. ViewModel still threads coverUrl through to
MusicTrackEvent.edit so editing an existing track keeps its image.
3. Tighten the new-music-track Scaffold's inset modifier order:
padding(pad) -> consumeWindowInsets(pad) -> imePadding() ->
horizontal padding -> verticalScroll. Without consumeWindowInsets,
imePadding double-counts the nav-bar inset on Android 11+ and the
bottom field gets shoved too far up when the keyboard opens.
Two NamecoinSettings classes had drifted:
- commons (used by Desktop): only enabled + customServers
- amethyst service.namecoin (used by Android): full schema with backend,
namecoinCoreRpc, fallbackToCustomElectrumx, fallbackToDefaultElectrumx
This left Desktop unable to persist any of the Namecoin Core RPC or
fallback-policy state introduced in the Android settings UI. Promote
the rich Android version into commons as the single source of truth
and delete the Android duplicate.
- Move the rich schema (backend, namecoinCoreRpc, fallback toggles,
hasUsableCoreRpc, toFallbackPolicy) into the commons NamecoinSettings.
- Delete amethyst/service/namecoin/NamecoinSettings.kt and its test.
- Repoint the two Android imports (NamecoinSharedPreferences,
NamecoinSettingsSection) at the commons class. No behaviour change on
Android.
- Fold the Android-only backend/RPC/fallback test cases into the commons
NamecoinSettingsTest so the shared schema stays covered.
Desktop persistence (DesktopNamecoinPreferences) still only reads/writes
enabled + customServers; the extra commons fields fall back to defaults
on the existing Desktop store. Wiring those new fields into Desktop is
the next change.
The inline Namecoin resolution row in the global search bar (and the
on-chain zap recipient field) is gated by looksLikeNamecoinIdentifier,
which previously only matched the '.bit' shapes. Direct namespace
references like 'id/mstrofnone' or 'd/mstrofnone' — both accepted by
NamecoinNameResolver.isNamecoinIdentifier — fell through the gate and
the resolver was never called, so no on-chain feedback appeared in the
search bar.
Bring the UI gate in line with the resolver:
- accept 'd/<name>' (domain namespace, direct reference)
- accept 'id/<name>' (identity namespace)
- lower the length floor so single-character labels (valid, if
expensive, on chain) still trigger; '.bit' inputs keep their
5-char floor
Doc comment for the row's behaviour and the NamecoinResolutionRow
KDoc are updated to reflect the broader accepted set. New unit tests
cover both new prefixes (with case-insensitivity and leading '@'),
short single-label names, bare-prefix rejection, and explicit
non-routing for other Namecoin namespaces ('a/', 'u/').
The auto-redeem loop on a fresh-start cache was hitting mint /v1/keys
once per inbound nutzap, every time triggerAutoRedeem fired (so once
per arriving event batch). For nutzaps locked to a stale wallet P2PK
key (sender saw an old kind:10019) this was pure waste — the P2PK
check happens after the DLEQ network call, so we paid for keysets
just to throw.
Two changes:
1. Move the P2PK lock check ahead of NUT-12 DLEQ inside redeemNutzap.
No-network rejection of wrong-key nutzaps now lands in microseconds.
2. Track deterministic failures (IllegalArgumentException — wrong P2PK,
no mint tag, no proofs, not P2PK-locked) in a session-local
sessionUnredeemableNutzaps set, parallel to sessionRedeemedNutzaps.
redeemPendingNutzapsSerialized skips both sets on every sweep, so
one auto-redeem cycle per nutzap is enough to learn it's a lost
cause; subsequent triggerAutoRedeem firings cost nothing.
Also flattens the try/catch from runCatching+onSuccess+onFailure to
make the bytecode straightforward for the JIT (the user's tombstone
showed a SIGSEGV in Jit thread pool right after three failed redeem
attempts; whether that's the cause or just a coincident ART bug, the
flatter form is friendlier for the verifier).
The previous cover used SubcomposeAsyncImage with a single content lambda
that stacked the loaded image and the chip as BoxScope siblings. Under
Coil's subcompose-layout pass the chip's wrap-content Box got measured
to the cover's full size, painting a translucent black square over the
whole artwork.
Drop SubcomposeAsyncImage. Use rememberAsyncImagePainter + a plain
Image + painter-state observation inside a normal Box. The image fills
the box (Modifier.matchParentSize) and the chip sits as a normal
BoxScope child with Modifier.align(BottomStart).padding(12.dp) —
predictable wrap-content sizing.
The Apps screen was missing the top-nav follow-list spinner that other
single-feed screens (Articles, Longs, Pictures) use, and nothing was
loading because the relay subscription only queried the user's own
outbox and `SoftwareApplicationEvent` (kind 32267) was never consumed
by `LocalCache.justConsumeInnerInner` — it fell through to the
"Event Not Supported" else branch and got dropped. `ReleaseArtifactSetEvent`
(kind 30063) and `SoftwareAssetEvent` (kind 3063) had the same gap.
- Register `SoftwareApplicationEvent`, `SoftwareAssetEvent`, and
`ReleaseArtifactSetEvent` in the `LocalCache` consume dispatch.
- Add `defaultSoftwareAppsFollowList` setting (Account/Settings/Prefs)
and `liveSoftwareAppsFollowLists{,PerRelay}` flows.
- Reshape `SoftwareAppsSubAssembler` to extend
`PerUserAndFollowListEoseManager<_, TopFilter>` and assemble per-list
relay filters via `makeSoftwareAppsFilter`, mirroring Pictures/Longs.
- Add a `SoftwareAppsTopBar` with the standard `FeedFilterSpinner` and
a `WatchAccountForSoftwareAppsScreen` to invalidate the DAL when the
selected list changes.
- DAL now applies `FilterByListParams` so the rendered feed honors the
active top-nav list.
The prior startup scrub was racing with kind:7375 events arriving from
relays after start() returned — by the time those events landed in
_tokenEntries the sweep had already finished against an empty list, so
ghost proofs persisted and the user still hit "proofs already spent" on
self-zap / self-send.
Move the scrub inline: sendNutzap / sendAsToken / meltToLightning all
call scrubLocallyStaleProofs(mintUrl) right before selecting proofs,
narrowed to the mint about to be spent. The scrub now also drops stale
entries from internal indexes via removeEvents() instead of waiting for
the bundled newEventBundles round-trip — without that, the very next
read of _tokenEntries (microseconds later, in the same coroutine) would
still see the ghosts.
Drops the mixed-state skip-and-log branch in favour of treating any
entry with ≥1 SPENT proof as stale — keeping mixed entries around just
lets them resurface as HTTP 400 on the next swap. The unspent portion
of a mixed entry is recoverable via NUT-09 restore.
Wraps sendAsToken + meltToLightning on CashuWalletState (parallel to
the existing sendNutzap wrapper) so the viewmodel calls go through the
same heal path. Drops redundant validation from the viewmodel.
Addresses review feedback on PR #3068: keep the entire NamecoinCoreRpcClient
bootstrap (config push + pinned-cert load) inside the single applicationIOScope
launch instead of doing setConfig synchronously in the by-lazy block. Matches
the ElectrumXClient init shape above.
Music tracks (kind 36787) belong in playlists (kind 34139), not in the
generic bookmark list — but the dropdown menu was showing both, which
made the bookmark rows feel like the default place to save tracks.
Mirror the EmojiPack pattern instead: one curation flow per kind. For a
MusicTrackEvent the Timestamp & Bookmarks section now shows only
'Add to playlist' (which opens the toggle sheet that already handles
add/remove across every owned playlist). Everything else still gets the
standard Manage / Private bookmark / Public bookmark trio.
The new-track composer was URL-only — users had to upload audio and
artwork elsewhere and paste links. Match the New Badge composer flow
instead: pick the files up front, Save uploads them through the user's
default Blossom/NIP-96 server and publishes the kind 36787 event with
the resulting URLs.
Cover image picker is the first item on the screen (square upload tile,
same style as Badge). Audio file picker sits below it. URL text fields
stay below the pickers so power users who source audio from Wavlake /
Stemstr / their own server can still paste links instead of uploading.
ViewModel changes:
- coverMedia / audioMedia: MultiOrchestrator slots fed by the pickers
- saveAndPublish() runs cover upload then audio upload then publish;
either failure short-circuits and surfaces a toast
- isValid() accepts either a picked audio file or a non-blank URL
- All Compose state writes from IO hop through Main.immediate
Audio picker uses OpenDocument(audio/*) — we don't reuse the shared
FileSelect because it also accepts application/pdf.
Up until now both screens shared one combined REQ that asked for both
kinds (36787 + 34139). That meant a single 'makeMusicTracksFilter' had
to cover both feeds, and the since cursor had to be the min of both
feeds' lastNoteCreatedAt to avoid over-fetching.
Split the pipeline so each screen owns its own kind:
- filterMusicEventsByX helpers are parameterized by kinds: List<Int>
- MUSIC_TRACK_KINDS = [36787], MUSIC_PLAYLIST_KINDS = [34139]
- makeMusicTracksFilter(...) and makeMusicPlaylistsFilter(...) are
thin wrappers picking the right kind list
- MusicTracksSubAssembler / MusicPlaylistsSubAssembler each use their
own helper and their own feed's cursor (no min-of-both)
Also:
- Cover the rest of the top-bar selectors that were previously falling
through to emptyList: Hashtag, Geohash (Location), AllCommunities,
SingleCommunity. Music feeds now respect all of them.
- Drop TimeUtils.oneWeekAgo() floor from filterMusicEventsGlobal —
music kinds are sparse on most relays, the floor silently hid older
content the user hadn't seen yet.
Tracks referenced by a playlist are still loaded on demand by each
PlaylistTrackRow's own observeNoteEvent, so the playlists REQ no longer
needs to bundle kind 36787.
Two changes that together address "Mint error (HTTP 400): proofs already
spent" on self-send / self-zap:
1. Replace the auto keyset-migration on wallet load with a non-destructive
NUT-07 sweep. The migration performed a swap-then-publish that wasn't
atomic — a failure mid-sequence (cancelled signer prompt, transient
network) left the mint with the source proofs marked spent while our
local kind:7375 still held them. The next user-initiated send would
then pick those ghost proofs and the mint would reject the swap.
The new sweep does a read-only /v1/checkstate per mint and NIP-09
deletes any kind:7375 whose proofs are all reported SPENT. Mixed-state
entries (rare; partial sub-swap landed) are logged and skipped — they
self-resolve when the user spends the entry. migrateStaleKeysets()
stays around for explicit on-demand wiring.
2. Track redeemed nutzap ids in an in-memory set inside the redeem mutex.
Without this, a second triggerAutoRedeem firing in the ~1s window
between publishing kind:7376 and the bundled newEventBundles emission
could re-pick the same nutzap and the mint would 400 with "proofs
already spent" on the P2PK swap.
When the user picks the Namecoin Core RPC backend and points at a
self-hosted node behind a self-signed cert (StartOS / Start9, umbrel,
LAN reverse proxy, …) the previous flow only worked if the cert's CA
was already in the device trust store. There was no in-app way to
inspect or pin the certificate, so users had to install the StartOS
root CA at the OS level — or settle for an unencrypted onion path.
This change brings the Namecoin Core RPC path up to parity with the
existing ElectrumX path:
- NamecoinCoreRpcClient.probe() now opens a short-lived, no-auth TLS
socket alongside the JSON-RPC call to capture the server's leaf
certificate (PEM + SHA-256 fingerprint). Capture is best-effort
and only runs for https:// URLs. Credentials are never sent over
the inspection socket.
- RpcProbeResult exposes serverCertPem, certFingerprint, and
tlsHandshakeFailed so the Settings UI can react. New fields are
nullable / default false so existing callers compile unchanged.
- NamecoinCoreRpcClient maintains its own dynamic-cert keystore and
a lazy pinned SSLSocketFactory (same shape as ElectrumXClient's,
minus the hardcoded list — Core RPC has no public defaults). When
cfg.usePinnedTrustStore is true and the URL is https, callRpc()
routes through the pinned factory with a permissive hostname
verifier (LAN/onion certs commonly carry IP-only SANs).
- NamecoinSettingsSection's Namecoin Core RPC card now shows a
'Trust Server Certificate?' AlertDialog after Test RPC when the
probe captured a cert and the user hasn't pinned yet, reusing the
existing namecoin_pin_cert_* strings. Accept persists the PEM
AND flips usePinnedTrustStore=true on the config. The result
card also displays the captured fingerprint and a '(pinned)'
marker so the user can see the current trust state at a glance.
- The pinned PEM list is stored in the existing
KEY_PINNED_CERTS DataStore entry, so a single TOFU confirmation
covers both backends. AppModules' namecoinCoreRpcClient init now
bootstraps the pinned list on app start, matching ElectrumX.
- Tests cover the new probe fields' defaults and the addPinnedCert
/ setDynamicCerts surface.
Local verification: builds clean (assembleFdroidDebug), :quartz:jvmTest
NamecoinCoreRpcClientTest all green, :amethyst:testFdroidDebugUnitTest
namecoin suites all green.
- isPublic() now always returns !isPrivate() so a playlist tagged with
both public=true and private=true is consistently reported as private,
matching the 'isPrivate wins' contract documented on isPrivate().
- AddToMusicPlaylistViewModel + NewMusicPlaylistFab: hop to
Dispatchers.Main.immediate for every Compose State write. The wrapping
coroutines (rescan loop, launchSigner) run on Dispatchers.IO; Snapshot
tolerates off-main writes but the codebase convention is main-only.
- MusicTracksSubAssembler + MusicPlaylistsSubAssembler: the single REQ
asks both kinds 36787+34139, so the since cursor must be the min of
both feeds' lastNoteCreatedAt to avoid over-fetching the lagging kind.
Both assemblers now also listen to the other feed's cursor flow.
- Extract formatTrackDuration into MusicFormatting.kt; MusicTrack and
MusicPlaylist share it.
- syntheticWaveformFor: replace inline FQN
com.vitorpamplona.amethyst.service.playback.composable.WaveformData
with an import.
- NewMusicPlaylistFab: drop the second .trim() — the dialog's confirm
button already trims before invoking onCreate.
The previous fix only ported the no-cover branch. Loading and error
fallbacks were still rendering banner-only with the chip floating alone,
hiding the author's avatar entirely whenever a cover URL was set but
hadn't loaded.
Drop the MyAsyncImage wrapper here and drive SubcomposeAsyncImage
directly so every painter state picks its own overlay:
- Success: real cover + floating chip alone (no avatar to collide with)
- Loading: blurred banner + avatar+chip side-by-side
- Error / no image: banner + avatar+chip side-by-side
When a playlist has no `image` tag, the cover falls back to the
author's profile banner with their avatar baked into the bottom-left by
DefaultImageHeader. The track-count chip was also pinned to BottomStart,
so it landed on top of the avatar.
Render the banner alone (DefaultImageBanner) and place the avatar and
the chip side-by-side in a single bottom row instead. Extract the chip
into a small TrackCountChip composable so both branches share the same
visual.
The three pinned per-user replaceable notes (NIP-65 / DM relays /
nutzap info) were lazy fields. Lazy delegation here adds a synchronized
read on every access for no gain — User is constructed via
LocalCache.getOrCreate, and the pinned notes are read on essentially
every interaction with the user. Resolving them at construction also
lets us drop the stored UserContext reference.
Real minibits / nutshell / CDK-backed mints were rejecting every mint
with "DLEQ verification failed for amount X — mint signature does not
match its published keyset key". The hash input format was wrong on
two axes:
1. Used 33-byte COMPRESSED points; spec uses 65-byte UNCOMPRESSED
(`04 || X || Y`). CDK's `hash_e` in crates/cashu/src/dhke.rs is
the authoritative reference — it calls `.to_uncompressed_bytes()`
then `hex::encode`.
2. Hashed RAW BYTES; spec hashes the UTF-8 of the hex-encoded form.
So the SHA-256 input is 520 ASCII chars (4 points × 130 hex chars),
not 132 raw bytes.
The earlier round-trip test for `signFull → verifyDleq` hid this
because BOTH halves agreed on the wrong format. The fix re-roots
against the verbatim NUT-12 spec vector from
cashubtc/nuts/tests/12-tests.md, which catches any future drift
without needing a live mint.
Carol path (NUT-12 §3) also fixed in the same commit. The previous
`verifyDleqCarol` passed the UNBLINDED `C` to `verifyDleq` as if it
were `C'` — DLEQ math is over the blinded form, so it always
returned false. Per spec, Carol reconstructs both:
B' = hashToCurve(secret) + r·G (what Alice sent)
C' = C + r·A (what the mint returned)
`Bdhke.addRTimesA(C, r, A)` is the inverse of [unblind]'s
`C' - r·A` step. Renamed the `blindSignature` parameter to
`unblindedC` to make the contract clear at call sites.
What still works after the fix:
- Round-trip self-consistency (signFull ↔ verifyDleq) — same algo on
both sides, still verifies.
- All existing tampered-input rejection cases.
- The new dleqProofTestVector pins against the spec's published
proof values; reproducible offline.
- The Carol verification call site in CashuMintOperations.verifyTokenDleq
was updated to the new parameter name.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
The playlist header and each PlaylistTrackRow were reading note.event
synchronously, so when a fresh playlist (or a track referenced by the
playlist) arrived from a relay after first composition, the row stayed
on its 'Unknown track' / stale-snapshot placeholder until the parent
recomposed for some other reason.
Switch both to observeNoteEvent<T>, which subscribes to the note's
metadata flow AND drives the EventFinderFilterAssembler — so a relay
delivering the event both updates the local cache and triggers
recomposition on the same call.
Three follow-ups from the CDK comparison audit:
(#1) NUT-12 §3 Carol verification on incoming proofs.
Previously the wallet only verified DLEQ on proofs the mint signed
directly (Alice-side: mint → our wallet). Proofs arriving from other
wallets — incoming nutzaps, imported cashuB tokens — were trusted at
face value and the validity check was deferred until spend time. A
malicious sender could hand us junk proofs and we'd only find out
when the swap failed, by which point the "payment" was already
considered done from the sender's side.
What's here:
- `CashuProof.dleq: DleqProofDto?` — proofs now retain the
`(e, s, r)` tuple after Alice-side unblinding. `r` is the wallet's
blinding factor (only the minter knows it), included in the
outbound proof so the next recipient can reconstruct `B'` and
verify against the mint's keyset key offline.
- `Bdhke.verifyDleqCarol(secret, r, e, s, C, A)` — Carol-side check.
Reconstructs `B' = hashToCurve(secret) + r·G` from the proof's
secret and the carried blinding factor, then delegates to the
existing `verifyDleq`. No round-trip to the mint required.
- `CashuMintOperations.verifyTokenDleq(proofs)` — batch helper that
fetches the mint's full keyset list, looks up each proof's amount
key, and verifies the DLEQ when the proof carries one. Best-effort:
proofs without dleq (legacy sender, mint stripped) skip cleanly so
we still accept their owner's intent; only an actual mismatch
fails.
- Wired into `CashuWalletOps.redeemNutzap` — every inbound nutzap's
proofs are now Carol-verified BEFORE we hand them to the mint for
swap. Mismatch throws `MintProtocolException`, refusing to redeem
rather than racing the bad proofs into our wallet event.
- `NutzapProofJson` extended with an optional `dleq` field so kind:9321
nutzaps round-trip the tuple between wallets that support it. Older
senders that omit it remain compatible (no Carol check, fall back to
spend-time validation).
(#2) `/v1/info` caching with 30-minute TTL.
Every Verify-mint tap, every future NUT-17 feature-detection lookup,
and every UI surface that shows mint metadata used to fire a fresh
GET. Mint info changes on the order of weeks; 30 minutes is a
compromise between staleness and not blocking a fresh user-visible
upgrade for an hour. Force-refresh path (force=true) for callers that
need certainty after a user-initiated retry. Volatile pair so the
read on the hot path doesn't need a lock — a torn read just causes
one extra HTTP call, never a wrong answer.
(#4) Proactive keyset migration on wallet load.
When the mint rotates its active keyset, proofs minted under the old
one remain spendable — until the mint actually retires that keyset's
private key, after which our held proofs become un-spendable. Without
proactive migration, the first warning is a melt that mysteriously
fails. We now sweep on every wallet start: group held tokens by mint,
fetch the current active id, identify entries with any stale-keyset
proof, and consolidate them via a single swap → fresh kind:7375 →
NIP-09 the source events. Best-effort per mint: a network failure on
one mint skips that mint and the sweep continues for the others.
Three new top-level surfaces:
- `CashuMintOperations.verifyTokenDleq(proofs)` — Carol verification
- `CashuWalletOps.fetchActiveKeysetId(mintUrl)`
- `CashuWalletOps.migrateToActiveKeyset(mintUrl, entries, activeId)`
- `CashuWalletState.migrateStaleKeysets()` — driver, called in start()
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Four small fixes from the post-NUT audit; none user-visible on the
happy path, but #1 is a real correctness issue that would have
rejected vanishingly-rare-but-valid mint signatures.
1. Bdhke.verifyDleq: stop rejecting valid `e >= N`.
NUT-12 treats the challenge `e` as bytes, not as a scalar mod n —
there's no requirement that `e < n`. The previous `ScalarN.isValid(e)`
check would have thrown MintProtocolException with probability ~2^-128
per proof (when sha256 happens to output >= curve order). The
downstream point multiplication `ECPoint.mul(out, A, eScalar)` handles
`e >= n` correctly via GLV reduction internally, so the rejection
was the only thing standing between us and a valid proof. Now we
only reject `e == 0` (which would degenerate verification — `sG -
0·A == sG`, independent of the mint's keyset key — so any junk
signature would pass). Tests still green; vector-style proofs hit
`e < n` 99.99...% of the time, so this isn't observable in the
suite.
2. CashuWalletViewModel Log.w: pass the throwable.
Four call sites (recommendMint / deleteRecommendation /
restoreFromMint / cancelMintQuote) interpolated the exception
message into the lambda but dropped the stack trace. Switched to
the (tag, message, throwable) overload so support reports keep
the trace. Same antipattern that find-non-lambda-logs flags.
3. SecretFactory: batch counter reservation.
The factory was called once per amount denomination inside
secretOutputFor. A 100-sat mint that splits into 7 powers of 2
meant 7 @Synchronized critical sections on
AccountSettings.reserveCashuCounters + 7 saveable.update calls.
Disk was already debounced (1s), but the lock-acquisition pattern
was wasteful and serialised any concurrent mints longer than needed.
New contract: `nextSecrets(keysetId, count): List<DerivedSecret>`.
The deterministic impl reserves the whole counter range in ONE
atomic reservation; the random impl just allocates N random pairs.
CashuMintOperations gained a `secretOutputsFor(amounts, keyset)`
helper that does one batch call; all four call sites (swap, swap
keep-change, swapToLocked keep-change, meltProofs change-outputs)
migrated. `nextSecret(keysetId)` stays as a default-method
single-output convenience.
4. CashuWalletState.ensureSeed: serialise via Mutex.
The previous @Volatile-only double-check could race two coroutines
into both calling `walletPrivkeyHex()` (signer round-trip). For local
signers that's a μs of wasted HMAC; for NIP-46 bunker signers it's
a network decrypt the user pays for twice. Wrapping with
`Mutex.withLock` after the unlocked fast-path gives proper
single-flight semantics without slowing the warm-cache path.
5. NUT-09 restore loop: micro-perf cleanup (rides along).
Pre-sized HashMap + ArrayList to `batchSize × denominations.size`
(was resizing ~10x per 1000-output batch). Hoisted
`bTick.toHexKey()` once per counter — the old code called it twice
per output (once for the map key, once for output.toDto()). Inner
loop now builds the BlindedMessageDto directly with the hoisted
hex instead of going through toDto's accessor.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
This commit closes the loop on the NUT-13/09 combo started in earlier
commits. With NUT-13 wired, every secret + blinding factor is now
seed-derivable; this commit adds the driver loop that asks the mint
"which of these did you sign?", filters out spent ones, and republishes
the survivors as kind:7375 events. The wallet now genuinely survives
losing all its kind:7375 token events — losing your nostr-relay-stored
proofs is no longer permanent funds loss.
What's here:
- CashuMintOperations.restore(seed, keysetId, startCounter, batchSize,
emptyBatchesToStop, amounts?): scans counters in batches, calls
/v1/restore for each batch, unblinds returned signatures, repeats
until N consecutive empty batches signal "no more proofs". Gap-limit
heuristic matches BIP-32 wallet recovery — bounds the scan to
"actually-used + buffer" rather than a fixed upper bound.
- CashuMintOperations.checkStates(proofs): NUT-07 batched proof-state
query, returning a Map<secret, ProofState>. Required because
/v1/restore returns even SPENT proofs (the mint signed them at the
time); without filtering we'd treat already-spent secrets as
recoverable balance.
- CashuMintOperations.activeKeyset() — public surface for the restore
driver. Was previously private.
- New ProofState enum (UNSPENT / SPENT / PENDING / UNKNOWN) with
fromWire() fallback for forwards compatibility with mints that
introduce new state strings.
- CashuWalletOps.restoreFromMint(mintUrl, seed, startCounter): drives
the scan, filters by /v1/checkstate, publishes a single kind:7375
for surviving proofs + a kind:7376 IN history row. Returns
RestoreOutcome with the totals + the highest-counter-seen so the
caller can bump persisted counter state.
- CashuWalletState.restoreFromMint(mintUrl): materialises the seed
via ensureSeed(), delegates to ops, then advances
cashuKeysetCounters past the highest recovered slot so subsequent
mints don't reuse a counter we've just confirmed in use. Returns
null when no seed is available yet (caller retries after wallet
load).
- CashuWalletViewModel.restoreFromAllMints() iterates every mint in
the wallet's mint list, aggregating recovered totals. Best-effort
per mint — one mint failing doesn't abort the others. State
exposed via restoreState: StateFlow<RestoreFlowState>.
- CashuWalletSettingsScreen "Recover from seed" row, sandwiched
between "Edit wallet" and "My mint recommendations". Subtitle
shows the running status, the result totals, or the error. Tap
to kick the scan; idempotent.
Subtle bit: NUT-09 returns proofs regardless of state. Without the
/v1/checkstate filter we'd republish spent proofs and the wallet
would think its balance had recovered, only for the next swap/melt
to fail. The two endpoints together produce a clean "recoverable
balance" answer.
Restore is idempotent — re-running after a successful pass finds
the same proofs (the mint still signed them) but checkStates
classifies them SPENT because the previous round of redemption
consumed them. The result is "Recovered 0 sats", not a duplicate.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Mints that support NUT-20 require the wallet to prove ownership of the
quote when redeeming it for proofs. Without this, anyone who observes
the quote id can race to /v1/mint/bolt11 and steal the freshly-minted
proofs. The earlier commit landed the protocol primitive
(MintQuoteSignature); this one threads it through the live flow.
What's here:
- CashuMintQuoteEvent gets a JSON content shape — `{"quote_id":
"...", "p2pk_priv": "..."}` instead of the bare quote-id string.
Backwards compatible: build() falls back to the plain-string shape
when no signing key is supplied, and decrypt() tries JSON first then
treats parse failure as a legacy plain string. Existing wallets keep
reading their old kind:7374 events; new ones carry the NUT-20 key.
- New CashuMintQuoteEvent.decrypt() returns both the quote id and the
optional signing privkey in one go. quoteId() / signingPrivkey()
are thin accessors over decrypt() so call sites that don't care
about the key stay unchanged.
- CashuMintOperations.requestMintQuote(amountSats, signingPubkey?)
forwards the pubkey to the mint. Always-on is safe: mints that
don't support NUT-20 ignore the extra field per spec.
- CashuMintOperations.mintProofs(quote, amountSats, signingPrivkey?)
signs `sha256(quote || B_0 || B_1 || …)` with the privkey and
attaches the signature when present. Null signingPrivkey skips
NUT-20 entirely (legacy events that don't carry a key).
- CashuWalletOps.startMintFromLightning generates a fresh per-quote
keypair, sends the pubkey to the mint, and stashes the privkey
inside the encrypted kind:7374. Ephemeral keypair per quote keeps
the privacy property — no observable correlation between quotes.
- CashuWalletOps.completeMintFromLightning reads back both fields
from the kind:7374 in one decrypt() call and forwards them to
mintProofs. Resume-from-relaunch works because the key lives with
the quote event (which is restored on app start), not in memory.
Compatibility:
- New quote events carry the NUT-20 key; old ones don't and skip the
signature. Either way the on-wire shape of /v1/mint/bolt11 is
identical to NUT-04 when signature is null.
- Pre-NUT-20 mints ignore the unknown pubkey field — JSON tolerance
per the spec.
- Persistence: the privkey is co-located with the quote in the same
NIP-44-encrypted blob, so backup/restore semantics are unchanged.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
The earlier commit landed the math primitives + spec-vector tests. This
commit threads them into the actual blind-message construction so every
mint / swap / melt the wallet performs now uses NUT-13-derived secrets
instead of pure randomness. Side effect: kind:7375 loss is no longer
permanent funds loss — the wallet can re-derive past secrets from the
seed and recover via NUT-09 /v1/restore (driver loop still to come).
What's here:
- `SecretFactory` strategy in quartz mintApi/. Two impls:
RandomSecretFactory — pure random; the new default for tests and
legacy callers that don't carry a seed.
DeterministicSecretFactory — NUT-13 derivation via a (seedProvider,
reserveCounter) pair. Seed is lazy (the wallet decrypts kind:17375
asynchronously); if the cache is empty, it falls back to random,
preserving pre-NUT-13 behaviour during the warm-up window.
- `CashuMintOperations` constructor now takes a SecretFactory
(default: random). `secretOutputFor` delegates to it. The on-wire
shape is identical either way — the mint can't tell which scheme
we're using.
- `CashuWalletOps` constructor takes a SecretFactory + a suspend
seedWarmer callback. Every blinding op (mintProofs / meltToLightning
/ sendNutzap / redeemNutzap) calls seedWarmer() before constructing
outputs so the seed cache is warm by the time the synchronous
SecretFactory queries it.
- `CashuDeterministic.deriveWalletSeed(p2pkPrivkey)` — derives the
64-byte NUT-13 master seed from the wallet's existing P2PK key via
`HMAC-SHA512("Cashu-Wallet-Seed-v1", priv)`. No new field on
kind:17375 needed; existing wallets become recoverable on first use.
Distinct key-derivation domain so leaking a Cashu secret doesn't
expose the P2PK key.
- `CashuWalletState.cachedSeed` (@Volatile) + `ensureSeed()` (suspend)
— derives once on first call (paying the signer round-trip for the
P2PK key), caches for the wallet's lifetime. Pure function of the
P2PK key, so the cache never invalidates.
- `AccountSettings.cashuKeysetCounters` (`MutableMap<keysetId, Long>`)
+ `reserveCashuCounters(keysetId, count)` — atomic, persistent
read-modify-write that hands out a strictly-monotonic counter range.
Two contracts the spec demands: never reuse a (seed, keysetId,
counter) tuple, and persist the increment BEFORE the secret is used.
Both satisfied — the saveAccountSettings call happens inside the
@Synchronized block before reserveCashuCounters returns.
- `peekCashuCounter(keysetId)` — read-only inspector for the upcoming
NUT-09 restore driver loop (needs to know how high to scan).
Approach (a) — derive seed from the existing P2PK key — was chosen
over (b) — add a new BIP-39 mnemonic field to kind:17375 — because:
- Zero migration: every existing wallet becomes recoverable on next
op, no save-and-republish required.
- The P2PK key is already the recovery-critical secret in kind:17375.
Anyone with the kind:17375 can derive the seed and reconstruct
everything; same threat model as today.
- Cross-wallet recovery via mnemonic is a separate UX feature we can
layer later by also storing/importing a mnemonic when the user
wants that contract.
Backwards compatibility: SecretFactory defaults to RandomSecretFactory
on CashuMintOperations and CashuWalletOps, so existing direct
constructions (mint-operations tests, ad-hoc helper code) keep their
previous behaviour. Production goes through CashuWalletState, which
injects the deterministic factory.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
NUT-17 lets a wallet subscribe to mint events instead of polling. The
big win is the receive flow: instead of `LaunchedEffect { while (true)
{ delay(3000); viewModel.checkAndCompleteMint() } }` hammering the
mint every three seconds, a single subscribe at quote-open time and a
push-notification at PAID time. Smaller wins for melt status and
proof-state tracking.
This commit lands the protocol-message layer + tests; the actual
WebSocket client wrapper and integration into the receive flow follow
in a separate commit. Splitting these because the framing is normative
(any mismatch is a wire-format bug we'd rather catch in unit tests
than against a real mint).
What's here:
- WsRequest / WsResponse / WsNotification — JSON-RPC 2.0 framing as
data classes. Wallet sends WsRequest; mint replies WsResponse for
acknowledgement and pushes WsNotification for state updates.
- WsRequestParams unifies the two shapes (subscribe needs kind +
filters + subId; unsubscribe needs only subId). kotlinx default
null-omission keeps unsubscribe payloads clean.
- WsNotificationParams.payload is left as `JsonElement` rather than
pre-deserialised to a discriminated union: the caller already knows
the kind (it owns the subId it created), so it decodes directly to
the typed DTO without a wasted intermediate parse.
- NutSeventeenKinds constants match the on-wire strings verbatim
(`bolt11_mint_quote`, `bolt11_melt_quote`, `bolt12_mint_quote`,
`bolt12_melt_quote`, `proof_state`). Renaming any of these would
break interop with every mint — a test pins the values.
- ProofStateNotificationDto for the proof-state push payload (NUT-07
shape: Y, state, optional witness).
Subtle bit: kotlinx.serialization omits fields whose value equals
the default. We need `jsonrpc: "2.0"` to ALWAYS appear on the wire —
mints reject anything else — so the field is annotated
`@EncodeDefault` (ExperimentalSerializationApi). Without this, the
first test of the round-trip showed `jsonrpc` missing in the encoded
payload, which a strict mint would reject before parsing further.
Tests: 10 cases covering subscribe / unsubscribe round-trip, the
unsubscribe shape omitting kind+filters cleanly, mint-quote and
proof-state notification decode, response with result vs error,
the wire-string constants, forwards-compat with unknown payload
fields, and a JsonObject-builder spoof for downstream tests that
want to simulate mint notifications without a real WS connection.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Some mints (and increasingly the upcoming ones) require the wallet to
prove ownership of the mint quote when redeeming it for proofs. Without
this, anyone who observes the quote id can race the wallet to /v1/mint/
bolt11 and steal the freshly minted proofs — the quote id is the only
credential. NUT-20 binds the quote to a wallet pubkey at quote creation
and requires a matching BIP-340 Schnorr signature at mint time.
What's here:
- MintQuoteBolt11RequestDto.pubkey — optional 33-byte compressed
secp256k1 hex. When set, the mint records it and refuses to issue
proofs without a matching signature.
- MintBolt11RequestDto.signature — optional 64-byte BIP-340 Schnorr
hex over `sha256(quote_id || B_0 || B_1 || …)` where each B_ is the
UTF-8 encoded hex string of an output's blinded message.
- MintQuoteSignature object — three-arg sign() taking quote id +
output hex list + privkey, plus a DTO convenience overload. Hashes
the concatenation with SHA-256 first per spec (BIP-340 is over a
32-byte digest, not arbitrary payload bytes).
Older mints ignore both fields and operate per NUT-04, so leaving
them null is the no-op default for wallets that don't care.
Tests: 8 cases covering the payload-construction contract (quote ||
outputs in order, no separator), signature length (always 64 bytes),
round-trip verification against the derived x-only pubkey, sensitivity
to either input changing, wrong-length key rejection, and the DTO
convenience overload agreeing with the plain string form.
Not yet wired into CashuMintOperations / CashuWalletOps — like NUT-13
and NUT-09 from the previous commit, this lands the protocol primitive
so the integration step (per-quote keypair generation + persistence,
attaching pubkey to startMintFromLightning and signature to
completeMintFromLightning) is mechanical.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Foundation for "lose your kind:7375 token events → still recover the
funds from a seed" — currently a permanent funds loss because secrets
are purely random and the mint won't return proofs the wallet can't
prove ownership of. NUT-13 fixes that by deriving every (secret, r)
pair from a seed + per-keyset counter, and NUT-09 gives the wallet a
way to ask the mint "which of these blinded messages have you signed?"
so a fresh wallet can reconstruct historic proofs.
What's here:
- `CashuDeterministic` (quartz commonMain) — full NUT-13 derivation
for both v00 and v01 keyset id formats. The two cases need different
algorithms:
v00 (8-byte id): BIP-32 hardened derivation at
`m/129372'/0'/{keyset_id_int}'/{counter}'/{0|1}`
keyset_id_int = int.from_bytes(keyset_id) % (2^31 - 1)
v01 (33-byte id): HMAC-SHA256 directly off the seed —
base = "Cashu_KDF_HMAC_SHA256" || keyset_id || counter_be64
secret = HMAC(seed, base || 0x00)
r = HMAC(seed, base || 0x01)
The v01 swap is necessary because a 33-byte id can't faithfully
encode into a 31-bit BIP-32 child index without lossy collapse.
First byte of the id selects the branch (`00` → v00, `01` → v01).
- `RestoreRequestDto` / `RestoreResponseDto` + `/v1/restore` on the
mint HTTP client (NUT-09). Wallet sends a batch of blinded
messages; mint echoes back the subset it has previously signed +
the issued signatures. Caller is responsible for the scan loop
(try counters [0..N], stop after M consecutive empty batches).
Tests against the verbatim NUT-13 spec vectors (mnemonic = "half
depart obvious quality work element tank gorilla view sugar picture
humble", both v00 and v01 keysets, counters 0–4):
- v00: 5 counters × {secret, blinding} = 10 vector checks
- v01: 2 counters × {secret, blinding} = 4 vector checks (samples)
- 4 sanity properties: leaf 0 ≠ leaf 1, counter advances change
output, different keysets produce different secrets, secretAsAscii
is exactly 64 lowercase hex chars
- 3 keysetIdToInt edge cases: zero, small, fits-in-31-bits
Initial implementation had v00 working but v01 producing wrong output
— spent some time confirming the spec actually swaps algorithm based
on version byte (cashu-ts and nutshell both do; spec text doesn't
make this obvious). The cashubtc/nutshell `_derive_secret_hmac_sha256`
is the authoritative reference for the v01 path.
Not yet wired into CashuMintOperations — that's the next commit
(per-keyset counter persistence, deterministic blinding on mint/swap,
the actual restore() driver loop, UI for "recover from seed"). This
commit lands the math primitives + endpoint plumbing so the
follow-up is integration only, no further protocol decisions.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Without DLEQ, a malicious or buggy mint can hand us a C' that doesn't
correspond to its published keyset key — we accept it, store the proof
as kind:7375, and only discover the forgery when we try to spend the
proof. By that point any sender already considers the payment done.
NUT-12 fixes this by having the mint return (e, s) alongside each C',
proving in zero knowledge that it used the same private key k for both
C' = k·B' and its published A = k·G. The wallet now verifies this on
every signature it accepts.
What's new:
- Bdhke.verifyDleq(e, s, B', C', A) — Alice-side check:
R1 = sG - eA
R2 = sB' - eC'
e' = SHA256(R1 || R2 || A || C') (compressed, 33-byte each)
return e' == e
Uses the existing ECPoint primitives; point negation via the same
affine-Y flip pattern already used in unblind(). Defensive against
malformed inputs — wrong sizes, off-curve points, zero/oversized
scalars all return false (no exception escapes to callers).
- Bdhke.signFull(B', k, r') — mint-side counterpart, test-only.
Lets round-trip tests exercise both halves without standing up a
real mint. Optional r' argument keeps the suite deterministic.
- DleqProofDto on BlindSignatureDto.dleq — optional, parsed from the
same JSON the mint already returns. Carries e, s, and an optional
blinding-factor r (only ever non-null in proofs that travel between
WALLETS — Carol verification, NUT-12 §3, not used here yet).
- CashuMintOperations.unblindOne now verifies the DLEQ proof when
present. Mismatch → MintProtocolException, aborting the swap/mint
before any proof event is published. Older mints that don't emit
the dleq field still pass through (backwards compatible).
Tests: 9 cases on the verify/sign round-trip — happy path, wrong
mint pubkey, tampered C / e / s, wrong-length inputs, zero scalars,
and a determinism check that different DLEQ nonces produce different
proofs that nonetheless both verify.
Two bugs fixed during implementation worth flagging:
1. Secp256k1.pubkeyCreate returns 65-byte UNCOMPRESSED format. The
first cut of signFull was passing that straight into the hash
input, overrunning into the C' slot. Now compresses to 33 bytes
first.
2. Initial verifyDleq rejected `e` scalars whose 32-byte value
exceeded n. sha256 output can technically be >= n with
probability ~2^-128 — vanishingly rare in practice, but the
check was also rejecting valid proofs spuriously through a
different path. ScalarN.isValid is only applied to `s` now;
`e` only needs to be non-zero.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Newer mints charge a per-input fee on swap and melt. Without reserving
it from the output total, the mint rejects every swap/melt with
"amount mismatch" the moment it has any fee configured. We were
reading input_fee_ppk into the KeysetSummaryDto but never threading
it through the actual ops math — fee-charging mints simply didn't
work for us.
Per NUT-02 the fee is `ceil(numInputs * input_fee_ppk / 1000)`. The
ceiling is load-bearing: floor undercharges by one sat in the common
case (numInputs * ppk not exactly divisible by 1000), which is also
exactly what mints reject. New `computeInputFee` helper does the
ceiling-division in pure Long math — `(n * ppk + 999) / 1000` — with
defensive zeroing for null / zero / negative ppk.
Applied in three paths:
- swap(): output total = inputs - fee. The change bucket shrinks by
fee; the send bucket (when split) stays whole.
- swapToLocked() (nutzap send): change shrinks by fee, recipient
still gets exactly targetSplit sats locked.
- meltProofs(): required inputs grow by fee (separate from
quote.feeReserve, which bounds LN routing fees, not the mint's
processing fee). Change-output upper bound shrinks accordingly.
Also exposes input_fee_ppk on the full KeysetDto (was only on the
summary) so the fee-aware paths can read it from the same /v1/keys
call we already make.
Tests: 10 cases on the ceiling-division helper covering null/zero
ppk, exact-divide boundaries (999 / 1000 / 1001 inputs at 1 ppk),
typical and large fees, and defensive negative-ppk handling.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Four spots that represent Cashu-specific things were using
MaterialSymbols.AccountBalanceWallet — a generic bank/wallet icon that
read no different from the on-chain or Lightning iconography on the
zap popup. Replace with the multi-tone Cashu logo that already ships
in commons/hashtags/Cashu.kt and is the canonical brand mark used by
CashuRedeem.kt and the hashtag chips.
Spots swapped:
- ReactionsRow.NutzapAmountChip — the purple chip in the zap popup
(most important — gives Cashu a distinct visual identity next to
the orange Lightning bolt and on-chain ₿ chips)
- CashuWalletSettingsScreen.RecommendationSuggestionList — the
autocomplete dropdown beneath "Recommend a mint"
- AddCashuWalletScreen.MintSuggestionList — autocomplete under the
mint URL input on the create/edit wallet form
- CashuWalletScreen.MintRow — the leading icon on each wallet-mint
row in the wallet header
Used `tint = Color.Unspecified` to preserve the icon's native tan +
amber palette (the icon ships with three solid colors on its paths);
flattening to a single colorScheme tint would drop the brand cue.
Brought in via `import androidx.compose.material3.Icon as Material3Icon`
because the file-level `Icon` is the project's custom MaterialSymbol-
only overload (in commons.icons.symbols) — couldn't reuse it for an
ImageVector.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Previously the popup rendered Lightning chips first, then on-chain, then
Cashu. With recipient-capability gating in place, lead with the rail
that's both fastest and free-of-fees when available — Cashu (nutzap) —
falling back to Lightning, then on-chain. Mirrors the priority order
RailCapabilityResolver uses for the underlying capability fallback,
so the visual top-down sweep matches "best rail for this recipient that
the sender has configured".
Mechanical reorder inside ZapAmountChoicePopupContent's FlowRow — no
behaviour change beyond layout. Gear settings button stays at the end.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Adds Android-Studio-visible previews for the recently-authored
composables on the Cashu wallet stack, so designers / reviewers can
eyeball them without building and launching the app. All previews use
the existing ThemeComparisonColumn helper to render dark + light side
by side, matching the convention used by SensitivityWarning and the
other existing preview files.
CashuWalletSettingsScreen:
- SettingsRow (with + without subtitle)
- EmptyRecommendationsHint
- AddRecommendationRow (empty + typing state)
- RecommendationSuggestionList
- RecommendationRow (plain + with review text)
AddCashuWalletScreen:
- MintSuggestionList (multi + single)
For RecommendationRow a tiny `fakeRecommendation` helper builds a
synthetic kind:38000 with stable tag layout (d / k / u) so the preview
exercises the same `mintUrls()` + `dTag()` paths the real renderer uses
without touching the signer.
Skipped: top-level screen composables (CashuWalletSettingsScreen,
CashuWalletScreen, AddCashuWalletScreen) — those take AccountViewModel /
CashuWalletViewModel / INav, which can't be cheaply faked. Anyone who
needs to see them previewed should iterate on the smaller sub-
composables included here.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Pins each per-user replaceable note to the User's lifetime so weak-ref
eviction from LocalCache.addressables can't lose them — same fix the
NIP-65 / DM relay list notes already had, generalised so adding new
pinned kinds is a one-liner.
Background: LocalCache.addressables is a LargeSoftCache<Address,
AddressableNote> backed by WeakReference. Without a strong reference
somewhere, an addressable note shell (and any event loaded into it) can
be cleared on any GC cycle even though it was successfully delivered.
The User constructor already held nip65RelayListNote / dmRelayListNote
fields exactly to defeat this for kinds 10002 and 10050. kind:10019
(NutzapInfoEvent) had no such pin, so the zap picker's "does this user
accept nutzaps?" check would silently return null for an evicted note —
the chip never showed even when the recipient had actually published.
This refactor:
1. Adds `UserContext` — a one-method `fun interface` exposing
`addressableNote(addr): Note`. User holds it for life; LocalCache
implements it via a single instance bound to ::getOrCreateAddressableNoteInternal.
2. Converts the three per-user pinned notes (nip65 / dm / nutzapInfo)
to `by lazy` fields backed by the context. Each is resolved the
first time it's read and then held by the User's strong reference
until the User itself is collected. `by lazy`'s default SYNCHRONIZED
mode handles concurrent reads from the zap picker + wallet state.
3. Adds typed accessors on User: nutzapInfo(), acceptsNutzaps(),
nutzapMints(), nutzapP2pkPubkey() — mirrors the existing
authorRelayList() / dmInboxRelayList() shape.
4. CashuWalletState.peekNutzapTarget now reads via
`cache.getOrCreateUser(recipientPubKey).nutzapInfo()` instead of
touching the cache's addressable map directly.
Tradeoffs vs the eager-constructor approach:
- No upfront allocation for kinds the screen never reads.
- Adding a new pinned kind (mute list, blocked relays, bookmark list)
is one `by lazy { context.addressableNote(...) }` line in User —
no constructor-signature churn across call sites.
- User now depends on a narrow `UserContext` interface; test fakes are
a one-liner: `User(hex) { addr -> Note(addr.toValue()) }`.
Migration:
- Single User constructor call site (LocalCache.getOrCreateUser) updated.
- Two existing test fakes (NoteOnchainZapTest, SearchResultSorterTest)
switched to the SAM-lambda form.
- No external behaviour change — the public `nip65RelayListNote` /
`dmRelayListNote` fields keep the same names and types, so the few
consumers (RelayFeedViewModel, ChatNewMessageViewModel) need no edits.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Two changes in the Cashu Wallet Settings flow:
1. My Mint Recommendations now has its own input row.
A new OutlinedTextField under the section header lets the user paste
a mint URL and tap "+" to publish a kind:38000 recommendation
without having to leave Settings, find the mint elsewhere, and
thumbs-up it. As the user types, the same cache-backed directory
autocomplete that AddCashuWallet uses surfaces matching mints from
the kind:10019 / kind:38000 / kind:38172 the cache already holds —
tap a suggestion to one-shot recommend (publish + clear the field),
useful for chaining several adds.
Suggestions are filtered to drop URLs the user has already
recommended (de-duped by the lowercased / trailing-slash-stripped
mint URL across the user's own kind:38000s) so the same row never
appears in both the autocomplete and the list directly below.
2. Autocomplete reacts only to typed text, not to an empty field.
The first iteration showed the whole directory the moment the
field gained focus — i.e. on the Edit Cashu Wallet mint-URL popup
the user saw mint URLs without typing anything, which felt like the
form was pre-populating itself. Both call sites
(AddCashuWalletScreen + CashuWalletSettingsScreen) now early-return
an empty suggestion list when the trimmed input is blank, so the
dropdown is purely a reaction to what the user types.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Adds a cache-backed Cashu mint directory sibling to LocalCache.relayHints
that aggregates mint URLs from every relevant event the cache sees, and
wires it into the AddCashuWallet mint-URL text field as inline
autocomplete so users don't have to remember mint URLs.
What feeds the directory:
- NutzapInfoEvent (kind:10019) — every nostr user with a Cashu wallet
publishes their accepted mints there. A typical inbox of cached
profiles seeds a useful starter directory automatically.
- MintRecommendationEvent (kind:38000) — explicit public vouches.
- CashuMintEvent (kind:38172) — formal mint announcements from the
NIP-87 directory subscription.
How it's populated:
- LocalCache.updateMintIndex(event) is called from
justConsumeAndUpdateIndexes alongside updateHintIndexes, so every new
event with a mint URL adds to the index. wasNew gating prevents
re-emissions from inflating popularity counters.
- LocalCache.ensureMintDirectoryBackfilled() does a one-shot scan of the
existing notes + addressables maps. The autocomplete UI kicks this in
a LaunchedEffect on screen open so suggestions are useful before the
next relay round-trip.
Where it surfaces today:
- AddCashuWalletScreen — under the mint-URL OutlinedTextField, a
MintSuggestionList card shows up to 6 cache-derived suggestions ranked
by popularity desc + URL asc. Tapping a row fills the field (does not
auto-add — users typically want to Verify first). Filters out URLs the
user already added and exact matches of what they typed.
The MintPicker dropdown inside the Receive / Send dialogs is unchanged
— those only need to choose between mints the user already has in their
wallet, so no directory autocomplete applies there.
Tests: 8 unit tests cover normalisation (case-insensitive, trailing-slash
stripping, http(s) gating), popularity ranking, substring filtering,
limit enforcement, and malformed-URL handling.
URL normalisation: trimmed, lower-cased, trailing `/` stripped, scheme
must be http(s). Same URL with different casing or trailing slash
collapses to one entry so popularity counts correctly.
Implementation notes:
- MintDirectoryIndex lives in commons/jvmAndroid (uses ConcurrentHashMap;
iOS doesn't ship Cashu wallet yet).
- Thread-safe; safe to read from any dispatcher.
- No persistence — purely in-memory, accumulates over the session.
- Entries are never removed: stale entries don't hurt (user always
verifies before adding), and tracking which event added which URL
would add bookkeeping without UX benefit.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Two bugs sharing the same root cause — LocalCache silently dropping or
losing events that downstream wallet state depended on.
1. Discard Invoice didn't remove the pending banner.
`CashuWalletState` listens on `cache.live.deletedEventBundles` to
prune `quoteEvents` when a NIP-09 delete of a kind:7374 is processed.
That stream is only emitted from `LocalCache.deleteNote`, which is
only reached when `consume(DeletionEvent)` finds the target Note
still resident in `notes` — a `LargeSoftCache<HexKey, Note>` backed
by `WeakReference`s (commons/.../LargeSoftCache.kt). Weak references
can be cleared on any GC cycle, so on a moderately busy device the
quote Note is often gone between publish and the deletion round-trip;
the cache's deleteNote path then no-ops, `_deletedEventBundles`
never fires, and our `quoteEvents` map keeps the deleted entry until
process death — manifesting as a pending-invoice banner that the
user can't dismiss.
Fix: process our own kind:5 deletions inline in the
`newEventBundles` collector (which DOES see every kind:5 we publish,
independent of soft-cache state) by extracting `deleteEventIds()`
and calling the existing `removeEvents()`. The wallet flows now stay
in sync regardless of weak-ref collection.
2. Thumbs-up on a mint never appeared in My Mint Recommendations.
`LocalCache.justConsumeAndUpdateIndexes` dispatches by event type and
falls into a `else -> Log.w("Event Not Supported")` branch for
anything missing a `when` arm — silently dropping the event. None of
the three NIP-87 events (`CashuMintEvent`, `FedimintEvent`,
`MintRecommendationEvent`) had a dispatch entry, so when the wallet
published a kind:38000 the cache rejected it, `newEventBundles`
never emitted, and `CashuWalletState.applyEvents` never indexed it.
Same broken path for mint announcements arriving from
`CashuMintDirectoryFilterAssembler`'s relay subscription.
Fix: add three dispatch entries routing all NIP-87 events through
`consumeRegularEvent`. They're parameterized-replaceable per spec
but none extend `AddressableEvent` in Quartz today, so
`consumeBaseReplaceable`'s `check(event is AddressableEvent)` would
throw — `consumeRegularEvent` works because the downstream consumers
(`CashuMintDirectoryState`, `CashuWalletState.applyEvents`) already
dedupe by `(pubKey, dTag)` and keep the newest.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Until now the popup showed Lightning chips whenever the sender had LN
amounts configured, regardless of whether the recipient had any way to
receive sats over LN. Nutzap chips were already recipient-gated; this
extends the same pattern to Lightning and (defensively) on-chain so a
single tap can't silently land in a no-op.
Adds a small `RailCapabilityResolver` that, given a note, returns three
flags evaluated against the author + every `zap` split tag on the event:
- hasCashu — any pubkey recipient has a kind:10019 with P2PK and
shares a mint with our wallet (delegates to the existing
CashuWalletState.peekNutzapTarget).
- hasLightning — any pubkey recipient has lud16/lud06 in kind:0, OR the
note has at least one direct ZapSplitSetupLnAddress.
- hasOnchain — at least one pubkey recipient exists (NIP-BC derives the
Taproot address from the pubkey, so any nostr pubkey is
payable; an event with only lnAddress-only splits has
nothing to tweak).
A flag is `true` when at least one recipient on the note can be paid
through that rail — matching the existing best-effort behaviour of the
real send paths (ZapPaymentHandler skips pubkeys with no lnAddress;
OnchainZapSendDialog separately warns about skipped lnAddress splits).
The popup gates the existing `zapAmountChoices` / `onchainZapAmountChoices`
lists on the corresponding flags by passing an empty list when the rail
is unsupported — same pattern already used for nutzap chips, so
ZapAmountChoicePopupContent and the chip composables stay unchanged.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
The top-bar pencil on the Cashu Wallet screen becomes a gear that opens a
new Settings hub instead of jumping straight to the edit form. The hub
hosts:
- "Edit wallet details" → routes to the existing AddCashuWallet form in
edit mode (mints + nutzap key).
- "My mint recommendations" → live list of NIP-87 kind:38000 events this
account has published, each with a NIP-09 retract button. Retraction
fires DeletionEvent with both `e` and (when a d-tag is present) `a`
tags so compliant relays drop all versions of the parameterized-
replaceable recommendation.
The wallet's existing CashuWalletFilterAssembler now pulls
MintRecommendationEvent.KIND alongside the other NIP-60 / NIP-61 kinds,
so the list populates without an extra subscription. CashuWalletState
indexes own recommendations into a new `ownRecommendations` StateFlow
(keyed by d-tag, falling back to event id for malformed events) and
keeps it in sync via the existing live cache + delete observers.
Future settings (auto-recommend toggle, nutzap relay overrides,
export/backup) belong here — consolidating wallet-shaped knobs in one
place avoids re-cluttering the main wallet screen.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Three small UI fixes on the Cashu wallet screen:
- Add a Discard button to the Receive dialog. When the user requested an
invoice they no longer want (typo'd amount, wrong mint, changed their
mind), tap Discard to NIP-09-delete the kind:7374 mint quote so the
pending-quote banner stops re-surfacing it.
- Stop the action-row labels from clipping. The previous
OutlinedButton-per-tile layout, with 4 tiles in a row plus default
24dp horizontal padding and a 20dp icon, clipped "Send Token" on
standard 360dp phones. Replaced with a custom Surface+Column tile that
gives us control over padding and lets the label wrap to 2 lines.
- Render history rows like the LN + on-chain transaction lists:
counterparty avatar on the left, name + timestamp in the middle,
signed amount on the right. For inbound nutzap redemptions we resolve
the sender's pubkey from the redeemed-marker `e` tag (the kind:9321
is in LocalCache thanks to the cashu filter assembler). For
everything else there's no Nostr counterparty, so we fall back to a
directional arrow icon matching the LN wallet's empty-counterparty
pattern.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Bug: after saving an NWC connection (or any conclusion of the form
screen), nav.popBack() landed on the wallet-type chooser instead of
the Wallet screen. The chooser would then sit there as a useless dead
end requiring another back press to escape.
Same bug existed on the Cashu add path from the chooser, though the
edit-from-CashuWalletScreen path was unaffected because it bypassed
the chooser entirely.
Fix: at the chooser, replace the chooser entry with the form via
popUpTo(target, Route.WalletAdd::class) instead of pushing the form
on top via nav.nav(target). Now:
* From + on Wallet → Choose → NWC → Save → back to Wallet (1 pop)
* From + on Wallet → Choose → Cashu → Save → back to Wallet (1 pop)
* From CashuWalletScreen → Edit → Save → back to CashuWallet (unchanged)
* Back button from inside the form now also goes directly to
Wallet, skipping the chooser that's no longer in the stack —
a minor improvement since the chooser had nothing useful to
return to after a type was picked.
Bug: every entry to the wallet screen re-opened the Receive dialog if
any pending kind:7374 mint quote existed. So a user who got an invoice
and then navigated away — for any reason, even without dismissing —
would be greeted by the same invoice dialog the next time they opened
the wallet. Worse: even after they'd paid and the mint had issued
proofs, the brief window before the kind:7374 NIP-09 delete propagated
would cause the dialog to pop again.
Fix: drop the LaunchedEffect(pendingQuotes) auto-resume; replace with
a non-modal banner card just under the BalanceCard that shows
"N pending invoices · Tap to resume". The user opts in by tapping it.
The underlying CashuWalletState.pendingQuotes flow + the
viewModel.resumeMintQuote() VM method are unchanged — only the
trigger surface moves from "auto" to "user-initiated".
playDebug + fdroidDebug compile clean; 24/24 jvm tests still pass.
It was redundant context — the screen title already says "Wallet" and
the cards underneath are clearly the user's. Removing the section
header reclaims vertical space on the main list. Kept the Spacer
above the first card so the cards don't bump against the OnchainSection
divider.
Leaving the wallet_your_wallets string resource in place so Crowdin
translations stay valid; removing it would force a fan-out across
every locale file for no functional gain.
UserMetadataForKeyKinds — the per-user kind list pulled by
UserWatcherSubAssembler every time the app renders a user (profile
pictures, names, status, identities, NIP-65, DM-relays, etc.) — now
also includes NIP-61 NutzapInfoEvent (kind:10019).
Net effect: when you scroll into a note authored by user X, the same
subscription that fetches X's kind:0 also pulls X's kind:10019 if
present. The Nutzap chip in the zap picker (which peeks
LocalCache via CashuWalletState.peekNutzapTarget) can then resolve
without an extra round-trip — so the chip appears immediately for any
user we've at least seen the profile of.
Deliberately NOT co-loading kind:17375 here. That's the user's
private wallet, NIP-44-encrypted to them; we couldn't decrypt it and
have no reason to fetch other people's wallet events. Only the public
kind:10019 announcement is useful cross-user. The owning user's own
17375 is fetched by AccountInfoAndListsFromKeyKinds2 (the always-on
account-load filter).
Cost: one extra kind in an already-batched per-relay author filter.
No additional round-trips, no extra subscriptions.
playDebug + fdroidDebug compile clean; 24/24 NIP-60 jvm tests still pass.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Two fixes that together make sure NIP-60 / NIP-61 events actually reach
the cache and the always-on account loader picks them up.
1) LocalCache dispatch
Before: justConsumeInnerInner's `when (event)` block had no branches
for any of CashuWalletEvent / CashuTokenEvent /
CashuSpendingHistoryEvent / CashuMintQuoteEvent / NutzapEvent /
NutzapInfoEvent. Events of those kinds were dropped on the floor by
the cache — only our private re-broadcast through
account.sendLiterallyEverywhere() (which calls
cache.justConsumeMyOwnEvent directly, bypassing the dispatcher) made
the wallet visible. Events arriving cleanly from relays were
silently lost.
Now: each kind dispatches through the same consumeBaseReplaceable
(kinds 17375, 10019) / consumeRegularEvent (7374, 7375, 7376, 9321)
paths used by every other Nostr kind in the app. Token events,
history, mint quotes, and inbound nutzaps now land in LocalCache
from any source (relay, restore-from-prefs, manual paste, …).
To make CashuWalletEvent dispatchable through consumeBaseReplaceable
(which requires AddressableEvent), promote it from `Event` to
`BaseReplaceableEvent`. The static helper `createAddress(pubKey)`
stays for callers that don't have an instance; FIXED_D_TAG kept for
backwards source compatibility.
2) Account-load filter
AccountInfoAndListsFromKeyKinds2 (the always-on per-account
subscription that loads kind:0 / NIP-65 / mute list / etc. on
signin) now also pulls kind:17375 and kind:10019. This means even
users who never open the wallet screen have their wallet event and
nutzap-info indexed against their home-relay set — so the wallet is
ready to render the moment they do open it, and inbound nutzaps can
target a known kind:10019 without a separate fetch.
Note: this doesn't replace CashuWalletFilterAssembler — that one
runs against outbox relays and also fetches the non-replaceable
kinds (7374, 7375, 7376, 9321). Both are needed; the relay client
dedupes overlapping filters on the wire.
playDebug + fdroidDebug compile clean; 24/24 NIP-60 jvm tests still
pass (BdhkeTest × 7, AmountSplit × 7, P2PK × 6, MintException × 4).
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Mirrors the existing local-backup pattern used for kind:0, kind:3,
NIP-65 relay list, mute list, etc.: every time the user's Cashu wallet
event or nutzap info event lands in LocalCache, the latest copy is
serialized into encrypted account prefs. On next launch the saved copy
is pushed into LocalCache before any relay round-trip, so the wallet
screen renders the user's existing wallet immediately — even if relays
are slow or unreachable. The AccountSessionManager also re-broadcasts
both events on signin, exactly like it does for kind:0 / kind:3.
Why this matters:
* Wallet event (kind:17375) holds the user's mint list AND the P2PK
private key used to receive nutzaps. If we couldn't fetch it from
relays on cold start and the user tapped "Create wallet", we'd
overwrite the remote one — destroying the P2PK key and orphaning
any inbound nutzaps. The discovering-state UI commit added a
timeout safety net; this commit makes the wallet actually load
instantly from local backup, removing the race entirely for
returning users.
* Nutzap info (kind:10019) tells other users which mints we accept
and which P2PK pubkey to lock proofs to. Losing it would mean
senders' new nutzaps wouldn't reach us.
Plumbing:
* AccountSettings gains backupCashuWallet + backupNutzapInfo +
updateCashuWallet/updateNutzapInfo setters (dedup by event id,
saveAccountSettings() on change — same shape as updateNIP65RelayList).
* LocalPreferences: LATEST_CASHU_WALLET + LATEST_NUTZAP_INFO PrefKeys
constants, putOrRemove in the writer, async parseEventOrNull in
the reader, constructor wiring on AccountSettings rebuild.
* AccountSessionManager: rebroadcast both events on signin alongside
the existing kind:0/3/NIP-65/etc. broadcast.
* CashuWalletState: takes AccountSettings, on start() pushes both
backups into LocalCache via justConsumeMyOwnEvent, and applyEvents
persists any new wallet/nutzap-info into settings via update*().
* isRelevantEvent + applyEvents + removeEvents now also handle
NutzapInfoEvent (we subscribe to kind:10019 but previously didn't
index it as a first-class state surface — now exposed as
cashuWalletState.nutzapInfoEvent).
playDebug + fdroidDebug compile clean; 24/24 NIP-60 jvm tests still
pass (BdhkeTest × 7, AmountSplit × 7, P2PK × 6, MintException × 4).
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
NIP-60 wallets are portable — kind:17375 + 7375 + 7376 + 10019 are
stored on relays, so a wallet created in another client (cashu.me,
Boardwalk, etc.) should appear in Amethyst when the user signs in with
the same Nostr key. The plumbing already handles this: our
CashuWalletFilterAssembler subscribes to kinds=[17375, 7375, 7376,
7374, 10019] authored by us, and CashuWalletState.applyEvents() indexes
incoming events regardless of which client published them.
The UX bug: the wallet screen had two states (wallet event present /
absent). On first launch, before relays delivered the existing wallet
event, we rendered the "No Cashu wallet — Create" state. Tapping Create
there published a fresh kind:17375 which (being replaceable) clobbered
the remote wallet — destroying the P2PK key and orphaning any inbound
nutzaps locked to it.
Fix:
* CashuWalletState gets a `discovering: StateFlow<Boolean>` set to
true at start() until either a wallet event arrives (cleared from
applyEvents) or DISCOVERY_TIMEOUT_MS (8 s) elapses, whichever first.
* CashuWalletScreen renders a "Looking for your wallet…" pane with a
spinner + explainer while discovering is true. Empty-create CTA only
fires after timeout for genuinely wallet-less users.
playDebug + fdroidDebug compile clean; 24/24 jvm tests still pass.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Stack trace from runtime:
java.lang.NullPointerException
at CashuWalletViewModel.getState(CashuWalletViewModel.kt:139)
at CashuWalletViewModel.getWalletEvent(CashuWalletViewModel.kt:142)
at WalletScreen.kt:94
Cause: `CashuWalletViewModel.state` dereferences `account!!`, which is
populated by init(). I had init() inside `LaunchedEffect(Unit)` in the
three call sites — that effect only fires *after* the first composition
returns, so the very first read of `viewModel.walletEvent` (line 94 of
WalletScreen) hit a null account and threw.
The existing `WalletViewModel` (NWC) handles this by calling init()
directly in the composable body — init() is idempotent (just assigns
two fields), so recomposing is fine. Match that pattern in
WalletScreen, CashuWalletScreen, and AddCashuWalletScreen.
Both flavors compile clean; 24/24 NIP-60 jvm tests still pass.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Adds end-to-end NIP-87 support so users can pick mints from the
network instead of having to know URLs upfront, and can publicly
endorse mints they use.
Discovery (commons + amethyst)
* commons/.../CashuMintDirectoryFilterAssembler — subscribes to
kind:38172 cashu mint announcements and kind:38000 cashu-scoped
recommendations (#k=["38172"]) on a configurable relay set.
Fedimint announcements (38173) are intentionally excluded — this
feeds the Cashu mint picker only.
* RelaySubscriptionsCoordinator.cashuMintDirectory — singleton
assembler reachable as Amethyst.instance.sources.cashuMintDirectory.
Indexing state (amethyst/model)
* CashuMintDirectoryState — account-scoped index of announcements +
recommendations. Reactive: backfills from LocalCache.notes on
first observer and listens to LocalCache.live.newEventBundles for
incremental updates. The relay subscription only runs while at
least one picker is on screen (ref-counted open()/close()).
* Ranking: follows-recommendations DESC, then total recommendations
DESC, then URL ASC. Dedup'd by (recommender, mint URL) so a
single recommender can't inflate counts by re-posting.
* CashuMintDirectoryEntry — display model with URL, latest
announcement, total and follows-recommendation counts.
Publishing recommendations (CashuWalletOps)
* recommendMint(mintUrl, dTag?, review) — publishes kind:38000 with
both the `a`-tag (pointing at the mint's announcement by
kind:pubkey:dTag) and a `u`-tag with the raw URL so older clients
indexing by URL still pick it up.
UI integration
* MintPickerSheet — ModalBottomSheet with search field + scrollable
list. Each row shows the mint name (parsed from the announcement
content) or URL, with badge chips for "from people you follow"
and total recommendation counts. The "Add" button writes the URL
back to the caller's mints list; already-added URLs show "Added"
instead.
* AddCashuWalletScreen gets a "Browse" button next to the Mints
section header that opens the picker. Selected mints are still
Verify-able via the existing ping; users can still paste manually
if they want.
* CashuWalletScreen's mint list gets a thumb-up icon button per
mint that fires viewModel.recommendMint(url) — best-effort,
silent failure (logged via Log.w("CashuWallet")).
Wiring
* cashuMintDirectoryFilterAssembler factory plumbs through Account →
AccountCacheState → AppModules. The mock test AccountViewModel
constructions in AccountViewModel.kt are updated to pass a fresh
assembler.
playDebug + fdroidDebug compile clean. 24/24 jvm tests still passing.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Bug: tapping the Edit pencil on CashuWalletScreen routed to
AddCashuWalletScreen, which always started with an empty mints list and
auto-generated a fresh P2PK key on save. Net effect: editing a wallet
silently wiped the mint list and invalidated any inbound nutzaps locked
to the previous key.
Changes:
* AddCashuWalletScreen now detects edit mode (walletEvent != null) and
pre-fills the mints list from CashuWalletState.mints on entry.
Subsequent state updates (e.g. mints arriving from relays mid-edit)
merge in via LaunchedEffect(existingMints).
* P2PK key handling is now an explicit 3-way radio (KeepCurrent /
AutoGenerate / Manual) with KeepCurrent as the edit-mode default.
AutoGenerate in edit mode shows a destructive-action warning. Create
mode hides KeepCurrent and defaults to AutoGenerate.
* CashuWalletState.exportP2pkPrivkeyHex() — suspending accessor used by
the VM when KeepCurrent is selected. Necessary because remote / NIP-46
signers need a round-trip to decrypt the wallet's NIP-44 content.
* CashuWalletViewModel.saveWallet(mints, keyMode, manualPrivkey?) —
replaces the old (autoGenPrivkey, manualPrivkey) shape with the
explicit P2pkKeyMode enum so the screen and VM agree on intent
instead of inferring it from a boolean.
* Title shows "Edit Cashu wallet" + button reads "Save changes" when
editing an existing wallet.
* Vertical scroll added so radio + manual key field don't push the
Save button off-screen on small devices.
Both playDebug + fdroidDebug compile clean; 24/24 jvm tests still pass.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Adds an end-to-end "Nutzap" path to the existing zap chooser popup.
Protocol layer (quartz)
* CashuMintOperations.swapToLocked: mints P2PK-locked outputs for a
recipient pubkey alongside the unlocked change. Uses the new
lockedOutputFor() helper which encodes NUT-11 P2PK secret strings
before blinding.
* NutzapInfoEvent.createAddress() mirrors CashuWalletEvent's helper so
LocalCache.getOrCreateAddressableNote can look up a recipient's
kind:10019 by pubkey alone.
Wallet ops (amethyst)
* CashuWalletOps.sendNutzap: spends [available] proofs at [mintUrl] to
produce locked outputs worth [amountSats], publishes a kind:9321 with
those proofs + the zappedEvent + recipient p-tag, rolls leftover
change into a new kind:7375 (with `del` referencing the sources),
NIP-09-deletes the source token events, and logs kind:7376 (direction
OUT, destroyed/created references).
* CashuWalletState.peekNutzapTarget(recipient): pure read against the
cached kind:10019 + our mint set. Returns a NutzapTarget (mint URL +
recipient P2PK pubkey) if (a) we have a Cashu wallet, (b) recipient
published kind:10019 with a P2PK pubkey, and (c) we share at least
one mint with them. Returns null otherwise so the UI can hide the
nutzap chip.
* CashuWalletState.sendNutzap: orchestrates target lookup + ops call.
UI integration
* ReactionsRow.ZapAmountChoicePopup gains a `nutzapEnabled: Boolean`
parameter. When true, the popup renders a NutzapAmountChip per zap
amount (tertiary-color, wallet icon) inline with the existing LN +
on-chain chips. Tap fires AccountViewModel.sendNutzap which
forwards into CashuWalletState.sendNutzap. Errors surface via the
same toast path as LN-zap errors.
* ReusableZapButton computes nutzapEnabled from the recipient's
cached kind:10019; chip is hidden when no nutzap target resolves.
Sender currently has to have the recipient's kind:10019 already in
LocalCache for the chip to appear (typical when viewing a note whose
author the user has interacted with). Background prefetch of kind:10019
for unfamiliar authors is a follow-up.
24/24 NIP-60 jvm tests still passing. Both playDebug and fdroidDebug
compile clean.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Replaces the `var publishDelegate` set-after-construction pattern with an
explicit `CashuWalletState.start(publish: suspend (Event) -> Unit)`.
Account now calls `cashuWalletState.start { event -> sendLiterallyEverywhere(event) }`
from its own init { } block, AFTER all field initializers complete.
Why this matters: the previous code launched the backfill + cache-live
collectors from inside the state's own init { } block. Those collectors
could (and would, for returning users) fire an auto-redeem during
Account's field-initializer phase — at which point `publishDelegate` was
still the no-op default AND `followPlusAllMineWithIndex` (which
sendLiterallyEverywhere depends on) wasn't initialized yet. The publish
would silently swallow or NPE. Gating all of start()'s work behind a
@Volatile started flag eliminates the window.
Also: `MintExceptionTest` (+4 tests) pins down the runtime-exception
contract of `MintHttpException` and the new `MintProtocolException` —
the latter is what callers branch on when distinguishing "mint refused"
from "HTTP failed". Kept simple so any future refactor that breaks the
hierarchy fails loudly here instead of silently in describeMintError.
24/24 NIP-60 jvm tests passing.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Two small follow-ups to the audit refactor:
* recomputeUnspent: replace the broken `getOrPut { ... return@forEach }`
pattern (which short-circuited the outer loop on a single decryption
failure, skipping remaining tokens) with an explicit containsKey
guard. Decryption failures are now individually skipped without
affecting other tokens in the same pass.
* CashuWalletScreen: when the wallet opens and pendingQuotes (live
flow from CashuWalletState) is non-empty, automatically resume the
most recent kind:7374 by re-polling the mint and reopening the
receive dialog. Without this, a user who backgrounded the app
mid-mint would see no indication their pending invoice exists.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Addresses the critical findings from the post-implementation audit:
A1. State holder lives on Account, not the ViewModel
New CashuWalletState owns the wallet event, decrypted token contents,
history, mint-quote, and inbound-nutzap indexes. It's constructed on
Account and runs for the lifetime of the login session — so nutzaps
arriving while the user is on Home/DMs/etc. get auto-redeemed without
requiring the wallet screen to be open. ViewModel becomes a thin
presenter that forwards flows + holds per-flow UI state (mint quote
in progress, melt confirmation pending).
A2. Reactive observation via LocalCache.live.newEventBundles
The state object backfills once from cache.notes at construction time,
then receives incremental updates from the live new/deleted event
bundles for any NIP-60/61 event authored by us (or addressed to us
via #p for nutzaps). NIP-44 decryption results for kind:7375 events
are cached by event-id, so the per-refresh re-decrypt is gone (D2).
A3. Mutex-guarded auto-redeem (no more duplicate /v1/swap races)
redeemPendingNutzapsSerialized uses tryLock so a sweep already in
flight short-circuits any new triggers; subsequent cache updates
catch up via the next bundle.
A4. Mint-quote recovery on launch
pendingQuotes flow surfaces unfulfilled kind:7374 events whose
expiration hasn't passed and whose id isn't yet referenced with a
"destroyed" marker in any kind:7376. ViewModel.resumeMintQuote()
re-polls the mint for the original quote and rebuilds the flow.
B1. NutzapInfoEvent now carries the wallet's outbox relays so senders
publish nutzaps where our assembler is actually listening.
B2. Subscription tracks the outboxRelaysFlow — when the relay list
changes, the assembler subscription is rebuilt with the new set.
B5. New MintProtocolException distinguishes "HTTP fine, protocol said
no" (e.g. melt state != PAID) from "HTTP error". Both surface
through describeMintError() (now top-level — C4).
B7. redeemNutzap now pre-checks the P2PK secret's pubkey matches our
wallet pubkey before signing — saves a wasted mint round-trip when
the lock targets someone else.
B8. Melt is a two-phase flow: startMelt() returns a Quoted state with
amount + fee_reserve so the UI confirms before paying; confirmMelt()
actually spends. No more silent fee acceptance.
C1. MintHttpClient + CashuMintOperations cached per mint URL via a
ConcurrentHashMap.
C3. AddCashuWalletScreen has a "Verify" button that pings /v1/info
before adding, with inline success / failure feedback.
C7. Inline JsonObject FQN in P2PK.kt replaced with proper import.
C8. Dead .also { _ -> secretJson } removed from redeemNutzap.
D1. runCatching {}.getOrNull() callsites in the state holder now log
via Log.w("CashuWallet") so silent failures surface in logcat.
D5. CashuWalletQueryState made @Immutable + data class for Compose
stability hygiene.
Touched files: Account.kt (state field + constructor params),
AccountCacheState.kt + AppModules.kt (wire the assembler factory +
okHttpClientForMoney through), CashuWalletOps.kt (decouples from
Account, takes signer + publish callback), CashuWalletState.kt (new),
CashuWalletViewModel.kt (presenter rewrite), CashuWalletScreen.kt
(two-phase melt UI), AddCashuWalletScreen.kt (Verify button),
strings.xml (new keys).
All 20 NIP-60 jvm tests still pass; playDebug + fdroidDebug compile
clean.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Builds out the Cashu wallet beyond the scaffold: a complete mint
protocol layer, the four user-facing wallet operations (mint, melt,
send-as-token, redeem), and auto-redemption of inbound NIP-61
nutzaps. Wires the relay subscription so the wallet state syncs
across devices.
quartz/ — mint protocol layer (commonMain + jvmAndroid)
* nip60Cashu/mintApi/MintApiDtos.kt — Kotlinx Serialization DTOs
for NUT-00..06 (info, keys, mint/quote/bolt11, mint/bolt11,
swap, melt/quote/bolt11, melt/bolt11, checkstate). ProofDto
carries the optional NUT-11 witness.
* nip60Cashu/mintApi/MintHttpClient.kt — OkHttp + kotlinx-json
client bound to a single mint URL; surfaces MintHttpException
with the mint's detail string preserved for the UI.
* nip60Cashu/mintApi/CashuMintOperations.kt — combines BDHKE +
HTTP + amount splitting. Exposes requestMintQuote / mintProofs
/ swap / requestMeltQuote / meltProofs / redeemNutzap. Power-
of-2 amount split per NUT-00.
* nip60Cashu/mintApi/AmountSplit.kt — extracted into commonMain
for testability.
* nip60Cashu/p2pk/P2PK.kt — NUT-11 locked-secret format and
BIP-340 Schnorr witness signing.
* CashuProof gains an optional witness field.
amethyst/ — wallet ops + UI
* model/nip60Cashu/CashuWalletOps.kt — Nostr publishing layer
over CashuMintOperations:
- publishWalletEvents (kind 17375 + kind 10019 together)
- startMintFromLightning / checkMintQuote /
completeMintFromLightning (kind 7374 lifecycle + 7375 +
7376 + NIP-09 deletion of the quote)
- meltToLightning (pre-swap if needed, melt, change rollover,
delete sources, history)
- sendAsToken (swap to exact split, V4Encoder for cashuB,
rollover, history)
- redeemToken (inbound cashuA/B via swap)
- redeemNutzap (NIP-61 P2PK unlock + swap, history with
unencrypted "redeemed" marker per spec)
* service/cashu/v4/V4Encoder.kt — inverse of the existing
V4Parser; encodes proofs to cashuB strings for send.
* ui/screen/loggedIn/wallet/CashuWalletScreen.kt — adds four
action buttons (Receive / Send LN / Send Token / Redeem) with
AlertDialog-based flows that poll the mint quote, paste/copy
from clipboard, and surface mint errors.
* ui/screen/loggedIn/wallet/CashuWalletViewModel.kt — new mint
/ melt / send-token / redeem state machines, subscribes via
CashuWalletFilterAssembler on init (auto-syncs the wallet
across devices), observes the wallet note's flow for reactive
refresh, and auto-redeems any inbound kind 9321 nutzap that
isn't already marked redeemed in our kind 7376 history.
relay subscription
* commons/.../CashuWalletFilterAssembler.kt refactored into the
standard ComposeSubscriptionManager + SingleSubEoseManager
pair (matches the NWC pattern). Now driven by subscribe(query)
/ unsubscribe(query) calls from the ViewModel.
* RelaySubscriptionsCoordinator.cashuWallet exposes a singleton
assembler reachable as Amethyst.instance.sources.cashuWallet.
Tests (jvmTest)
* BdhkeTest — 7/7
* AmountSplitTest — 7/7 (NUT-00 vectors + sum invariants)
* P2PKTest — 6/6 (secret round-trip, witness verifies under
BIP-340, compressed + x-only acceptance)
Total: 20 new NIP-60 jvm tests, all passing. Both playDebug and
fdroidDebug compile clean.
Deferred (clearly bounded follow-ups):
* Sending nutzaps (kind 9321) from the zap picker UI — requires
integrating with the existing LN zap chooser surface. The
underlying P2PK locking primitives are in place.
* Recovering an interrupted kind 7374 mint quote on next launch
— current flow keeps polling while the dialog stays open.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Adds the user-visible scaffolding for a Cashu wallet alongside the
existing NWC wallets. View-only for now — minting, send/receive, and
NIP-61 nutzaps land in a follow-up commit on this branch.
UI
* AddWalletScreen is now a wallet-type chooser. The existing NWC
flow moves verbatim to AddNwcWalletScreen; AddCashuWalletScreen
is new: takes one or more mint URLs, auto-generates a separate
P2PK key for nutzap receiving (or accepts a pasted hex key), and
publishes a kind:17375 wallet event via the account's signer
using CashuWalletEvent.build(mints, privkey).
* CashuWalletScreen renders the wallet's mint list, total balance
in sats (summed across all unspent kind:7375 token events the
signer can decrypt, with rollover applied via the `del` field),
and a chronological history view sourced from kind:7376.
* WalletScreen surfaces the Cashu wallet as a card under "Your
Wallets" when one exists, so the Wallets entry point shows both
wallet kinds side by side.
Relay subscription
* CashuWalletFilterAssembler (commons) subscribes one filter per
relay covering kinds 17375/7375/7376/7374/10019 by author and
one targeting inbound kind:9321 via #p. Not yet wired into
Account.kt — the view path works because we feed our own writes
through cache.justConsumeMyOwnEvent. Cross-device sync requires
the assembler subscription wiring, which comes next.
Plumbing
* Routes.WalletAddNwc / WalletAddCashu / CashuWallet added and
registered in AppNavigation.
* CashuWalletEvent.createAddress(pubKey) mirrors MetadataEvent for
looking up the replaceable wallet event from LocalCache.
Compiles clean on playDebug + fdroidDebug; BDHKE jvm tests still
pass (7/7).
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Implements blind Diffie-Hellman key exchange per NUT-00 — the
cryptographic core that lets a Cashu mint sign blinded messages
without seeing the underlying secret. Used by the upcoming NIP-60
wallet flows (mint, swap, melt) to issue and verify ecash proofs.
- hash_to_curve (NUT-00 try-and-increment, with Cashu domain separator)
- blind: B_ = Y + r·G
- unblind: C = C_ - r·K
- sign/verify: mint-side helpers used by tests and DLEQ-less
client-side proof validation.
All operations sit on top of the existing pure-Kotlin secp256k1
implementation in quartz/utils/secp256k1/, so they run on every KMP
target without JNI. Includes the official NUT-00 hash_to_curve test
vectors and a BDHKE round-trip with both the trivial (a=1, r=1) and
a random key.
7/7 jvm tests pass.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
Tapping the player while the codec-not-supported overlay is up would still
toggle the gradients/buttons in. Gate the controls block on a clear error
state so the overlay stays the only thing on screen.
The playlists screen was reusing the tracks subscription, which is keyed
on the tracks follow list. If the user picked a different list in the
playlists spinner, the REQ ignored it and the feed showed empty whenever
the two lists disagreed.
Add a dedicated MusicPlaylistsFilterAssembler/SubAssembler keyed on
defaultMusicPlaylistsFollowList so the spinner change actually rewires
the REQ. The filter still asks for both kinds 36787 + 34139 per relay so
opening this screen alone is enough to populate cached tracks.
When a note's only zap split recipient is the post author, the split is
redundant — the author already receives the zap. Skip rendering the row
in those cases by gating on a new `hasZapSplitSetupBesidesAuthor` helper.
ExoPlayer entered the ERROR state silently when a codec was missing or the
container/format wasn't supported, leaving a blank video area with no
recourse. Track the player error in MediaControllerState, render an overlay
with the error code, and offer an "Open in browser" button so the user can
fall back to the system browser for codecs the device can't decode.
LoadAddressableNote only resolves the AddressableNote shell from cache —
it doesn't kick off a relay query. So a freshly-arrived playlist whose
track events weren't already in the cache sat on the "Loading…"
placeholder row forever.
For each resolved track shell, wire up EventFinderFilterAssemblerSubscription
(same pattern ProfileBadgesScreen uses for badge definitions). Its
NoteEventLoaderSubAssembler asks relays for the addressable's actual
event, and EventWatcherSubAssembler picks up reactions / replies on top.
The subscription is keyed on the AddressableNote and lifecycle-aware, so
scrolling the playlist off-screen drops the watchers.
Some publishing tools copy the same blurb into both the `description` tag
and the JSON `content` field of a kind-34139 playlist. The renderer was
showing both, producing a duplicate paragraph stacked on itself.
When the trimmed strings match (case-insensitively), suppress the short
description tag and keep the content field — `content` goes through
TranslatableRichTextViewer below, which handles Markdown and NIP-19
references, so it's the richer of the two to keep.
Two changes that go together.
1) Dedicated Playlists feed screen.
A new MusicPlaylistsScreen sits alongside MusicTracksScreen with the
same mechanics — FeedContentState, top-bar follow-list spinner,
FAB, refreshable + lifecycle-aware feed loader — but renders kind-34139
playlists instead of kind-36787 tracks. Each row goes through NoteCompose
so it picks up the existing RenderMusicPlaylist path (cover, track-count
chip, descriptions, track rows).
- MusicPlaylistsFeedFilter pulls kind 34139 from LocalCache.addressables,
keyed on a new account-settings flag defaultMusicPlaylistsFollowList
so the spinner choice is independent from the tracks feed.
- Account exposes liveMusicPlaylistsFollowLists + …PerRelay.
- LocalPreferences persists the new setting (key
"defaultMusicPlaylistsFollowList") so it survives a relaunch.
- The screen mounts MusicTracksFilterAssemblerSubscription — the same
REQ already fetches both kinds, so opening the playlists tab on its
own is enough to populate the cache.
- NewMusicPlaylistFab opens an AlertDialog asking for just a name and
publishes an empty MusicPlaylistEvent. Users then add tracks via the
existing "Add to playlist" sheet on any track note.
- Route.MusicPlaylists, NavBarItem.MUSIC_PLAYLISTS (in drawer Feeds
section), ScrollStateKeys.MUSIC_PLAYLISTS_SCREEN, and the new strings
route_music_playlists / new_music_playlist round out the wiring.
2) Add-to-playlist sheet stops imitating bookmarks.
The previous sheet copied the bookmark-management pattern — TopAppBar,
FAB → AlertDialog, ListItem rows with leading icon stack and a trailing
round add/remove IconButton. That UI exists because bookmarks juggle a
public/private split; playlists don't, so those affordances were just
visual noise that didn't fit.
The new sheet is a quick picker:
- "Create new playlist" row inlined at the top (name field + Create).
- LazyColumn of compact rows: a check-circle icon when the track is
already in that playlist, the playlist-add icon when it isn't,
playlist title, and the track count below in a small caption.
- Tap anywhere on the row to toggle membership.
- Bottom link "Manage all playlists" → Route.MusicPlaylists, so users
who want richer management hop over to the new feed screen.
Drops MusicPlaylistManagementItem.kt (bookmark-style ListItem) and the
five strings that only existed for that file's leading-icon labels.
Four UI polish fixes that all touch RenderMusicTrack:
1) Suppress the "Listen to my song …" auto-fill text.
Several Blossom uploaders pre-populate the kind-36787 content field
with that exact prefix — it just restates the title/artist that the
card already shows above. MUSIC_TRACK_BOILERPLATE_PREFIXES is the
one place to extend if more publishing tools start producing similar
noise.
2) Cover and player now read as a single piece of UI.
Previously the cover wore top-rounded corners and the audio player
(via RenderAudioWithWaveform) used `imageModifier`'s all-rounded
chrome plus a 5dp top padding — so two visually disconnected blocks
sat above and below a gap.
The audio player is now inlined: GetMediaItem → GetVideoController →
RenderVoicePlayer with a custom border modifier that rounds only the
bottom corners. No top padding. The result reads as one continuous
card with the cover up top and the player below.
3) Internal DisplayUncitedHashtags row is gone.
RenderAudioWithWaveform renders its own hashtag row for voice
messages (where it's the only chip display). MusicTrack already
renders TopicChips externally, so the inner row was producing a
second, visually-different row of the same chips. Inlining the
player chain (point 2) also drops that row.
4) TopicChip is clickable.
Tapping `#electronic` now navigates to Route.Hashtag("electronic") —
the same destination RichTextViewer's inline hashtags go to. The chip
adds an optional `onClick` parameter so existing callers (and the
preview block) keep working unchanged.
The previous version drew envelope and carrier as pure functions of the
sample position — so 60% of every bar was identical across all tracks and
only the per-bar jitter (40% weight, narrow range) wiggled. Every track
ended up with the same slow-fade-sine silhouette and only the noise
pattern differed, which looked like every waveform was the same shape.
Now every shape parameter — phase offset, carrier frequency, baseline,
envelope strength, noise envelope — is drawn from the seeded RNG before
the per-bar loop. Two different track ids produce visibly different
waveforms: some have many tight bars, some few wide ones, some fade in
and out, others stay flat. The seed is also bit-shuffled with a Knuth
multiplicative constant XOR'd with the id length so two ids whose Java
hashCode happens to collide still diverge.
ExoPlayer still owns playback progress; the bars are purely decorative.
style(nests): import TimeUtils in CreateNestViewModel instead of inline FQN
HIGH-1: import java.io.RandomAccessFile in MetadataStripper instead of
inline fully-qualified name
HIGH-2: catch AvifMetadataNotVerifiableException in the 6 ViewModels
that call MetadataStripper.strip directly (profile picture, emoji pack
list+display, bookmark group, nest, channel)
MEDIUM-1: tighten AvifAnimatedDecoderFactory.createAnimatedImageDecoder
annotation from @RequiresApi(P) to @RequiresApi(S); the outer guard is
already SDK_INT < S.
MEDIUM-2: replace the curried lambda DI seam in MetadataStripper with
a named fun interface (AvifExifReader).
MEDIUM-3: rename isGifUrl -> isAnimatedMediaUrl (MyAsyncImage) and
BaseMediaContent.isGif() -> isAnimatedMedia() (ZoomableContentView)
since both predicates now cover AVIF as well as GIF.
- AvifAnimatedDecoderFactory.isAvif now iterates a single brand list
with .any { rangeEquals(8, it) } instead of three || branches.
- MetadataStripper.inspectAvifMetadata dropped the outer defensive
try/catch; the inner catch already converts parse failures to
AvifMetadataNotVerifiableException and the rest of the function
cannot realistically throw.
- PreviewMetadataCalculator extracts the shared ImageDecoder allocator
+ exception path from decodeAvifBytes and decodeAvifFromUri into a
single private decodeAvif(source) helper.
- RobohashFallbackAsyncImage merges its identical Loading and Error
when branches into one via Kotlin's multi-value branch syntax.
- MediaCompressorTest drops a no-op MockKAnnotations.init(this) call
and the now-unused import; no @MockK fields exist.
fix(ui): default avatar contentScale to Crop, not Fit
fix(images): skip thumbnail cache for animated AVIF profile pictures
fix(ui): animate profile pictures regardless of URL extension
15 bite-sized tasks across 6 phases (A foundation, B upload pipeline, C animation
lifecycle audit, D test fixtures + instrumented tests, E manual on-device
verification, F ship). Each task has exact file paths, full test code, full
patch code, exact commands, expected output, and per-task commits.
Companion to amethyst/plans/2026-05-26-avif-support.md spec.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
docs(amethyst): document strip-toggle-off AVIF EXIF leak as known limitation
docs(amethyst): document Desktop AVIF gaps from spot-check
docs(amethyst): record animated AVIF playback caveats from on-device testing
docs(amethyst): note API < 31 gallery-picker greys out AVIF (OS limit)
docs(amethyst): tighten API < 31 known-limitation with on-device findings
docs(amethyst): plan and design for AVIF instrumented tests
Three follow-ups against testing the Scatman track event.
1) Music feed pulls from relays now.
The "doesn't load anything even on Global" bug: there was no relay
subscription wired for kind 36787 / 34139, so the feed only showed
tracks that happened to already be in LocalCache (own publishes,
direct nostr: links, hashtag visits). Mirrors the LongsFilterAssembler
stack:
- MusicTracksFilterAssembler + MusicTracksSubAssembler register a
per-user-and-follow-list subscription on the music screen and
whenever MUSIC_TRACKS is pinned to the bottom bar.
- SubAssemblyHelper.makeMusicTracksFilter dispatches the active
TopFilter to the right per-relay filter set:
· Global → unscoped kind 36787/34139 from outbox/proxy relays
· AllFollows / Authors / MutedAuthors → scoped to the active
author set
Hashtag/Geohash/Community variants fall through to emptyList for
now; add per-case handlers when the spinner grows those routes.
- RelaySubscriptionsCoordinator owns the assembler instance.
- LocalPreferences persists defaultMusicTracksFollowList (key
"defaultMusicTracksFollowList") so the spinner's choice survives a
relaunch like every other feed.
2) Synthetic waveform replaces the empty audio strip.
Kind 36787 has no `waveform` tag in the spec, so RenderAudioWithWaveform
was given `null` and showed a flat audio bar. Now seeded off the track
address: each track keeps the same decorative shape across recompositions,
and a sine envelope + carrier + jitter makes the result read as "music
waveform" rather than pure noise. ExoPlayer still owns playback progress
— this is purely visual.
3) Hashtags now render in exactly one row.
The Scatman test case (4 `t` tags including "music") was showing both
DisplayUncitedHashtags (every `t`) and a TopicChip FlowRow (every `t`
except the "music" genre marker), so the user saw visually-different
duplicate chip rows. Dropped DisplayUncitedHashtags from MusicTrackHeader;
TopicChips is the canonical music-aesthetic chip row and already
excludes the genre marker.
Tapping a chip silently failed if no installed app handled the
type-specific URI scheme (bitcoin:, ethereum:, monero:, etc.).
Surface that case through the existing toastManager so users know
to install a compatible wallet.
https://claude.ai/code/session_01R7kRziq14Hc22dPwAnZRAr
Two related polish passes after comparing the music UI against the rest of
the codebase.
1) Voice-message audio player wins for audio-only tracks
The Voice (NIP-A0) renderer's RenderAudioWithWaveform is the right
playback widget for a kind-36787 track that has no `video` URL — it's
ExoPlayer-backed (same as VideoView) but exposes:
- inline tap-to-show controls with play/pause centered
- top buttons for Share / Save-to-gallery / Picture-in-Picture / Mute
- a 100dp compact strip (vs. VideoView's 16:9 video surface that
leaves a blank rectangle for audio-only streams)
- waveform-aware (we pass null since kind 36787 has no `waveform` tag)
The new layout for an audio-only track:
- album-art cover at the top (square, 1:1)
- compact audio player below
- title / artist / meta below that
When a `video` URL is set the track keeps the full VideoView path —
that's a real music-video file and deserves the video surface.
2) Music feed top bar now has a follow-list spinner
Every sibling feed (Articles, Longs, Polls, Pictures, etc) exposes a
FeedFilterSpinner backed by its own settings flag. The music screen
previously only showed a static "Music" title and silently reused the
Home follow list, so users couldn't filter the music feed independent
of Home.
- AccountSettings: defaultMusicTracksFollowList (defaults to Global) +
changeDefaultMusicTracksFollowList(name) setter pair.
- Account: liveMusicTracksFollowLists + …PerRelay flows, sourced from
the new setting via the existing topNavFilterFlow machinery.
- MusicTracksFeedFilter switches its filter params + feedKey to the
dedicated flow.
- MusicTracksTopBar swaps the static Text for FeedFilterSpinner with
the same kind3GlobalPeopleRoutes catalog Articles/Longs use (All
Follows, Your Follows, kind3 Follows, Around Me, Global, custom
people lists, interest sets, mute list).
- WatchAccountForMusicTracksScreen now watches liveMusicTracksFollowLists
so list switches invalidate the feed.
Audit pass turned up several real bugs in the music feature. Rolled the
fixes into one commit since they all touch the publish/edit path.
Data-loss bugs (must-fix)
- NewMusicTrackViewModel.publish() in edit mode rebuilt the event via
MusicTrackEvent.build() with only the composer-visible fields, silently
dropping every other tag the original event carried — video URL,
released, track_number, format, bitrate, sample_rate, language,
explicit, extra `t` genre tags, zap splits, anything custom. Adds a
MusicTrackEvent.edit(earlierVersion, ...) companion that clones the
existing TagArray and only mutates composer-managed fields, mirroring
PinListEvent/BookmarkListEvent's `add`/`remove`/`resign` pattern.
- AddToMusicPlaylistViewModel.toggle() and .createWithTrack() had the
same problem for playlists. Adds MusicPlaylistEvent.addTrack /
removeTrack companions that preserve the rest of the tag array. The
VM now uses these instead of round-tripping through build().
Correctness bugs (must-fix)
- MusicPlaylistEvent.build() emitted only one of `public`/`private`
while isPublic() defaulted to `true`-when-absent. A private playlist
therefore round-tripped as isPublic() && isPrivate(). isPublic() now
falls back to !isPrivate(), and build() still emits the explicit pair
so unrelated clients reading either flag agree on visibility.
- AddToMusicPlaylistViewModel's `isWorking` flag wasn't a concurrency
primitive — fast taps on different rows could race and the loser's
broadcast would replace the winner's. Adds a Mutex around toggle /
createWithTrack so they serialize.
- The initial rescan() ran on the composition thread (init() is called
from the sheet's body). Both initial and live re-scans now run on
Dispatchers.IO inside the same Job, and the live collector filters
bundles by kind so we don't re-walk LocalCache for every Text Note.
- NewMusicTrackViewModel.init() set `isEditing = true` based purely on
`editDTag != null`, so a stale dTag pointing at no cached event left
the user on a Delete button that no-op'd. `isEditing` now derives
from loadedEvent and falls back to create-mode when the lookup misses.
- MusicTrackHeader synthesized mimeType "video/${format ?: "mp4"}" when
a `video` URL was present — but `format` is the AUDIO format per
spec, so a track with both `video=...mp4` and `format=mp3` emitted
"video/mp3". Pass null when videoUrl wins and let ExoPlayer sniff.
- MusicTracksFeedFilter only consulted params.match(), which checks
the follow list but not mute/spammer/word lists. Muted authors leaked
through. Mirror LongsFeedFilter and AND `account.isAcceptable(note)`.
Search & idiomatic fixes (should-fix)
- MusicTrackEvent + MusicPlaylistEvent now implement SearchableEvent so
the local SQLite FTS indexes title/artist/album/description rather
than only the JSON content. Searching "Pink Floyd" by artist now
matches the local cache instead of waiting for relay results.
- MusicTracksFeedFilter.feedKey() now class-prefixes with "music-" so it
can't collide with other feeds that key off the same Home follow list.
- MusicPlaylistEvent.build() renamed `description`/`shortDescription` to
`content`/`description` so the parameter names match the spec.
Nits
- Drop trackCount() — callers prefer `trackAddresses().size`.
- Drop six unused strings (composer placeholders for not-yet-wired
Blossom audio/cover upload UI).
- Wrap preview runBlocking { justConsume(...) } in remember{} so it
runs once per preview key instead of every recompose.
- Use a hex-shaped id for preview events instead of "track_xxx_yyy".
Closes the UX gap where a user who creates a pack via the in-app UI has no
path to add it to their NIP-51 kind-10030 selection without leaving the
pack-management screens.
- Wire DragAndDropTarget on avatar circle and banner area
- Image-only filter (jpg/png/gif/webp/avif)
- Visual drag-over feedback (primary border highlight)
- Fix avatar: only show placeholder icon when no image set
(previously overlay was visible behind the loaded avatar)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Both umbrelOS (via getumbrel/umbrel-apps#4962) and StartOS / Start9
(via Start9-Community/namecoin-core-startos) ship a self-hosted
Namecoin Core that this backend can target. Generalize the
help/strings so umbrel users discover the feature too.
No logic changes.
Replace the awkward small icon button with a full 100dp tappable circle.
Shows surfaceVariant background when empty, semi-transparent overlay with
centered upload icon when image is present. Spinner replaces icon during
upload.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace plain text payment-target rows with a FlowRow of pill-shaped
clickable chips that carry a type-aware icon, brand color, uppercase
label, and a truncated address. Tap opens a type-specific URI scheme
(bitcoin:, lightning:, ethereum:, monero:, liquidnetwork:, dash:,
payto:// fallback) so wallets can actually pick up the intent; long-press
still copies the authority to the clipboard.
Adds previews wherever there's something visual worth previewing:
- MusicTrack.kt: RenderMusicTrack (full event with cover + meta),
RenderMusicTrackExplicit (explicit-badge variant), MusicTrackCover (no
cover fallback), ExplicitBadge, TopicChip. Uses the same
Event-constructor + LocalCache.justConsume + LoadNote pattern Attestation
and Wiki previews already use.
- MusicPlaylist.kt: RenderMusicPlaylist (with three resolved track refs),
RenderMusicPlaylistCollaborativePrivate (chips visible),
MusicPlaylistCover, MissingPlaylistTrackRow, TrackCoverPlaceholder,
PlaylistTag.
- MusicTracksScreen.kt: full feed scaffold via mockAccountViewModel().
- MusicTracksTopBar.kt: the static-title top bar.
- NewMusicTrackButton.kt: the FAB on its own.
- NewMusicTrackScreen.kt: the composer form.
- AddToMusicPlaylistSheet.kt: NewMusicPlaylistDialog leaf, and the empty
state of the full sheet.
- MusicPlaylistManagementItem.kt: four variants — not-in / in /
empty (zero tracks) / untitled.
Skipped MusicTracksFeedLoaded — FeedState.Loaded can't be easily mocked
and no existing feed-loaded previews exist in the codebase. The screen
preview already covers the scaffold around it.
URLs in the constructed events point at example.invalid so MyAsyncImage
falls back to the deterministic DefaultImageHeader robohash and VideoView
shows its tap-to-load thumbnail state — both of which are the real
first-paint state users see before tapping anything.
Compared the music-playlist-management sheet against the existing
PostBookmarkListManagementScreen and noticed the playlist sheet was the odd
one out — custom TopAppBar with a Done action, inline TextField + Button row
for creating new lists, custom Row + Checkbox per playlist, no way to tap
into a playlist to view it.
The bookmark screen uses a richer Material3 pattern that's already familiar
to users. Aligning the music sheet to it:
- TopBarWithBackButton (back arrow + title) replaces the bespoke top bar.
- Scaffold FAB → NewListButton opens a small AlertDialog with a name field
(lighter than the bookmark route to a full edit screen, but consistent in
affordance). The previous inline create-row is gone.
- Each row is now a MusicPlaylistManagementItem mirroring
BookmarkGroupManagementItem: leading icon + total-track-count chip,
headline title, supporting "In this playlist" / "Not in this playlist"
status text, trailing round IconButton (red Remove / blue Add).
- Tapping the row navigates into the playlist's note view (Route.Note with
the addressable's tag); tapping the trailing button toggles membership.
Previously these were collapsed into a single whole-row tap that toggled
but never let the user actually see the playlist.
ViewModel unchanged — the same toggle()/createWithTrack() operations now
just feed a more conventional UI.
Builds three discrete user-facing surfaces on top of the kind 36787 / 34139
quartz events:
1. Music feed screen (Route.MusicTracks)
- MusicTracksFeedFilter pulls every kind-36787 addressable that passes the
user's Home follow list + hidden/blocked rules. Reuses liveHomeFollowLists
rather than introducing a new AccountSettings flag (deferred to a future
iteration that wants its own spinner).
- MusicTracksScreen mirrors LongsScreen's scaffold: bottom-nav-aware top
bar, refreshable feed list, FAB. Each row renders through NoteCompose so
reactions/zaps/replies behave like Home.
- Discoverable via the side drawer (DrawerFeedsItems) and pinnable to the
bottom bar (NavBarItem.MUSIC_TRACKS).
2. New-music-track composer (Route.NewMusicTrack)
- NewMusicTrackViewModel mirrors NewCalendarCollectionViewModel: title /
artist / audio URL / cover URL / album / duration / lyrics fields,
dTag-preserving edit mode, NIP-09 deletion. Publishes via
account.signAndComputeBroadcast(MusicTrackEvent.build(...)).
- NewMusicTrackButton FAB wires the music screen entry to the composer.
- MVP intentionally skips audio-file Blossom upload from inside the
composer — users paste URLs they uploaded elsewhere. NewMediaModel
wiring can be added later without disturbing the rest of the flow.
3. Add-to-playlist sheet (Route.AddToMusicPlaylist)
- AddToMusicPlaylistViewModel scans LocalCache.addressables for the user's
own kind-34139 playlists (filterIntoSet(kind, pubKey)) and offers
toggle-membership + create-with-track operations. Each mutation
re-signs the playlist with the existing dTag so the address points at
the new ordered track list.
- AddToMusicPlaylistSheet renders a checkbox list with an inline
"new playlist" creator row.
- DropDownMenu exposes the entry from the …-menu on any MusicTrackEvent
note via the existing M3ActionRow row pattern.
String resources added under values/strings.xml. Drawer + nav-bar catalog
entries cover the new route. BottomBarFeedPreloaders adds a documented `Unit`
arm for MUSIC_TRACKS so the exhaustive `when` stays exhaustive without
forcing a relay-subscription file before there's logic to put in it.
The previous layout stacked a non-functional static cover (with a decorative
play-button overlay) above the real VideoView, so tapping the prominent UI
element did nothing while the actual playback widget sat smaller below.
Use LoadThumbAndThenVideoView / VideoView as the primary header so the
album art is the player's own thumbnail and ExoPlayer handles the
tap-to-play / streaming. Falls back to a plain cover only when the event
has no playable URL at all (data-integrity case).
If a user's NIP-65 outbox advertises only relays that don't hold their
kind 0, profile fetching used to give up after EOSE on those relays.
filterUserMetadataForKey now widens to the account's indexer relays
once every outbox relay has either EOSE'd or is in cannotConnectRelays
and metadata is still missing. UserWatcherSubAssembler invalidates
filters on EOSE so the fallback re-evaluates without waiting for an
unrelated trigger.
Audit pass after LocalCache wiring: MusicTrackEvent (36787) and
MusicPlaylistEvent (34139) were materialized and rendered, but a dozen
filter sites still listed AudioTrackEvent.KIND alone, so music events
silently dropped out of feeds, search, notifications, and relay labels.
Mirrors the AudioTrackEvent pattern in every place AudioTrackEvent.KIND
or `is AudioTrackEvent` appears:
Feed display filters (acceptableEvent + ADDRESSABLE_KINDS):
- HomeNewThreadFeedFilter
- FollowPackFeedNewThreadFeedFilter
- UserProfileNewThreadFeedFilter
- UserProfileMutualFeedFilter
- HashtagFeedFilter
- GeoHashFeedFilter
Relay subscription kind lists:
- FilterPostsByGeohash (PostsByGeohashKinds)
- FilterPostsByHashtags (PostsByHashtagKinds2)
- FilterPostsByRelay (PostsByRelayKinds2)
- SearchPostsByText (SearchPostsByTextKinds1)
- Desktop SearchFilterFactory.defaultKindGroup1
Notifications:
- NotificationFeedFilter.ADDRESSABLE_KINDS (NOTIFICATION_KINDS picks
this up automatically)
Relay information screen:
- kindDisplayName → R.string.kind_music_track / kind_music_playlist
(with the two new string resources)
MusicTrackEvent (36787) and MusicPlaylistEvent (34139) are addressable, so
both are dispatched through consumeBaseReplaceable in justConsumeInnerInner.
Without this, the events arrive from relays but are never stored in
LocalCache and the UI renderers see nothing to render.
Adds quartz support for two new addressable Nostr event kinds, modeled after
the NIP-88 poll structure, plus modern Compose renderers wired into both the
feed (NoteCompose) and the thread/master view (ThreadFeedView).
Quartz:
- MusicTrackEvent (36787): title/artist/url and optional album, track_number,
released, duration, format, bitrate, sample_rate, language, explicit, image,
video. Each field is a dedicated *Tag class with parse/assemble, plus a
TagArrayBuilder DSL and a typed build() factory. Auto-emits the "t music"
hashtag and an NIP-31 alt description.
- MusicPlaylistEvent (34139): title, image, description, ordered "a" track
references to MusicTrackEvent, plus public/private/collaborative flags.
- Both registered in EventFactory so LocalCache materializes them as typed
events instead of generic Event.
Amethyst UI:
- MusicTrack.kt: square cover with overlaid play affordance, large title,
artist row with note icon, meta row (album/track #/release/duration/explicit
badge), embedded VideoView for audio (or video URL when present) with
cover thumbnail, lyric/credit content via TranslatableRichTextViewer, and
topic chips for extra t tags.
- MusicPlaylist.kt: cover with track-count badge, title, count + collaborative
/private chips, descriptions, and an ordered list of tracks resolved via
LoadAddressableNote (clickable, falls back to "loading"/"unknown track" for
missing references). Capped at 25 with a "+N more tracks" footer.
- Track-count strings use <plurals> for correct CLDR pluralization.
- Wired into NoteCompose `when` dispatch and ThreadFeedView header dispatch.
Replace the up/down chevron IconButtons on each wallet card with a drag
handle, matching the pattern used across the relay-settings screens.
Reuses RelayDragState / rememberRelayDragState / draggableRelayItem /
relayDragHandle from relays/common — same gesture handling, elevation
animation, and swap-on-threshold behavior.
The handle and item modifier are only attached when there is more than
one wallet to reorder.
The wallet detail screen's Send, Receive and Transactions buttons navigated to
parameterless routes. Each destination created a fresh WalletViewModel with no
selection, so the action ran against `_defaultWalletId` (the account default)
instead of the wallet being viewed. Paying, invoicing, and listing
transactions could therefore go to the wrong wallet.
Parameterize WalletSend/WalletReceive/WalletTransactions with `walletId`,
plumb it through AppNavigation, pass it from WalletDetailScreen, and have
each screen call `selectWallet(walletId)` before operating.
Dropping both `authors` and `#p` from the kind-23195 subscription filter
fixed wallets that don't set those fields the way NIP-47 implies, but
broke purpose-built NWC relays (notably relay.getalby.com/v1) that use
`#p` as the routing key — without it the relay never delivers the
response to our subscription, so the wallet screen sits on a spinner.
Restore `#p: [client pubkey]` in the relay filter. Keep `authors` out
since that field was the one actually causing the broader interop pain.
Spec-compliant responses always carry the `p` tag, so adding it back
does not exclude any conforming wallet. End-to-end authenticity is
still enforced by NIP-04 decryption against the per-connection shared
secret and by the client-side author check in NwcPaymentTracker.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug had four ingredients (per the kdoc on TorArtiNativeIntegrationTest).
We had one test for #1; now there's targeted coverage for each:
1) Native TorClient gets stuck (bad guards / dead circuits / expired
consensus) with no way to drop it in-process:
`destroy then re-initialize releases the state file lock cleanly`
(was already there — added exit-IP logging so the developer can eyeball
that the circuit actually changed across the destroy).
2) In-flight per-connection handlers holding Arc<TorClient> clones,
pinning the state file lock past destroy:
NEW `destroy aborts an in-flight SOCKS handler quickly`
Opens a SOCKS HTTPS request, lets the handler get into the data plane,
calls destroy() concurrently, asserts:
- destroy() returns within 3s (the budgeted abort+sleep window),
- the in-flight request thread dies within 5s,
- a fresh initialize on the SAME data dir succeeds afterward
(this is the actual regression net — pre-fix the orphaned handler's
Arc would keep the TorClient alive and the lock held).
3) stopSocksProxy *deliberately* preserves the running client so the
legitimate stop/start toggle is fast. We need to keep that path
working after the destroy/abort changes:
NEW `stopSocksProxy then startSocksProxy reuses the running TorClient`
Asserts the second startSocksProxy returns in < 5s — no re-bootstrap.
4) State / fd / memory leaks accumulating across many destroy/init cycles
(the watchdog can drive these forever):
NEW `survives multiple destroy then initialize cycles`
5 full cycles of initialize → startSocksProxy → fetch → destroy.
Logs per-cycle elapsed time + exit IP so degradation is observable
even when it's not yet a hard failure.
Plus two extra robustness tests:
NEW `proxies concurrent SOCKS requests in parallel`
5 in-flight HTTPS-via-SOCKS requests at once. Exercises the Rust
accept loop, HANDLER_TASKS retain-on-push, and Arc<TorClient> clone
independence under load.
NEW `destroy is idempotent`
destroy-without-init, double-destroy, init-after-double-destroy.
Cheap regression net for unwrap-on-None panics in the Rust shim.
All new tests gated by -Pamethyst.arti.integration=true alongside the
existing ones; the smoke test (`library loads and reports a version`)
still runs unconditionally on Linux x86_64. Total runtime for the slow
suite is ~10-15 minutes against Tor, depending on bootstrap luck.
Closes the test gap below the tier-1 unit tests by running the real Arti
JNI shim end-to-end on JVM. Cheaper than an emulator + connectedAndroidTest,
and exercises the exact Rust + JNI code path the Android .so does.
Three tests in TorArtiNativeIntegrationTest:
1. `library loads and reports a version` — always-on smoke check. Loads
libarti_android.so via System.loadLibrary and calls ArtiNative.getVersion.
~10ms. Catches build/link regressions (e.g. a stale .so after an ARTI
bump, a missing JNI symbol export, a forgotten rebuild on this path).
Skipped on non-Linux-x86_64 hosts with a clear message pointing at the
build-arti-host.sh rebuild step.
2. `bootstraps and proxies an HTTPS request through Tor` — opt-in via
-Pamethyst.arti.integration=true. ArtiNative.initialize → startSocksProxy
→ OkHttp-via-SOCKS → check.torproject.org/api/ip. Asserts "IsTor":true.
Regression net for the rustls CryptoProvider install we added after the
v2.3.0 bump and for the destroy/handler-abort fixes in the Rust shim.
3. `destroy then re-initialize releases the state file lock cleanly` — opt-in.
The direct unit-test mirror of the self-heal path: bootstrap, destroy, hit
the SAME data dir with initialize again, verify it succeeds without a
"state file already locked" error and that traffic still flows.
Wiring:
- New tools/arti-build/build-arti-host.sh — companion to build-arti.sh.
Cargo-builds the wrapper crate for the host target (x86_64-linux on most
dev machines, but the script maps macOS / arm64-linux too) and copies to
amethyst/src/test/native-libs/<host-tag>/libarti_android.so.
- amethyst/build.gradle.kts testOptions.unitTests.all configures
-Djava.library.path so System.loadLibrary("arti_android") finds the
checked-in host .so. Also forwards -Pamethyst.arti.integration so the
opt-in gate works from a Gradle invocation.
- Checked-in src/test/native-libs/x86_64-linux/libarti_android.so for the
most common dev/CI host (~6 MB).
Wrapper change to make the JVM path actually run:
- lib.rs: on #[cfg(not(target_os = "android"))], call
builder.storage().permissions().dangerously_trust_everyone() so Arti's
fs-mistrust check doesn't reject /tmp data dirs on hosts where parent
directories have unusual UIDs (typical in containers). Android keeps its
strict default — the app's private filesDir is already sandboxed by the OS.
Compiled-out on Android, so the shipped Android .so is functionally
unchanged.
Verified in this session:
- Smoke test passes without -P (3 tests, 1 ran, 2 skipped).
- Full unit test suite still passes.
- With -P the bootstrap tests get past Arti's permissions check; they hang
on actual relay I/O in this container because outbound TCP egress is
restricted to a CDN allow-list, not Tor relays. Tests succeed on hosts
with unrestricted outbound — see the test kdoc.
Zaps and DMs move real artifacts (money, private messages) to a Nostr
pubkey. Nostr has no global namespace, so "zap Alice" is ambiguous —
multiple users can publish the same display name. Four safeguards now
make it much harder for Gemini (or any agent) to misroute a write:
1. `expectedDisplayName: String?` on followUser / sendDm / zapUser.
Agent passes the name it understood; verb cross-checks that the
resolved profile's name / display name / NIP-05 contains it (or
vice-versa). Mismatch aborts with a typed error carrying the npub
and NIP-05 so the agent can re-prompt.
2. `requireFollow: Boolean = true` default on sendDm and zapUser.
Refuses to act on a pubkey the user doesn't already follow on
Nostr. Strongest guard against same-name impersonators — even if
the agent picked the wrong Alice, the user almost certainly isn't
following her. Override to false only when the user explicitly
approves acting on a stranger.
3. Updated kdocs instruct the agent to confirm with the user using
all three identity signals (display name + npub + NIP-05) before
invoking. The kdoc is what Gemini reads to learn the verb's
contract, so this is where the instruction goes.
4. searchProfiles now filters out hits whose NIP-05 claim explicitly
fails verification (the listed domain refuses to sign for that
pubkey). Network errors / no-claim profiles are kept (inconclusive,
not refutations). Verifications run in parallel with a 4s overall
budget; on timeout we surface all candidates rather than censor.
https://claude.ai/code/session_013NKVhEF2KqyCrV7ufaiQ6N
Tier 1 — 18 fast unit tests for the self-heal logic, virtual time only:
- Extracted TorBackend interface (status + start/stop/reset/resetWithCleanState),
TorService implements it. TorManager now takes a TorBackend by injection
rather than constructing a TorService itself.
- Extracted TorPreferencesPort (torType + externalSocksPort flows + load/save
bypass-approval). TorSharedPreferences implements it via forwarding properties.
- Injected ioDispatcher (default Dispatchers.IO) and nowMs clock (default
System::currentTimeMillis) so tests drive the 45s watchdog + 5-min cooldown
in milliseconds of virtual time.
- Tests cover: persisted-approval load, torType-change bypass clear,
approveBypassForOneHour, onNetworkChange (clear + reset + cooldown prime),
watchdog gentle-reset before first Active, watchdog full-reset after Active,
watchdog cancellation on Active, cooldown blocks within window + permits
outside, status routing for OFF/EXTERNAL/INTERNAL, sessionBypass forcing Off,
activePortOrNull mirroring.
- Uses UnconfinedTestDispatcher inside runTest — flowOn(ioDispatcher) +
WhileSubscribed cross-dispatcher channel needs eager dispatch for
MutableStateFlow.value updates to propagate through advanceUntilIdle.
Tier 3 — TorBootstrapInstrumentedTest scaffold (@LargeTest, @Ignore by default):
- Cold-start bootstrap: TorService.start → first { Active } within 120s.
- HTTPS round-trip: OkHttp via SOCKS to check.torproject.org, asserts IsTor:true.
This is the regression net for the rustls CryptoProvider install after the
Arti bump and for the destroy/handler abort race in the Rust shim.
- reset → re-start: verifies the state-file-lock is released so the second
TorService.start can re-create the TorClient cleanly.
- KDoc documents how to enable + run on a real device (the test needs Tor
network egress + 60–120s of wall-clock per case, hence default-Ignored).
No production behavior changes — only injection seams + interfaces.
Adds a `chain` parameter to zapUser so Gemini can route the zap over
Lightning (default) or onchain Bitcoin (NIP-BC kind:8333).
* chain="lightning" (or null, "ln") — existing Lightning flow:
build kind:9734 → fetch BOLT11 → NWC auto-pay if configured →
return invoice + nwc fields.
* chain="onchain" (or "btc", "bitcoin") — new path:
1. Require the user's Bitcoin chain backend to be configured
in Amethyst Settings → Bitcoin. Throw NotSupported with a
pointer to the settings screen if absent.
2. Validate feeRateSatPerVByte (0.1 ≤ rate ≤ 1000).
3. Call Account.sendOnchainZap, which uses the existing
OnchainZapSender pipeline: build P2TR-paying tx, sign,
broadcast, publish kind:8333 receipt.
4. Return ZapResult with onchainTxid + feeSats + changeSats +
receiptEventId on success, or onchainError + onchainStage on
failure. When the failure stage is "publishing" the tx is
already on-chain — we still surface broadcastTxid so the user
can verify on a block explorer.
ZapResult gains a `chain` discriminator field plus six onchain*
fields. Lightning zaps populate the existing fields and null out
the onchain ones; onchain zaps do the inverse. The kdoc on ZapResult
explains the split.
Trigger phrases in the verb kdoc now include "send N sats onchain to
[user]" / "send Alice N sats via Bitcoin" so Gemini's matcher picks
up the onchain intent specifically.
New parameters:
* chain: String? = null — "lightning" | "onchain" (case-insensitive)
* feeRateSatPerVByte: Double = 5.0 — fee rate for onchain rail,
ignored for Lightning. 5 sat/vB targets fast confirmation under
typical mempool conditions without being aggressive.
zapEvent stays Lightning-only for now — onchain event zaps with
NIP-57 splits need a different pipeline (sendOnchainZapWithSplits)
and the result shape would be quite different. Deferred to a
follow-up if there's demand.
app_metadata.xml updated so the LLM picker pitches the dual-rail
capability to users.
Wins: reduced GeoIP memory usage (moved off heap), CircuitClosed→NotConnected
error change (affects our handler error paths), DATA-cells-on-closed-streams
fix, and a flow-control sidechannel mitigation bug fix. Nothing here directly
addresses the stuck-Tor recovery work in the prior commits, but it's a clean
overdue bump while we're in this code.
Wrapper changes required by the bump:
- arti-client + tor-rtcompat: 0.41 → 0.42 to match the new crate versions
shipped with arti-v2.3.0.
- arti-v2.3.0's tor-rtcompat no longer installs a rustls CryptoProvider
implicitly (changelog: "if the application fails to install a rustls
CryptoProvider, tor-rtcompat no longer installs one itself"). Add a direct
`rustls = "0.23"` dep with the `ring` feature and `install_default()` it
inside INIT_ONCE before runtime creation — otherwise create_bootstrapped
panics on the first TLS handshake. Keeping `ring` (same as 2.2.0
effectively used) rather than 2.3.0's new default `aws-lc-rs`, which is
heavier on Android and has known build.rs pain on aarch64-linux-android.
Heads-up for the next bump: arti-v2.4.0 will explicitly wrap TorClient in
Arc rather than implicitly having Arc-like semantics. We already wrap
explicitly so the migration is a no-op aside from potential Arc<Arc<...>>
cleanup.
Rebuilds: libarti_android.so for arm64-v8a + x86_64.
Audit of db378a1 surfaced three issues; this commit addresses them.
1) First-bootstrap self-heal storm (TorManager). On a fresh install with a
slow network the legitimate first bootstrap takes 30–60s. The 45s
stuck-Connecting watchdog used to fire resetWithCleanState, wiping an
empty state dir and adding a full bootstrap cycle of delay for no gain.
Now: track hasEverBootstrapped (flipped when status reaches Active);
pre-first-bootstrap self-heals use the gentler reset (drop client only,
keep state), post-first-bootstrap use resetWithCleanState. Wiping stale
on-disk guards only matters once we know Arti can actually work.
2) Rust destroy() race (lib.rs). The accept loop in startSocksProxy has no
.await between accept() returning and HANDLER_TASKS.push(h), so an
abort() alone is racy — a new handler can be spawned and pushed AFTER
our drain runs, which then holds an Arc<TorClient> past destroy() and
keeps the state file lock alive. Now: after abort(), await the SOCKS
JoinHandle with a 1s timeout so the listener fully terminates before
we drain HANDLER_TASKS. No new handlers can be added once the listener
is gone.
3) TOKIO_RUNTIME mutex held during block_on(sleep). The previous
`if let Some(rt) = TOKIO_RUNTIME.lock().unwrap().as_ref()` kept the
mutex held for the full sleep duration, blocking any other JNI caller
that needs the runtime. Now: clone the runtime Handle and release the
mutex immediately. Same fix applied to stopSocksProxy.
Rebuilds: libarti_android.so for arm64-v8a + x86_64.
When Arti's in-memory TorClient gets into a broken state (bad guards from a
previous network, dead circuits, expired consensus held in memory), nothing
short of a process restart used to recover it: the JNI exposed initialize /
startSocksProxy / stopSocksProxy but no way to drop the TorClient, and the
Kotlin side gated initialize behind a one-shot AtomicBoolean. force-stop
preserved the on-disk arti/state/, toggle-off-then-on only re-bound the SOCKS
listener on the same broken client, and wiping app data was the only way out.
Rust side
- New JNI Java_..._ArtiNative_destroy: aborts the SOCKS listener task, aborts
all in-flight per-connection handlers (each holds an Arc<TorClient> clone
that would otherwise pin the state file lock), waits 500ms, drops the static
ARTI_CLIENT. Next initialize() call creates a fresh client and re-bootstraps.
- Track handler JoinHandles in HANDLER_TASKS so destroy can abort them; cull
finished ones on each accept to keep the Vec bounded.
Kotlin side
- TorService.reset() / resetWithCleanState() — drop the native client, flip
initialized=false. The second variant also wipes arti/state/ on disk to
rebuild guard selection from scratch.
- TorManager.resetEpoch StateFlow is now part of the status combine; bumping
it re-fires the INTERNAL branch which calls service.start() and runs full
Arti re-init.
- onNetworkChange (wired from ConnectivityManager.networkId distinctUntilChanged)
now calls service.reset() + clears the persisted bypass approval + bumps the
epoch. Replaces the previous clearSessionBypass() which only touched the
in-memory bypass half.
- Self-heal watchdog: when status sits at Connecting for >45s (before the 60s
connectionFailure dialog), calls resetWithCleanState. Rate-limited to one
per 5 minutes so a permanently broken network doesn't loop us. onNetworkChange
primes lastSelfHealAtMs so a slow legitimate post-network-change bootstrap
doesn't get a second reset on top of itself.
Rebuilds: libarti_android.so for arm64-v8a + x86_64 (NDK 27, 16KB-page aligned).
Once the user picked "Use regular connection" after a 60s stuck-Connecting
prompt, `lastBypassApprovalMs` was persisted to DataStore for an hour. Inside
that window the connection-failure flow silently flipped `sessionBypass = true`
on every later Connecting span instead of re-prompting, which caused the
status flow to call `service.stop()` and emit `Off` regardless of the user's
`TorType`. The DataStore-backed approval survived force-stop, and toggling
Tor off/on only cleared the in-memory `sessionBypass` half — so the next
bootstrap attempt re-triggered the silent bypass after 60s and the user was
trapped until wiping app data.
Any user-initiated `TorType` change now wipes both halves: the in-memory
`sessionBypass` flag and the persisted approval. The next stuck-Connecting
span will surface the dialog again so the user has a real choice instead of
a silent fall-back to direct.
User feedback: returning "summarize my feed" results that didn't match
what's actually on the home page is misleading. Now the verb invokes
the same `HomeNewThreadFeedFilter` the foreground UI uses, against the
same LocalCache, so the LLM sees what the user would see if they
opened Amethyst.
What this fixes:
* Reposts, polls, long-form, comments, audio, etc. — the home filter
accepts ~17 event kinds; the previous verb saw only kind:1.
* Muted users — now filtered out.
* Replies — excluded (top-level threads only, matching the UI).
* Repost dedup — same note via multiple reposts collapses to one
entry, as on the screen.
* The user's currently-selected NIP-51 follow list (custom lists,
hashtag feeds, communities) — now respected. Was previously
hardcoded to plain kind:3.
Trade-off: reads from LocalCache, so the verb reflects what the
foreground has already pulled. If the user hasn't opened Amethyst in
a while, the digest is sparse. Acceptable for "summarize what I'm
seeing" semantics — for fresh data, the other verbs (search*,
getRecentFromFollows) do their own relay drain.
Implementation:
* Event.toFeedNoteHit() generic projection — handles the broader
event range with snippet-truncation for long content.
* NoteHit gains a `kind: Int` field so the LLM can distinguish
"Alice posted a note" from "Alice published an article" or
"Alice ran a poll".
* TextNoteEvent.toNoteHit() delegates to the generic helper.
* searchArticles' inline NoteHit construction also folds into the
generic helper — one less code path to maintain.
Plus amethyst/plans/2026-05-26-appfunctions-screens-as-verbs.md
documenting the broader pattern: every Amethyst screen has a
FeedContentState driven by a *FeedFilter; we'd add one AppFunction
verb per screen, all going through the same filter pipeline the UI
uses. Lists the ~25 unmapped feeds with proposed verb names so the
work has a clear roadmap. Same pipeline will back the future MCP
server.
New verb: getFeedDigest(hoursBack, maxNotes).
Use when the user asks "summarize my Nostr feed", "give me a digest
of what my follows posted today", "recap Nostr", or any other
summary / digest / recap intent.
Returns a structured snapshot for AI summary instead of a raw note
list: total note count, unique author count, top hashtags (≤10) and
top mentioned users (≤10) — with display names resolved from the
local kind:0 cache — alongside the trimmed note body. The LLM uses
the aggregate signals to write a one-paragraph "the conversation
focused on X, with N people posting about Y" instead of having to
re-derive frequencies from a raw list.
Implementation:
* Shared core extracted into fetchFollowFeed(account, since, limit)
so getRecentFromFollows and getFeedDigest don't duplicate the
drain logic.
* Over-fetches by 3× the visible cap so stats are computed over a
larger sample than the LLM sees, capped at 500 events for bounded
on-device work.
* Hashtag bucketing: lowercases + strips leading #, so #Bitcoin
and #bitcoin collapse.
* Mention bucketing: skips self-mentions (some clients tag the
author themself, not useful for the digest).
New @AppFunctionSerializable result types:
* HashtagFrequency, MentionFrequency — count + identifier.
* FeedDigestResult — windowHours, totalNoteCount, uniqueAuthorCount,
topHashtags, topMentions, notes.
Total verb count: 22. app_metadata.xml updated so Gemini's tool
picker can pitch the summary surface specifically.
Known scope: currently returns kind:1 from the user's kind:3 follow
list — does NOT match the in-app home feed exactly. The home feed
includes reposts, long-form, polls, comments, etc., respects the
user's currently selected NIP-51 list, and filters muted users.
Aligning the digest to the home feed (via HomeNewThreadFeedFilter
against LocalCache) is a documented follow-up.
withTimeoutOrNull(deferred.await()) returns a flattened Response? —
both "timeout" and "wallet sent null" produce null, and we already
catch null via the elvis-return above. The explicit `null ->` arm in
the response switch was dead code; the compiler warned about it.
Folded the "wallet sent null we couldn't decrypt" case into the
timeout error message since they're indistinguishable to the caller.
- cover CodePoints helpers and Channel.relays() equal-count behaviour
- Two new test files in commons/src/commonTest/, both run under :commons:jvmTest.
Returning a BOLT11 invoice for the user to paste somewhere defeated
the point of "Gemini, zap Alice 21 sats". Now when the active account
has a Nostr Wallet Connect (NIP-47) wallet configured in Amethyst,
both zap verbs pay the invoice automatically over NIP-47 and report
the outcome inline.
Implementation:
* payViaNwcOrNull(account, bolt11, zappedNote) — null when no NWC
set up (caller falls back to manual). Otherwise wraps the
callback-based Account.sendZapPaymentRequestFor in a
CompletableDeferred + withTimeoutOrNull. 30s budget; if the
wallet doesn't answer in that window the caller sees an
nwcError of "wallet didn't respond within 30s" and still has
the raw invoice to fall back on.
* Decodes the wallet's response: PayInvoiceSuccessResponse carries
the preimage, PayInvoiceErrorResponse carries a typed code +
message, NwcErrorResponse covers transport-level errors, null
means "couldn't decrypt the reply" (rare — wallet misconfigured
or our signer rejected). Each case maps to a typed
NwcOutcome the verbs can render.
* ZapResult / ZapInvoice grow four fields: nwcAttempted, nwcPaid,
nwcPreimage, nwcError. The invoice is still always returned so
Gemini can show it as a manual-payment fallback when NWC isn't
configured or rejects. zapEvent attempts each split independently
— one wallet failure doesn't block the rest.
Kdoc updates note the NWC behavior so the LLM picks up "if NWC is
configured, this just works" — that's the user-visible promise of
asking Gemini to tip someone.
PR #3047 enabled iosArm64 + iosSimulatorArm64 on :commons and added
:commons:compileKotlinIosSimulatorArm64 as a CI gate, but the Phase 2
migration was incomplete — JVM-only APIs survived in commonMain and
several expect declarations had no iOS actual. Every main CI run since
the merge failed at "Compile Commons for iOS".
Migrations in commonMain
- Dispatchers.IO: add `import kotlinx.coroutines.IO` to 16 files, matching
the quartz/NostrClient.kt pattern (kotlinx-coroutines 1.11 exposes IO on
Native via this import; no shim needed).
- synchronized {}: replace with the existing KmpLock + withLock in
EOSECache, AcceptedGamesRegistry, EventDeduplicator, ThumbHashDecoder,
PeerSessionManager. Restructure two PeerSessionManager methods that
late-init vals from inside the lock — withLock returns a tuple now.
- Unicode code points: drop java.lang.Character / String.codePointAt /
String.offsetByCodePoints. Add commons/util/CodePoints.kt with surrogate
-pair-aware KMP helpers; rewrite EmojiCoder + EmojiUtils against them.
- Byte<->String: encodeToByteArray() / decodeToString() / concatToString()
in EmojiCoder, Base83, BlurHashEncoder, RobohashAssembler,
LongFormPublishAction (drops Charsets / String(CharArray) / toByteArray
no-arg).
- Math.round → Double.roundToLong in BlurHashEncoder.
- String.format → Compose Resources stringResource(res, vararg) overload
in LoadingState (FeedErrorState).
- toSortedSet → sortedByDescending { }.mapTo(LinkedHashSet) in Channel —
preserves the descending-by-relay-count iteration order callers depend
on.
- Comparator<T>: kotlin.Comparator on Native takes non-null T. Align
CreatedAtComparator / CreatedAtComparatorAddresses to compare(a, b) and
drop dead null checks in CreatedAtIdHexComparator.
iOS actuals (commons/src/iosMain/)
- WeakReference: switch from typealias to explicit `actual class`. The
expect param is `referent` (matches java.lang.ref); kotlin.native.ref.
WeakReference uses `referred`, so typealias fails the expect/actual
name-match check on Native. Add @file:OptIn(ExperimentalNativeApi).
- PlatformImage: functional IntArray-backed actual (used by BlurHash and
ThumbHash decoders at runtime); Phase 3 will swap to CGImage.
- ChessDismissedGamesStorage: in-memory only; NSUserDefaults wiring lands
with iosApp in Phase 3.
- SecureKeyStorage: stub throwing SecureStorageException. Keychain
Services binding is Phase 4 per the iOS plan.
- formattedDateTime: NSDateFormatter with "yyyy-MM-dd-HH:mm:ss" + POSIX
locale + local time zone (semantically matches the JVM
DateTimeFormatter "uuuu-MM-dd-HH:mm:ss" for post-1970 timestamps).
- checkNotInMainThread: no-op (mirrors jvmMain).
- PlatformNumberFormatter: NSNumberFormatter(.DecimalStyle), with
NSNumber.numberWithLongLong to disambiguate the NSNumber(Long)
overload set.
- isDebug: false constant; iosApp can flip via Swift `DEBUG` flag later.
Verified locally
- :commons:compileKotlinIosSimulatorArm64 + compileKotlinIosArm64 green
- :quartz:iosSimulatorArm64Test green
- :commons:jvmTest + :quartz:jvmTest green (no JVM regression)
- :quartz:verifyKmpPurity + :commons:verifyKmpPurity + spotlessCheck green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A "missing" key in values-<locale>/strings.xml is not always actionable:
Crowdin omits source-identical translations on export (translator chose
"use English" for brand terms like "Nowhere X", loanwords like "Apps",
or version prefixes like "v%1$s"). Adding source-identical fallbacks
locally is noise that the next Crowdin sync strips again; Android already
falls back to values/strings.xml at runtime.
Add a Step 2.5 sync-timestamp filter that uses the latest
"New Crowdin translations by GitHub Action" commit reachable from HEAD as
the cutoff. Keys added to values/strings.xml after that commit are
genuinely new (Crowdin hasn't exported them yet); anything older is
Crowdin's responsibility. The reachable-from-HEAD check survives the
common workflow of deleting the l10n_crowdin_translations branch after
merging.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the single-field display name AlertDialog with a comprehensive
profile editing Dialog supporting all 13 Nostr profile fields: name,
display name, about, avatar, banner, website, pronouns, NIP-05,
lightning address, LNURL, and NIP-39 social proofs (Twitter, GitHub,
Mastodon).
New shared EditProfileFields state holder in commons/commonMain using
MutableStateFlow (matching ChatNewMessageState pattern) benefits both
Android and Desktop platforms.
Desktop-native features:
- Blossom image upload via DesktopFilePicker + UploadOrchestrator
- Live NIP-05 verification with debounced network check
- Keyboard shortcuts: Ctrl+S/Cmd+S save, Esc cancel
- Unsaved changes confirmation dialog
- Collapsible social proofs section
- Avatar/banner URL live preview via AsyncImage
- ProfileBroadcastBanner for relay broadcast feedback
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three deliverables:
1) Two new write verbs:
* zapUser(user, sats, comment?) — builds the NIP-57 kind:9734
profile zap and fetches a BOLT11 invoice from the recipient's
Lightning service. Returns the invoice — caller pastes into a
Lightning wallet (no NWC auto-pay yet). 21 sats default,
1M sats cap, 280-char comment cap.
* zapEvent(eventId, sats, comment?) — same but for a specific
note, with full NIP-57 zap-split support via
ZapActions.buildEventZapRequestsForSplits. Returns one invoice
per recipient when the post carries `zap` tags.
Total verb count: 21 (8 read for feeds/profiles, 3 read for
identity / followers, 4 read for inbox/zaps/streams, 4 write
for note/follow/unfollow/dm, 2 write for zaps).
2) Reworked every verb's kdoc first sentence into an LLM-friendly
"use when..." trigger phrase. Gemini's tool picker matches user
queries against the descriptions (we generate them via
@AppFunction(isDescribedByKDoc = true)) — phrasing like "Find a
person on Nostr by name. Use when the user wants to look someone
up..." gives the model concrete prompts to recognise instead of
internal NIP names.
Affected: searchProfiles, getRecentFromFollows, getNotesByUser,
getProfile, searchByHashtag, getActiveAccountInfo, getRecentDms,
getZapsReceived, postNote, followUser, unfollowUser, sendDm,
zapUser, zapEvent.
3) amethyst/plans/2026-05-26-appfunctions-gemini-discovery.md —
verification protocol for testing on-device whether Gemini's
tool picker actually surfaces our verbs from natural-language
prompts. Includes specific test prompts mapped to expected
verbs, fallback diagnostics (clear AppSearch + restart), and
the conditions under which it'd be worth defining our own
@AppFunctionSchemaDefinition namespace.
Plus minor: comment parameters switched to nullable (String? = null)
because KSP rejects non-nullable types with defaults.
Phase 4 from the signer-prompt plan, scoped to Option B (refuse NIP-55
with a typed NotSupportedException). Internal-key and NIP-46 bunker
accounts can now publish from Gemini.
New @AppFunction methods:
* postNote(text) — kind:1 short text note. Caps at 8000 chars to
catch accidentally-pasted documents; publishes to outbox relays
with per-relay ack reported.
* followUser(user) / unfollowUser(user) — kind:3 contact list
update via FollowActions. Detects already-following / not-
following and returns WriteResult.unchanged() rather than
re-publishing the same kind:3. New follows stamp the relay hint
from the target's cached kind:10002 write list, mirroring
User.bestRelayHint().
* sendDm(recipient, text) — NIP-17 gift-wrap via DmActions.buildTextDm.
Resolves per-recipient relay set through DmActions.resolveDmRelays
(permissive mode — falls back through NIP-65 read to bootstrap so
Gemini users don't trip on the strict kind:10050 rule). Returns
one DmDelivery per wrap (recipient + sender's own copy).
Signer gating — requireInProcessSigner():
* Read-only signers (npub-only login) → AppFunctionNotSupportedException
"sign in with a private key or NIP-46 bunker to publish".
* NIP-55 external signers (Amber) → AppFunctionNotSupportedException
"open Amethyst directly to complete the action". Detected via
qualified class name to avoid hard-coupling the bridge to the
nip55AndroidSigner module.
* NostrSignerInternal / NostrSignerRemote — sign in-process; the
NIP-46 round-trip already suspends through .sign(), no special
handling needed.
New @AppFunctionSerializable types:
* WriteResult — { changed, eventId?, publishedTo, rejectedBy }
* SendDmResult — { messageEventId, deliveries: List<DmDelivery> }
* DmDelivery — { recipientNpub, recipientPubkeyHex, wrapId,
publishedTo, rejectedBy, relaySource }
All 19 verbs now registered in the generated dispatcher (15 read + 4
write). app_metadata.xml updated so Gemini's tool picker pitches the
broader surface, including the NIP-55 caveat.
Every verb that returned a pubkey now also returns the best-effort
display name from the local kind:0 cache. Before this commit Gemini
could only say "you got a DM from npub1abc…" — now it can say
"you got a DM from Alice" because the LLM has the field at hand
instead of having to chain another lookup.
* NoteHit gains authorDisplayName (cache-resolved, null when the
author's kind:0 isn't local yet). Applied to every verb that
returns notes: searchNotes / getRecentFromFollows / getNotesByUser /
searchByHashtag / getMyRecentNotes / getMyMentions /
getRepliesToNote / searchArticles.
* DmMessage gains fromDisplayName + sentByMe — the latter lets
the caller distinguish "Alice said X" from "I said Y" when both
appear in the same thread snapshot.
* LiveStreamHit gains streamingUrl (was missing entirely — without
it the verb is useless, you can't watch a stream you can't open)
plus hostDisplayName.
* getProfile cache-hit path now actually populates `about` — was
silently null before because the early-return branch didn't read
it out of UserInfo. Cache-miss path was always correct.
Implementation: one `displayNameOf(HexKey): String?` helper reads from
Amethyst.instance.cache (LocalCache) — the same cache the foreground
UI uses. Zero allocations beyond the lookup, no network round-trip.
Now exposing the full read-only Nostr surface to Gemini. Seven new
@AppFunction methods on top of the previous eight:
* getMyRecentNotes(limit) — author=me filter on kind:1.
* getMyMentions(limit) — p-tag=me filter on kind:1. "Did anyone @ me?".
* getRepliesToNote(eventId, limit) — e-tag=eventId filter on kind:1.
Pair with getMyRecentNotes(1) for "did anyone respond to my last post?".
* getZapsReceived(hoursBack) — drains kind:9735 receipts addressed to
the user in the window, parses the bolt11 invoice from each, sums
sats. Returns total + zap count + unique zappers + count of
receipts whose bolt11 was unparseable.
* getRecentDms(peer?, hoursBack, limit) — NIP-17 gift-wrap drain +
unwrapAndUnsealOrNull decrypt. kind:14 text DMs only for v1 (skip
kind:15 encrypted-file headers to keep payloads bounded). Widens
the `since` filter by 2 days for NIP-59's randomised-past
created_at trick, then trims back to the requested window.
* searchArticles(query, limit) — same as searchNotes but kind:30023
long-form articles. Content snippet truncated at 2000 chars so a
book-length article doesn't blow up the AppFunctions response;
Gemini can ask the user whether to fetch the full article via a
different verb.
* getLiveStreams(limit) — NIP-53 kind:30311 with status=live (uses
quartz's 8-hour staleness guard via LiveActivitiesEvent.isLive).
Returns title, summary, host npub, start time, event id.
Plus updated res/xml/app_metadata.xml so Gemini's tool picker pitches
the full surface to users.
KSP-verified — 15 verbs total in the generated dispatcher:
getActiveAccountInfo getFollowing getLiveStreams
getMyMentions getMyRecentNotes getNotesByUser
getProfile getRecentDms getRecentFromFollows
getRepliesToNote getZapsReceived searchArticles
searchByHashtag searchNotes searchProfiles
Write verbs (post, follow, zap, sendDm) still deferred behind the
signer-prompt plan in amethyst/plans/2026-05-25-appfunctions-signer-prompts.md
— no behavior change there.
After the on-device round-trip proved the AppFunctions plumbing works,
adding the verbs that make Gemini actually useful for a Nostr user.
All read-only, no signer interaction, all build on existing actions /
Account state.
* getRecentFromFollows(limit) — "what's happening on Nostr today?"
Drains recent kind:1 from people the user follows; same relay set
the home-feed UI uses (account.homeRelays).
* getNotesByUser(user, limit) — "what did Vitor post recently?"
Accepts npub or 64-hex. Prefers the target's NIP-65 write relays
when cached, falls back to the active account's home relays.
* getProfile(user) — "who is npub1xq5...?". Cache-first via
LocalCache; falls back to a short network drain for unseen users.
Returns GetProfileResult{found, profile} so callers know whether
the user just isn't in cache or doesn't have a kind:0 yet.
* searchByHashtag(hashtag, limit) — "find Nostr posts about Bitcoin".
NIP-12 `t` tag filter, lowercased to match the client convention.
* getActiveAccountInfo() — "who am I logged in as?" Diagnostic verb
returning npub, display name, follow count, outbox + DM relay
counts. Distinguishes signed-in from signed-out via a flag rather
than a magic empty result.
Plus:
* decodeUserOrThrow helper for npub/hex parsing, throws
AppFunctionInvalidArgumentException with a typed message so
callers see "expected npub1… or 64-char hex" instead of a stack.
* TextNoteEvent.toNoteHit helper — extracted from the existing
searchNotes path to avoid duplication.
* Updated res/xml/app_metadata.xml description so Gemini's tool
picker can pitch a broader summary to the user.
KSP-verified: $AmethystAppFunctions_AppFunctionInvoker now dispatches
all eight verbs (the three from the previous commits plus these five).
After fixing the missing aggregated XML, Pixel 8 logcat still showed:
D AppFunctions: Unable to resolve AppFunctionMetadata.
Comparing against Google's FilipFan/AppFunctionsPilot sample turned up
a separate metadata pointer the system requires:
<property
android:name="android.app.appfunctions.app_metadata"
android:resource="@xml/app_metadata" />
This goes on the <application> element (not the service) and points to
an XML resource — distinct from the asset-side `app_functions.xml`
that the library auto-merges onto the service. The asset metadata
declares "here are my function ids and schemas"; the resource
metadata gives the agent a user-facing summary like "Search Nostr and
read your follows" to show users before they grant access.
Without the resource, the system can find our service and our
function list but can't resolve the descriptive metadata it shows
the user — so Gemini's tool picker stays empty.
Two new files:
* amethyst/src/play/res/xml/app_metadata.xml — short description +
displayDescription. Update when the @AppFunction surface grows.
* play AndroidManifest <property> pointing at the resource.
Also dropped our explicit <service> declaration for
PlatformAppFunctionService — confirmed via the appfunctions-service
AAR that the library auto-merges that exact entry, complete with
permission + intent-filter, so our copy was redundant.
The androidx.appfunctions-compiler runs in per-module mode by default,
emitting only the dispatcher Kotlin code. The aggregator that builds
the `app_functions.xml` + `app_functions_v2.xml` assets is gated behind
a KSP argument that was off.
Symptom on a Pixel 8 running our APK:
D AppFunctions: Unable to resolve AppFunctionMetadata.
Without the aggregated asset, the manifest's
`android.app.appfunctions` property pointed at a file that didn't
exist; the System UI couldn't enumerate our @AppFunction methods so
Gemini's tool picker never saw them.
Setting `appfunctions:aggregateAppFunctions = "true"` on the amethyst
module turns the aggregator on. Verified post-build:
assets/app_functions.xml (688 bytes — manifest pointer + ids)
assets/app_functions_v2.xml (19.7 KB — full schemas + kdoc descriptions)
Both list searchProfiles / searchNotes / getFollowing with the kdoc
descriptions Gemini will render.
Library modules (commons/quartz) would set this to "false" — only the
final app emits the aggregate. We don't currently apply the KSP plugin
in any library module, so this is the only place that matters.
Two follow-up cleanups from the audit.
Base64Image.parse: when the regex matched but the data capture group
was missing, the migrated version returned an empty ByteArray. The
original threw NPE (java.util.Base64.getDecoder().decode(null)). Both
behaviors are accidents — restore the intended contract: throw the
existing "Unable to convert base64 to image" Exception explicitly.
FeedDefinitionSerializerTest gains a serializesToExpectedWireFormat
test that pins the byte-exact JSON output for a representative
multi-field feed. The legacy-Jackson migration claimed byte-identity
but only round-trip and reverse-compat were covered. Any future change
to field ordering / null handling / number formatting now fails this
test loudly, protecting users who have saved feeds on disk and any
downstream consumer expecting the stable order.
Pure text paragraphs now render as a single Text composable instead
of individual words in FlowRow, eliminating unwanted inter-word gaps.
Co-Authored-By: Claude <noreply@anthropic.com>
Type @ followed by a name in the compose/reply dialog to see a
dropdown of matching users from the local cache. Selecting a user
inserts their nostr:npub reference. Shows avatar + display name +
truncated npub.
Co-Authored-By: Claude <noreply@anthropic.com>
Address bugs and gaps surfaced by an audit of the prior 14 commits.
JVM tests passed because of typealias / platform-type lenience that
won't hold on Native; these are real iOS compile / behavior issues.
BUG fixes (iOS compile failures):
- commons/.../Note.kt:899 — Iterable.sumOf { -> BigDecimal } is a
JVM-stdlib-only overload. Common stdlib ships sumOf only for
Int/Long/Double/Float/UInt/ULong. Replaced with fold(BigDecimal(0)).
- commons/.../Note.kt:889 — BigDecimal(it.event?.content): the quartz
expect-class constructor takes String non-null; JVM accepted nullable
via platform-type lenience and threw NPE caught downstream. Switched
to ?.let { content -> BigDecimal(content) }.
- commons/.../Note.kt:838 — `catch (e: java.lang.Exception)` -> `Exception`.
- commons/.../feeds/custom/FeedDefinitionBuilder.kt + FeedBuilderState.kt:
inline FQN `java.util.UUID.randomUUID().toString()` -> kotlin.uuid.Uuid.
random().toString() (Kotlin 2.0+, @OptIn ExperimentalUuidApi).
inline `System.currentTimeMillis() / 1000` -> TimeUtils.now() (already
used elsewhere in the codebase).
- commons/.../viewmodels/NestViewModelTest.kt: moved from commonTest to
jvmTest. The test imports NestViewModel + nestsclient, both of which
the prior PR moved to jvmAndroid. commonTest depends on commonMain
only, so the test would fail to compile for iosSimulatorArm64Test.
SUBTLE fixes:
- commons/.../UserRelaysCache.kt: the flow field used double-checked
locking on a non-volatile var. JMM hazard on Native (ARM weak memory
model) — outer fast-path could observe a partially-published
WeakReference. Added @kotlin.concurrent.Volatile.
- commons/.../util/UrlValidation.ios.kt: NSURL.URLWithString("http:")
returns non-null with scheme="http" and no host; JVM's URI.toURL()
rejects with MalformedURLException. Reject scheme-only network URLs
(http/https/ws/wss/ftp without a host) to match JVM behavior.
- commons/.../util/KmpLock.kt commonMain doc: corrected "NSLock" ->
"NSRecursiveLock" to match the actual iOS implementation.
verifyKmpPurity gate extended (commons + quartz):
- Adds patterns: System.currentTimeMillis, Thread.sleep, java.util.UUID,
kotlin.jvm.Synchronized, kotlin.jvm.Volatile.
- Each pattern paired with a hint pointing at the canonical KMP
replacement; the error message surfaces both.
- Skips lines that start with //, *, or /* to avoid false positives on
KDoc / migration notes.
Pre-stages the three iosMain actuals that the macOS CI run is most
likely to demand once it compiles :commons for Native (the dev
container can't extract the K/N LLVM toolchain to validate locally).
- KmpLock.ios.kt: NSRecursiveLock — mirrors the ReentrantLock
semantics the jvmAndroid actual exposes (reentrant per-thread).
- WeakReference.ios.kt: actual typealias to kotlin.native.ref.
WeakReference<T> — same constructor + get(): T? shape as the
jvmAndroid typealias to java.lang.ref.WeakReference<T>.
- UrlValidation.ios.kt: NSURL.URLWithString with an explicit scheme
check, since NSURL is more permissive than JVM's URI.toURL() and
accepts scheme-less relatives that the JVM contract rejects.
Lands together so the next CI run's failure mode (if any) is more
informative than "iosArm64 unresolved reference" three times over.
Read-only surface for the Gemini bridge now covers profiles, notes, and
the active account's follow set:
* searchNotes(query, limit) — NIP-50 search over kind:1 short text
notes via SearchActions.searchNotesFilter + INostrClient.fetchAll.
Returns NoteHit list (eventId, npub, content, createdAt). alpha09
of androidx.appfunctions doesn't support List<Int> parameters, so
no caller-configurable kinds — kind:1 only for now.
* getFollowing(limit) — reads account.kind3FollowList.userList.value
(already resolved through LocalCache) and projects to FollowedUser
with display-name / nip05 / picture from cached kind:0. Reports
totalFollowing so the caller knows when limit truncated the list.
Both verbs follow the searchProfiles pattern: snapshot active account +
client at entry, never re-query sessionManager during the dispatch.
Plus amethyst/plans/2026-05-25-appfunctions-signer-prompts.md —
design plan for write verbs. Three signers (Internal / Remote /
External), three different latency + interaction models. Concrete
proposal: Internal first via postNote pilot, Remote as a follow-up,
External via PendingIntent (Option A) or NotSupportedException
(Option B — recommended for v1) depending on what bundle keys the
system shell respects. Open questions enumerated so the experiment
day is bounded.
Phase 2 task 10 of the iOS plan — flip on iOS targets for :commons.
Gradle dep resolution is fully green for iOS; actual Kotlin/Native
compilation runs on the macOS CI job (the dev container in which this
was authored can't extract the K/N LLVM toolchain).
Dep reshuffle to match what's actually KMP-available:
- commonMain: kept project(":quartz"), Compose Multiplatform, coil-compose,
androidx-collection, kotlinx-collections-immutable, kotlinx-serialization-json,
compose components-resources, androidx-lifecycle-viewmodel,
androidx-lifecycle-runtime-compose. These all publish iosArm64 +
iosSimulatorArm64 variants per `.module` inspection.
- jvmAndroid (NEW location): project(":nestsClient") (JVM+Android-only),
coil-okhttp (JVM-only), markdown-commonmark / markdown-ui /
markdown-ui-material3 (the RenderMarkdown.kt consumer is already in
jvmAndroid), and androidx-lifecycle-viewmodel-compose (AndroidX publishes
android + jvmStubs + linuxx64Stubs variants — no iOS, so the viewModel()
Composable helper stays JVM-bound until we either swap to the
org.jetbrains.androidx.lifecycle variant or accept a platform-specific
ViewModel access pattern on iOS).
- libs.versions.toml: adds androidx-lifecycle-viewmodel catalog entry.
- New intermediate source set iosMain → both iosArm64Main and
iosSimulatorArm64Main depend on it (clean place for iOS-only actuals
when KmpLock, WeakReference, etc. get their iOS implementations).
- .github/workflows/build.yml: test-quartz-ios job now also runs
:commons:compileKotlinIosArm64 + :commons:compileKotlinIosSimulatorArm64.
Three changes that bring commons/commonMain to zero java.* imports
(down from 18 at the start of Phase 2).
- EventListMatchingFilter, NoteListMatchingFilter: moved to jvmAndroid.
Both use ConcurrentSkipListSet + SortedSet for ordered concurrent
iteration, and their only consumer is LocalCache in the Android app.
iOS-time we can revisit if a KMP ordered concurrent set is needed.
- Note.kt's BigDecimal: switch import from java.math.BigDecimal to
quartz's existing expect/actual com.vitorpamplona.quartz.utils.BigDecimal.
BigDecimal.ZERO -> BigDecimal(0); BigDecimal.valueOf(longVal) ->
BigDecimal(longVal) (the expect class already has the Long
constructor). NoteOnchainZapTest gets the same treatment.
- Adds two top-level extensions in quartz commonMain (separate
BigDecimalOps.kt file to avoid the duplicate-JVM-classname collision
with the existing BigDecimal.kt actuals):
operator fun BigDecimal.plus(other: BigDecimal)
operator fun BigDecimal.minus(other: BigDecimal)
Lets += / + / - continue to work on commonMain BigDecimal values.
Commons/commonMain is now structurally iOS-ready as far as the
java.* import audit can tell. Remaining iOS work: actually flip on
the iOS targets, see what UI / dep transitives break, and address.
Fourth verb extraction alongside FollowActions / SearchActions /
ZapActions. Closes the largest remaining amy-expert "thin assembly"
violation in cli/.
Two pieces moved out of cli/.../DmCommands.kt into commons:
* DmActions.resolveDmRelays applies the strict-kind:10050 → NIP-65-
read → bootstrap fallback policy the in-app flow uses. Returns a
DmRelaySet with a typed RelaySource (KIND_10050 / NIP65_READ /
BOOTSTRAP / NONE) so callers can surface the source — amy emits
it on stdout, a future Gemini adapter could mention it in the
assistant response.
* DmActions.buildTextDm / buildFileDmReference are thin wrappers
over NIP17Factory.createMessageNIP17 / createEncryptedFileNIP17
that build the kind:14 / kind:15 template and gift-wrap in one
call. Matches the FollowActions / ZapActions builder shape.
amy's DmCommands is now genuinely thin assembly: requireUserHex,
flag plumbing, call DmActions, render JSON. The 583-line file shrank
slightly and — more importantly — no longer carries NIP-17 logic
the rest of the codebase needs to look at.
Receive-side decrypt loop (3 lines of unwrapAndUnsealOrNull) stays in
amy; too small to extract and tightly coupled to amy's per-relay
attribution.
10 new tests for DmActions: strict/permissive fallback chain, null
recipient lists, RelaySource enum stability, and a smoke test that
buildTextDm produces a kind:14 with the right wrap count (sender +
recipient).
Clears the two java.net.* importers from commons/commonMain.
- Adds expect fun isValidUrl(url: String?): Boolean in
commons/commonMain/util/. The jvmAndroid actual preserves the
exact JVM semantics (URI.toURL() + the same 3 catch arms);
iOS actual will use NSURL when the target lands.
- RichTextParser.isValidURL becomes a thin wrapper around
isValidUrl. Keeps the existing static call site so callers in
the Android app and Desktop need no change.
- UrlInfoItem.kt (link-preview model that wraps URI) moves to
jvmAndroid; its only consumers are the Android link-preview
pipeline (HtmlCharsetParser, UrlPreviewState, UrlPreviewCard),
which already live outside commonMain.
commons/commonMain is now down to 3 java.* importers: Note
(BigDecimal) and the two SortedSet-based observables.
Clears the remaining easy iOS blockers in commons/commonMain by
relocating files whose underlying feature isn't iOS-ready yet, rather
than fabricating expect/actuals we won't need until that feature ships.
- NestViewModel + ActiveSubscription: depend on :nestsClient (audio
rooms — Phase 5 per the iOS plan). Moved as-is; both already lived
in a jvmAndroid-shaped package.
- HtmlCharsetParser: depends on java.nio.charset.Charset, used only
by the Android link-preview pipeline (no Desktop / iOS consumer
today).
- RenderMarkdown: depends on com.halilibo.richtext.* — needs iOS
artifact verification before it can return to commonMain (tracked
for Phase 3).
- MediaContentModels.kt is split:
* URL-based models (BaseMediaContent, MediaUrlImage/Video/Pdf,
EncryptedMediaUrlImage/Video) stay in commonMain — pure KMP, no
java.io.File reference.
* Locally-cached variants (MediaPreloadedContent, MediaLocalImage,
MediaLocalVideo) move to a new MediaLocalContent.kt under
jvmAndroid — they hold a java.io.File and call .exists().
After this PR commons/commonMain has 5 remaining java.* importers
(Note's BigDecimal, the two SortedSet observables, URL parsing in
RichTextParser + UrlInfoItem). Down from 18 at the start of Phase 2.
quartz already ships an extension that does exactly what
AmethystAppFunctions.drain reimplemented — subscribe with the given
filters, collect events until every relay sends EOSE / closed /
cannot-connect or the timeout elapses, unsubscribe, dedup by id,
return sorted newest-first. See
quartz/.../relay/client/accessories/NostrClientFetchAllExt.kt.
Replacing the local drain with `client.fetchAll(filters, timeoutMs)`
trims 70+ lines of subscription listener boilerplate and gives the
adapter the same behavior the rest of the codebase already trusts.
amy's Context.drain stays — it adds per-event signature verification
and persistence to the file event store (the trust boundary for amy)
that fetchAll doesn't do.
The previous wiring forced Amethyst to be `open`, added a 30-line
PlayAmethyst subclass that only implemented AppFunctionConfiguration
.Provider, and used tools:replace="android:name" in the play manifest
to swap classes. The justification was that the appfunctions runtime
discovers @AppFunction host classes via Application.appFunctionConfiguration.
Reading the KSP-generated dispatcher
($AmethystAppFunctions_AppFunctionInvoker.kt) shows that's only half
true. The invoker passes a default-construction fallback lambda when
instantiating the host class, and ConfigurableAppFunctionFactory takes
that fallback as a constructor argument. Provider is only consulted to
*override* construction — required for classes with non-default
constructors, optional otherwise.
AmethystAppFunctions has a no-arg constructor, so:
* PlayAmethyst is deleted entirely
* Amethyst goes back to `class Amethyst : Application()` (no `open`)
* Play manifest reverts to plain `android:name=".Amethyst"`, no
tools:replace gymnastics
Verified by assemblePlayDebug (APK builds clean) and the merged play
manifest still pinning the appfunctions service. If we ever add a
host class with constructor parameters (an Account-injected one, say),
we'll need to add Provider back — kdoc on AmethystAppFunctions
documents that.
Clears the last of the JVM-only synchronization annotations from
commons/commonMain so the model layer can compile on iOS. 15
methods across 4 files migrated.
- @Synchronized -> KmpLock.withLock { } with one per-instance syncLock
field per class. Original semantics preserved: @Synchronized on
methods of the same class synchronized on `this`, and a single
per-instance KmpLock gives the same exclusion.
* Channel.kt: addRelaySync, createOrDestroyFlowSync
* Chatroom.kt: addMessageSync, removeMessageSync
* MarmotGroupChatroom.kt: placeholderNote, addMessageSync,
restoreMessageSync, removeMessageSync, clearAllMessagesSync
* Note.kt: innerAddZap, innerAddOnchainZap,
innerRemoveOnchainZapForSource, innerAddZapPayment, addRelaySync,
createOrDestroyFlowSync
- Note.kt's @Volatile fields: now use kotlin.concurrent.Volatile
(KMP) instead of kotlin.jvm.Volatile (JVM-only) via explicit
import. Volatile semantics preserved on every target.
NestViewModel.kt also uses @Volatile (and the nestsClient project
dep); that file moves to jvmAndroid in a separate PR as planned
(audio rooms is Phase 5).
Closes the remaining items from the comparative review of the extracted
actions against the in-app Amethyst flows. All small, all surfaced by the
review.
* amy follow now stamps the relay hint on new contact-list `p` tags.
Best-effort read from the target's cached kind:10002 advertised
relay list (first writeRelaysNorm). Mirrors User.bestRelayHint() —
follows added via amy no longer have empty relayUri.
* amy search user now dedups by pubkey (sorted newest-first) instead
of by event id, matching the App Functions adapter. Multiple relays
surfacing different kind:0 revisions for the same author collapse
to one hit.
* AmethystAppFunctions.searchProfiles captures the active account AND
the relay client at function entry, then never touches sessionManager
or Amethyst.instance again during the drain. Closes the account-
switch race surfaced in the review.
* FollowActions / SearchActions / ZapActions kdoc now lists the
caller-side responsibilities each builder leaves to the consumer
(publish, writeable check, relay hint, pseudo-kind filtering,
LN round-trip, receipt verification, etc.). Documents the design
rather than letting it leak through reviews.
Address review feedback: the project already has LargeCache (in quartz,
with jvmAndroid/appleMain/linuxMain actuals) as its KMP concurrent-map
abstraction — it's used pervasively in the model layer. Adding stately
duplicated that capability with an external dep.
- Comparable-key maps switch to LargeCache:
* ChessEventCollector.moves (String key)
* ChessEventCollectorManager.collectors (String key)
* ChessRelayFetchHelper.events (String key)
* ChessRelayFetchHelper.relayEventCounts: LargeCache<NormalizedRelayUrl,
AtomicInt> with getOrCreate { AtomicInt(0) }.addAndFetch(1) — replaces
the stately .block { compute } increment idiom. getOrCreate is atomic
via ConcurrentSkipListMap.putIfAbsent so all threads end up
incrementing the same AtomicInt instance.
* ChessLobbyLogic.recentlyLoadedGames (String key)
- The SubscriptionManager pair (MutableComposeSubscriptionManager,
ComposeSubscriptionManager) keeps a plain mutableMapOf — T :
MutableQueryState is generic and not Comparable, so LargeCache's
ConcurrentSkipListMap backing would ClassCastException at put time.
Concurrency comes from a KmpLock-guarded map.
- Set-shaped uses switch to KmpLock + mutableSetOf:
* ChessEventCollector.processedEventIds
* ChessRelayFetchHelper.eoseReceived
* ChessLobbyLogic.dismissedGameIds + seenEventIds (the bounded LRU
keeps insertion-order eviction; mutableSetOf returns LinkedHashSet
on every KMP target).
- UserRelaysCache.flow's lock: stately Lock -> KmpLock.
Adds expect class KmpLock() with jvmAndroid actual that wraps
ReentrantLock. iOS actual (NSLock) will land with the iOS target.
Mirrors the WeakReference pattern from the previous PR.
Drops stately-concurrent-collections 2.1.0 from libs.versions.toml and
commons/build.gradle.kts (no remaining consumers).
The previous ZapActions.buildEventZapRequest signed a single zap request
to a single recipient. Notes carrying NIP-57 zap-split tags, NIP-53
live-activity host tags, or NIP-89 app-definition metadata expect the
payment to be distributed across multiple parties — so `amy zap event`
silently overpaid one party and underpaid the rest. The correctness
review on the action-set flagged this as the only real bug in the
extracted verbs; this commit fixes it.
* ZapSplitResolver — new commonMain object mirroring the resolution
order in ZapPaymentHandler.kt (splits > live-activity hosts > app
metadata > author fallback). Pure logic; pubkey→LN-address lookup
is passed in as a suspend lambda so amy reads from its file store
and Android reads from LocalCache, no shared cache-coupling.
* ZapActions.buildEventZapRequestsForSplits — high-level helper that
composes the resolver with per-share LnZapRequestEvent signing.
Each request's `relays` tag unions sender + author + recipient
inbox relays so the kind:9735 receipt routes to every interested
party (matches signAllZapRequests in the Android handler).
* amy zap event — rewired to the split-aware path. JSON output now
enumerates each recipient with its share, LN address, request id,
and BOLT11 invoice (or per-recipient invoice_error). Profile zaps
(amy zap user) keep the simple single-recipient path since they
have no split tags.
Tests: 12 new cases — LN-address splits, weighted pubkey splits, author
fallback, drop-silently-on-missing-LN, relay unioning, share rounding.
All 41 action tests green; both Android flavors compile.
Phase 2 of the iOS plan — clears the java.lang.ref.WeakReference
blocker from commons/commonMain. Four model files migrated; one
additional sync primitive replaced.
- Adds expect class WeakReference<T : Any> in
commons/commonMain/util/, with a jvmAndroid actual that typealiases
to java.lang.ref.WeakReference. iOS actual will typealias to
kotlin.native.ref.WeakReference when the target is added.
- Channel / Chatroom / MarmotGroupChatroom: the WeakReference(null)
initializer relied on platform-type nullability of
java.lang.ref.WeakReference's constructor. With T : Any in the expect
class, fields become nullable (WeakReference<...>? = null) and the
.get() callsites become ?.get(). Behaviorally equivalent.
- UserRelaysCache: same WeakReference migration, plus the
synchronized(this) double-checked-locking idiom is replaced with
co.touchlab.stately.concurrency.Lock + withLock (KMP).
kotlin.synchronized is JVM-only; Lock comes in transitively via
stately-concurrent-collections already added in the previous PR.
Model-layer @Synchronized usage in Channel/Chatroom/MarmotGroupChatroom/
Note (also JVM-only) is a separate iOS blocker and a separate PR.
Phase 2 of the iOS plan — clears the ConcurrentHashMap blockers from
commons/commonMain. Five files migrated (the four flagged in the
initial audit + ChessLobbyLogic, which used fully-qualified inline
java.util references that the import-based audit missed).
Adds co.touchlab:stately-concurrent-collections 2.1.0 — a small,
mature KMP library that provides ConcurrentMutableMap /
ConcurrentMutableSet with semantics equivalent to ConcurrentHashMap /
ConcurrentHashMap.newKeySet on every Kotlin target. The .block { }
helper covers the compound-update paths (ChessRelayFetchHelper's
per-relay event-count compute, ChessLobbyLogic's bounded-LRU dedup).
- ComposeSubscriptionManager + MutableComposeSubscriptionManager:
ConcurrentHashMap -> ConcurrentMutableMap
- ChessEventCollector + ChessEventCollectorManager: map and Set
- ChessRelayFetchHelper: in-function event/relay state
- ChessLobbyLogic: replaces dismissedGameIds (synchronizedSet),
recentlyLoadedGames (ConcurrentHashMap), seenEventIds (bounded LRU
using LinkedHashSet via Collections.synchronizedSet + synchronized {}).
seenEventIds keeps insertion-order eviction semantics because
mutableSetOf returns LinkedHashSet on every KMP target.
First Phase 2 verb wired through to the Android App Functions runtime so
Gemini (and other system agents) can drive Amethyst.
Scope is intentionally narrow:
* One read-only verb (searchProfiles), built on top of the existing
SearchActions in commons. No write verbs yet — they need a story
for NIP-46 / NIP-55 signer prompts from a background dispatcher.
* Play channel only. appfunctions 1.0.0-alpha09 is a Google AI alpha;
F-Droid builds continue to ship without any Google AI dependencies.
Architecture:
* AmethystAppFunctions — plain Kotlin host with @AppFunction methods.
The KSP-driven appfunctions-compiler discovers them and generates
the dispatch metadata XML at build time.
* PlayAmethyst — play-only Application subclass implementing
AppFunctionConfiguration.Provider; supplies the factory the
library uses to construct the host class. Manifest replaces
android:name in the play flavor only; F-Droid keeps the unmodified
Amethyst class.
* The androidx-provided PlatformAppFunctionService is registered in
the play manifest as the bind point — Amethyst doesn't ship a
custom Service.
KSP is now a project-wide plugin (apply false at the root); applied in
amethyst/ to run the appfunctions-compiler over the play sourceSet.
Amethyst becomes `open class` so PlayAmethyst can extend it. No other
behavior change.
Phase 2 of the iOS plan — two of the ~9 small migrations to clear
java.* imports out of commons/commonMain.
- ChessLobbyState: the AtomicLong stateVersionCounter only existed to
bump a MutableStateFlow<Long>. MutableStateFlow.update is itself
atomic, so the counter is redundant — replaced with
_stateVersion.update { it + 1 }. Removes the dep and simplifies the
code.
- SigningState (GlobalSigningStatus): AtomicInteger is doing real
cross-thread coordination. Migrated to kotlin.concurrent.atomics.
AtomicInt (KMP stdlib). The common-API method names differ from
AtomicInteger — addAndFetch(±1) / store(0) instead of
incrementAndGet / decrementAndGet / set.
Phase 2 of the iOS plan — first of ~9 small migrations to clear the
java.* imports out of commons/commonMain. Replaces java.util.Base64
with kotlin.io.encoding.Base64 (stdlib, KMP-clean). The two callers
(Android Base64Fetcher, Desktop DesktopBase64Fetcher) use the public
parse() signature only, which is unchanged.
Also documents the full Phase 2 audit in
amethyst/plans/2026-05-24-ios-support.md: out of 335 commonMain files,
21 are real iOS blockers grouped into ~10 small mergeable PRs. The
remaining 183 androidx.compose users and 7 androidx.lifecycle users
already map to JetBrains Compose Multiplatform / AndroidX KMP and need
no work.
Rewrites the policy/terms doc with three goals:
1) **Concise & easier to read.** Plain English, short sentences,
removed redundant intros (the "How Amethyst Works (and Why That
Matters Here)" block restated the Privacy intro), merged the
"Visibility" + "Permanence" sections into one paragraph, and
collapsed the Child Safety POC section into a single contact
block near the top of the document.
2) **More truthful.** Two corrections:
- F-Droid build uses UnifiedPush for notifications, not FCM. The
previous text only mentioned Google Firebase Cloud Messaging,
which was inaccurate for the F-Droid distribution.
- Replaced "We rely on Google Play's age verification to make sure
the user downloading the app is an adult" with "Amethyst's Google
Play listing is rated 17+. The app does not request or store age
information." Google Play does not actually verify user age, so
the old wording overstated the protection.
3) **Lower liability.** Several specific changes:
- Dropped the "We aim to acknowledge child-safety reports within
72 hours" service-level commitment that the solo developer cannot
reliably meet.
- Softened "we will recommend that the offending relay be removed"
and "What we can do: acknowledge the report, forward..." to
discretionary "may forward" / "may stop recommending" phrasing.
- Removed the absolute "data is strictly confidential and cannot
be accessed by other apps" guarantee. Replaced with the narrower,
verifiable claim that other apps cannot read app-local storage
on a standard, non-rooted Android device.
- Narrowed "Amethyst is built and distributed to comply with
applicable child safety laws and regulations" to "Amethyst is
distributed under Google Play's Child Safety Standards policy and
applicable law" — same in spirit, smaller surface for dispute.
Content that the Google Play Child Safety Standards checklist
requires is unchanged: explicit CSAE prohibition, child-safety point
of contact (amethyst@vitorpamplona.com), in-app feedback mechanism
(Report Post / Report Account / Block Post / Block Account / Block
Relay / Mute), method for addressing CSAM (in-app report → block
relay → NCMEC/INHOPE → optional developer notice), compliance
statement, and references to the app name "Amethyst" and the Google
Play publisher "Vitor Pamplona". The F-Droid carve-out also remains:
the MIT License in LICENSE is identified as the only instrument
governing source-built distributions, with no additional terms.
The Play-Store-only Terms-of-Use checkbox and the "About & Legal"
Settings section both contain hardcoded GitHub URLs to the published
PRIVACY.md. Previously they were in src/main and gated at call sites
by `BuildConfig.FLAVOR == "play"` — which works at runtime but still
compiles the GitHub URLs into the F-Droid APK.
This commit moves the URL-bearing UI into src/play and adds empty
src/fdroid stubs with the same signatures, so the F-Droid build is
physically free of the URLs.
New composables (same package + signature in both flavor source sets,
so callers in src/main link to whichever flavor is being built):
- com.vitorpamplona.amethyst.ui.screen.loggedOff.legal.TermsGate
* src/play: renders an AcceptTerms checkbox with the PRIVACY.md link
and the "acceptance required" error text.
* src/fdroid: empty body.
- com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.LegalSettingsSection
* src/play: renders the SettingsSection with Privacy Policy and
Child Safety Standards rows that open the GitHub-hosted PRIVACY.md.
* src/fdroid: empty body.
Call-site changes:
- LoginScreen: drop `BuildConfig.FLAVOR == "play"` guard and call
`TermsGate(...)` inside the existing `isFirstLogin` block. The
flavor source set picks the right body.
- SignUpScreen: drop `BuildConfig.FLAVOR == "play"` guard, call
`TermsGate(...)` unconditionally.
- AllSettingsScreen: drop `BuildConfig.FLAVOR == "play"` guard and
the inline About & Legal SettingsSection, call
`LegalSettingsSection()` instead. LocalUriHandler and BuildConfig
imports removed.
The old `src/main/.../loggedOff/AcceptTerms.kt` is deleted; its body
moves into the play-flavor TermsGate as a file-private helper.
The LoginViewModel / SignUpViewModel still use `BuildConfig.FLAVOR` to
decide the initial `acceptedTerms` value (so the login/signup button
isn't disabled on F-Droid). That check is logic only, contains no
URLs, and is safe for F-Droid distribution.
Verified: `grep -r "github.com/vitorpamplona/amethyst/blob"` against
src/main + src/fdroid returns zero matches. Both
:amethyst:compileFdroidDebugKotlin and :amethyst:compilePlayDebugKotlin
compile cleanly.
The one Jackson holdout in commons/commonMain. Migrating it unblocks
the iOS purity gate for :commons (Phase 1 of the iOS plan).
- Rewrites FeedDefinitionSerializer with the kotlinx.serialization JSON
tree API (JsonObject / JsonArray / JsonPrimitive). Wire format is
byte-identical, so users' existing on-disk custom-feed definitions
keep deserializing — covered by a new parsesLegacyJacksonOutput test
that pins a hand-written Jackson-shaped JSON blob.
- Adds :commons:verifyKmpPurity (mirrors the one in :quartz) and wires
it into the CI lint job alongside :quartz:verifyKmpPurity.
- Pulls in kotlinx-serialization-json as a commonMain dep; the
serialization plugin was already applied on :commons.
Third verb extraction alongside FollowActions / SearchActions, scoped
to event building so the action stays target-agnostic (commonMain,
no JVM/Android coupling).
* buildUserZapRequest / buildEventZapRequest wrap the two
LnZapRequestEvent.create overloads with a uniform call shape and
sensible defaults (PUBLIC zap, no LNURL, no poll).
* extractLnAddress pulls lud16 (preferred) or lud06 from a kind:0
metadata event, returning null when neither is set.
* satsToMillisats covers the sats→msats conversion that every
caller would otherwise duplicate.
Wires up amy zap user|event as the first consumer. The Lightning
round-trip (LNURL fetch + invoice retrieval) goes through the existing
LightningAddressResolver in commons/jvmAndroid; the BOLT11 invoice is
printed but not auto-paid since amy has no NWC wallet wired up yet.
F-Droid distributes Amethyst as MIT-licensed free software with no
acceptable-use terms layered on top — only the Play Store build needs
a ToS-acceptance checkbox at login/signup and links to the published
Child Safety Standards.
Gates added behind `BuildConfig.FLAVOR == "play"`:
- Settings → "About & Legal" section (Privacy Policy + Child Safety
Standards) is now Play-only; F-Droid settings no longer link to
PRIVACY.md.
- AcceptTerms checkbox in LoginScreen and SignUpScreen is now Play-only.
- LoginViewModel.load() / clear() and SignUpViewModel pre-accept
`acceptedTerms` on F-Droid so the login/signup button isn't disabled.
The Play build is unchanged: first-time login still requires checking
the ToS box, and the About & Legal section still surfaces the published
Child Safety Standards link required by Google Play.
Introduce SearchActions alongside FollowActions as the second of the
shared "verbs" usable by amy CLI and a future Android App Functions
adapter for Gemini.
* searchProfilesFilter / searchNotesFilter build the relay-side
Filter with the NIP-50 `search` field set; blank queries return
null so callers don't issue unconstrained searches that relays
would reject anyway.
* resolveSearchRelays picks the caller's kind:10007 list when
configured (decrypting NIP-44 private entries via the signer) and
falls back to DefaultSearchRelayList — the same set the Android UI
uses when the user has no list of their own.
Wires up amy search user|note as the first consumer.
F-Droid requires that apps add no restrictions to the FOSS license that
ships with the source. The previous wording ("Amethyst strictly prohibits
the use of the app to...") could be read as an EULA clause that
restricts use beyond what the MIT LICENSE grants.
Reframe the prohibition as a published community standard / acceptable-
use policy — which is exactly what Google Play's Child Safety Standards
policy requires anyway — and add an explicit "Free Software License"
note clarifying that the MIT license terms in LICENSE are unchanged,
and that F-Droid users and source redistributors retain every right
granted by MIT.
Google Play's requirements remain satisfied: the prohibition of CSAE is
still explicit, the in-app reporting mechanism is documented, the
child-safety point of contact and NCMEC escalation path are unchanged,
and the app/developer name is still referenced.
Phase 1 of the iOS support plan (amethyst/plans/2026-05-24-ios-support.md).
Two independent guards so JVM-only imports can't silently appear in
quartz's iOS-bound source sets:
- :quartz:verifyKmpPurity (Linux, ~1s): scans commonMain + apple/native
source sets for com.fasterxml.jackson / okhttp3 references and fails
the build with a clear pointer to the offending file:line. Wired into
the existing lint job so it runs on every PR.
- test-quartz-ios (macos-latest): runs :quartz:iosSimulatorArm64Test on
the simulator (NIP-04, NIP-17, NIP-19, NIP-49 vectors + AES-GCM and
chatroom-key tests already in quartz/src/iosTest) and additionally
compileTestKotlinIosArm64 to catch device-variant compile drift.
Google Play rejected v446 because the published Child Safety Standards
did not explicitly prohibit CSAE, name a child-safety point of contact,
describe the in-app reporting mechanism, or reference the app/developer
as listed on Play.
Rewrites the section in PRIVACY.md to:
- Explain that Amethyst is a client, not a host: third-party relays host
content and are responsible for moderation and any NCMEC reporting
obligations (e.g. 18 U.S.C. §2258A).
- Explicitly prohibit CSAE/CSAM in the app.
- Document the in-app tools users have: Report Post, Report Account,
Block Post/Account, Block Relay (NIP-51 Blocked Relay List), Mute
Words/Hashtags.
- Describe the escalation path: report in-app, block the hosting relay,
report to NCMEC CyberTipline / INHOPE, optionally email the developer.
- Provide a child-safety point of contact (amethyst@vitorpamplona.com)
with realistic scope of action (forward to relay ops, drop the relay
from default lists).
- Reference the app name "Amethyst" and developer "Vitor Pamplona" as
required by the checklist.
Also surfaces the document from inside the app by adding an
"About & Legal" section to Settings with two items: Privacy Policy and
Child Safety Standards (anchor link to the section).
Introduce commons/.../actions/FollowActions as the canonical, non-UI
entry point for NIP-02 kind:3 mutations. Accepts pubkeys as HexKey
rather than the Compose-bound User model, so callers without a cache
(amy CLI, future Android App Functions adapter for Gemini, automation
scripts) can drive follow/unfollow directly.
Kind3FollowListState.follow/unfollow now delegate to FollowActions,
preserving the existing Account.follow(user) signature on Android.
Behavior is unchanged for UI callers.
Wires up amy follow/unfollow as the first consumer — fetches the
freshest kind:3 from outbox relays before mutating so concurrent
follows from another client are preserved.
The previous attempt weighted every item, which made even Share collapse
to the left of its slice instead of pinning to the right edge.
Restore the natural-width carve-out for the last item, but gate it on
`!showCounter` — Share/Pay have no counter so they stay flush against
the right padding as before; Zap/Like/etc. become weighted when last so
the counter doesn't sprawl out to the edge and the row stays balanced.
LocalPreferences.setDefaultAccount called setCurrentAccount before
saveToEncryptedStorage. setCurrentAccount emits the new list onto the
savedAccounts MutableStateFlow, which AlwaysOnNotificationServiceManager
collects and reacts to by calling loadAccountConfigFromEncryptedStorage
for every saved account — including the just-added one. That call hit
encryptedPreferences(newNpub) before NOSTR_PUBKEY had been written, got
null, and cached the null in cachedAccounts.
cachedAccounts is a process-lifetime map, so the poisoned entry survived
the eventual disk write. Every subsequent switchUser to that account
took the cached null path, fell through to requestLoginUI(), and AccountScreen
rendered LoggedOffSetup — the onboarding screen with TOS unchecked, asking
the user to re-do the Amber handshake.
Write the per-npub file first, then seed the cache with the in-memory
AccountSettings, then publish onto the savedAccounts flow. Also stop
caching null returns in loadAccountConfigFromEncryptedStorage so any
future racy reader can't poison the cache either.
The reaction row gave every item except the last a `Modifier.weight()`,
which made the last item collapse to its natural width and hug the right
edge of the content area. With Share (icon-only) as the default last
item, all icons appeared evenly distributed.
When the user disabled Share, the last weighted slot moved to Zap. Zap
renders icon + counter, so its natural-width row took more space at the
right and pulled the rightmost icon away from where the other icons sat
(each at the left of a now-wider weighted slice), leaving the row
looking unbalanced.
Give every reaction an equal weighted slice so icons sit at the left of
their slice regardless of which reactions are enabled. The unused space
at the end of the last slice naturally provides the right-side padding
where Share used to sit.
Moves the asynchronous chain-verification side of NIP-BC onchain zaps out of
LocalCache into a dedicated OnchainZapResolver class living alongside other
NIP-specific subpackages under model/nipBCOnchainZaps/. LocalCache shrinks by
~240 lines and now owns only the synchronous event-dispatch responsibility:
loading the event, attaching the optimistic UNVERIFIED entry for the sender's
own zap, and delegating the verifier launch to the resolver.
The resolver owns:
- launchVerification(event, source, repliesTo) — async fire-and-forget
- reverifyOnchainZapsForNote(note) — used by the gallery's screen-driven loop
- onchainTipHeightFlow — shared chain-tip poller, lazy + WhileSubscribed
- verifyingEventIds / reverifyingNoteIds — in-flight de-duplication
- reverifySemaphore — parallelism cap
OnchainZapGallery now calls LocalCache.onchainZapResolver.{reverifyOnchainZaps
ForNote, onchainTipHeightFlow} directly. consume(OnchainZapEvent) passes the
already-computed repliesTo into launchVerification so the new-event path
doesn't recompute it on the verifier side.
No behavior change — all 22 onchain-zap tests still pass.
Test referenced formatSats, DEFAULT_ZAP_AMOUNTS, and ZapType which
were removed/made private in upstream merge.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Rewrite SendDialog with sealed state machine that auto-detects input
type (BOLT11, LNURL bech32, lightning address). For LNURL/address:
resolves endpoint, shows amount form with min/max hint, optional
comment field, fetches invoice, then pays via NWC. Strips lightning:
URI prefix. Inline copiable errors with retry.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SendDialog: switch to Dialog+Card with X close, inline copiable error
messages, button resets to "Pay Invoice" on error for retry.
LightningAddressResolver: return error body from callback responses so
server error messages (e.g. "Recipient wallet error") surface to user
instead of generic "Failed to fetch invoice". Also check "message"
field in addition to "reason" for error extraction.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace AlertDialog with Dialog+Card pattern. Invoice created state now
shows centered amount, description, 240dp QR code, and full-width
"Copy Invoice" button. Close via top-right X button. Input form gets
full-width "Create Invoice" button.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add LaunchedEffect that rescans cache when followedUsers populates after
startup, fixing empty feed when contact list arrives after initial scan.
Remove diagnostic println from NwcPaymentHandler.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
LoginScreen's fire-and-forget save coroutine used rememberCoroutineScope
which got cancelled when the composable left composition after login.
Move saveCurrentAccount() to onLoginSuccess in Main.kt which uses the
app-level scope that survives recomposition. Fixes both nsec login and
generate-new-account flows.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Addresses the 15 issues from the second audit pass. Key changes:
- Per-event resolution flag (`Note.onchainZapResolved`) replaces the unbounded
rejection blocklist. The flag is set on terminal verifier verdicts
(Confirmed or hard-Rejected) and gates the verifier launch in `consume()`.
Travels with the Note so it clears on `removeAllChildNotes()`.
- Per-event in-flight set (`verifyingEventIds`) deduplicates concurrent
verifier launches across `consume()` echoes and `reverifyOnchainZapsForNote`
races. Solves: profile-only zaps bypassing the all-CONFIRMED guard,
Rejected entries re-firing the verifier on every echo, and the
consume()/reverify TOCTOU race.
- Per-note reverify gate (`reverifyingNoteIds`) prevents multiple visible
galleries from launching concurrent reverify passes for the same note.
- `removeOnchainZapForSource` now refuses to remove a CONFIRMED entry — only
an explicit fresh CONFIRMED replacement can change one. Prevents the
cross-target downgrade where one target's transient ZERO_VERIFIED_AMOUNT
erases a sibling target's already-confirmed entry. Also non-nullable
pubkey parameter to close the null-vs-null comparison hole.
- `innerAddOnchainZap` dedup tightened: exact structural equality skips
spurious flowSet invalidations on relay echoes, but same-level + equal
verifiedSats from a DIFFERENT source now replaces (fixes multi-signer
attribution lock-in).
- Tip flow uses explicit try/catch that re-throws CancellationException
instead of `runCatching` (same fix the previous audit applied to the
verifier). Lazy initializer falls back to a constant-null StateFlow if
`Amethyst.instance` isn't initialized yet, instead of throwing.
- Gallery driver: unconditional first-view kick (no longer waits for the
tip flow's first non-null emission), separate effect keyed on pending
entry count so a fresh UNVERIFIED arrival kicks reverify immediately
instead of waiting up to 60s for the next tip poll.
- `observeNoteZaps`'s memoization now keys on the `onchainZaps` map
reference so lightning-zap traffic on the same note doesn't churn the
onchain gallery.
- `reverifyOnchainZapsForNote` uses `supervisorScope` so a single failed
verifier doesn't cancel its siblings, and the semaphore permits bump
from 4 → 8 reduces head-of-line blocking when many galleries reverify
concurrently.
On resizing CDNs the imeta `x` (post-resize hash) can differ from the
`ox` (original hash) embedded in the URL. The bridge previously preferred
`explicitHash` over the URL's sha for "authoritative casing", but the
upstream file on `xs` is named after the URL's sha, not the imeta hash.
For URLs like https://image.nostr.build/<ox>.png with imeta x=<post-resize>
the cache would request /<x>.png and 404 on miss.
Always use the sha parsed from the URL path; drop the explicitHash
parameter. `extractSha256FromUrlPath` already lowercases, so the casing
concern is moot.
- Add kind 1 (replies) to interaction subscriptions
- Key count reads on FlowSet state for reactive updates
- Wire Quote menu item to ComposeNoteDialog with q-tag support
- Add BoostsPopup on long-press repost icon (who boosted)
- ComposeNoteDialog now accepts quoteOf param with nostr: URI pre-fill
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove premature ensureRelayConnected check — NostrClient connects
on subscribe/publish via sendOrConnectAndSync
- Fix disconnect crash: use appScope instead of rememberCoroutineScope
to survive recomposition when nwcConnection goes null
- Surface balance errors/timeouts as snackbars instead of silent swallow
- Add ensureRelayConnected helper to RelayConnectionManager
- Add Phase 2 embedded wallet research doc
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a kind-23195 event arrives signed by someone other than the wallet
service we sent the request to, we now count it on the pending entry and
leave the entry in place so the legitimate reply can still resolve. But
if no legitimate reply arrives and the 30s timeout fires, the user used
to see a generic "Wallet request timed out" — indistinguishable from
"the wallet is just slow", even when an active attacker was forging
replies and dropping the real ones.
Carry the per-request spoof count through to the timeout error message:
- NwcPaymentTracker.PendingRequest gains an AtomicInteger spoofAttempts.
onResponseReceived increments it on WrongAuthor.
- New tracker method spoofAttemptsFor(requestId) reads the count.
- Account exposes nwcSpoofAttempts() and cleanupNwcRequest() so the
UI doesn't need to reach into LocalCache.
- Account.sendNwcRequestToWallet now returns the request event id so
callers can identify the pending entry.
- WalletViewModel.launchTimeout takes a () -> HexKey? provider and
fetches the spoof count when the timeout fires. The error becomes
"Wallet request timed out — N replies were rejected because they
were signed by an unexpected key. Your relay may be untrusted."
Also calls cleanupNwcRequest on timeout to avoid leaking the entry.
Silent on the happy path: a forged reply followed by the real one does
not trigger any user-facing message — the spoof count is discarded with
the matched entry.
Replace the placeholder "N assets bundled" line with a real list of
compact rows for each `e`-tagged Software Asset in the release.
Each row loads the referenced asset event id through
`LoadAssetNote` (uses LocalCache first, falls back to
`checkGetOrCreateNote` for ids never seen) and then
`observeNoteEvent<SoftwareAssetEvent>` — which both observes the
LocalCache flow and registers the note with `EventFinder` so the
relay round-trips the missing asset event. When the asset arrives
the row recomposes with MIME, version, optional variant, size,
platform chips, and a Download link to the asset url.
The standalone `RenderSoftwareAsset` card (kind 3063 in a feed or
thread) is unchanged; this only fills out the release detail view.
filterMissingChannelsById had an inverted isEmpty() check that emitted
zero filters, so kind 40 was never requested from any relay. Channels
discovered from kind 42 messages stayed as empty stubs unless the
creator also happened to publish a kind 41 metadata update findable on
the same relay — which is why most cards in the Public Chats feed
loaded with no name or picture.
Fix:
- Drop the inverted condition; mapOfSet guarantees non-empty values, so
emit a RelayBasedFilter for every (relay, channelIds) entry.
- Widen the relay set per channel to include the user's search and
indexer relay lists. Falls back to DefaultSearchRelayList /
DefaultIndexerRelayList when those lists are empty.
- Plumb the Account through ChannelFinderQueryState so the assembler
can read the search/indexer flows. Mirrors EventFinderQueryState.
Addresses the 15 findings from the high-effort code review on top of the
optimistic-attach fix. Notable behavior changes:
- Per-source removal: `Note.removeOnchainZapForSource(txid, pubkey)` only
drops an entry whose source matches, preventing a spoofed kind:8333 with
the same txid but a bystander recipient from erasing a legitimate
CONFIRMED entry. Rejected (txid, sender) pairs are recorded so a fresh
event id from the same attacker no longer re-flickers into the gallery.
- Sender-only optimistic attach: only the user's own outgoing zap (relay ==
null path) gets the optimistic UNVERIFIED entry. Incoming zaps render
only after on-chain verification, so an attacker-controlled `amount` tag
can't briefly mislead viewers. `claimedSats` is clamped >= 0.
- Reverification across every screen: the chain-tip poller moves from the
thread screen into `LocalCache.onchainTipHeightFlow` (lazy, shared,
WhileSubscribed). The onchain-zap gallery itself drives reverification
whenever it composes with non-CONFIRMED entries — covers home feed,
notifications, profile, channel and single-note views. The gallery
observes the tip flow and the note's zap state, so new arrivals while
the gallery is on screen are picked up too.
- Verifier fan-out + parallelism: re-arrivals skip the verifier launch
when every target note already holds a CONFIRMED entry for the txid.
`reverifyOnchainZapsForNote` now runs verifier calls in parallel,
capped by a 4-permit semaphore.
- Monotonic upgrade based on explicit `OnchainZapStatus.level` instead of
`ordinal`, with a unit test locking the order. Same-level entries with a
larger `verifiedSats` are accepted so a stale indexer estimate isn't
permanent.
- Cancellation propagation: `catch (Throwable)` rethrows
`CancellationException` in `verifyAndUpgradeOnchainZap` so screen-scoped
callers tear down cleanly.
- Memory visibility: `Note.onchainZaps` is `@Volatile` since the
reverification driver reads it on Main while the IO scope writes.
The previous commit dropped `authors` and `#p` from the relay subscription
filter to match Primal's interop shape. Without those, the relay will
deliver any signed kind-23195 event that carries our request id in `#e`,
so an attacker who can observe the request on the relay could forge a
"response" with their own keypair: Amethyst would happily derive a shared
secret from `event.pubKey` (the attacker), decrypt the payload, and
display attacker-controlled balance/transaction data. Even worse,
`paymentTracker.onResponseReceived` removed the pending entry on first
match — so the legitimate wallet reply that followed was silently dropped.
Move the author check from the relay layer into NwcPaymentTracker:
- `registerRequest` now requires the expected wallet-service pubkey
(read from the request's `p` tag). LocalCache extracts it during
`consume(LnZapPaymentRequestEvent)` and refuses to register if the
request has no `p` tag.
- `onResponseReceived` takes the response author and returns a sealed
MatchResult of NoMatch / WrongAuthor / Matched. A WrongAuthor result
leaves the pending entry in the map so the legitimate response can
still resolve it.
- Android LocalCache and DesktopLocalCache both adopt the new API and
log a warning on suspected spoof attempts.
End-to-end the response is still encrypted under the per-connection shared
secret, so this is a second layer of defence rather than the only one,
but matching the author keeps a forged kind-23195 from consuming the
pending slot and DoSing the legitimate reply.
Wire NIP-82 Software Applications (kind 32267), Releases (kind 30063)
and Assets (kind 3063) into the Quartz event model and surface them
through a dedicated rendering path in Amethyst.
Quartz: extend the existing experimental NIP-82 builders with topic
(`t`) and NIP-34 app-link (`a`) helpers, and add a small detector
(`isNip82SoftwareRelease`/`asSoftwareRelease`) so kind 30063 events
can be disambiguated from NIP-51 ReleaseArtifactSetEvent at the
renderer layer. Pin behavior with unit tests covering build paths,
disambiguation, and the real-world Amethyst NIP-82 description event.
Amethyst: add modern card visualizations for each kind — application
header with icon/screenshots/platforms/topics/links, release header
with channel pill and bundled-asset count, and asset row with MIME,
size and platforms — and dispatch to them from both `NoteCompose`
and `NoteMaster` (`ThreadFeedView`).
A new "Apps" feed (left nav drawer) mirrors the Picture Feeds shape:
`SoftwareAppsFeedFilter` reads kind 32267 from `LocalCache`, a
`PerUserEoseManager`-backed subscription pulls applications and
releases from outbox relays, and a dedicated screen renders them in
a `LazyColumn` of `RenderSoftwareApplication` cards.
When the first item of either the pinned or unpinned block changes,
animate-scroll back to index 0 if the user was at or near the very top.
This mirrors the pattern from ChatFeedView so a new chat bubbling up
doesn't leave the user one row below it.
- Pin glyph moved from top-right (overlapping LikeReaction/ZapReaction)
to top-left, inside a surface-tinted circular badge that overlays the
cover image corner. Reads against any cover image and frees the
reaction buttons from being eclipsed and hit-tested through.
- 8dp Spacer item inserted between the pinned items{} and unpinned
itemsIndexed{} blocks, only when both sides have content, so the
section boundary reads as a section break instead of just another row.
Some wallet services and relays don't produce or index the `p` tag on NIP-47
response events the way the spec implies for ephemeral kinds, which made
Amethyst's strict relay-side filter (kinds + authors + #e + #p) match
nothing while looser clients (Primal uses just kinds + #e) work against the
same connection string.
Reduce the relay filter to the same shape Primal uses. The request event id
in #e is a unique 32-byte identifier, so the false-positive rate is
effectively zero, and the wallet's identity is still authenticated end-to-end
by NIP-04 decryption against the per-connection shared secret — the relay
filter was never the security boundary.
NWCPaymentQueryState no longer needs `fromServiceHex` or `toUserHex`; remove
them and propagate the simpler ctor through callers.
Overlay a small push-pin glyph at the top-right of pinned channel cards
so the user can tell at a glance why those rows are at the top. The
underlying ChannelCardCompose is untouched; only the row wrapper changes
to a Box to host the overlay.
Outgoing onchain zaps never appeared in the sender's thread view because
LocalCache.consume(OnchainZapEvent) ran the chain verifier milliseconds
after the broadcast — before the backend's indexer had picked up the
transaction. The resulting TX_NOT_FOUND rejection skipped addOnchainZap,
and the duplicate guard blocked re-verification when the same event
later echoed back from relays.
Attach kind:8333 entries optimistically as UNVERIFIED with the claimed
amount so the sender sees their zap on the thread immediately, then
upgrade to PENDING/CONFIRMED as the chain catches up. Hard rejections
(zero-paid-to-recipient, missing tags) drop the entry; transient
TX_NOT_FOUND keeps it UNVERIFIED for a later retry. ThreadScreen now
re-verifies non-confirmed entries on view and again whenever the chain
tip advances.
Two problems caused wallet timeouts that affected only Amethyst users:
1. The NWC subscription went through a 500 ms BundledUpdate debounce while
the request event was published immediately. Kind 23195 responses are
ephemeral, so if the wallet replied faster than the REQ reached the
relay, the reply was dropped with no replay. Add a synchronous
subscribeAndFlush() that bypasses the bundler so the REQ is queued on
the WebSocket before the EVENT.
2. Three failure paths were swallowed: decryption returning null, an
unknown response subtype, and a response arriving with no matching
pending request. Users saw "Wallet request timed out" with no clue.
Surface a specific error in WalletViewModel for the first two, and
log a warning in LocalCache for the third.
Native-speaker review surfaced consistency and wording fixes:
- Calendar collection naming: use "kalender" for action labels
(New / Edit / Delete) matching the English source's user-facing
simplification; use "kalenderlista" only in the empty-state body
and route header (matches route_calendar_collections =
"Kalenderlistor" and English's "Calendar lists"). Same hybrid as
Czech, pt-BR, and German.
- Capitalize "Nostr" in calendar_share_nostr.
- "on-chain-backend" over "chain-backend" in wallet_onchain_no_backend
(rest of batch already uses the on-chain- compound).
- "blockchainen" (definite form, matches existing ots_info_description)
over bare "blockchain" in wallet_onchain_public_dialog_body.
- Tighten Lightning comparison: "större än för Lightning" over
"större än Lightning" (the bare comparison was elliptical).
- wallet_onchain_public_dialog_body: clearer privacy wording
("fyll på från icke-privata konton och skicka tillbaka medel dit"
over "fyll på och töm från och till"); make antecedent of "spendera"
explicit ("spendera medlen" over ambiguous "spendera dem").
- Symmetric RSVP pair: "Kommer" / "Kanske" / "Kommer inte"
(instead of mixed "Kommer" / "Kanske" / "Kan inte"). Matches the
same fix applied for German and pt-BR.
Note: calendar_rsvp_section / calendar_rsvp_none already use the
established Swedish "OSA" acronym (matching kind_appt_rsvp =
"OSA för möte"), so no rename needed unlike de/pt-BR where the
section noun ("Zusagen"/"Confirmações") had to be replaced.
Native-speaker review surfaced consistency and wording fixes:
- Calendar collection naming: use "Kalender" for action labels
(New / Edit / Delete) matching the English source's user-facing
simplification; use "Kalenderliste" only in the empty-state body
and route header (matches route_calendar_collections =
"Kalenderlisten" and English's "Calendar lists"). Same hybrid as
Czech and pt-BR.
- On-Chain hyphenation: "On-Chain-Backend" over "Chain-Backend"
in wallet_onchain_no_backend; "Stattdessen On-Chain senden" over
"Stattdessen on-chain senden" in send_onchain_instead. Matches the
rest of the batch's German compound-noun convention.
- "Nowhere Event" over "Nowhere Veranstaltung" — aligns with the
English source's all-English Nowhere brand prefixes (Drop / Forum /
Petition were already English).
- "RSVPs" / "Antworten" over "Zusagen" for calendar_rsvp_section and
calendar_rsvp_none — section also includes "Vielleicht" and "Komme
nicht", so "Zusagen" was misleading. Aligns with the existing
"RSVP für Termin" usage in kind_appt_rsvp.
- Symmetric RSVP pair: "Komme" / "Vielleicht" / "Komme nicht"
(instead of mixed "Komme" / "Vielleicht" / "Kann nicht").
- wallet_onchain_public_dialog_body: clearer privacy wording
("lade auf und sende zurück" over "befülle und entleere von und
zu"); make antecedent of "ausgeben" explicit ("die Mittel
ausgeben" over ambiguous "es").
- contact_list_containing_label: rephrase to dodge German declension
for n=1 ("Folgenliste (%1$d Nutzer):" over "Folgenliste mit %1$d
Nutzern:" which is dative-plural-only).
Native-speaker review surfaced consistency and wording fixes:
- Capitalize "Nostr" in calendar_share_nostr.
- "no blockchain" (masculine, matches ots_info_description) over
"na blockchain" in wallet_onchain_public_dialog_body.
- Calendar collection naming: use "calendário" for action labels
(New / Edit / Delete) matching the English source's user-facing
simplification; use "lista de calendário" only in the empty-state
body and route header (matches route_calendar_collections = "Listas
de calendário" and English's "Calendar lists"). Same hybrid we
resolved for Czech.
- Symmetric RSVP pair: "Vou" / "Talvez" / "Não vou" (instead of mixed
"Vou" / "Talvez" / "Não posso").
- "RSVPs" over "Confirmações" for calendar_rsvp_section and
calendar_rsvp_none — the section also includes "Talvez" / "Não vou"
responses, so "Confirmações" was misleading. Aligns with the
existing "RSVP do Compromisso" usage in kind_appt_rsvp.
- wallet_onchain_public_dialog_body: clearer privacy wording
("abasteça a partir de contas não privadas e envie os fundos de
volta" over "abasteça e esvazie a partir de e para"); make antecedent
of "gastar" explicit ("gastar os fundos" over ambiguous "-lo").
- calendar_empty_feed: reorder to drop trailing "ainda" — "Ainda não
há eventos…" reads more naturally than "…ainda" at end.
- calendar_collection_delete_confirm_message: align tenses (future +
future "será removido / não serão excluídos" over future + present).
- "backend on-chain" over "backend de chain" in wallet_onchain_no_backend.
Note: contact_list_* strings use the semantically-correct "seguidos"
(people you follow, per kind:3), even though existing
follow_list_selection / follow_sets use the inverted "seguidores".
Recommended follow-up: clean up legacy strings to match.
Native-speaker review surfaced several consistency and wording fixes
in the Czech translations from this branch:
- Use "Kanál" for "feed" (matches existing feed/home_feed/global_feed).
- Use "sats" not "satoshi" (matches new_amount_in_sats / amount_in_sats).
- Match Lightning sibling string's "odstraníte" (vs "odeberete") on
quick_zap_amounts_onchain_explainer.
- Capitalize "Nostr" in calendar_share_nostr.
- Calendar collection naming: use "kalendář" for actions (New / Edit /
Delete) matching the English source's user-facing simplification;
keep the disambiguating "seznamy" wording for the empty-state body
and title (matches route_calendar_collections = "Seznamy kalendářů"
and English's "No calendar collections yet").
- Rephrase contact_list_containing_label to dodge Czech count
inflection ("Seznam sledování (%1$d):" rather than "obsahující %1$d
uživatelů" which is only correct for n ≥ 5).
- Wallet privacy dialog: "vkládejte do peněženky a vybírejte z ní"
(deposit/withdraw verbs) over "plňte a vybírejte"; "váš nsec" (masc.
inanimate) over "vaše nsec"; "nesoukromých účtů" over "neprivátních".
- "on-chain backend" over "chain backend" in wallet_onchain_no_backend.
clearNwcConnection and setNwcConnection now require npub param and are
suspend functions. ConnectWalletDialog validates URI prefix inline
before firing the async connect callback.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Map Desktop ZapType (Public/Private/Anonymous) through to
LnZapEvent.ZapType and pass to ZapAction.fetchZapInvoice
- Add relayHint + authorRelayHint params to NoteActionsRow;
FeedScreen now passes Note.relayHintUrl() for reactions/reposts
- Replace metadata preload TODO with design rationale comment
- Add 70-test manual testing sheet for wallet & zapping features
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Send, Receive, and Connect are now AlertDialogs instead of full-screen
sub-pages. The wallet home content is centered with widthIn(max=360.dp)
for a polished desktop look. Dialogs are the native desktop pattern
(consistent with zap dialog, bookmark dialog, tor settings dialog).
- ConnectWalletDialog: NWC URI input with paste + validation
- SendDialog: BOLT11 invoice input with paste + progress
- ReceiveDialog: amount/description input, transitions to show
generated invoice with copy button
- Home content: centered balance card + action buttons + connection info
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
getBalance and makeInvoice were publishing the request event before
subscribing for the response. Fast wallet responses (like get_balance)
would arrive before the subscription was active, causing timeouts.
Fix: subscribe first via onSubscribed callback in waitForGenericResponse,
then publish. The original payInvoice wasn't affected because Lightning
routing takes long enough for the subscription to be ready.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Wire AccountManager.setNwcConnection() and clearNwcConnection() into
wallet column for persistent connect/disconnect
- Implement NwcPaymentHandler.getBalance() via NIP-47 get_balance RPC
- Implement NwcPaymentHandler.makeInvoice() via NIP-47 make_invoice RPC
- Add generic waitForGenericResponse() helper for NWC RPC operations
- Auto-fetch balance on wallet column load via LaunchedEffect
- Wire receive screen to generate real invoices via NWC
- All wallet column features now functional (no blocking TODOs)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Instead of sorting followed chats to the top of the existing feed (which
hides them when the feed filter or TopFilter excludes them), render them
in a dedicated items{} block above the rest of the feed and skip them in
the main itemsIndexed{} block. This keeps the feed filter, feedKey, and
screen watchers untouched.
The OnchainZapSendDialog promised a sticky bottom Send button, but the
inner scrollable Column had no weight and the outer Column did not fill
the sheet height. In split mode, SplitsRecipientSection grew the content
past the sheet viewport and pushed the Send button off-screen.
Add ChannelMessageEvent.KIND (NIP-28 kind 42) to the Notifications feed's
allow-list so channel messages that p-tag the user surface in the in-app
Notifications screen, alongside text-note mentions and DMs.
The relay subscription already requests kind 42 with #p (see
FilterNotificationsToPubkey.NotificationsPerKeyKinds), and push notifications
already route ChannelMessageEvent through notifyMention. Only the in-app
feed filter was rejecting them at the kind check; the existing
tagsAnEventByUser gate handles the per-kind "is this for me" rule via the
BaseNoteEvent branch (reply-to-me, cited-author, citation-in-content).
ReactionsRow zap popup
- Merge the Lightning and on-chain chips into a single FlowRow so all
amounts wrap together instead of stacking on two lines.
- Drop the on-chain row's own Tune (settings) button; the Lightning
row's Tune is the single entry point to the settings screen.
UpdateZapAmountDialog
- Reorder sections: Quick Zap Amounts, Zap Privacy, Quick On-chain Zap
Amounts, Nostr Wallet Connect — privacy now sits next to the
Lightning amounts it actually applies to, and on-chain (which has no
privacy concept) is grouped further down.
OnchainZapSendDialog
- Move the preset amount chips above the sats text field in
AmountSection so the user sees the quick picks first and the
free-form input second.
Defaults
- DefaultOnchainZapAmounts is now [10_000] (just one chip) so a fresh
install / first-upgrade shows 4 chips total in the popup (3
Lightning defaults + 1 on-chain default), not 6. kotlinx
serialization defaults handle the back-compat for existing users
who never set their on-chain choices explicitly.
Migrating the mention TextFields to BasicTextField(state) routed them
through ThinPaddingTextField, which uses TextFieldDefaults.DecorationBox
(filled, surfaceVariant container). That replaced the original
OutlinedTextField look — visible rounded border + transparent inside —
with a filled gray rectangle. On ForwardZapTo the change was especially
visible since the original outlined border was the only visual cue that
the search row was an input.
Add OutlinedThinPaddingTextField, a sibling of ThinPaddingTextField that
wraps the same TextFieldState pipeline with
OutlinedTextFieldDefaults.DecorationBox + Container. Same thin padding,
same onTextChanged/inputTransformation/outputTransformation surface, but
genuinely outlined (notched label, transparent inside).
- ForwardZapTo: switch to the new component, drop the
focused/unfocusedIndicatorColor=Transparent overrides — defaults give
the proper outlined border the original OutlinedTextField had.
- EditPostView.MessageField: switch to the new component and use
OutlinedTextFieldDefaults.colors(focusedBorderColor=Transparent,
unfocusedBorderColor=Transparent) to keep the inner border invisible
so the existing 1dp Modifier.border(surface, RoundedCornerShape(8.dp))
remains the only visible frame, matching the original.
The lead-time chip label "%1$d min" pairs a count with a noun, which
inflects in Slavic / Baltic languages. Switch it from <string> to
<plurals> in the default locale and in the three locales that already
have a translation (pl-rPL, zh-rCN, hu-rHU), and update the single
caller in CalendarReminderSettingsScreen to use pluralStringResource.
The abbreviation "min" / "min." / "perc" / "分钟" doesn't decline with
the count in any of these specific languages, but the resource shape
is now correct for future locales that do (e.g. Russian, Ukrainian).
Mocks a kind-3 ContactListEvent with nine p-tags and renders
DisplayContactList in a ThemeComparisonColumn so the card can be
inspected in the IDE preview pane.
Captures the rule we just hit twice in a row (hashtag-limit + calendar
count): any count-bearing string must be <plurals>, every locale must
include the CLDR categories it actually uses, and don't hardcode "1" in
quantity="one" items. Loads automatically whenever a file under res/ is
edited.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Taproot address is derived from the Nostr pubkey, so copying it out
is the moment a user is about to share an account-linked, publicly
auditable wallet. Gate the Copy button on the Onchain card with the same
explanation shown by the Public chip, and offer a "Don't show again"
opt-out persisted in the existing UI preferences DataStore.
The NIP-52 calendar feature shipped two count-bearing strings as <string>
resources ("%1$d events" and "Starts in %1$d minutes"), which forces the
wrong noun form in Slavic languages where the declension depends on the
threshold integer. Convert both to <plurals> in the default locale and in
zh-rCN/pl-rPL/hu-rHU (the locales that already have translations), and
update the 4 callsites — 3 Composables use pluralStringResource and the
CalendarReminderWorker uses the pluralStringRes helper.
pl-rPL and hu-rHU keep only the "other" quantity, preserving existing text
without regression; proper CLDR fan-out (one/few/many for Polish, one for
Hungarian) will come from Crowdin or a native translator.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a NoteCompose render for kind-3 ContactListEvent showing a label with
follow count and a row of the first 6 user avatars (with a "+N" overflow
chip). Tapping the row opens a new ContactListUsersScreen that lists every
followed user with the same UserCompose row used on the search screen.
From an independent audit + my own pass, addressing concrete issues:
OnchainZapSendDialog
- Fee estimate fetch now retries with bounded backoff (4 tries, 1/2/3s
spacing) instead of giving up after one attempt. Covers two real
boot races: LocalCache.onchainBackend not yet wired at first
composition, and a flaky feeEstimates() call. Without retry the
Send button stayed permanently disabled.
- SplitsRecipientSection now indexes preview shares by pubkey once
via remember(previewShares) { associateBy { ... } } instead of an
O(N²) firstOrNull lookup per split row.
- belowDustShares is now wrapped in remember(previewShares) so it
doesn't re-filter the list on every recomposition.
- canSend now also requires resolvedRecipient != senderPubKey in
single-recipient mode, so the user can't tap Send when the only
fallback recipient is themselves (would fail at the builder's
"cannot zap yourself" check).
- formatWeight no longer prints "50.0%" for whole-percent shares —
trailing ".0" is stripped (was a Double->String artifact).
OnchainZapSplitter
- Added distributeUnchecked(): same allocation as distribute() but
never throws on dust; returns every share so the UI preview can
render the full shape in one pass. distribute() (used by the
build/send path) still throws via DustRecipientException so the
real send keeps its dust gate.
- Added check(remainder < splits.size) before the remainder loop to
pin the invariant that bounds remainder.toInt() and the k % size
defensive mod.
- Test for distributeUnchecked.
ReactionsRow / ReusableZapButton / ZapCustomDialog
- baseNote.toEventHint<Event>() is now wrapped in remember(baseNote)
in all three dialog launchers so it's not allocated on every
parent recomposition.
The legacy VisualTransformation paired with TextField(value, onValueChange)
goes through Compose Foundation's LegacyCursorAnchorInfoBuilder, which
throws "endOffset must be greater than startOffset" when the IME has
requested CURSOR_UPDATE_MONITOR and the field's visible bounds momentarily
collapse to zero width. The OutputTransformation API paired with
BasicTextField(state: TextFieldState) routes through a different,
non-legacy cursor anchor info controller and does not hit that bug.
Migrate the two remaining VisualTransformation call sites
(EditPostView.MessageField and ForwardZapTo) to the OutputTransformation
+ TextFieldState path. This requires:
- IZapField: forwardZapToEditting becomes TextFieldState; the
TextFieldValue-shaped updateZapForwardTo() callback is replaced by
onForwardZapTextChanged() which reads the current state.
- 8 ViewModels (ChannelNewMessage, ChatNewMessage, NestNewMessage,
NewProduct, LongFormPost, NewPublicMessage, ShortNotePost,
CommentPost): clear via clearText() instead of reassigning a new
TextFieldValue; rewrite onForwardZapTextChanged() to read from
forwardZapToEditting directly.
- ForwardZapTo: switch from OutlinedTextField to ThinPaddingTextField
with MentionPreservingInputTransformation +
UrlUserTagOutputTransformation.
- EditPostViewModel: message becomes TextFieldState; updateMessage()
becomes onMessageChanged() (the state updates itself); the in-place
replaceCurrentWord/insertUrlAtCursor TextFieldState overloads
replace the value-based ones.
- EditPostView: MessageField now uses ThinPaddingTextField with the
OutputTransformation. Also drops a stale commented-out subject row
that still referenced the legacy transformation.
- Delete UrlUserTagTransformation and its androidTest (no callers
remain).
- Remove MainThreadCrashGuard and its hook in Amethyst.onCreate: the
legacy cursor-anchor-info crash surface is gone with the remaining
mention TextFields, so the framework workaround is no longer
earning its keep.
Adds a "Send on-chain instead" link button at the bottom of
ZapCustomDialog. Tapping it opens OnchainZapSendDialog with the entered
amount and message prefilled, the note as zappedEvent, and the same
split-detection / dust-preview / fee-tier picker that the Reactions
zap chip uses.
This also fixes the participant zap path in nests audio rooms
(ParticipantHostActionsSheet long-press → "Zap") since that menu item
opens ZapCustomDialog under the hood — fixing the custom dialog covers
both entry points transitively.
OnchainZapSendDialog gains a `prefillComment: String = ""` parameter so
the message field carries over from the LN dialog.
Poll-note zaps (FilteredZapAmountChoicePopup) intentionally stay
Lightning-only — the poll_option vote is encoded in the kind:9734 zap
request and counted via kind:9735 receipts; kind:8333 on-chain
receipts have no poll_option analog and the count infrastructure
doesn't index them. This is a protocol design constraint, not a UI
oversight.
Coverage audit summary — every user-facing zap initiation point now
has on-chain support except the poll-voting path:
- ZapReaction (reactions row) ✅
- ZapAmountChoicePopup (popup chips) ✅
- ZapCustomDialog (custom amount + message) ✅ (this commit)
- ReusableZapButton (Zap the Devs + DVM buttons) ✅
- NestActionBar (audio room) ✅
- ParticipantHostActionsSheet (audio room participant) ✅ (transitive)
- ChatMessageCompose / live-activity headers (use ZapReaction) ✅
- FilteredZapAmountChoicePopup (poll votes) ⚠️ LN-only by protocol
ReusableZapButton now passes the user's on-chain zap amount choices to
ZapAmountChoicePopup and renders OnchainZapSendDialog when a chip is
tapped. The dialog receives the release note as the zappedEvent, so
the existing split detection picks up the kind:1 release notes' zap
splits and pays the dev team via one Bitcoin tx with N receipts.
The donation card is the canonical multi-recipient on-chain zap flow —
release notes are tagged with weighted splits across the team, the
sender's own pubkey gets filtered out, and the per-recipient share
preview shows live as the amount is typed.
Other callers of ReusableZapButton (DVM zap buttons, etc.) get the
on-chain row automatically since it's driven by the user's settings;
empty on-chain amounts list hides the row, matching prior behavior.
Audit findings from an independent code review:
- HIGH: When the user zaps their own post (a common flow), every split
that included the post author put the sender on the recipient list,
and OnchainZapBuilder.buildSplit refused the whole tx with "cannot
zap yourself". Fix: new OnchainZapSplitter.prepare() filters the
sender's pubkey out of the splits before they reach the builder.
- HIGH: NIP-57 lets the same pubkey appear in zap-split tags more than
once (additive weights). buildSplit rejected duplicate recipients.
Same prepare() helper merges duplicates by summing weights, in
first-seen order.
- HIGH: The dialog's live preview only showed amounts for recipients
whose share was BELOW dust (because DustRecipientException only
carries belowDust). Fix: parent composable computes shares with a
zero dust threshold for the preview, gating the Send button on a
separate belowDustShares check so the user can see all amounts and
can't tap Send into a guaranteed BUILDING-stage failure.
- MEDIUM: OnchainZapSendResult.Failure didn't carry the ids of
receipts that successfully published before a partial-publish
failure. Added publishedReceiptEventIds: List<HexKey>.
- LOW: useSplits state was keyed by zappedEvent reference; re-emitted
bundles would silently reset the toggle. Now keyed on the event id.
Tests added:
- splitter: prepare() drops sender, merges duplicates, filters
non-positive weights; floating-point weights (0.1 + 0.2) sum exactly
- builder: buildSplit produces N recipient outputs + 1 change at index
N, conserves sats, rejects duplicates and below-dust shares
- sender: sendSplit publishes one receipt per recipient sharing the
txid with correct per-recipient amount; partial-publish failure
carries the broadcast txid and the ids of receipts that did publish
When pickRelaysToLoadUsers exhausts every candidate tier (outbox / hints /
index / search / connected / common) for a user without finding kind 10002,
it returns an empty map and the REQ closes. A late-published relay list
from that user would never reach the UI for the rest of the session
(unless EOSEAccountFast's LRU evicts the entry).
Compute the set of users we couldn't place this pass and add a permanent
fallback filter on DefaultIndexerRelayList + DefaultSearchRelayList. The
filter is deterministic (sorted authors, set-based relays) so the relay
client de-duplicates it across passes, keeping the REQ open without
re-sending — any future kind 0 / kind 10002 publication will be pushed
to us live.
Offline relays are subtracted via failureTracker.cannotConnectRelays.
Extends NIP-BC onchain zaps to honor a note's NIP-57 zap-split tags: one
Bitcoin transaction pays every pubkey-based recipient atomically, and
one kind:8333 receipt is published per recipient (each receipt carries
the recipient's pubkey + sat share and shares the same i:<txid>).
quartz / OnchainZapBuilder
- new buildSplit(recipients = listOf(pubkey to sats), ...) produces a
PSBT with one output per recipient + optional change output
- existing build(...) now delegates to buildSplit; coin selection and
change-vs-dust logic are unchanged for the single-recipient path
commons / new OnchainZapSplitter
- distribute(totalSats, splits, dustThreshold) does the weighted
integer-math allocation, dropping the rounding remainder onto the
largest-weight recipient first so the per-recipient sats sum exactly
to totalSats
- throws DustRecipientException if any share lands below dust; the
caller surfaces that as a build-stage failure before the tx is built
- unit tests cover equal weights, fractional weights, remainder
distribution, dust rejection, and input-order preservation
commons / OnchainZapSender.sendSplit
- mirrors send() but takes the precomputed shares, builds via
buildSplit, and publishes N receipts using the same txid; if one
receipt publish fails the broadcast txid + already-published receipt
ids are surfaced in the Failure result
amethyst / Account.sendOnchainZapWithSplits
- thin wrapper that hands off to OnchainZapSender.sendSplit using the
signer's pubkey
amethyst / OnchainZapSendDialog
- detects pubkey-based zap splits on the zappedEvent and, when present,
defaults to split mode: a SplitsRecipientSection renders one row per
recipient with weight % and live per-recipient sats preview
- lnAddress-only splits are filtered out (no pubkey -> no Taproot
address); a short note tells the user how many recipients were
skipped
- the send button label switches to "Send X sats, N ways"; an opt-out
button lets the user fall back to single-recipient mode
- on send: shares are recomputed via OnchainZapSplitter; below-dust
configurations surface as a BUILDING-stage failure before signing
Compose Foundation's LegacyCursorAnchorInfoBuilder.addCharacterBounds
computes a TextRange(startOffset, endOffset) over the visible portion of
a TextField when the IME has requested CURSOR_UPDATE_MONITOR. During a
layout transition (sheet animation, keyboard insets settling, etc.) the
visible region can momentarily collapse so startOffset == endOffset, and
MultiParagraph.fillBoundingBoxes throws "endOffset must be greater than
startOffset". This is a framework bug we can't fix from app code, and
it crashes the whole process from an onGloballyPositioned callback
during dispatchDraw.
Install a main-thread crash guard at app startup that wraps Looper.loop()
and swallows only this very specific signature (IllegalArgumentException
with the exact message AND a stack frame in LegacyCursorAnchorInfo* /
MultiParagraph* / AndroidParagraph / TextLayout). Anything else
re-throws into the default uncaught handler so UnexpectedCrashSaver
still records real bugs. The IME just misses a cursor update on the
swallowed frame — no user-visible impact.
The Blossom entry for nostrcheck.me was dropped in bcd2bc06
("Removes nip96 and updates Blossom recommendations", v1.06.0,
Feb 2026) along with the NIP-96 cleanup. This re-adds only the
Blossom endpoint.
Adds a second row of bitcoin-orange chips to the zap amount popup that
opens the existing OnchainZapSendDialog prefilled with the chosen amount
and the note's author + zappedEvent. The on-chain stack (kind 8333,
OnchainZapSender, Account.sendOnchainZap, OnchainZapSendDialog) already
existed end-to-end; only the entry point from the reactions row and the
on-chain preset amounts were missing.
Settings:
- AccountZapPreferencesInternal: new onchainZapAmountChoices (defaults
10k/50k/250k sats); back-compat via kotlinx.serialization defaults
- AccountSyncedSettings / AccountSettings / Account.updateZapAmounts:
wired through end-to-end alongside the existing Lightning list
- UpdateZapAmountDialog: new "Quick On-chain Zap Amounts" section with
its own chip list and add-amount field; the existing zap-privacy
dropdown stays Lightning-only since on-chain has no zap type
Reactions UI:
- ZapAmountChoicePopup now collects both choice lists and offers an
on-chain row in addition to the Lightning one
- New on-chain callback opens OnchainZapSendDialog with the prefilled
amount; the existing dialog gains a prefillAmountSats parameter and
now reads the on-chain preset list for its own quick-pick chips
- Other zap entry points (ReusableZapButton, NestActionBar,
FilteredZapAmountChoicePopup) are unchanged: on-chain row defaults
off so they keep their existing Lightning-only behavior
The bundled Material Symbols font ships only the codepoints referenced
from MaterialSymbols.kt. Without regenerating the subset, newly added
icons render as tofu at runtime. Document this as a mandatory step in
CLAUDE.md so agents pick it up automatically.
Move the chip label, dialog title/body, and confirm button text out of
the composable and into strings.xml under wallet_onchain_public_* so they
can be translated via Crowdin.
Replace the vague "private channels" wording with concrete advice: fund
and drain via non-private accounts (e.g. exchanges), never mix with cold
wallets, and treat the balance as funds anyone with the nsec can spend.
- Extract PaymentTargetsDialog from PaymentButton so the reactions row
can render the same QR/Copy/Pay layout instead of the legacy two-row
M3ActionRow per target.
- PayReaction now subscribes via EventFinderFilterAssemblerSubscription
and observes the PaymentTargetsEvent reactively, so targets actually
load when the wallet icon is tapped.
- Correct the QrCode2 codepoint (U+E00A) and re-subset the bundled
Material Symbols Outlined font so the glyph is no longer a tofu box.
https://claude.ai/code/session_01JtJuvSYMusKiDMWo7N8Aj8
The onchain wallet's Taproot address is derived from the account's Nostr
pubkey, so anyone with the npub can see its balance and transaction
history on-chain. Surface that fact directly on the card with a tappable
"(i) Public" chip that opens a dialog explaining the privacy implication
and recommending private (non-Nostr) channels for funding and draining.
Each target now renders in a single row with the payment method and
address on the left and three trailing icon buttons: QR (opens a dialog
showing the raw address as a QR code), Copy (clipboard + toast), and
Pay (existing payto:// intent). Adds QrCode2 to MaterialSymbols.
https://claude.ai/code/session_01JtJuvSYMusKiDMWo7N8Aj8
srcDirs(vararg) on AGP 9 source-set Resources is deprecated; switch to
the directories MutableSet API. Preserves the original behavior of
including quartz/src/androidDeviceTest/resources in :benchmark's
androidTest java resources (verified bip39.vectors.json shows up in
processBenchmarkAndroidTestJavaRes output).
Converts the last four Groovy build scripts to Kotlin DSL so the entire
build is consistent with the rest of the modules (commons, quartz, etc.).
- settings.gradle -> settings.gradle.kts
- build.gradle (root) -> build.gradle.kts
- amethyst/build.gradle -> amethyst/build.gradle.kts
- benchmark/build.gradle -> benchmark/build.gradle.kts
Notes:
- Switched the per-subproject spotless format from groovyGradle to
kotlinGradle now that the only gradle scripts are .gradle.kts.
- benchmark module: moved targetSdk from defaultConfig to testOptions —
AGP 9 removed targetSdk from library defaultConfig.
- Reimplemented getCurrentBranch() with ProcessBuilder (Groovy's
String.execute() isn't available in Kotlin DSL).
Accounts whose reactionRowItems were persisted before ReactionRowAction.Pay
existed still load a 5-item list, so Pay never appears in Reaction Settings.
Add mergeWithDefaultReactionRowItems mirroring mergeWithDefaultVideoPlayerButtons
and apply it where reaction rows enter the StateFlow (constructor + updateFrom),
appending any default actions missing from the saved list while preserving
the user's existing order and toggles.
Capture the crashing thread's name from the UncaughtExceptionHandler and
surface it in the report (both in the device-info table and at the top of
the stack-trace block), so main-thread crashes can be distinguished from
coroutine-worker crashes at a glance.
The new Tune button inside the zap popup already opens the quick-zap
amounts editor, so the long-press shortcut from the zap button itself
(and from each chip inside the popup) is now redundant. Drop those
gestures and replace them with the custom-amount dialog that used to
live behind double-click:
- ZapReaction's button: long-press now opens ZapCustomDialog;
double-click is gone.
- Each chip in the popup: long-press now opens ZapCustomDialog
(via a new onCustomAmount callback threaded through the popup
overloads).
- NestActionBar's zap button wires onCustomAmount to its existing
wantsToSetCustomZap state.
- ReusableZapButton has no custom-zap dialog, so chip long-press
just dismisses the popup there.
The Tune button used the 40dp reactionBox with a 28dp icon, which was
visibly larger than the surrounding bolt chips. Use a compact 32dp box
with a 18dp icon so it lines up with the bolt glyph inside each chip.
The bolt-icon pill chips and the Tune settings button have different
intrinsic heights, so FlowRow's default top alignment leaves the
settings button visually higher than the chips. Set itemVerticalAlignment
so every item is centered within the row.
Media3's MediaSessionService.onStartCommand calls stopSelfSafely() when
delivered an intent (e.g. MEDIA_BUTTON from a headset) while no playback
is active. stopSelfSafely() invokes startForeground() to detach the
notification before stopping, which Android 12+ rejects when the app is
backgrounded with ForegroundServiceStartNotAllowedException.
Swallow that case in onStartCommand and stopSelf, matching the pattern
already used in NotificationRelayService.
https://claude.ai/code/session_01KTZDxK7zacrhFfqMHqt8dm
Wrap the zap-amount choices in the same elevated card / surfaceVariant
container used by the reactions popup, replace the flat primary-colored
material Buttons with pill-shaped Bitcoin-orange chips that pair a bolt
icon with the amount, and add a Tune action at the end of the row (the
zap equivalent of the AddReaction button in the reactions popup) so the
user can jump straight to editing their quick-zap amounts.
Use LocalConfiguration.current.locales[0] so the date formatter recomposes
when the device locale changes, satisfying the NonObservableLocale lint
check.
The Notifications feed could occasionally render as two stacked
LazyColumns — visible as ghosted text and "only the top one scrolls"
because both LazyColumns share the same `listState`. Reproduced by
double-tapping the bottom-nav Notifications button (each tap calls
`invalidateDataAndSendToTop(true)` → `clear()` + `refreshSuspended()`).
`RenderCardFeed` wrapped its `when (feedState)` in `CrossfadeIfEnabled`
→ `MyCrossfade`. `CardFeedState.Loaded` is a plain class with identity
equality, so back-to-back refreshes that transit through `Empty` or
`Loading` and back to `Loaded` inside the 100 ms animation can leave
two `Loaded` instances pinned in `currentlyVisible`. Each one composes
its own `FeedLoaded { LazyColumn(...) }`, both bound to the same
`LazyListState`, and only the topmost gets scroll input.
Switch directly on `feedState` with no crossfade — the animation was
only 100 ms and barely perceptible. Other CrossfadeIfEnabled callers
are untouched.
The remember(...) call returned Unit, which tripped the
RememberReturnType lint. Use LaunchedEffect, which is the
idiomatic Compose API for keyed side effects.
Two things turned out to be the same bug:
1. New calendar events arriving from relays didn't show up in either
feed without a manual pull-to-refresh. The screen was stuck on
whatever the last full refresh saw.
2. After a top-nav filter switch the feed looked like it was reflecting
"a previous state" — same root cause: filter switch ran a full
refresh from LocalCache, but events that the new subscription
subsequently delivered were dropped on the floor.
AccountFeedContentStates.updateFeedsWith / deleteNotes had calls for
every other feed but neither calendarAppointmentsFeed nor
calendarCollectionsFeed — so LocalCache.live.newEventBundles flowed
past them. Added both calls (and the matching deleteFromFeed entries)
so new appointments/collections insert into the visible feed live.
Also dropped the redundant "Calendar Lists" label above the top-nav
filter spinner on the collections screen — it duplicated the screen
title shown by the navigation chrome.
When loadRelayInfo was invoked from a main-thread Compose coroutine and
the call got cancelled, okhttp's executeAsync cancellation handler
closes the Response on the resuming dispatcher (AndroidUiDispatcher).
Closing the response drains any unread bytes from the SSL socket, which
triggers a blocking read on the main thread and throws
NetworkOnMainThreadException — surfaced to the user as
CompletionHandlerException.
The previous withContext(Dispatchers.IO) was nested inside the .use {}
block, so neither the initial suspension at executeAsync() nor the
implicit response close in the cancellation handler ran on IO. Moving
the dispatcher switch to wrap the entire function ensures the
continuation is dispatched on IO and the cancellation/close path runs
there too.
Self-audit of the previous MLS reply commit surfaced four concrete
improvements:
1. Push-notification cold-start replies now thread reliably. The
receiver previously rebuilt the parent inner event by looking it up
in LocalCache; in a cold-process broadcast that cache hasn't been
re-hydrated yet (Account.restoreAll runs async on init), so the
q-tag silently dropped. Carry the parent's inner event id AND
author pubkey through the Intent extras and feed them straight to
MarmotManager.buildTextMessage, which now takes (eventId, author)
instead of a full Event. The cold-start reply is now always
threaded, not just the warm-cache case.
2. marmotGroupRelays() is no longer duplicated. The receiver was
reimplementing what AccountViewModel had as a private fun (which
itself was used 9× inside the VM). Lifted to Account so headless
callers can reach it without spinning up a ViewModel; both sites
now share one implementation.
3. Dropped the dead editFromDraft / draftId plumbing. Added for route
symmetry with NIP-17 but Marmot has no draft persistence, so the
parameter rode all the way through MarmotGroupChatView only to be
ignored under @Suppress("UNUSED_PARAMETER"). Per CLAUDE.md, don't
pre-emptively abstract; reinstate when drafts actually land.
4. MarmotGroupMessageComposer no longer has default-param `remember`
blocks. There's exactly one caller and it always passes both
messageState and replyTo — the defaults were just noise.
The in-chat send path also captures (id, pubKey) under the replyTo
guard before launching the send coroutine, so a slow send + a user-
cleared reply state can't race into a partially-threaded message.
No protocol or behavioral change for the warm-cache happy path; the
threading improvement is observable only on cold-start push-reply.
Calendar collections (kind 31924) on the Calendar Lists screen routed
through Route.Note → ThreadView, which rendered them as a regular note
with the calendar metadata block at the top — no idea that the
collection is a curated list of appointments.
Fix:
- CalendarCollectionsView: route taps to Route.CalendarEventDetail
(using the collection's own Address) like every other calendar surface.
- CalendarEventDetailScreen: branch on event kind. CalendarEvent (31924)
now renders a new CollectionBody — title, description, ReactionsRow,
and a list of the collection's member appointments. Each member row
uses observeNote so an appointment that arrives later in the session
fills in without leaving the screen; tapping a row routes to its
appointment detail.
The "Add to phone calendar" icon I added on the calendar detail screen
top bar mapped to MaterialSymbols.EventAvailable (\\uE614), but the
checked-in TTF was a subset built before that codepoint existed in
MaterialSymbols.kt — so the third icon from the right rendered as the
.notdef placeholder.
Regenerated via tools/material-symbols-subset/subset.sh. The subset is
now 214 codepoints (up from 213); file size unchanged at 420K.
LocalCache stores notes via WeakReference (LargeSoftCache), so while the
user is on People List B the GC can reclaim the global calendar notes
that aren't strong-referenced from B's UI. When the user flips back to
Global, the relay subscription rebuilds with `since = lastEose` for
Global — which still says "you already have everything up to T" from the
prior visit. The relay won't re-send events with createdAt < T, and the
ones that were evicted from LocalCache stay gone. Symptom: switching
back to Global shows fewer events, or only past events, or nothing.
Fix: when the calendar listName changes, drop the EOSE cursor for the
list we're switching to before invalidating filters. The next assembly
runs with a fresh `since` (defaultSince / oneMonthAgo) and the relay
re-streams the events the cache lost. Added a `clear` method on
EOSEByKey + EOSEAccountKey and a protected `clearEoseFor(key)` helper
on PerUserAndFollowListEoseManager.
Same shape of bug likely affects pictures/discovery/etc. that share this
assembler pattern — left those alone for now and can apply the same
fix if reported.
Two related fixes for the calendar surface:
1. Filter switch leaving the user in the past section. The feed-content
state already calls sendToTop() when feedKey changes, but the calendar
feed's LazyColumn never subscribed to the scrollToTop signal — so a
filter switch (e.g. People List → Global) preserved the previous
scroll position. Often that landed the user mid-feed in the "Past"
section of the much larger Global list, looking like "the events
didn't come back". Added a LazyListState + WatchScrollToTop wiring.
2. Week/month/day headers stayed pinned when the DisappearingScaffold's
top bar collapsed on scroll. The views had a Column with
disappearingScaffoldPadding() that reserved a fixed top inset, so when
the bar slid up the headers stayed put and a visual gap opened. Pulled
the nav header (and for the week view, the WeekStrip + DaySummaryHeader)
into the same LazyColumn as the events, with rememberFeedContentPadding
as contentPadding — now the whole stack scrolls under the bar together.
The outer scroll Column had verticalArrangement.spacedBy(16.dp), which
compounded with the ReactionsRow's own internal vertical padding to make
the row look like it had huge top/bottom margins (~22dp on each side).
Drop the blanket spacedBy and place spacing deliberately: small explicit
Spacers between divider-separated sections, none around the ReactionsRow
itself so its 6dp internal padding is what shows.
- The calendar event/collection cards' top user header showed the note's
createdAt time-ago, which read as "the event time" at a glance — but
it's actually the publication time. Added `showTimeAgo` to UserCardHeader
and pass `false` from both calendar card surfaces so the only timestamp
on the card is the event's own start/end.
- Replaced the New Calendar Event participant input (paste an npub or
64-char hex) with the standard UserSuggestionState + ShowUserSuggestionList
pattern used by the badge-award / DM / new-post screens. Typing a name,
npub, hex, or NIP-05 surfaces matching users live; tapping one adds
them.
NIP-52 \`location\` is free-text — some events stash a Zoom / Jitsi /
livestream URL there instead of a place name. The detail row was sending
every value through the geo: intent, which on most devices punts to the
maps app and renders the URL as a search query.
Now: if the trimmed value starts with http:// or https://, open it with
ACTION_VIEW on the URL itself (which the browser claims). Otherwise the
geo: path is unchanged. The leading icon and content description flip to
Link / "Open link" when it's a URL so the affordance is clear before the
tap.
The DisappearingScaffold places content at y=0 by design (so feeds scroll
behind the bar) and exposes the bar height via [LocalDisappearingScaffoldPadding]
for inner scrollables to consume. The static-headered grid views and the
collections list weren't reading that local, so:
- The week view's WeekStrip (day-number row) rendered behind the top bar.
- The month/year title and grid header sat behind the bar similarly.
- The collections LazyColumn's first card was clipped under the bar.
Fix:
- Add a small `Modifier.disappearingScaffoldPadding()` helper that applies
the local padding. Month/week/day views opt in with one line.
- Collections LazyColumn uses `rememberFeedContentPadding(FeedPadding)`
so cards inset on first render but still scroll behind the bar (matching
the feed view's behaviour).
DAL extraction (commons/src/jvmAndroid/.../model/nip52Calendar/):
- CalendarSortKeys, CalendarAppointmentView, MonthGridBars, IcsExport now
live in commons so desktop and the future CLI can consume them. Package
changed to com.vitorpamplona.amethyst.commons.model.nip52Calendar.
- IcsExportTest moved to commons/jvmTest so it can see the `internal`
escapeText helper without exposing it as public API.
- Feed-filter classes (CalendarAppointmentsFeedFilter,
CalendarCollectionsFeedFilter) stay in amethyst — they depend on Account
and LocalCache. The ViewModels stay for the same reason; lifting them
requires moving Account/LocalCache too, out of scope here.
- A few smart-cast call sites needed local bindings because cross-module
properties don't support implicit smart-cast.
Accessibility:
- Each month-grid cell announces a full content description ("Wednesday,
January 15, 2025, 2 events, today, selected") via mergeDescendants so
TalkBack reads the cell as one item with role=Button.
- Week-strip cells get the same treatment with role=Tab.
- Header title now announces both the title and "jump to today" so the
affordance is discoverable.
- The expanding FAB describes itself as a toggle, and each sub-FAB as
the concrete create action.
Inline-FQN cleanup pass:
- CalendarEventDetailScreen (already in a prior pass), CalendarCollectionsView,
NewCalendarEventScreen, NewCalendarCollectionScreen: hoisted
fully-qualified androidx.compose / com.vitorpamplona references into
proper imports per the codebase style.
All 56 calendar tests still pass (48 in amethyst + 8 IcsExport in commons).
- New IconButton in the detail screen opens the system event composer
(Google Calendar / Samsung / iCloud) pre-filled with title, range,
location, and description via Intent.ACTION_INSERT on CalendarContract.
Falls back to the .ics share path if no calendar app is installed.
- Replaced the per-cell event-dot row with horizontal bars that span the
cells a multi-day event covers. Bars are laid out by a greedy
lowest-lane assignment so overlapping events stack rather than collide;
cells removed their horizontal padding so adjacent bars merge into one
uninterrupted line.
- Bars round their ends only at the event's actual start/end (or at week
boundaries), so a 3-day conference reads as one continuous pill across
the row.
- Added MonthGridBarsTest (7 tests) covering single/multi-day, lane
collision, longer-event tiebreak, and the empty/ghost edge cases.
- ReactionsRow on the detail screen so zap/like/repost/reply use the same
shared component every other note surface uses (no calendar-specific
reinvention)
- Multi-day events now appear on every day they cover in month/week/day
grids (groupByDayKeyExpanded), with "Day X of Y" / "Continues" markers
on continuation rows
- Horizontal swipe gestures on month/week/day views via a shared
calendarSwipeNavigation modifier
- Deep-link prefetch: notification taps on a calendar naddr route
directly to CalendarEventDetail (skipping EventRedirect) and the
detail screen issues a per-event relay subscription via observeNote
- "Happening now · ends in X" relative-time label for ongoing events
- Richer empty-state copy with a shared CalendarEmptyState composable
(feed / day / week / collections each get a title + actionable hint)
- Cleanup: hoist inline fully-qualified imports across the detail screen
#8 Share-as-nostr-link:
- Second share IconButton on the event-detail top bar (next to the
existing .ics share). Builds a nostr:naddr1… URI via NAddress.create
and hands it to the system share sheet as text/plain. Lets users
paste a calendar event into DMs, posts, or any other nostr client
that understands naddr URIs.
#10 Reactive availableAppointments in collection editor:
- NewCalendarCollectionViewModel now subscribes to
LocalCache.live.newEventBundles in a viewModelScope job and
re-scans loadOwnedAppointments() on every emit. New appointments
published from another screen (or arriving from relays) while the
editor is open now appear in the picker without dismissing.
- onCleared() cancels the subscription so the VM doesn't leak the
collector after the screen closes.
#11 Calendar-as-filter overlay:
- New CalendarFilterChip composable: an AssistChip in the top-bar
trailing slot showing "All" or the selected calendar's title. Tap
opens a ModalBottomSheet with radio rows for "All" + each of the
user's own kind-31924 calendars. Sheet is reactive so newly-
published calendars appear immediately.
- rememberCalendarFilterAddresses() resolves the selected calendar's
member-address set (or null when "All"). The Set lives one
composition above the view bodies and reacts to LocalCache changes
via produceState.
- Feed / Month / Week / Day views each take an optional
filterAddresses parameter. A new List<Note>.applyCalendarFilter()
extension does the membership check. Client-side filtering means
flipping the filter doesn't trigger a relay refetch.
- CalendarsTopBar grew a generic trailing slot so future controls
can plug in alongside the view-mode chips without further surgery.
https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
#5 Configurable reminder lead time + #6 enable/disable toggle:
- CalendarReminderPrefs is a device-level SharedPreferences wrapper
with isEnabled / leadMinutes accessors. Device scope (rather than
per-account) because the worker that consults it runs globally —
per-account preferences would require account-context plumbing into
WorkManager that the rest of the app doesn't have today.
- CalendarReminderWorker now reads enabled + lead-minutes on each
cycle. When disabled the worker short-circuits to Result.success()
rather than cancelling itself — flipping the toggle back on takes
effect immediately without a relaunch.
- New CalendarReminderSettingsScreen: a Switch for enabled, a row of
FilterChips for the 5/15/30/60-minute lead-time choices. The lead-
time row disables when reminders are off.
- New Route.CalendarReminderSettings wired through AppNavigation and
surfaced as an entry in the existing AllSettingsScreen list under
the notification settings divider.
#7 Tests:
- CalendarReminderPrefsTest exercises the prefs round-trip
(defaults, set/get of enabled and lead-minutes) and the store
contract (wasNotified false-by-default, true after markNotified,
false again when start changes, and forgetBefore pruning).
- Backed by an in-memory FakeSharedPreferences so the tests run on
the JVM without Robolectric. mockk stubs the Context to hand back
the fake prefs.
https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
#3 Image upload from gallery:
- NewCalendarEventViewModel.uploadAndSetImage(uri, mime, context)
picks up the user's configured default file server (Blossom / NIP-96
/ NIP-95), uploads via UploadOrchestrator with MEDIUM compression and
metadata stripping, then writes the resulting URL into imageUrl. On
failure the screen surfaces a toast and the URL field stays usable
for the manual fallback.
- New ImageRow on the create screen: keeps the URL OutlinedTextField
but adds an AddPhotoAlternate icon button next to it. While the
upload runs the field disables and a small CircularProgressIndicator
takes the icon's slot.
#9 Participant picker:
- New `participants` mutableStateListOf on the VM, plumbed through
publish() as `p` tags via the existing dayParticipants/timeParticipants
TagArrayBuilder extensions. Edit-mode load pre-populates from the
event's existing p-tags.
- New ParticipantsRow on the create screen: an OutlinedTextField that
accepts an npub or a 64-char hex pubkey, with an Add button that
resolves via Nip19Parser. Existing participants render as the
standard avatar + display-name row with an X button to remove.
Invalid input shows an inline error.
https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
#1 Add-to-calendar bottom sheet:
- New AddToCalendarSheet shows the user's own kind-31924 calendars with
a checkbox per calendar. Tapping a row toggles membership of the
current event in that calendar and re-signs with the updated `a` tag
list. Reactive — collects LocalCache.live.newEventBundles so own-just-
published edits and incoming calendar events update the sheet
without dismissing.
- Trigger is a + icon next to the "In calendars" section title on the
event-detail screen. Empty-state shows a hint when the user has no
own calendars yet.
#2 Delete collections:
- NewCalendarCollectionViewModel.deleteLoaded() builds a NIP-09
deletion via account.delete(). No-op outside edit mode.
- Edit screen now shows a red OutlinedButton "Delete calendar" below
the form. Tapping opens an AlertDialog with a confirmation — the
confirmation clarifies that only the calendar list is removed; the
individual events inside continue to exist.
#4 Maps row:
- LocationRow lost the nested-but-non-functional "Open in maps"
TextButton. The row is the single click target with a trailing
ChevronRight icon to signal "tap to navigate". Content description
on the LocationOn icon now carries the action name for TalkBack.
https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
Visual: every calendar surface now uses the same avatar + display-name
pattern as the picture feed / shorts / video cards — UserCardHeader at
the top of each list card, ClickableUserPicture + UsernameDisplay in
the detail screen's people sections. Truncated pubkeys (`pub…XX`) only
appear as a fallback while LoadUser resolves a real metadata record.
Cards:
- CalendarEventListCard now starts with UserCardHeader (avatar, name,
time-ago, more-options) above the date badge + body. Layout matches
PictureCardCompose / VideoCardCompose so the calendar feed reads as
part of the same product, not a tacked-on tab.
- CalendarCollectionCard same treatment.
Detail screen:
- ParticipantsSection now renders a 35dp avatar + display name per
p-tag, tappable to the user's profile.
- RsvpsSection renders the RSVP author the same way with the status
badge as a trailing element (Going / Maybe / Can't go in the matching
scheme colour).
- InCalendarsSection renders each calendar's author avatar alongside
the calendar title — clicking the row still navigates to that
calendar's detail.
- RSVPs + calendars sections are now reactive: produceState collects
LocalCache.live.newEventBundles and re-runs the scan, so RSVPs that
arrive from relays while the screen is open appear without leaving
and returning.
Notifications:
- Tap the notification → opens the calendar event detail. The reminder
PendingIntent now carries the `nostr:naddr…` URI as ACTION_VIEW data;
AppNavigation's existing uriToRoute pipeline resolves it via NAddress
→ RouteMaker, where new branches map CalendarTimeSlotEvent /
CalendarDateSlotEvent to Route.CalendarEventDetail. Previously the
tap just opened the home screen.
- CalendarReminderStore now keys on (eventId, startSeconds). If the
author updates the appointment with a new start time, the stored
value won't match and the worker fires a fresh reminder for the new
time — previously a moved meeting would be silently skipped because
the eventId already had a record.
https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
Adds a 15-minute periodic WorkManager job that scans LocalCache for
kind-31925 ACCEPTED RSVPs whose target appointment starts within the
next 15 minutes, and posts a notification per event.
- CalendarReminderNotifier: posts notifications on a dedicated
"Calendar reminders" channel; pattern-matches the existing
ScheduledPostNotifier shape so the two notification surfaces share
conventions. Per-event ID derived from the appointment's event id so
a re-notification for the same event collapses rather than stacks.
- CalendarReminderStore: SharedPreferences-backed "already notified"
set keyed by event id, with the recorded start time as the value so
the entry can be pruned when the event has ended (forgetBefore()).
Without persistence, every worker run after a restart would re-fire
the same reminders until the event started — LocalCache has no
memory of past reminders.
- CalendarReminderWorker: 15-minute periodic CoroutineWorker that
walks accepted RSVPs in LocalCache, resolves the target appointment
via its addressable address, and posts the reminder if it's in the
lead window and not previously notified. Skips multi-account
coordination — accepts any RSVP in cache regardless of which
account authored it.
- Hooked into AppModules alongside the scheduled-posts worker
(independent of the always-on notification toggle so reminders fire
even when that's disabled). POST_NOTIFICATIONS is already declared
in the manifest.
- Strings: channel id/name/description, default title fallback, and
"Starts in N minutes" body template.
https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
Relative time:
- New relativeTimeLabel() wraps Android's DateUtils.getRelativeTimeSpanString
for locale-aware "in 2 hours" / "yesterday" / "in 3 days" phrasing. Uses
DAY resolution for all-day events so a 31922 doesn't get a misleading
hour-precision phrase.
- Special-cased to "Happening now" when an event has started but its end
is still in the future — DateUtils would otherwise produce "started 5
minutes ago" which reads wrong while the user is mid-event.
- Wired into the appointment list card (below the date range) and the
event detail screen (between the range and the location).
iCalendar export:
- New IcsExport object generates RFC 5545 text. Produces a single-event
VCALENDAR for appointments and a multi-VEVENT VCALENDAR for kind-31924
collections (member events that haven't arrived from relays yet are
skipped). Date-slot events emit DTSTART;VALUE=DATE so calendar apps
don't render them as midnight events. Text escaping handles backslash,
comma, semicolon, and newline per §3.3.11.
- Share button (MaterialSymbols.Share) on the event detail screen and
on each collection card. shareIcs() writes the file to cacheDir/calendar
and hands it to the system share sheet via the existing FileProvider,
with FLAG_GRANT_READ_URI_PERMISSION scoped to the chooser.
Tests:
- 8 new IcsExportTest cases covering time-slot vs date-slot output,
escaping, hashtag→CATEGORIES, multi-event calendar wrapping, and
filename safety/fallback.
https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
Adds an edit flow for kind-31922/31923 appointments authored by the
current account. The detail screen surfaces a pencil icon in the top
bar when isOwnEvent is true; tapping it navigates to the new
Route.EditCalendarEvent(kind, pubKeyHex, dTag), which routes to the
existing NewCalendarEventScreen in edit mode.
ViewModel:
- NewCalendarEventViewModel.loadForEdit() pre-populates all fields
(title, summary, location, image, hashtags, start/end seconds) from
the cached event. It's idempotent across recompositions and a no-op
if the address isn't in LocalCache yet.
- publish() now preserves the addressable's d-tag and kind in edit
mode so the broadcast replaces the original rather than minting a
new event.
- The all-day toggle is disabled while editing — switching kinds mid-
edit would leave a stale event under the original kind/d-tag
combination. The UI shows an explanatory subtitle.
Also escapes the leading `?` in calendar_rsvp_maybe_prefixed (aapt was
parsing it as a theme-attribute reference and refusing to link).
Tests:
- New CalendarEditLoadTest covers the round-trip parsing from packed
tags back to the fields the VM reads — time-slot, date-slot, and an
empty-optional-tags variant.
https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
Removes the English literals that were leaking through user-facing
surfaces of the calendar feature:
- Empty-state copy in week and day views ("No events", "No events on
this day").
- Navigation content descriptions ("Previous/Next month/week/day")
used by accessibility services and TalkBack.
- DatePicker / TimePicker dialog buttons ("OK", "Cancel") and the
TimePicker dialog title ("Pick time"); the OK side now reuses the
app-wide R.string.confirm / R.string.cancel.
- "All-day" label in the day view's time column and the collection
editor's appointment picker rows.
- "(untitled)" fallback shown in the picker and in the event-detail
"In calendars" section.
- The RSVP-list status badges in the detail screen (✓ Going, ? Maybe,
✗ Can't go) — the glyph stays in the string so translators can keep
or replace it per locale.
NewCalendarCollectionViewModel no longer embeds "(untitled)"; the
fallback moves to the picker row composable so the VM stays free of
string resources.
https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
Detail screen (item 3):
- Adds Route.CalendarEventDetail(kind, pubKey, dTag) and a dedicated
CalendarEventDetailScreen that loads the appointment by address. The
screen renders the hero image, full title/range/location/summary,
the RSVP button row, participants, all RSVPs from LocalCache that
a-tag the event, and the calendars (kind 31924) this event belongs
to. Tapping a parent calendar navigates back into the detail screen
with that calendar's address.
- The "Open in maps" affordance fires a geo: intent for the location
string, falling back gracefully when no maps app is installed.
- Tapping a card now navigates to the dedicated detail screen instead
of the generic Route.Note when the event is addressable.
Strings cleanup (item 6):
- Removed unused entries that the audit flagged and that didn't end up
wired into the detail screen: calendar_section_today,
calendar_event_pick_time, calendar_event_publish,
calendar_event_publishing, calendar_collection_save,
calendar_rsvp_responded, calendar_rsvp_sending.
Unit tests (item 8):
- 27 tests covering the pure helpers — parseIsoDateToUnixSeconds,
Note.calendarStartSeconds / calendarEndSeconds / calendarLocalDayKey,
appointmentView, groupByDayKey, partitionUpcomingPast,
upcomingFirstCalendarOrder transitivity, and rsvpDTagFor/Address.
- Made partitionUpcomingPast take `nowSeconds` as a parameter (default
TimeUtils.now()) so the split is deterministic in tests — same
pattern as upcomingFirstCalendarOrder.
https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
RSVP (item 4):
- CalendarRsvpRow now uses a deterministic d-tag of the form
'rsvp:<kind>:<pubkey>:<dtag>' derived from the target appointment's
address. Each tap replaces the user's single addressable RSVP for that
event rather than appending another, eliminating the previous footgun
where rapid taps spammed relays with parallel kind-31925 events.
- Buttons now reflect the current RSVP status reactively: the matching
status renders as filled-tonal with the status colour; the others
render outlined. Reads `LocalCache.getOrCreateAddressableNote()` and
observes the metadata flow so the row updates as soon as the new
event lands in cache (own broadcast or relay echo).
Collections (item 5):
- Lifted NewCalendarCollectionScreen onto NewCalendarCollectionViewModel
with title/description/selected-event state. Editing mode pre-populates
from an existing kind-31924 by dTag (already supported by
Route.NewCalendarCollection but previously unused), preserves the
d-tag so the publish replaces the addressable, and seeds the
selected-event list from the existing `a` tags.
- Added a multi-select picker that lists the user's own appointments
(kinds 31922/31923 authored by `account.userProfile()`), sorted
upcoming-first. Toggling a row adds or removes its address from the
outgoing `a` tag list.
- Wired Route.NewCalendarCollection.dTag through to the screen so the
edit flow becomes reachable from anywhere that has the calendar's
dTag (the event-detail screen will plug into this in a follow-up).
https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
Naming:
- CalendarsFeedFilter → CalendarAppointmentsFeedFilter, calendarsFeed →
calendarAppointmentsFeed. NIP-52 calls kind 31924 the "calendar" (a
list of events); kinds 31922/31923 are appointments that *go into*
calendars. The old name made `calendarsFeed` look like "the feed of
calendars" when it was actually the feed of appointments.
Architecture:
- Dropped CalendarsViewMode.COLLECTIONS. Calendar collections are a
sibling feed, not a view mode of the appointment timeline. They now
live on a dedicated CalendarCollectionsScreen reached via the new
Route.CalendarCollections, with their own drawer entry and bottom-bar
slot under NavBarItem.CALENDAR_COLLECTIONS.
- Both screens share the same CalendarsFilterAssembler subscription
(which already pulled all four kinds), so opening either keeps the
relay subscription warm for the other.
https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
Correctness:
- Replaced millisecond arithmetic with LocalDate.plus/minusDays in day
and week views. Day stepping with MILLIS_IN_DAY drifts at DST
transitions (a day is 23h or 25h), so after a couple of spring/fall
crossings 'next day' landed on the wrong calendar date.
DRY:
- Introduced CalendarAppointmentView, a small projection that exposes
title/image/summary/location/start/end/isAllDay for both 31922 and
31923 events. Three call sites previously did a 4-block
`when (event) { is Time -> e.x(); is Date -> e.x() }` per accessor;
they're now single linear reads.
- Extracted CalendarNavigationHeader for the shared [◀] title [▶]
pattern used identically by month, week and day views.
- Moved groupByDayKey from CalendarMonthView.kt into the dal package
alongside calendarLocalDayKey — it's used by all three grid views,
not just the month view.
Cleanup:
- `Modifier.size(width = 72.dp, height = Dp.Unspecified)` in DayRow
was the residue of an earlier failed `width()` helper; replaced with
the native `Modifier.width(72.dp)`.
- Removed obsolete startOfWeekMs / dayKeyForMs / MILLIS_IN_DAY /
MILLIS_PER_DAY / MILLIS_PER_WEEK helpers along with their imports.
Line counts: MonthView 341→273, WeekView 299→234, DayView 270→196,
EventListCard 236→201.
https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
Correctness:
- Day-cell grouping now uses local calendar dates (LocalDate.toEpochDay)
consistently across feed, month, week and day views. The previous
implementation keyed events by UTC year/month/day while cells used
local year/month/day, so a time-slot event at 2025-01-15 00:00 UTC
silently disappeared from the user's Jan-14 cell in zones west of UTC.
- 31922 date-slot events anchor at local midnight instead of UTC, so
"Jan 15" lands on Jan 15 in every viewer's grid (previously appeared
a day early west of UTC).
- Switched ISO date parsing to DateTimeFormatter; the SimpleDateFormat
predecessor was shared by sort (background) and grouping (UI) and is
not thread-safe — concurrent reads could throw or return garbage.
- Snapshot TimeUtils.now() once per sort; reading the clock inside
the comparator could violate transitivity on boundary elements and
trigger IllegalArgumentException from the JDK sort.
- Multi-day events that have started but not ended are now classified
as "upcoming" — previously a 3-day conference starting yesterday was
silently dropped into the past section.
Performance:
- Hoisted MonthShortFormatter as a file-level DateTimeFormatter; was
allocating a SimpleDateFormat per CalendarDateBadge recompose.
- Removed `derivedStateOf` over-keying on year/month/weekStart/dayMs;
groupByDayKey only depends on the note list.
- Dropped the wasted `remember { Any() }` indirection driving
LaunchedEffect — feed the keys directly.
Cleanups: removed unused UnusedShape, ChipPad, FeedCardPadding,
IconTintPlaceholder, startOfDayLocal, dayKeyUtc, startOfWeekMsForNote,
and the dead `dayCal` line inside MonthGrid.
https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
Adds NewCalendarEventScreen (Material3 form with all-day toggle,
DatePicker/TimePicker chain, location, summary, image, hashtags)
and NewCalendarCollectionScreen (title + description). Both publish
via the standard signAndComputeBroadcast pipeline and are wired
into AppNavigation as bottom-up routes.
https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
Calendar appointment cards now show Going/Maybe/Can't-go buttons that
publish a NIP-52 RSVP (kind 31925) when tapped. Calendar collections
(kind 31924) and RSVP events (kind 31925) get dedicated inline renders
wired into NoteCompose so they no longer fall through to the generic
unknown-kind path.
https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
Adds the parent CalendarsScreen with Feed/Month/Week/Day/Collections
view-mode chips, a drawer + bottom-bar slot, top-bar follow-list
filter, and a FAB menu that opens the create flows. Feed view splits
events into Upcoming / Past sections; Month view is a 7-column grid
with event-dot indicators per day; Week view is a 7-day chip strip
with the selected day's events listed below; Day view stacks events
on a vertical timeline. Calendar collections (kind 31924) get their
own list view.
https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
Wires per-account calendar follow-list settings, two feed filters
(appointments 31922/31923 and collections 31924), and a relay
subscription assembler that pulls all four calendar kinds. Sorts
the appointment feed with upcoming events first, past events after,
so the calendar timeline behaves like a calendar rather than a chat
feed.
https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
Tapping reply on a Marmot/MLS (kind:445) message in the Notifications
screen previously fell through routeReplyTo()'s `else` branch and opened
the generic public-comment composer (Route.GenericCommentPost). Sending
it would publish a plaintext kind:1111 that references the encrypted
inner event id, leaking that the user has decrypted that group message.
Mirror the NIP-17 pattern (Route.Room carries replyId, draftId,
draftMessage; the same chat screen renders the "replying to" quote
above the input) for MLS:
- routeReplyTo() now detects MarmotGroupChatroom in note.inGatherers,
matching how routeFor() at line 67 already finds the parent group, and
returns Route.MarmotGroupChat(groupId, replyId = note.idHex).
- Route.MarmotGroupChat gains message/replyId/draftId fields.
- MarmotGroupChatView resolves the replyId into a Note, shows
DisplayReplyingToNote above the composer, wires onWantsToReply for
in-chat replies, and threads the parent inner event through
AccountViewModel.sendMarmotGroupMessage into
MarmotManager.buildTextMessage, which now adds a NIP-18 q-tag on the
inner kind:9 (the same convention ChatEvent.reply() uses).
Push notifications: notifyGroupMessage previously passed
chatroomMembers=null, so the inline Reply action was never attached for
MLS group notifications. Add a parallel MARMOT_REPLY_ACTION wired with
the group id + parent inner event id; NotificationReplyReceiver loads
the account, rebuilds the parent inner event from LocalCache (or sends
unthreaded if the cache was pruned), and publishes the reply through
the same MarmotManager path — so the inline notification reply stays
encrypted inside the group instead of taking the NIP-17 PTag fallback.
On AOSP / GrapheneOS without Google Play Services (or microG), Android's
system Geocoder has no backing IGeocodeProvider, so `Geocoder.isPresent()`
returns false. Two compounding bugs left the Around Me top-bar spinner
running forever in that case:
1. `CachedReversedGeoLocations.geoLocate` only invoked `onReady` when
Geocoder was present AND returned a non-null, non-blank city name. In
the not-present and empty-result paths the callback never fired.
2. `LoadCityName` looped while `notReady`, but `notReady` was only flipped
off when `newCityName != cityName`. With both null the loop never
terminated, doubling its delay each pass.
Now `geoLocate` always calls `onReady` exactly once, short-circuiting
with `null` when no Geocoder backend exists. `LoadCityName` bridges the
callback to a suspending call, caps retries at 5, and falls back to
rendering the raw geohash so the spinner always stops. The "no geocoder
backend" check short-circuits the whole retry path on devices that will
never be able to resolve a name.
Earlier commits kept I2pType.INTERNAL as a placeholder for a follow-up
embedded daemon. We're not shipping that: I2P bootstrap on Android is
structurally minutes-long (no equivalent of Tor's hardcoded directory
authorities — NetDB peer discovery is protocol-inherent), so an embedded
router would mean a permanently-warming UX. Users who want I2P run i2pd /
Java I2P independently and point Amethyst at its SOCKS port.
Changes:
- commons I2pType drops the INTERNAL variant; parseI2pType collapses unknown
codes to OFF
- I2pManager loses its INTERNAL switch arm
- I2pSettingsDialog drops the "treat INTERNAL as OFF" mapping it used to
carry — the enum no longer has the case
- Android UI I2pSettings.resourceId drops the i2p_internal branch
- strings.xml drops the now-unused i2p_internal string
- I2pSharedPreferences hardens load: a stored "INTERNAL" from an earlier
branch is no longer a valid I2pType, so runCatching swallows the
IllegalArgumentException and the user lands on OFF
- PrivacyRouterTest fixtures use I2pType.EXTERNAL throughout
The close animation lerped from the image's unzoomed layout bounds to
the source thumbnail rect, so dismissing a zoomed-in image jumped: the
image was visible at the inner zoomable's transformed bounds but the
exit animation rewound from the layout bounds instead.
Hoist the per-page ZoomState up to the dialog and feed its live scale +
offset into the outer graphicsLayer, so the grow/shrink animation
matches whatever the user is currently seeing on screen. At identity
zoom (entry case) behavior is unchanged.
nostr-protocol/nips#2332 adds optional ["block", …] and ["proof", …] tags
on kind:8333 that ship the SPV proof inline. The data-model side is already
shipped in Quartz (BlockTag, ProofTag, OnchainZapEvent.block()/.proof() and
the TagArray helpers), but no production code path produces or consumes
either tag yet.
amethyst/plans/2026-05-14-onchain-zaps.md — onchain zaps plan:
- Update the "Chain backend" decision bullet to flag the spec change and
the two production gaps (send-side emit, receive-side consume).
- Add a dedicated "Inline SPV proofs" section covering: spec status and
the merkle-proof encoding ambiguity flagged back to the PR; per-layer
status matrix (shipped vs gap, with file locations); send-side
two-publish design (Design B — keep instant receipt, add post-confirm
republish with block+proof; dedupe by (txid, target)); receive-side
fast-path with fall-through on proof failure (never hard-reject);
phased delivery G.1–G.7 with effort estimates (~3–4 d after S1 ships
and spec encoding lands).
- Add the two new pending items to the existing "What's still pending"
list to keep that section authoritative.
quartz/plans/2026-05-08-local-headers-explorer.md — headers-explorer plan:
- Rewrite §19 (follow-up onchain-zap verification) to reflect the spec
change. Original section assumed we'd need BIP-37 merkleblock or
full-block fetch over P2P; with the proof inline none of that
infrastructure is required. Estimate collapses from 10–15 d to ~3–4 d.
- Point §19 at the full implementation plan in the onchain-zaps file,
keeping S1 focused on OTS while the follow-up details live with the
rest of the NIP-BC work.
Every consensus-relevant layer of the planned headers explorer (BlockHeader80
parser, DifficultyTarget compact↔target, CalculateNextWorkRequired retarget,
MedianTimePast, header validator end-to-end, P2P wire codecs, reorg/chain
selection, OTS proofs) is pinned to upstream test vectors committed under
quartz/src/commonTest/resources/bitcoin/, matching the existing
nip44.vectors.json / bip39.vectors.json / mls/*.json pattern.
The single highest-value test is a nightly differential check asserting
LocalHeadersBitcoinExplorer.blockHash(h) == OkHttpBitcoinExplorer.blockHash(h)
for every height in [checkpoint, tip] — any consensus drift surfaces as a
disagreeing height.
Maps cleanly onto the existing Phase 1/3/4/5/7/9 work, adding ~5–7
engineer-days total to the plan budget. No new top-level phase needed.
LocalCache.getNoteIfExists(event.id) returns the id-keyed version note,
which has its replyTo moved to the replaceable note during insertion
(consumeBaseReplaceable). The id-keyed lookup therefore returns an empty
replyTo for AddressableEvent kinds — LongTextNoteEvent, WikiNoteEvent,
LiveChess*, VideoHorizontal/Vertical — and NotificationFeedFilter's
replyTo-based check in tagsAnEventByUser silently fails for replies into
long-form articles or wiki notes.
Switch to LocalCache.getNoteIfExists(event), which dispatches on
AddressableEvent and returns the address-keyed note with proper replyTo.
Applied in three places: the dispatcher predicate, consumeFromCache's
per-event match, and dispatchForAccount's muted-thread check (the last
one only handles non-addressable kinds today but is updated for
consistency).
Lock the design choices from the 2026-05-19 review against the intervening
2026-05-14 onchain-zaps work:
- Move the module from quartz/.../nip03Timestamp/bitcoin/ to a sibling
quartz/.../bitcoin/ package so the headers explorer, header validator,
peer pool and store can be reused by the future onchain-zap merkle-proof
work without an inverted import path.
- Use androidx.sqlite + BundledSQLiteDriver for HeaderStore, matching the
existing SQLiteEventStore. Schema lives in commonMain via IModule. Drops
the hand-rolled flat-file + sidecar height index.
- Drop the bundled headers blob. Ship a single hardcoded PinnedCheckpoint
constant; first-run sync starts from the checkpoint and pulls forward
over P2P. APK growth: 0 bytes.
- Pre-checkpoint OTS heights fall through to OkHttpBitcoinExplorer via
BitcoinExplorerEndpoint (shared with the onchain-zap EsploraBackend).
Strict-mode users get an explicit error instead of a network call.
- Mark trustless NIP-BC onchain-zap verification as out of scope and
capture it as a follow-up plan (BIP-37 merkleblock or full-block fetch
on top of this stack).
Resolves open questions Q1, Q2 and Q4 from the original plan; Q3 (Quartz
public API vs internal) left open for Phase 0.
- WakeUpEvent.notifies is unconditionally true (wake every signed-in
account). The previous commit's isTaggedUser-only routing dropped
WakeUps that didn't happen to p-tag a logged-in pubkey, breaking the
wake-the-device-for-everyone semantic. Both the dispatcher predicate
and consumeFromCache now bypass the feed-style match for WakeUpEvent.
- LocalCache.getNoteIfExists(event.id) is hoisted out of the per-pubkey
any-loop in the dispatcher predicate and out of the per-account forEach
in consumeFromCache. The note is the same for every account, so one
lookup per event is enough.
- Drop RepostEvent / GenericRepostEvent from the new muted-thread check
in dispatchForAccount: they aren't routed below to any notify(), so the
guard was partial dead code. Comment updated to call out that push has
no repost notifier today (the feed does — separate gap).
- Comment on the dispatcher predicate now flags the two behavior deltas
vs. the old event.notifies routing: WakeUp bypass, and NIP-22 Comments
where the user is only the root author (uppercase `P`) no longer push.
A newer AddressableEvent (e.g. VideoVerticalEvent) arriving on a relay
thread can swap a Note's event mid-sort, changing the createdAt value
the comparator returned moments earlier. TimSort then trips
"Comparison method violates its general contract!" and the feed
refresh crashes.
Snapshot createdAt once per note before sorting via a new
sortedByDefaultFeedOrder() helper.
Push notification routing now uses the same recipient + per-kind logic the
in-app Notifications feed uses (isTaggedUser + tagsAnEventByUser), so the
two surfaces agree on what counts as a mention, reply, citation, fork, or
community moderation. Adds the explicit muted-thread check for
reactions/zaps/reposts that the feed performs on the target note.
tagsAnEventByUser is hoisted to NotificationFeedFilter's companion so the
dispatcher and consumer can call it without depending on a feed instance.
The playback notification's tap target (MediaSession.setSessionActivity)
was only bound from MediaSessionCallback.onAddMediaItems, which fires
when the in-app MediaController calls setMediaItem. GetVideoController's
warm-pool fast path skips setMediaItem when the acquired ExoPlayer was
retained with the same mediaId, so the new MediaSession was left with
no session activity and tapping the notification did nothing.
Bind the PendingIntent in newSession() from the acquired player's
current MediaItem extras, and share the construction with onAddMediaItems
via a single helper.
Receipts were only being checked for a valid event signature — anyone could
sign a kind:9735 and have it counted toward another user's zap totals. NIP-57
Appendix F mandates three additional checks: receipt.pubkey == LNURL
provider's nostrPubkey (MUST), bolt11 invoice amount == zap request "amount"
tag (MUST), and lnurl tag == recipient's lnurl (SHOULD).
- Adds LnZapReceiptValidator + LnurlForm in quartz commonMain (pure logic).
- Adds LnurlEndpointCache (jvmAndroid) and the LnurlEndpointResolver
interface for async lookup. The cache is primed by outbound zaps (existing
LightningAddressResolver fetches now extract nostrPubkey) and on demand for
inbound receipts when no entry is present.
- Adds OkHttpLnurlEndpointResolver in commons, wired into LocalCache via
AppModules using the existing money-tier OkHttp builder (so Tor settings
apply).
- LocalCache.consume(LnZapEvent) now: drops receipts that fail MUST checks
synchronously when the cache is warm, defers credit until async resolution
finishes on cache miss, and falls back to legacy signature-only behavior
when no resolver is wired (tests).
- LnZapRequestEvent.create() now accepts amountMillisats + lnurl; both are
threaded through Account.createZapRequestFor and emitted as tags so future
receipts can be validated against them.
21 new tests cover validator reasons, lnurl form canonicalization across
lud16/URL/bech32, and cache eviction.
When a filter (e.g. Non-Zaps) excluded every loaded transaction, the
empty-state composable replaced the whole screen body — including the
filter chip row — so the user couldn't switch back to All or Zaps
without leaving the screen. Expose `hasAnyTransactions` from the
ViewModel so the screen distinguishes "no chain rows at all" from "no
rows for this filter": the chips stay rendered as long as any
transaction has loaded, and the LazyColumn shows a per-filter empty
message inline beneath them. Also drop the bc1 address header from the
list since the screen title already identifies the wallet.
Two related fixes to the thread quick-action popup:
- ThreadFeedView.FullBleedNoteCompose declared a `modifier` parameter
that NoteMaster used to attach `combinedClickable(onLongClick = showPopup)`,
but the body built a fresh `Modifier.fillMaxWidth().padding(top = 10.dp)`
for its root Column and discarded the incoming modifier. Long-press on
the root note in thread view never fired. Spread the incoming modifier
onto the Column.
- LongPressToQuickAction emitted the Popup as a sibling of the content
with no wrapping layout. The Popup's `parentLayoutCoordinates` then
resolved to the enclosing LazyColumn, so `alignment = Alignment.Center`
centered the menu on the whole list (visually middle of the screen)
instead of the long-pressed card. Wrap content + Popup in a Box so the
popup's parent bounds match the note card.
Disabling individual sub-passes (method/specialization/returntype,
method/marking/static) was not sufficient. The interaction between
multiple optimization passes causes IncompatibleClassChangeError in
kmp-tor's AsyncFs.of() at launch. Shrink (dead code removal) stays
ON for the size win; only optimize is disabled.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The smoke test discovered that ProGuard's method/marking/static pass
converts kmp-tor's AsyncFs.of() from instance to static. The JVM
then throws IncompatibleClassChangeError at launch because callers
still use invokevirtual.
Also updates smoke-test-desktop.yml trigger: runs on any PR touching
desktopApp/ (not just build config files).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reverts the intentional breakage from d0a6bbc96. The smoke test
proved it catches the crash: removing java.management from jlink
modules causes a fatal IncompatibleClassChangeError in kmp-tor
at launch (the module is needed for ManagementFactory + tor runtime).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit should FAIL the release-deb-launch CI job, proving the
smoke test works. Will be reverted in the next commit.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
dpkg -i exits non-zero when the post-install script fails even with
--force-all. Allow the error with || true, then verify the binary
was actually extracted to /opt before proceeding.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
jpackage's post-install script calls xdg-desktop-menu which fails on
GitHub Actions runners with "No writable system menu directory found".
Menu registration is irrelevant for smoke testing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fixes#2819 — v1.08.0 .deb crashed on Ubuntu with ManagementFactory
error because the build had no jlink modules() declaration. Current
main already has the fix; this PR adds CI to prevent regressions.
- Add compose.desktop.uiTestJUnit4 dependency
- DesktopLaunchSmokeTest: renders LoginScreen, asserts title text
- build.yml: xvfb for Linux leg so UI test runs on every PR
- smoke-test-desktop.yml: builds release .deb, installs it, launches
under xvfb, verifies process stays alive 10s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two improvements to onchain history attribution:
1. Coalesce UI updates. The OnchainZapEvent observation in the
ViewModel now passes through .sample(500), so a relay flooding
a backlog of historical zaps in quick succession produces at
most one map update per 500ms instead of one per event. The
downstream combine + StateFlow conflation handles the rest.
2. Bound relay queries to the visible window. New
OnchainZapsFilterAssembler (SingleSubEoseManager-based) wakes
only while the screen is on top and asks the user's inbox/
outbox relays for kind-8333 events with since = the oldest
visible blockTime — incoming (p-tag = user) and outgoing
(authors = user) — so we don't drag the whole NIP-BC history
when the user only scrolled through last week. As the user
pages back, oldestBlockTime drops, the assembler re-subscribes
with a wider window.
OnchainTransactionsScreen now wires OnchainZapsFilterAssemblerSubscription
with the StateFlow-derived window, and the coordinator owns the
parent assembler so the lifecycle matches other screen subs.
OnchainTransactionsViewModel now subscribes to LocalCache via
observeEvents for kind-8333 zaps that either p-tag the user
(incoming) or are signed by the user (outgoing), merging both into
a txid -> OnchainZapEvent map. The FilterIndex inside LocalCache
narrows the fanout so we only wake on relevant events.
The list of chain rows and the zap map are now independent
StateFlows combined into the displayed views, so a zap event
arriving after the page has loaded immediately attributes the
existing row to its Nostr counterparty without a refresh.
Replaces the per-row LocalCache.notes scan from the previous commit.
Three callers were still bypassing PrivacyRouter and would route Tor-only or
direct-only regardless of the picker:
- Coil ImageLoader (AppModules): called
okHttpClients.getHttpClient(shouldUseTorForImageDownload(it)), which
collapses to a boolean and can't pick I2P. Now uses
roleBasedHttpClientBuilder.okHttpClientForImage(it) so images route through
the user's preferred clearnet transport and hidden-service hostnames
hard-pin.
- ExoPlayer pool (PlaybackService): had separate poolNoProxy / poolWithProxy
named fields and bound them to getDynamicCallFactory(useProxy: Boolean) —
i.e. with-proxy was always the Tor-proxied client. Reshaped to a
poolsByPort: Map<Int, MediaSessionPool> keyed by the SOCKS port returned
by proxyPortForVideo(url). DualHttpClientManager gains getHttpClientForPort
and PortBasedCallFactory to resolve the right OkHttpClient (direct / Tor /
I2P) live based on port.
- Nostr relay websocket builder (AppModules): only consulted torEvaluator,
which has no notion of .i2p. Now hard-pins .onion to Tor and .i2p to I2P
(throwing BlockedRouteException via the fail-closed contract when the
matching daemon isn't ready) and falls through to torEvaluator for
clearnet relays — the existing TorRelayEvaluation per-relay-class booleans
stay as-is for clearnet routing.
Replaces the inline IOException thrown by DualHttpClientManager.getHttpClient
when a hidden-service URL is requested while its matching daemon is off.
The new exception extends IOException so existing HTTP error paths (Coil,
OkHttp call factories, etc.) propagate it unchanged, but carries the
BlockReason so future UI work can surface a clear "Enable Tor / I2P to view
this content" hint instead of just a broken-image placeholder.
DualHttpClientManager.blockedException(...) now returns BlockedRouteException
rather than a bare IOException; the messages move into the typed exception's
companion.
Tests cover the message wording (mentions Tor/.onion and I2P/.i2p) and pin
the factory's return type so the typed information can't be silently widened
back to a bare IOException without breaking the test.
Surfacing this to the UI (snackbar on image-load fail, etc.) is left to a
follow-up — that work is cross-cutting (Coil event listeners, NIP-05 error
paths, etc.) and orthogonal to the routing decision itself.
All ad-hoc HTTP traffic now consults PrivacyRoutingFlow → PrivacyRouter:
images, videos, URL previews, NIP-05 lookups, Money operations, uploads, push
registration. Each call computes a PrivacyRoute (Direct / Tor / I2p /
Blocked) and asks DualHttpClientManager for the matching OkHttpClient,
which in turn picks the right SOCKS-attached client or — for Blocked
hidden-service routes — throws IOException so the request fails closed
instead of silently leaking to the clearnet.
Constructor switched from TorSettingsFlow to PrivacyRoutingFlow. AppModules
swaps the wiring; the flow construction order now puts privacyRouting
before the builder.
Legacy shouldUseTorFor* booleans are kept but now return true iff the route
would actually end up on Tor — they no longer drive routing themselves.
External method-references in AppModules (OtsResolver, etc.) keep working
with the same semantics they had before for clearnet calls.
Push registration still routes through the URL_PREVIEW role (closest analogue
to the old trustedRelaysViaTor global flag, which had no URL to consult).
That's fine for now — trusted-relay push services live on the clearnet and
hard-pin behavior is unchanged for hidden-service push URLs.
The dedicated ConcurrentHashMap<txid, OnchainZapEvent> in LocalCache
didn't pay for itself at current NIP-BC volumes. The on-chain
transactions view goes back to scanning LocalCache.notes for the
matching OnchainZapEvent per row.
I2pManager: EXTERNAL-only mirror of TorManager. When the persisted I2pType is
EXTERNAL it emits Active(externalSocksPort); OFF and INTERNAL both surface as
Off (no embedded daemon in this branch). Exposes activePortOrNull as a
StateFlow<Int?> with the same shape Tor uses, so the http client managers can
keep the proxy-port wiring symmetric.
DualHttpClientManager + DualHttpClientManagerForRelays:
- New constructor param i2pProxyPortProvider: StateFlow<Int?> (defaults to a
closed null flow so existing tests / callers compile unchanged).
- Third StateFlow<OkHttpClient> built against the I2P SOCKS port.
- New route-aware methods:
getHttpClient(route: PrivacyRoute): OkHttpClient
getCurrentProxyPort(route: PrivacyRoute): Int?
Direct → no-proxy client, Tor → Tor-proxy client, I2p → I2P-proxy client,
Blocked → throws IOException with a user-readable reason so the call fails
closed instead of silently leaking through the clearnet.
- Existing boolean-based methods (getHttpClient/useProxy) untouched.
AppModules wires i2pManager.activePortOrNull into both http managers. No
caller switched onto the new methods yet — that's the next chunk
(RoleBasedHttpClientBuilder reshape).
Without busy_timeout SQLite returns SQLITE_BUSY immediately when
BEGIN IMMEDIATE can't acquire a lock — e.g. during a WAL
auto-checkpoint or a reader briefly upgrading its snapshot — instead
of retrying. ParallelInsertTest's reader+writer test hit this ~10%
of runs. 5s matches Room's default and adds no overhead in the
uncontended case.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same Privacy Options screen as before, now also surfaces:
- A three-way "Privacy Network" picker (Direct / Tor / I2P) for clearnet
traffic. Hidden services aren't affected — .onion / .i2p still hard-pin to
their matching daemon (and fail closed via PrivacyRouter when it's off).
- An I2P engine + per-feature toggle section mirroring the existing Tor
section. Only OFF and EXTERNAL are offered in the picker until an embedded
daemon ships; persisted INTERNAL is treated as OFF in the UI but kept in the
enum so a future commit can light it up without a migration.
- Save action posts all three settings — Tor, I2P, preferred transport — to
their separate DataStore-backed writers in one click.
New UI pieces:
- I2pDialogViewModel — mirror of TorDialogViewModel, no preset DSL since
there's no canonical I2P configuration yet
- I2pSettingsBody — composable mirror of PrivacySettingsBody, shares
SwitchSettingsRow with the Tor body
- PrivacyTransportPickerViewModel + PreferredTransportPicker — global picker
- I2pType.resourceId — Android string-id extension matching TorType.resourceId
Strings: i2p_* per-feature toggles, i2p_off/internal/external, i2p_socks_port,
privacy_clearnet_transport (+ direct / tor / i2p labels).
UI is wired against the live flows from AppModules (torPrefs / i2pPrefs /
privacyPrefs from the previous commits). No HTTP routing change — those flows
still go through TorManager only; the new toggles only have routing impact
once DualHttpClientManager grows tri-state in the daemon chunk.
Voice notes go through GetVideoController, so the 30s background
release timer + warm-pool reattach already applied to them. What was
missing was the immediate ON_PAUSE handler: voice didn't use
ControlWhenPlayerIsActive, so playback kept running for the full 30s
before the release timer hit and cut it off via the warm-pool pause.
Extract a small PauseControllerWhenInBackground composable from the
ON_PAUSE arm of ControlWhenPlayerIsActive and call it from VoiceTrack.
Result matches video: pause on ON_PAUSE, release at 30s, reassemble
the controller (paused, position preserved) when the user returns —
no auto-resume since voice has no autoplay setting.
PiP exemption (BackgroundMedia.isMutex) is preserved.
https://claude.ai/code/session_01RoEUbAN8ejF21Ns3eM6xad
- LocalCache now keeps a ConcurrentHashMap<txid, OnchainZapEvent>
populated in consume(OnchainZapEvent). First sender to claim a
txid wins. The on-chain transactions ViewModel switches to
LocalCache.getOnchainZapByTxid(txid), replacing the per-row scan
of notes.
- Each row in OnchainTransactionsScreen is now clickable: rows with
a matched zap event navigate to the zap's note thread; the rest
open the transaction on mempool.space in the system browser.
The 30s release timer fires on ON_PAUSE, but PipVideoActivity enters
PiP mode (which dispatches ON_PAUSE) immediately after onCreate. The
MediaController is built asynchronously and RegisterBackgroundMedia
runs only after inner() mounts, so there's a window where the timer's
BackgroundMedia.isMutex check sees a stale (or null) bgInstance and
releases a controller that was about to start background playback —
blanking the PiP window.
Add an opt-out parameter on GetVideoController and disable the timer
from PipVideoActivity. PiP IS the explicit background-playback opt-in,
so the timer doesn't apply by design.
https://claude.ai/code/session_01RoEUbAN8ejF21Ns3eM6xad
Read-side seam that snapshots TorSettingsFlow + I2pSettingsFlow + the
preferredClearnetTransport StateFlow into a PrivacySettings, then delegates
to PrivacyRouter.route. Gives the I2P daemon manager and the eventual HTTP
routing rewrite a single stable API to consult instead of poking every flow
themselves.
DualHttpClientManager is not touched in this commit — that grows to tri-state
in the chunk where the I2P daemon lands a real proxy port.
AppModules:
- Constructs `privacyRouting` alongside roleBasedHttpClientBuilder
- Uses torPrefs.value + i2pPrefs.value + privacyPrefs (added last commit)
No callers wired yet — this is the seam those callers will move onto.
- Replace `remember { side effect }` with lazy `entry.ensure(context)` in
the row's onClick; the system per-channel page only needs the channel
to exist at open time, not before, and the ensure call is idempotent.
- Replace the `refreshKey++` invalidation trick with a real
`Map<channelId, ChannelStatus>` state holder updated inside
`LifecycleResumeEffect`; downstream reads are direct map lookups.
- Replace `Triple`-with-destructuring in `ChannelStatusBadge` with three
direct `when` branches calling `StatusChip` — no tuples, no
temporaries.
- Drop the unused `notification_settings_open_system` string.
Verify video/x-m4v maps to video/mp4 (the failing case), normalization
is case-insensitive, and supported image/video/audio types pass through
unchanged. Required making normalizeMimeTypeForMediaStore internal.
Reorganize Notification settings into three sections that reflect what
each control actually does:
- Delivery: how notifications reach the device — push provider (fdroid)
and the always-on relay service live together here.
- In-app display: how the notifications screen renders incoming activity —
currently the Split-by-Follows toggle.
- Categories: one row per user-facing Android NotificationChannel (DMs,
Mentions, Replies, Reactions, Zaps, Chess, Scheduled posts, Calls),
showing the current importance (On / Silent / Off) and opening the
system per-channel settings page on tap. Foreground-service channels
are intentionally omitted — disabling them breaks the service contract.
The screen ensures every listed channel exists on first open and
re-reads importance via LifecycleResumeEffect so the badge reflects
changes made in system settings.
API surface bumped for the channel registry:
- CallNotifier.CALL_CHANNEL_ID is now public.
- ScheduledPostNotifier.ensureChannel is now public.
Replaces the previous narrow try/catch fix. The earlier patch silently
swallowed SignerExceptions in NewMediaModel / EditPostViewModel — no
toast, no error dialog — so the user would tap Post, see the dialog
close, and never learn that the signer prompt timed out or was
rejected. It also bypassed the project's standard signer-error
pipeline.
This version routes the sign+publish phase through
AccountViewModel.launchSigner, which is the same path every other
signing entry point uses:
- ReadOnly / SignerNotFound / UnauthorizedDecryption / IllegalState →
toastManager surfaces a localized alert.
- TimedOut / ManuallyUnauthorized / etc. → logged silently (no crash),
matching the rest of the app's behavior when a user dismisses the
external signer.
NewMediaModel.upload now takes an AccountViewModel parameter; the three
inner viewModelScope.launch(Dispatchers.IO) blocks become
accountViewModel.launchSigner { ... }. The joinAll wait is preserved
(launchSigner returns Job).
EditPostViewModel.uploadUnsafe routes its single launch the same way
and wraps the body in try/finally so mediaUploadTracker.finishUpload()
still runs if a signer throws mid-flow.
launchSigner is changed from Unit to Job (= viewModelScope.launch ...)
so callers can join — non-breaking for the ~190 existing callsites that
ignore the return value.
- Promote the duplicate `SwitchTile` from SecurityFiltersScreen and
NotificationSettingsScreen into a shared `SettingsSwitchTile` in
SettingsSectionCard so all settings switches share one implementation.
- Render BatteryOptimizationBanner outside the in-app section card
instead of nesting a Card inside a Card and splitting the section's
divider away from the row it explains.
- Refresh the battery-optimization exemption on LifecycleResumeEffect
so the banner disappears after the user returns from the system
settings page; drop the racy post-button re-read.
- Demote `HasPushNotificationProvider` to a plain `hasPushNotificationProvider`
since it returns a per-flavor constant and reads no Compose state.
#2946 fixed the ClassCastException with a bespoke AtomicReference<Map>
+ CAS copy-on-write helper. Quartz already has a concurrent-map
abstraction for exactly this purpose — LargeCache — with platform-tuned
actuals (ConcurrentSkipListMap on jvmAndroid, CacheMap on Apple, custom
on Linux). Swap to it.
Removes the bespoke putAuthStatus/removeAuthStatus helpers, the
ExperimentalAtomicApi opt-in, and the AtomicReference imports.
The RelayAuthenticatorConcurrencyTest from #2946 still passes against
the new implementation.
Reuses the NamecoinResolutionRow composable already shipping for the
onchain-zap recipient field, promoting it from
ui/screen/loggedIn/wallet/ to a generic ui/components/namecoin/
location so it can be mounted anywhere a .bit-shaped search input may
race the local-cache prefix search.
In the global search bar, typing a bare ".bit" host (e.g.
"testls.bit") used to surface a cached sibling profile like
"m@testls.bit" first (LocalCache.findUsersStartingWith hits the
prefix) and only several seconds later be corrected by the slower
on-chain ElectrumX resolution from
SearchBarViewModel.directNip05Resolver. No in-flight indicator and no
feedback on hard failures (timeout, malformed record, etc.).
Changes:
- git-rename NamecoinResolutionRow.kt and its test from
ui/screen/loggedIn/wallet/ to ui/components/namecoin/, updating
the package declaration only.
- Add an optional `modifier: Modifier = Modifier` parameter to the
composable (standard Compose convention) and wrap the spinner /
result / error rows in a Column taking the caller-provided
modifier. No visual change in OnchainZapSendDialog.
- Update OnchainZapSendDialog import to the new package location.
- Mount NamecoinResolutionRow in SearchScreen.SearchBar between
SearchTextField and SearchFilterRow, with horizontal padding to
match the rest of the bar. onUserResolved navigates to the user
and clears the field, matching the bech32 auto-resolve path in
SearchBarViewModel.directRouteResolver.
State is held in the shared
commons.NamecoinResolveState (no new state class introduced) and
diagnostic wording comes from the existing mapOutcomeToResolveState
helper, so every Namecoin surface continues to produce the same
message for the same outcome.
The local-cache suggestion dropdown can momentarily show a stale match
when the user types a .bit identifier. For example, after resolving
"m@testls.bit" earlier in the session, typing the bare host
"testls.bit" causes findUsersStartingWith() to return the cached
m@... profile first; the correct _@testls.bit profile only appears a
few seconds later when ElectrumX resolution finishes. There is no
visual hint that a Namecoin lookup is in flight, and on hard failures
(timeout, malformed record, no nostr field, etc.) the user gets no
feedback at all.
This adds a dedicated NamecoinResolutionRow composable mounted between
the recipient field and the local-cache dropdown:
- "Resolving <name> on Namecoin…" spinner while the on-chain
lookup is in flight (after a 300 ms debounce that matches the
dropdown's own debounce).
- On success, a tappable row showing the resolved profile with a
distinct "Namecoin" badge (MaterialSymbols.Link), so the user
can pick the on-chain-verified profile unambiguously.
- On failure, a single explanatory error line covering all
NamecoinResolveOutcome variants the resolver already produces:
NameNotFound, NoNostrField, MalformedRecord (with the underlying
parser error verbatim), ServersUnreachable, InvalidIdentifier and
Timeout.
State is held in the shared NamecoinResolveState sealed class already
used by NamecoinNameService and the desktop SearchScreen — no new
state model is introduced. A small mapOutcomeToResolveState() helper
mirrors the same wording desktop ships, so all Namecoin surfaces
produce the same diagnostic string for the same outcome.
The row owns its own LaunchedEffect keyed on the typed query, so it
cancels in-flight lookups whenever the user keeps typing, and it
unmounts cleanly once a recipient is selected. It uses the existing
Amethyst.instance.namecoinResolver instance, so no DI plumbing or new
network calls beyond what was already wired up for .bit resolution.
The dropdown is left untouched: local-cache results are still valid
hits (just not necessarily the *intended* on-chain identity), so they
remain available below the new row.
Pure helpers (looksLikeNamecoinIdentifier + the outcome-to-state
mapper) are covered by unit tests in NamecoinResolutionRowTest.
The Overview tab is a plain vertical-scroll Column inside DisappearingScaffold,
which lays content under the top app bar + tab row and bottom nav. The Issues
and Patches tabs go through RefresheableFeedView and already consume
LocalDisappearingScaffoldPadding internally, but the Overview tab did not, so
its top and bottom were hidden behind the surrounding UI.
OkHttp dispatches WebSocket callbacks on one thread per relay socket,
so RelayAuthenticator's plain LinkedHashMap was mutated concurrently
from many threads during connection storms. When a bucket crossed
HashMap's TREEIFY_THRESHOLD the racing treeify corrupted internal
state and threw ClassCastException: LinkedHashMap$Entry cannot be
cast to HashMap$TreeNode from onDisconnected.
MacOsVlcDiscoverer.setPluginPath() called LibC.INSTANCE.setenv() via
JNA, but macOS 13+ uses versioned symbols (setenv$3b99ba0d) that JNA
can't resolve, breaking all video playback without system VLC.
Changes:
- Replace LibC.setenv with direct JNA Function.getFunction("c","setenv")
call that bypasses the problematic interface binding
- Store discoveredPluginPath for --plugin-path factory arg fallback
- Add -Dvlc.plugin.path JVM property as ultimate fallback
- Delete stale VLC plugin cache on macOS before factory creation
- Pass --plugin-path to audio factory too when env var fails
- Add jdk.unsupported module to jlink (VLCJ ByteBufferFactory needs
sun.misc.Unsafe for video frame buffer allocation)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Currently the only profile fields with an explicit copy affordance are
npub and nprofile (small ContentCopy IconButtons next to the keys).
The other identifier-shaped fields rendered by DrawAdditionalInfo and
DisplayLNAddress (NIP-05 Nostr Address, Website URL, lud16 Lightning
Address, NIP-39 external identities, NIP-A3 payment targets) all have
a tap action that *uses* the value (opens the URL, expands the zap
sheet, etc.) but no way to actually copy the value itself short of
selecting the underlying "View source" or opening the link and
copying from the browser. Closes that gap with the platform-native
long-press gesture.
Adds a small reusable LongPressCopyText composable in
ui/components/util/ that:
- renders plain Text styled in primary colour (visual match for the
previous ClickableTextPrimary),
- wires onClick through Modifier.combinedClickable (so existing tap
behaviour is preserved),
- adds onLongClick to copy the supplied raw value to the system
Clipboard and surface a Toast.makeText "Copied to clipboard"
confirmation,
- announces the long-press action to TalkBack via onLongClickLabel.
The composable deliberately uses a plain Text + outer
combinedClickable rather than an AnnotatedString with an inline
LinkAnnotation.Clickable: an annotation-level click can't see a
parent combinedClickable's long-press, because the parent's tap area
sits above the annotation's hit-test region and would consume the
tap before the annotation could fire it. Plain Text + outer modifier
keeps both gestures behaving correctly.
Wired into:
- DisplayNip05ProfileStatus (NIP-05 Nostr Address) — tap opens the
domain in the browser, long-press copies the full
user@domain value.
- The website row in DrawAdditionalInfo — tap opens the URL,
long-press copies the *raw* website value as stored in the
profile (the displayed form is stripped of the scheme + trailing
slash for readability; the copy preserves the original).
- The external-identity rows (Twitter, Mastodon, Telegram, GitHub)
— tap opens the proof URL, long-press copies the identity handle.
- DisplayLNAddress lud16 — tap toggles the zap sheet (unchanged),
long-press copies the lud16 address.
- PaymentTargetRow authority — tap opens the payto: URI, long-press
copies the authority.
The existing inline ContentCopy IconButtons on npub and nprofile are
left in place; the long-press gesture is purely additive for the
other rows.
Two notable side-effects from the ClickableTextPrimary -> Text +
combinedClickable rewrite:
- Touch ripple feedback now appears on tap (combinedClickable's
default indication). Previously the inline LinkAnnotation rendered
no ripple. This is consistent with how other clickable rows in the
app behave and provides better tap feedback.
- The NIP-05 row no longer renders the click region as a styled
inline span; the entire text behaves as one tap+long-press target.
Visually identical for short addresses; for any address long
enough to wrap, the click region now matches the visible bounds
rather than the per-glyph bounds.
Adds a new copied_to_clipboard string. Picks up the existing
copy_to_clipboard string for the TalkBack long-press hint.
The Send-onchain-zap Recipient field already supported typing a NIP-05
or bare .bit name and tapping the resolved suggestion. If a user typed
a fully-qualified name (e.g. "m@testls.bit" or "testls.bit") and hit
Send without first tapping the suggestion, the eager resolver only ran
decodePublicKeyAsHexOrNull(), which returns null for non-npub inputs,
so the button stayed disabled with no inline feedback.
Mirror the dropdown's existing NIP-05 / .bit resolution into the dialog
state via UserSuggestionState.nip05ResolutionFlow, and use the resolved
User's pubkey as one more fallback in resolvedRecipient. The flow is
already debounced (300ms) and shared with the suggestion list, so this
adds zero extra network traffic and zero new code paths -- just enables
Send 300ms after a valid name resolves.
Behaviour:
- Typing "m@testls.bit" + waiting 300ms -> Send enables.
- Typing "testls.bit" + waiting 300ms -> Send enables (same path the
dropdown uses for bare .bit names; resolves as _@testls.bit).
- Typing an npub or hex pubkey -> unchanged (already worked).
- Typing an unrecognised string -> Send stays disabled, unchanged.
- Tapping the suggestion before Send -> unchanged (selectedUser wins).
No behaviour change anywhere outside the onchain zap dialog.
When the initial chatroom list picked a ChannelMetadataEvent or
ChannelCreateEvent as the representative note for a public channel
(because no ChannelMessageEvent had been seen yet), the arrival of a
later ChannelMessageEvent caused updateListWith to append a second
entry for the same channel — its match check only recognized old
notes whose event was a ChannelMessageEvent. Two notes for the same
channelId then produced the same PublicChannelLazyKey in the LazyColumn
and crashed with IllegalArgumentException: Key was already used.
Extract the channel id from any of the three public-chat event types
when matching the existing entry so the new message replaces the
placeholder instead of duplicating it.
Android's MediaStore rejects video/x-m4v (returned by MimeTypeMap for .m4v
URLs) with IllegalArgumentException. M4V is an MP4 container variant, so
map it to video/mp4 before the ContentResolver.insert call.
When a post is filtered out by the max-hashtag-per-post setting, the
"Show Anyway" card previously displayed the generic "Post was muted or
reported by" text with no avatars below it, which was confusing.
Plumb a hasExcessiveHashtags flag and the configured limit through
NoteComposeReportState and BlockReportChecker so HiddenNote can render a
hashtag-specific message ("This post has more than N hashtags"). When
both reports and the hashtag limit apply, show both reasons.
Primal-style clients emit "dim 317.0x498.0" in NIP-92 imeta tags. DimensionTag.parse
called Int.parseInt on each component, threw NumberFormatException, and returned
null. With dim==null and no cached aspect ratio, GifVideoView / UrlImageView built
the container without an aspectRatio modifier, so the inline image collapsed to
zero height and the post body looked empty until Coil delivered the bitmap.
Parse each component as Double then truncate to Int. Adds DimensionTagTest covering
integer, float, truncation, 0x0 and malformed inputs.
https://claude.ai/code/session_01W1crao6Hwip8k5ByoLxVrc
PaymentButton and DisplayPaymentTargets read note?.event inside a
remember(note) keyed on the AddressableNote reference, which is stable
across event arrival. They also rely solely on LoadAddressableNote's
cache lookup with no relay subscription, so when viewing a profile
outside the path that activates UserProfileMetadataFilterSubAssembler
(or before the event arrives) the UI never updates.
Match the canonical pattern from ProfileBadgesScreen: pair
LoadAddressableNote with EventFinderFilterAssemblerSubscription to
request the event from relays, and observeNoteEvent<PaymentTargetsEvent>
to recompose when it arrives.
Group every notification-related preference under a new
"Notifications" entry in the settings menu, rendered with the modern
card/section design used by SecurityFiltersScreen.
Moves out of "UI preferences":
- Push notification provider (UnifiedPush, fdroid)
- Always-on notification service + battery optimization banner
- Split notifications by Follows
The fdroid PushNotificationSettingsRow becomes PushNotificationProviderTile
built around SettingsBlockTile; the play stub gains a matching
HasPushNotificationProvider() so the push section is hidden on Play builds.
17 strings: 5 for the call-permission-denied dialog plus 12 for the new
git repository overview / issues / patches screen and status pills.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a third public Namecoin ElectrumX server to the default and
Tor-preferred lists in DEFAULT_ELECTRUMX_SERVERS / TOR_ELECTRUMX_SERVERS:
electrum.nmc.ethicnology.com:50002 (IPv4 142.44.246.181, OVH Canada)
Operated by @ethicnology, who ships the namecoind + ElectrumX + mempool
podman stack at github.com/ethicnology/namecoin-compose. Probed live:
- server.version -> ElectrumX 1.19.0, protocol 1.4
- server.features -> Namecoin mainnet genesis 000000000062b72c...c770
- scripthash.get_history for d/testls -> full history (heights up to
822885), and blockchain.transaction.get decodes the OP_NAME_UPDATE
output correctly. Same code path used by ElectrumXClient against all
other public servers, no client changes required.
TLS uses a publicly-trusted Let's Encrypt cert, so usePinnedTrustStore
is left at the default (false). This makes it the first entry in the
list whose TLS does NOT depend on PINNED_ELECTRUMX_CERTS, and adds
useful diversity:
- electrumx.testls.space (self-signed, pinned, often ECONNRESETs)
- nmc2.bitcoins.sk / 46.229.238.187 (self-signed, pinned)
- relay.testls.bit / 23.158.233.10 (self-signed, pinned)
- electrum.nmc.ethicnology.com (LE cert, system trust store)
If every self-signed peer is unreachable (e.g. corporate networks that
strip unknown CAs but allow LE chains), resolution can still succeed.
No bare-IP companion entry is added for 142.44.246.181: unlike the
46.229.238.187 / 23.158.233.10 pinned peers (which use DER-SHA256
pinning that ignores hostname verification), an IP-literal endpoint
against the LE cert would fail standard hostname verification under
the system trust manager (SAN covers only the hostname). The IP is
captured in this commit message and the source comment for reference.
Verification on this branch:
- :quartz:spotlessCheck OK
- :quartz:jvmTest OK (BitRelayResolverTest etc. unchanged)
`bridgeUrl` accepted the imeta `x` hash as a sufficient signal to route
a media URL through the local Blossom cache, even when the URL itself
didn't have the sha256 in its last path segment. For URLs like
https://i.nostr.build/M5AwJ.gif the upstream serves the blob under an
opaque filename, so the resulting `xs=https://i.nostr.build` hint sent
the local cache after https://i.nostr.build/<sha>.gif on miss — which
404s because the real blob isn't at that path.
Match the behaviour `bridgeProfilePictureUrl` already had: require
`extractSha256FromUrlPath(url)` to succeed before bridging. The imeta
hash is still preferred for canonical casing when present, but is no
longer enough on its own.
Add OnchainZapEvent handling to NotificationSummaryState so NIP-BC
onchain zaps targeting the current user contribute their claimed sats
to the daily zap total and chart alongside LnZapEvent receipts.
Pause-on-background (0de70e51) stops audio/video from leaking past the
foreground, but the MediaController itself stays bound — so the
underlying ExoPlayer keeps holding its codec and decoder buffer the
whole time the app is away.
Schedule a 30s timer on ON_PAUSE; if the activity is still backgrounded
when it fires, flip a keepAlive gate that swaps the controllerAsFlow
out for flowOf(null). collectAsState cancels the previous collection,
awaitClose releases the MediaController, the session disconnects, and
the ExoPlayer goes back to the pool (warm slot keyed by URI). ON_RESUME
cancels any pending timer and flips the gate back, so the flow rebuilds
a fresh MediaController. The onEach warm-pool fast path then re-attaches
to the same paused player — currentMediaItem still matches, no
setMediaItem call, no re-prepare, so position + buffer come back intact.
PiP is exempt via the existing BackgroundMedia.isMutex check, same
pattern the pause-on-background handler uses.
https://claude.ai/code/session_01RoEUbAN8ejF21Ns3eM6xad
Tapping the Bitcoin card on the wallet screen now navigates to a new
OnchainTransactionsScreen that lists transactions touching the account's
Taproot address, mirroring the NWC transactions view.
- OnchainBackend gains getTxsForAddress(address, afterTxid) returning
BitcoinAddressTx rows (netValueSats, confirmations, blockHeight,
blockTime, counterparty addresses). EsploraBackend implements it via
GET /address/{addr}/txs and /address/{addr}/txs/chain/{last_seen}
for pagination; CachingOnchainBackend passes through.
- OnchainTransactionsViewModel loads the address from the account
signer + LocalCache.onchainBackend, paginates, and for each chain
row scans LocalCache for an OnchainZapEvent with a matching txid so
the UI can render the Nostr counterparty (sender pubkey for
incoming, p-tagged recipient for outgoing).
- ALL / ZAPS / NON-ZAPS filter chips reuse the existing
TransactionFilter enum. Mempool rows are flagged "Pending" in
bitcoin-orange.
Lightning fields had moved entirely into the NWC wallet setup screen
(NIP47SetupScreen), so a user with no NWC connection had no way to set
their lud16/lud06 — required for receiving zaps. Add an expandable
Lightning section in the profile editor that reads/writes lud16 and
lud06, auto-expanded when either is set. NIP47SetupScreen keeps its
lud16 field; both flows go through sendNewUserMetadata which already
no-ops null params, so editing only one side never wipes the other.
https://claude.ai/code/session_01R2E6rMfhxKA8csVarjxKJd
Mirrors the existing Tor persistence stack so I2P settings + the global
clearnet-transport preference survive process restart.
New on Android:
- I2pSettingsFlow — StateFlow-per-field shape matching TorSettingsFlow,
including the propertyWatchFlow that the prefs class listens to.
- I2pSharedPreferences — DataStore-backed load/save with `i2p.*` pref keys,
debounced-save wired to propertyWatchFlow.
- PrivacySharedPreferences — single MutableStateFlow<PrivacyTransport> for
preferredClearnetTransport, persisted under `privacy.preferredClearnetTransport`.
Kept separate from Tor/I2p prefs because the decision spans both transports.
AppModules:
- Parallel-load i2pPrefs and privacyPrefs alongside torPrefs in async/await pairs
so cold-start blocking time stays at ~max() not sum().
- Lazy accessors mirror torPrefs/uiPrefs.
No routing wired yet — RoleBasedHttpClientBuilder still consumes torPrefs only.
That swap is the next chunk.
Nowhere (https://github.com/5t34k/nowhere) encodes whole mini-sites in
a URL fragment, so the OpenGraph preview path returns nothing useful and
hostednowhere.com 403s scrapers. Detect URLs on hostednowhere.com and
nowhr.xyz that carry a fragment as a NowhereLinkSegment and render them
through a NowhereLinkCard that labels the tool type (Event, Store,
Message, ...) and opens the URL in the browser on click.
OnchainSection now mirrors WalletCard's layout structurally so both
payment rails read as the same kind of object on the wallet screen,
with bitcoin-orange (BitcoinDark #F7931A / BitcoinLight #B66605)
replacing NWC's primary purple to keep them distinct at a glance:
- RoundedCornerShape(16.dp), 2.dp bitcoinColor border,
bitcoinColor.copy(alpha = 0.12f) container — same idiom NWC uses
for the 'default wallet' card.
- Header row: small orange ₿ chip + 'Bitcoin' title + 'Onchain ·
Taproot' subtitle on the left, big 24sp bold balance + 'sats'
label on the right (same typography as NWC).
- Loading shows an orange CircularProgressIndicator in the balance
slot. Error / no-backend states render an em-dash placeholder
with a small status caption.
- Address block: 'Your Taproot address' caption + monospace
truncated address.
- Action row: outlined 'Copy' with copy icon on the left, filled
bitcoin-orange 'Send' with send icon on the right — same 36dp
height + RoundedCornerShape(8.dp) as the NWC card actions.
OnchainZapSendDialog moves from AlertDialog to ModalBottomSheet —
matches the design language already used by CreateNestSheet,
EditNestSheet and the participant-action sheets. The form gets
imePadding + navigationBarsPadding, a scrollable content area, and a
sticky full-width Send button at the bottom.
Layout changes:
- Header with Bitcoin ₿ glyph + title + close.
- Recipient picker: the suggestion dropdown now sits flush under
its text field (was: separated by the outer Column's spacedBy gap).
- Amount section: big number field with 'sats' suffix + FlowRow of
SuggestionChips bound to AccountViewModel.zapAmountChoices (same
quick picks the LN zap dialog uses, formatted with showAmount).
- Fee priority: FlowRow of FilterChips with explicit vertical
padding around the row and inside each chip; each chip shows
'Slow / Normal / Fast', the sat/vB rate, and a rough ETA.
Selected chip uses bitcoinColor.
- State machine: idle → sending (orange spinner) → success
(bitcoinColor checkmark) / failure (error tint), each with a
'Done' / 'Close' bottom button.
Also wire RenderOnchainZap into ThreadFeedView.NoteMaster's event-type
dispatch right after RenderLnZap, so kind 8333 receipts get the full
rich render on the thread screen instead of the default text body.
Drop the per-feature 3-way picker — no splitting clearnet traffic between Tor
and I2P at the same time. Both daemons can still run side-by-side so .onion
and .i2p hidden services stay reachable independently, but only one transport
carries clearnet traffic at a time.
Routing model:
- .onion → Tor required; Blocked when Tor is OFF (no clearnet fallback)
- .i2p → I2P required; Blocked when I2P is OFF (no clearnet fallback)
- clearnet → preferredClearnetTransport (NONE/TOR/I2P) picks the active
transport; that transport's own per-feature toggle decides this request;
otherwise Direct
Commons:
- Add PrivacyRoute sealed type { Direct, Tor, I2p, Blocked(BlockReason) } so
fail-closed has somewhere to land — callers must surface Blocked instead of
silently leaking over clearnet
- PrivacySettings drops `features`, adds preferredClearnetTransport
- I2pSettings gains imagesViaI2p / videosViaI2p / urlPreviewsViaI2p /
profilePicsViaI2p / nip05VerificationsViaI2p / moneyOperationsViaI2p /
mediaUploadsViaI2p — mirrors TorSettings; only effective when I2P is the
preferred clearnet transport
- PrivacyRouter rewritten to the new model
- Delete FeatureTransportChoices and TransportChoice
- Keep FeatureRole as the per-request hint that selects which toggle to read
Tests: PrivacyRouterTest rewritten to cover the new outcomes, including
both-daemons-running clearnet preference, fail-closed for .onion / .i2p, and
that the non-preferred transport's toggles have no effect on clearnet.
Recipient field now uses ShowUserSuggestionList + UserSuggestionState
(same plumbing as the post composer @-mention and AwardBadgeScreen).
The user can type a display name, NIP-05, or paste an npub/hex; picked
recipients render as a chip with avatar + name + clear button. Raw
npub paste still works as a fallback when no result is picked.
Fee tier chips moved from Row to FlowRow so 'Slow · 1 sat/vB',
'Normal · 12 sat/vB', 'Fast · 50 sat/vB' wrap to a second line on
narrow dialog widths instead of clipping the rightmost chip.
observeEvents<LnZapEvent>(filterAcceptingBothKinds) is generic-erased
at runtime, so an OnchainZapEvent landing in the cache (e.g. right
after sending an onchain zap from the wallet) flowed through and
crashed in sumAmountsByUser's forEach checkcast.
Observe as <Event> and dispatch on type: LnZapEvent keeps its
private-zap decryption path; OnchainZapEvent attributes the
claimedAmountInSats to event.pubKey (no zap-request envelope).
Several callers pass Color.Transparent as the rich-text backgroundColor.
getGradient was copying that color and forcing alpha=1, which produced
opaque black (Color(0,0,0,1)) as the gradient end — so on a light theme
the fade behind the "Show more" button looked pitch black.
Fall back to MaterialTheme.colorScheme.background when the supplied
background has alpha 0, so the gradient fades from transparent into the
actual surface color.
Adds RenderOnchainZap, dispatched from RenderNoteRow alongside
RenderLnZap. The card shows a pulsing Bitcoin badge, sender→recipient
avatars, big sats amount in Bitcoin orange, an animated 'ON-CHAIN'
pill, a live confirmation pill (Verifying → In mempool → Confirmed at
block N) sourced from LocalCache.onchainBackend.getTx, and a
tap-to-copy mempool.space tx link.
Restores ReplyNoteComposition's parentBackgroundColor parameter (reverting
part of the previous commit). With calculateBackgroundColor now returning
the parent State directly when routeForLastRead is null, every inner
NoteCompose call site (replies, reposts, reactions, reports, approvals,
attestations, multi-set / message-set notification cards, and rich-text
nostr:event/nostr:note quotes) shares the level-0 bgColor State and fades
in lockstep with the outer note.
linuxdeploy auto-walks every binary in the staged AppDir with ldd to
bundle their shared-library deps. That fights jpackage's self-contained
output on two axes and aborts the createReleaseAppImage task before any
AppImage is produced:
- The bundled JRE puts libjvm.so at usr/lib/runtime/lib/server/, while
its sibling libs (libmanagement.so, libawt_xawt.so, libfontmanager.so
and others in usr/lib/runtime/lib/) have RPATH=$ORIGIN. ldd cannot
resolve libjvm.so from those libs without help, so linuxdeploy errors:
ERROR: Could not find dependency: libjvm.so
ERROR: Failed to deploy dependencies for existing files
- The bundled libvlc plugins under usr/lib/app/resources/vlc/ are
UPX-compressed by the vlc-setup plugin. patchelf cannot read their
section headers, so linuxdeploy aborts:
ERROR: Call to patchelf failed:
patchelf: no section headers. The input file is probably a
statically linked, self-decompressing binary
- Even after working around libjvm.so via LD_LIBRARY_PATH, several VLC
libs have RUNPATH pointing at the wrong directory, so ldd fails on
libva.so.2 / libvlccore.so.9 next.
This is why v1.09.2 shipped .deb + .rpm from the linux leg but no
.AppImage and no linux .tar.gz from the linux-portable leg — the gradle
step failed on linuxdeploy, so the subsequent "Build portable archives"
step never ran.
Swap to appimagetool, which only embeds the AppDir as-is into a
SquashFS-backed, runtime-prepended AppImage and never touches the
contents. jpackage already bundles a complete, self-contained app —
we don't need linuxdeploy's dep-discovery. AppRun continues to handle
LD_LIBRARY_PATH at launch.
Verified locally on ubuntu-24.04: a fresh
:desktopApp:createReleaseAppImage now produces a valid 254 MB
Amethyst-1.09.2-x86_64.AppImage in ~30 s on top of an up-to-date
createReleaseDistributable.
Side changes:
- Workflow installs desktop-file-utils (appimagetool 1.9.0 calls
desktop-file-validate on the .desktop entry).
- BUILDING.md updated with the new local-dev fetch command.
- .gitignore replaces the linuxdeploy paths with appimagetool's.
When the user denied RECORD_AUDIO or CAMERA for a DM call, rememberCallWithPermission's
launcher callback only re-checked permissions and called onCall() on success — the denial
branch was empty. On the next button press Android skips the system dialog (permanently
denied) and immediately returns deny, so the call button appeared dead with no UI feedback.
Now the denial path opens an AlertDialog with an "Open settings" deep-link, matching the
NestActionBar pattern. Also splits the optional BLUETOOTH_CONNECT request onto its own
launcher so its result callback no longer triggers a second onCall().
calculateBackgroundColor captured parentBackgroundColor?.value once inside
remember(createdAt), so an inner NoteCompose (repost or reply preview) that
first composed while the outer was still highlighted would snapshot the
purple color and never observe the outer's fade back to the default
background.
- Repost inner notes now share the parent's bgColor State directly when
they have no own read-tracking, so they fade in lockstep with the outer.
- Reply previews no longer receive parentBackgroundColor at all; they rely
on replyModifier for their own visual style and never inherit the
purple "new item" highlight from the surrounding post.
GitHub's macos-13 runner image is being retired. Drop the x64 macOS
legs from both the desktop and CLI release matrices; macos-14 arm64
continues to ship the macOS builds.
Decision: keep the hand-rolled psbt/ + taproot/ consensus layer rather than
adopting fr.acinq.bitcoin-kmp. Rationale: a deliberately small single-key-path
P2TR subset, pinned to authoritative BIP-341/350 test vectors at every layer
(sighash, tweak, witness signature bytes, addresses, tx serialization), and
consistent with the project's minimal-dependency stance.
Recorded in amethyst/plans/2026-05-14-onchain-zaps.md with the consequence
spelled out (we own correctness; revisit if scope expands past single-key-path
P2TR). The psbt/ and taproot/ packages now carry a pointer back to that
decision so a future reader doesn't reflexively swap in a library.
Doc-comment + plan-doc only; no logic change.
The NIP-BC EsploraBackend was wired with a hardcoded mempool.space URL,
silently bypassing the user's Tor preference and ignoring the explorer
server they may have configured for OpenTimestamps.
- New BitcoinExplorerEndpoint: the single source of truth for the Bitcoin
explorer base URL, shared by OTS verification and onchain zaps. A
user-configured custom URL always wins; otherwise mempool.space when Tor
is active, blockstream.info when not.
- TorAwareOkHttpOtsResolverBuilder.getAPI now delegates to it (behaviour
unchanged for OTS).
- AppModules wires EsploraBackend through BitcoinExplorerEndpoint using the
same OTS server setting (otsPrefs) and the same money-flavoured Tor-aware
OkHttp client OTS uses — so onchain zaps honour Tor and the user's
explorer choice.
- Removed the dead AccountSettings.onchainEsploraEndpoint field and
DEFAULT_ESPLORA_ENDPOINT const: the OTS explorer setting is now the single
configuration point. The field was never persisted or read.
Adds BitcoinExplorerEndpointTest. amethyst compiles; the new test passes.
The big one: the source package was named `nipBCOnchainZaps/build/`, which
the repo .gitignore (`build/`) silently matched — so OnchainZapBuilder.kt
(the main source, not just its test) was NEVER committed. The pushed branch
did not compile; the pre-commit hook only checks the working tree, so this
went unnoticed. Renamed the package `build` -> `builder` (a source package
must never be named `build`) and updated all references; the recovered files
are now actually tracked.
Re-audit findings on the previous fix commit:
- CachingOnchainBackend.txCache was unbounded -> memory leak in a long
session. Added a bounded cache (maxCachedTxs, oldest-entry eviction).
- OnchainZapSender still trusted the signer's returned PSBT for witness
UTXOs and tap internal keys (used to compute the sighash + the verified
output keys). Now it copies ONLY the PSBT_IN_TAP_KEY_SIG records back onto
the PSBT we built, so a signer can contribute signatures and nothing else;
verification and finalization run entirely on our own PSBT.
Test coverage added:
- OnchainZapBuilderTest: confirmed-UTXO filter — unconfirmed UTXOs excluded
by default, spendable only with allowUnconfirmed, confirmed preferred.
- CachingOnchainBackendTest: confirmed-tx cached forever, unconfirmed
re-fetched after TTL, not-found never cached, tip/fee TTL, bounded
eviction.
All quartz / commons jvmTest suites pass; quartz android + amethyst compile.
CRITICAL
- PsbtSignatureVerifier: independently verifies every key-path signature in a
PSBT (BIP-340 sig over the BIP-341 sighash, against the tweaked output key).
- OnchainZapSender now rejects a signer that returns a different transaction
than it was asked to sign (byte-compares the unsigned tx) and verifies all
signatures before broadcasting — closes a substitution attack where a
malicious/buggy external signer could redirect funds.
HIGH — break test circularity
- Pin the full BIP-341 keyPathSpending input-0 witness: signing the vector
sighash with the vector tweaked key reproduces the vector signature
byte-for-byte (also pins BIP-340 nonce determinism).
- Pin TaprootAddress.fromPubKey + SegwitAddress.encodeP2TR against all seven
BIP-341 wallet-test-vector P2TR mainnet addresses.
- EsploraBackendTest: JSON-parsing coverage for /tx, /address/{}/utxo, the
mempool.space and blockstream fee formats, and schema-fallback.
MEDIUM
- OnchainZapBuilder filters to confirmed UTXOs by default (allowUnconfirmed
opt-in) and signals BIP-125 RBF (nSequence 0xFFFFFFFD).
- CachingOnchainBackend: TTL-caching decorator (confirmed tx forever,
unconfirmed/tip/fees short TTL) so a feed of onchain zaps doesn't fan out
into one HTTP request per event. Wired in AppModules.
- OnchainZapVerifier computes real confirmation depth from the chain tip and
asserts the backend echoed the requested txid.
- EsploraBackend falls back to the standard Esplora /fee-estimates endpoint
(blockstream.info) when /v1/fees/recommended 404s.
LOW
- BitcoinTransaction.parse caps input/output/witness-item counts to stop a
hostile varint from triggering a giant pre-allocation.
- OnchainZapEventTest: asserts kind:8333 on-the-wire tag structure against the
NIP-BC spec.
- alt tag now includes the amount ("Onchain zap: N sats"), matching the spec
example.
All quartz / commons / androidHostTest suites pass; amethyst compiles.
Wires the NIP-55 Android external-signer contract for sign_psbt, so
NostrSignerExternal.signPsbt is real instead of a stub.
- CommandType.SIGN_PSBT ("sign_psbt") — also available in NIP-55 `perms`
lists, since Permission wraps CommandType directly.
- SignPsbtResult result type.
- SignPsbtQuery (background ContentResolver) + SignPsbtRequest /
SignPsbtResponse (foreground Intent), modeled on the derive_key
string-in/string-out shape: the PSBT hex rides the `nostrsigner:` URI,
the signed (not finalized) PSBT comes back in the `result` field.
- BackgroundRequestHandler.signPsbt / ForegroundRequestHandler.signPsbt.
- NostrSignerExternal.signPsbt now runs the background-then-foreground
query. Signer apps that predate sign_psbt reply with no `result`, which
surfaces as CouldNotPerformException — the send dialog shows it as a
failure ("update your signer").
NIP-46 (NostrSignerRemote) still throws UnsupportedMethodException — the
bunker-side command isn't standardized yet.
Wires the Phase A.2 quartz send-side foundation into a working end-to-end
send: build → sign → broadcast → publish kind:8333.
commons/onchain/OnchainZapSender — stateless orchestrator. Loads the
sender's UTXOs, builds the unsigned PSBT via OnchainZapBuilder, signs it
through NostrSigner.signPsbt, finalizes + broadcasts via OnchainBackend,
then publishes the kind:8333 receipt through an injected publish callback.
Per-stage failures surface as OnchainZapSendResult.Failure (with the
broadcast txid preserved when only the receipt publish failed) instead of
throwing; CancellationException always propagates.
Account.sendOnchainZap — thin wrapper binding the account's signer, the
LocalCache.onchainBackend, and signAndComputeBroadcast into the
orchestrator.
amethyst wallet UI:
- OnchainZapSendDialog — collects recipient npub (or a fixed recipient when
launched from a note's zap menu), amount, fee tier (slow/normal/fast from
the backend's fee estimates), and an optional comment; runs the send and
shows progress + success/failure.
- OnchainSection — the Bitcoin card on the wallet screen gains a "Send"
button next to "Copy address" that opens the dialog for a profile zap.
Tests: OnchainZapSenderTest covers the happy path (receipt references the
broadcast txid, recipient, and amount), insufficient-funds failure at the
build stage, broadcast failure (no txid leaked), and publish failure (txid
preserved for retry).
Pending: Phase B (NIP-55 sign_psbt Intent) — external/remote signers still
throw UnsupportedMethodException; note-zap-menu entry point can reuse
OnchainZapSendDialog's recipientPubKey/zappedEvent parameters once wired.
Implements the send half of the onchain zaps plan: a minimal, fund-safe
BIP-174 PSBT pipeline plus the OnchainZapBuilder and `NostrSigner.signPsbt`.
Every cryptographic step is validated against the BIP-341 wallet test
vectors.
nipBCOnchainZaps/psbt/:
- BitcoinIO: little-endian Bitcoin consensus byte reader/writer (u16/u32/u64,
compact-size varint, var-bytes).
- BitcoinTransaction: OutPoint / TxIn / TxOut / transaction model with legacy
and BIP-144 segwit serialization, segwit-aware parsing, and witness-stripped
txid. Validated against the genesis coinbase tx and the BIP-341 9-input
unsigned tx.
- TaprootSigHash: BIP-341 SigMsg + TapSighash tagged hash for key-path spends.
All six base sighash types plus both ANYONECANPAY variants verified against
the seven BIP-341 keyPathSpending vectors.
- Psbt: BIP-174 container subset (global unsigned-tx, per-input witness-utxo /
tap-internal-key / tap-key-sig / sighash-type, per-output tap-internal-key).
Unknown records round-trip verbatim. Typed accessor extensions.
- PsbtSigner: signs the key-path P2TR inputs a given private key controls —
derives the BIP-341 tweaked key, computes the all-inputs sighash, produces a
BIP-340 Schnorr signature. Leaves inputs the key does not control untouched.
- PsbtFinalizer: moves tap-key-sigs into the witness stack, yields a
broadcastable transaction.
nipBCOnchainZaps/build/:
- OnchainZapBuilder: largest-first coin selection + unsigned-PSBT assembly for
a NIP-BC zap. Sender spends from / changes back to the single Taproot address
derived from their Nostr pubkey; recipient output pays the recipient's
derived address. Dust-aware change handling, InsufficientFundsException,
self-zap and dust-amount guards.
nipBCOnchainZaps/taproot/:
- TaprootAddress.tweakSecretKey: BIP-341 taproot_tweak_seckey (key-path-only),
including the odd-y internal-key negation. Validated against the BIP-341
vector's tweakedPrivkey.
Secp256k1Instance: new `privKeyNegate` primitive across commonMain expect +
jvm / android / native actuals.
Signer hierarchy — new `NostrSigner.signPsbt(psbtHex): String`:
- NostrSignerSync / NostrSignerInternal: real implementation.
- NostrSignerWithClientTag: delegates to the wrapped signer.
- NostrSignerRemote (NIP-46) and NostrSignerExternal (NIP-55): throw
SignerExceptions.UnsupportedMethodException — the bunker command and the
Android Intent contract are Phase B, and depend on signer-app support.
Tests: 31 new tests across the psbt/build/signers packages, anchored on the
BIP-341 wallet-test-vectors so the tweak, sighash, signature, and finalized
transaction are all checked against an authoritative source.
Still pending: Phase B (NIP-55 sign_psbt Intent) and Phase D (send UI +
broadcast + publish kind 8333).
Marks the receive + display phases done and spells out exactly what
Phase A.2 (send-side PSBT foundation) needs before send can ship —
notably the fund-safety-critical BIP-174 codec and BIP-341 tweaked-key
signing path.
OnchainSection now fetches UTXOs for the derived Taproot address from the
configured Esplora backend and displays the sum as the section balance.
Loading / unavailable / error states are surfaced inline. No retry on
failure — the user refreshes by re-entering the screen.
Balance fetch runs in a LaunchedEffect keyed on the derived address (which
is keyed on the account pubkey), so it kicks off once per account view.
Phase C of the onchain zaps plan (amethyst/plans/2026-05-14-onchain-zaps.md).
Plumbs the Quartz receive foundation from Phase A.1 into the Android app so
incoming kind 8333 zaps show up in note totals and the wallet screen.
Subscription / fetch path (kind 8333 rides existing zap filters, no new
assemblers per the plan):
- FilterRepliesAndReactionsToNotes: OnchainZapEvent.KIND in
RepliesAndReactionsKinds (#e on notes)
- FilterUserProfileZapReceived: OnchainZapEvent.KIND in
UserProfileZapReceiverKinds (#p on profiles)
- UserProfileZapsViewModel: kinds = LnZap + Onchain inline
- FilterNotificationsToPubkey: OnchainZapEvent.KIND in SummaryKinds
- NotificationFeedFilter / NotificationDispatcher: kind 8333 in
NOTIFICATION_KINDS
- FilterMessagesToLiveStream / FilterGoalForLiveActivity: kind 8333
alongside 9735
Display path (Note.zapsAmount fold-in — no UI changes needed downstream):
- commons/model/Note.kt: new `onchainZaps: Map<String, OnchainZapAmount>`
(keyed by txid for `(txid, target)` dedup); `addOnchainZap(txid, sats,
confirmed)` mutator; `updateZapTotal` now also sums confirmed onchain
sats into `zapsAmount`. Pending entries are tracked but excluded from
the aggregate total per the NIP-BC spec.
- commons/model/OnchainZapAmount.kt: new data class for per-tx state.
- LocalCache: new `onchainBackend: OnchainBackend?` slot; new
`consume(OnchainZapEvent)` handler that runs the same sig-verify and
computeReplyTo pipeline as `consume(LnZapEvent)`, then kicks off async
verification on `applicationIOScope`. Self-zaps are rejected
synchronously per the spec. Verification failures, mempool-only txs,
and confirmed txs all route to `Note.addOnchainZap` with the correct
flag. Without a backend wired, events are still cached (so subscriptions
see them) but skip the total contribution.
- LocalCache.computeReplyTo: new `is OnchainZapEvent ->` branch that
enumerates `e`/`a` targets — profile-only zaps return an empty list and
flow through profile zap queries instead.
Wallet UI:
- Wallet screen now renders a "Bitcoin" Card above the existing NIP-47
NWC wallet list. Single card (one onchain wallet per Nostr identity,
derived from the pubkey). Shows the bc1p taproot address + Copy button.
Balance, recent incoming zaps, and tap-to-detail come in later phases.
Settings + wiring:
- AccountSettings: new `onchainEsploraEndpoint: MutableStateFlow<String>`,
default mempool.space, plumbed for a future settings UI.
- AppModules: builds a single shared `EsploraBackend` at app init using
the role-based money-flavored OkHttp client (Tor-aware), installs it on
`LocalCache.onchainBackend` so verification can run.
Secp256k1Instance: new `pubKeyTweakAdd` was added in Phase A.1.
No PSBT, no signing, no broadcasting — send-side (Phase A.2 + D) is still
pending.
Phase A.1 of the onchain zaps plan (amethyst/plans/2026-05-14-onchain-zaps.md):
the verify/receive path of NIP-BC. Send-side (PSBT, builder, signPsbt) is the
next phase.
Adds:
- nipBCOnchainZaps/taproot/SegwitAddress: BIP-173 / BIP-350 native segwit
address encoder/decoder, witness v0..v16, on top of the existing Bech32 util.
- nipBCOnchainZaps/taproot/TaprootAddress: BIP-341 key-path-only derivation
from a Nostr pubkey. Computes the TapTweak tagged hash, applies the additive
tweak via Secp256k1Instance.pubKeyTweakAdd, and encodes as bc1p... bech32m.
- Secp256k1Instance: new pubKeyTweakAdd primitive wired through commonMain
expect + jvm / android / native actuals.
- nipBCOnchainZaps/chain: pluggable OnchainBackend interface plus BitcoinTx,
BitcoinTxOutput, Utxo, FeeEstimates data models.
- nipBCOnchainZaps/chain/EsploraBackend (jvmAndroid): OkHttp-based
Esplora-compatible client (mempool.space / blockstream.info) covering
/tx, /address/{addr}/utxo, /tx broadcast, /blocks/tip/height, and
/v1/fees/recommended.
- nipBCOnchainZaps/verify/OnchainZapVerifier: enforces every NIP-BC client
rule -- reject self-zap, fetch tx, derive recipient Taproot scriptPubKey,
sum only outputs paying the recipient (excluding change back to sender),
cap claimed amount at verified amount, mark unconfirmed as Pending.
Tests (commonTest, runs on jvmTest):
- SegwitAddressTest: BIP-350 known-good vectors for v0/v16 + round-trip for
v1 (P2TR).
- TaprootAddressTest: BIP-341 wallet-test-vectors tagged hash + tweaked
output key + scriptPubKey, plus address round-trip and prefix/length
sanity.
- OnchainZapVerifierTest: confirmed / pending / self-zap / missing tx /
zero-verified-amount / multi-output-sum / amount-capping with an
in-memory FakeBackend wired to real TaprootAddress-derived scripts.
Plan: amethyst/plans/2026-05-14-onchain-zaps.md documents the full v1 scope,
the merge into the existing NIP-47 wallet UI, the subscription kind-list
edits, and the Note.zapsAmount fold-in approach for display.
Implements NIP-BC onchain zap events analogous to NIP-57 lightning
zap receipts but for Bitcoin transactions. The recipient's Nostr
pubkey is used directly as the internal key of a BIP-341 P2TR output.
Mirrors the nip88Polls package structure with a single `zap/` subpackage:
- OnchainZapEvent (kind 8333) with profile / event / addressable-event
zap builders
- Tags: BitcoinTxIdTag (i = bitcoin:tx:<txid>), AmountTag (sats),
BlockTag (hash + height), ProofTag (raw-tx + merkle proof) for
optional SPV verification
- TagArrayExt / TagArrayBuilderExt for parsing and assembly
- EventFactory wired so kind 8333 deserializes into OnchainZapEvent
Reuses standard ETag/ATag/PTag/KindTag from nip01Core. Does not yet
implement client-side transaction verification or PSBT signer methods.
2026-05-14 03:25:44 +00:00
2194 changed files with 174822 additions and 20289 deletions
- System integrations (notifications, file pickers)
**Rationale:** ViewModels contain platform-agnostic state management (StateFlow/SharedFlow) and business logic. Screens consume ViewModels but render differently (Desktop sidebar + content area vs Android bottom nav).
### Step 4: Extract Shared Components
When extracting UI components:
1. Identify reusable composables in Android code
2. Move to `commons/commonMain/` (consult `/compose-expert` for patterns)
3. Create expect/actual declarations for platform-specific behavior (consult `/kotlin-multiplatform`)
4. Update both Android and Desktop to use shared component
**Note:** `quartz/` is protocol-only (no composables). Shared UI goes in `commons/` after converting it to KMP.
When extracting a composable: move it to `commons/commonMain/` (see
`/compose-expert`), add expect/actual for any platform behavior (see
`/kotlin-multiplatform`), then point both Android and Desktop at the shared
version. `quartz/` is protocol-only — no composables.
## Build Commands
@@ -270,41 +160,65 @@ When extracting UI components:
./gradlew spotlessApply
```
## Dependency Licensing
**MANDATORY whenever you introduce a new third-party dependency** — in *any*
-`references/immutability-patterns.md` - @Immutable annotation, data classes, ImmutableList/Map/Set
**Differentiation:** Complements kotlin-coroutines agent (deep async). This skill = Amethyst Kotlin idioms (StateFlow state management, sealed for type safety, @Immutable for Compose, DSL builders).
**Status:** ✅ SKILL.md (455 lines) + 4 references created at `.claude/skills/kotlin-expert/`
**10-Step Progress:**
1. ✅ UNDERSTAND - Defined scope (Flow/sealed/DSL/immutability/inline)
2. ✅ EXPLORE - Found 173 @Immutable events, StateFlow in AccountManager/RelayManager, SignerResult generics, TagArrayBuilder
3. ✅ RESEARCH - StateFlow vs SharedFlow, sealed class vs interface best practices 2025
4. ✅ SYNTHESIZE - Extracted Amethyst patterns (hot flows for state, sealed for results, @Immutable for perf)
-`references/desktop-navigation.md` - NavigationRail vs BottomNav patterns
-`references/keyboard-shortcuts.md` - Standard shortcuts by OS with DesktopShortcuts helper
-`references/os-detection.md` - Platform detection, file paths, system integration
**Differentiation:** Desktop-only APIs, OS conventions (Cmd vs Ctrl), NavigationRail, delegates build to gradle-expert and shared code to kotlin-multiplatform/compose-expert.
**Status:** ✅ SKILL.md + 4 references created at `.claude/skills/desktop-expert/`
8.**ios-expert** — ⏸️ deferred (iOS targets are mature, but no iOS-specific UI work has surfaced in this repo yet)
## Phase 2 (2026-04): Audit & Expansion
After a full audit of the skill library, the following changes were made:
### Stale references fixed
-`CLAUDE.md` tech-stack versions updated to Compose 1.10.3 / Kotlin 2.3.20.
-`kotlin-multiplatform` reframed iOS as a mature target (not future) and added secp256k1-kmp 0.23.0 version notes.
-`desktop-expert` Main.kt line references rewritten to match current layout (Main.kt grew from ~270 to ~1341 lines; NavigationRail moved to `ui/deck/SinglePaneLayout.kt:97`); the obsolete "hardcoded ctrl = true anti-pattern" section replaced with a note that `isMacOS` branching is now applied throughout.
-`CLAUDE.md` tech-stack versions replaced with a pointer to `gradle/libs.versions.toml` as the source of truth.
-`kotlin-multiplatform` reframed iOS as a mature target (not future) and added secp256k1-kmp version notes.
-`desktop-expert` Main.kt line references rewritten to match current layout (NavigationRail moved to `ui/deck/SinglePaneLayout.kt`); the obsolete "hardcoded ctrl = true anti-pattern" section replaced with a note that `isMacOS` branching is now applied throughout.
### Redundant files removed
-`.claude/skills/compose-desktop.md` deleted (superseded by `desktop-expert/`).`quartz-kmp.md` kept as a small breadcrumb pointer.
-`.claude/skills/compose-desktop.md` deleted (superseded by `desktop-expert/`).
description: Account state and in-memory event store patterns in Amethyst. Use when working with `Account.kt` (per-user StateFlow properties — follow list, relays, settings, mutes, bookmarks), `LocalCache` (the object-level event store backed by `LargeCache`), `User`/`Note` model classes, or any ViewModel that reads user-specific state. Covers how account events cascade from relay arrival to UI state, how to add a new account-scoped setting, and when to read from `LocalCache` vs subscribe to a StateFlow.
description: Account state and in-memory event store patterns in Amethyst. Use when working with `Account.kt` (per-user state objects — `kind3FollowList`, `nip65RelayList`, `muteList`, `bookmarkState`, each exposing a `.flow` StateFlow), `LocalCache` (the object-level event store backed by `LargeCache`), `User`/`Note` model classes, or any ViewModel that reads user-specific state. Covers how account events cascade from relay arrival to UI state, how to add a new account-scoped setting, and when to read from `LocalCache` vs subscribe to a StateFlow.
---
# Account & Local Cache State
@@ -21,10 +21,10 @@ The backbone of Amethyst's client state: one `Account` per signed-in user, plus
`LocalCache` is the event store. `Account` is the *derived* per-user view (follow list, relays, mutes, emojis, bookmarks, etc.). UI listens to `Account`'s StateFlows, not directly to `LocalCache`, except for note-level rendering.
`LocalCache` is the event store. `Account` is the *derived* per-user view (follow list, relays, mutes, emojis, bookmarks, etc.). UI listens to the `.flow` of `Account`'s state objects, not directly to `LocalCache`, except for note-level rendering.
## Key Files
### `Account.kt` (singleton-per-session)
-`class Account(...)` — holds 50+ StateFlow properties, each wired to a specific Nostr kind:
-`followListFlow` ← NIP-02 ContactList (kind 3)
-`relayListFlow` ← NIP-65 RelayList (kind 10002)
-`muteListFlow` ← NIP-51 Lists (kind 10000)
-`bookmarkListFlow` ← NIP-51 Lists (kind 10003)
-`topNavFeedsFlow`, `marmotGroupsFlow`, `customEmojisFlow`, `privateBookmarksFlow`, etc.
- Each flow has a private `MutableStateFlow` and a public read-only `StateFlow` view. Mutation goes through specific methods (`sendPost`, `follow(pubKey)`, `addBookmark(...)`) that both update the flow and publish the signed replaceable event.
- Uses `CoroutinesExt.launchIO` for network / crypto; UI reads via `collectAsStateWithLifecycle` on Android and `collectAsState` on Desktop.
- Sibling files per feature live alongside: `AccountSettings.kt`, `AccountSyncedSettings.kt`, plus per-NIP state objects under `model/nip02FollowLists/`, `model/nip51Lists/`, `model/nip65RelayList/`, etc.
-`class Account(...)` — holds 50+ **state objects**, one per feature, each wired to a specific Nostr kind:
-Derived/merged views: `hiddenUsers`, `allFollows`, `homeRelays`, `outboxRelays`, `dmRelays`, `notificationRelays`, `trustedRelays`, and the `live*FollowListsPerRelay` outbox loaders.
-**The pattern:** each `XState` class pins its addressable note via `cache.getOrCreateAddressableNote(address)` (a long-term reference so GC/eviction can't drop it), exposes `val flow: StateFlow<…>` derived from the note's metadata flow (decrypted through a per-feature `DecryptionCache`, with backup fallback from `AccountSettings`, `stateIn(scope, Eagerly, …)`), and offers suspend mutation helpers (e.g. `MuteListState.hideUser(pubkey)`) that build the updated signed event. Consumers read `account.muteList.flow`, never a raw `MutableStateFlow` on `Account`.
- Encrypted lists pair the state object with a `DecryptionCache` sibling (`muteListDecryptionCache`, `peopleListDecryptionCache`, …) so NIP-44 decryption results are cached per event.
- UI reads via `collectAsStateWithLifecycle` on Android and `collectAsState` on Desktop.
- Sibling files per feature live alongside: `AccountSettings.kt`, `AccountSyncedSettings.kt`, plus per-NIP state classes under `model/nip02FollowLists/`, `model/nip51Lists/`, `model/nip65RelayList/`, etc.
1. If the setting is persisted as a Nostr event, pick the right kind (e.g. NIP-51 list, NIP-78 app-specific data, NIP-65 relay list).
2. Add a model folder under `amethyst/.../model/nipXX…/` with an `ExtState`/builder class if needed.
3. In `Account.kt`:
-Add a private `MutableStateFlow<T>`.
-Expose a `StateFlow<T>` read view.
- Subscribe to the relay (via the relayClient subscription pattern — see `relay-client` skill).
- On event arrival, parse with the quartz event class and update the flow.
- Write a mutation method (`updateX(...)`) that builds a new event via the corresponding `TagArrayBuilder`, signs through `NostrSigner`, and publishes.
4. Add UI that `collect`s the flow. Settings screens live in `amethyst/.../ui/screen/loggedIn/settings/`.
2. Add a model folder under `amethyst/.../model/nipXX…/` with an `XState` class modeled on an existing one (`MuteListState` for an encrypted list, `BookmarkListState` for a plain one):
- Pin the addressable note: `val xNote = cache.getOrCreateAddressableNote(XEvent.createAddress(signer.pubKey))`.
-Expose `val flow: StateFlow<…>` mapped from `xNote.flow().metadata.stateFlow`, decrypting through a per-feature `DecryptionCache` if the list is private, with backup fallback from `AccountSettings`, then `stateIn(scope, Eagerly, default)`.
-Add suspend mutation helpers that build the updated event via the quartz event class (`XEvent.add/remove/create`) and return it signed.
3. In `Account.kt`, instantiate the state object (and its `DecryptionCache` sibling if encrypted) as a `val`. Publishing the returned event goes through `Account`'s send path; the relay subscription side is the relayClient pattern (see `relay-client` skill).
4. Add UI that `collect`s `account.x.flow`. Settings screens live in `amethyst/.../ui/screen/loggedIn/settings/`.
## `LocalCache` vs `Account` Flow — Which to Read?
- **Are you rendering a specific note / user you hold an id for?** → `LocalCache.getOrCreateNote(id)` + collect `note.flowSet.metadata`.
- **Are you rendering "my follows", "my mutes", "my relays"?** → `Account.<featureFlow>`.
- **Are you rendering "my follows", "my mutes", "my relays"?** → `account.<feature>.flow` (e.g. `account.kind3FollowList.flow`, `account.muteList.flow`, `account.nip65RelayList.flow`).
- **Are you rendering a feed?** → Use a `FeedFilter` + `FeedViewModel` (see `feed-patterns` skill). Don't scan `LocalCache` in a composable.
## Gotchas
- **`LocalCache` is a singleton across accounts.** Switching accounts doesn't wipe it — `Account` re-derives its flows from the same cache.
- **Don't store Flows inside `Note` / `User`** expecting them to survive eviction. Eviction drops the whole object.
- **Mutations to `Account` flows must also publish the signing event.** A flow update without a publish means other clients won't see it.
- **State-object mutation helpers return a signed event — publishing it is the caller's job.** A locally updated list without a publish means other clients won't see it.
- **`Note` is mutable** — treat instances as identity-based (same id → same Note). Use `.flowSet` when you need reactive state.
- **`MemoryTrimmingService` can evict aggressively** on Android under pressure. Don't assume a previously-seen note is still resident.
## References
-`references/account-state-flow.md` — catalog of major `Account`StateFlow properties and their source kinds.
-`references/account-state-flow.md` — catalog of major `Account`state objects and their source kinds.
`Account.kt`exposes dozens of `StateFlow` properties that mirror different facets of the current user. This is a map from flow → Nostr kind → model package.
`Account.kt`composes ~50 **feature state objects** (not raw StateFlow
properties). Each object pins its backing addressable note in `LocalCache`,
exposes `val flow: StateFlow<…>` (decrypted + backup-merged + `stateIn`), and
offers suspend mutation helpers that return signed events. Consumers read
`account.<property>.flow`.
(Flow names are exact as of the current `Account.kt`; if a flow has been renamed, grep `Account.kt` for the old name.)
(Property and class names are exact as of the current `Account.kt`; if one has
been renamed, grep `Account.kt` for the class name.)
- **Bunker URL** (`bunker://...`) → `RemoteSignerManager.connect(url)` in `nip46RemoteSigner/signer/RemoteSignerManager.kt` returns a `NostrSignerRemote`.
- **Bunker URL** (`bunker://...`) → `NostrSignerRemote.fromBunkerUri(bunkerUri, localSigner, client)` in `nip46RemoteSigner/signer/NostrSignerRemote.kt` parses the URI and returns a `NostrSignerRemote`; then call its `suspend fun connect()` to perform the NIP-46 handshake.
- **Installed external signer app** (Amber, nos2x, etc. on Android) → `ExternalSignerLogin.launch(...)` opens the signer app; approval yields a `NostrSignerExternal`.
The UI hosts both flows via `amethyst/.../ui/screen/loggedOff/login/` — look there for `ExternalSignerButton.kt` and the bunker-URL paste screen.
description: Compose Multiplatform Desktop patterns for the `desktopApp/` module. Use when working with (1) Desktop-only APIs (Window, WindowState, Tray, MenuBar, Dialog), (2) keyboard shortcuts and menu systems with OS-aware conventions (Cmd vs Ctrl, isMacOS branching), (3) desktop navigation (NavigationRail/sidebar vs Android bottom nav, multi-window), (4) file system integration (file pickers, drag-and-drop, Desktop.getDesktop()), (5) OS-specific behavior on macOS/Windows/Linux, (6) desktop UX principles (keyboard-first, tooltips). Delegates shared composables to compose-expert, build/packaging to gradle-expert, and source-set structure to kotlin-multiplatform.
---
# Desktop Expert
Expert in Compose Multiplatform Desktop development for AmethystMultiplatform. Covers Desktop-specific APIs, OS conventions, navigation patterns, and UX principles.
@@ -69,7 +74,7 @@ fun main() = application {
-`rememberWindowState()` manages size/position
-`onCloseRequest` handles window close
**See:**`desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt` — `fun main()` at L172, `application {` at L186, top-level `Window` at L229,`MenuBar`at L234.
**See:**`desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt` — grep for`fun main()`, `application {`, the top-level `Window`, and`MenuBar {`(the file is large and line numbers drift; navigate by symbol).
---
@@ -275,9 +280,9 @@ Row(Modifier.fillMaxSize()) {
}
```
**See:**`desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt` (NavigationRail at L97, items at L103+). `DeckLayout` alongside it handles multi-pane workspaces.
**In Amethyst Desktop:** the sidebar is the custom `MainSidebar` composable in `desktopApp/.../ui/deck/DeckSidebar.kt`, instantiated from `Main.kt` and shared by both layout modes (`SinglePaneLayout` and the multi-pane `DeckLayout` alongside it). It is hand-rolled, not Material's `NavigationRail` — use `NavigationRail` only for new, simpler cases.
@@ -11,11 +11,11 @@ Comparison of mobile vs desktop navigation patterns in AmethystMultiplatform.
---
## Desktop: NavigationRail
## Desktop: Left Sidebar
### Current Implementation
**File:**`desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt` (NavigationRail begins at L97; `NavigationRailItem`s at L103 and L127+).
**File:**`desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt` — the custom `MainSidebar` composable, instantiated from `Main.kt` and shared by both `SinglePaneLayout` and the multi-pane `DeckLayout`. Amethyst Desktop does **not** use Material's `NavigationRail`; the snippet below shows the generic Compose pattern for reference, useful for simpler new surfaces.
description: Feed composition and data-access layer patterns in Amethyst. Use when adding or modifying a feed (home, profile, hashtag, bookmarks, notifications, DMs, communities), working with `FeedFilter` / `AdditiveComplexFeedFilter` / `ChangesFlowFilter` / `FilterByListParams` in `amethyst/.../ui/dal/`, or extending the `FeedViewModel` family in `commons/.../viewmodels/`. Covers how feeds scan `LocalCache`, react to changes, apply ordering, and render through Compose.
description: Feed composition and data-access layer patterns in Amethyst. Use when adding or modifying a feed (home, profile, hashtag, bookmarks, notifications, DMs, communities), working with the shared `FeedFilter` / `AdditiveFeedFilter` / `ChangesFlowFilter` /`FeedContentState` in `commons/.../ui/feeds/`, the Android-only `AdditiveComplexFeedFilter` /`FilterByListParams` in `amethyst/.../ui/dal/`, or extending the `FeedViewModel` family in `commons/.../viewmodels/`. Covers how feeds scan `LocalCache`, react to changes, apply ordering, and render through Compose.
---
# Feed Patterns
@@ -24,27 +24,33 @@ Amethyst's "feed" abstraction is: a `FeedFilter` that decides which notes belong
│ ◄── ChatroomFeedViewModel │
│ ◄── MarmotGroupFeedViewModel │
│ │
│ FeedContentState — the flow the UI collects │
│ │
│ commons/.../ui/feeds/ (shared, KMP) │
│ IFeedFilter / FeedFilter<T> (abstract base) │
│ IAdditiveFeedFilter / AdditiveFeedFilter<T> │
│ ChangesFlowFilter │
│ FeedContentState, FeedState — the flow the UI collects │
- **`FeedFilter.kt`** — `abstract class FeedFilter<T> : IFeedFilter<T>`. Has `feed(): List<T>` (the sync query against the cache), `feedKey(): String` (identity used to cache), `limit()`, and `loadTop()`.
- **`AdditiveFeedFilter.kt`** — `abstract class AdditiveFeedFilter<T> : FeedFilter<T>(), IAdditiveFeedFilter<T>`. Adds incremental updates (the "additive" part): `updateListWith(oldList, newItems)` runs `applyFilter(newItems)` and grafts accepted items onto the existing list (re-`sort` + `take(limit())`) without recomputing everything.
- **`ChangesFlowFilter.kt`** — wraps a filter with a coarse "state changed" signal so the ViewModel knows to re-query.
- **`FeedContentState.kt` / `FeedState.kt`** — the reactive state the UI collects.
- **`FeedFilters.kt`** — `abstract class FeedFilter<T>`. Has `feed(): List<T>` (the sync query against `LocalCache`) and `feedKey(): String` (identity used to cache).
- **`AdditiveComplexFeedFilter.kt`** — `abstract class AdditiveComplexFeedFilter<T, U> : FeedFilter<T>()`. Adds incremental updates (the "additive" part): when a single new event arrives, the filter can decide whether to graft it onto the existing list without recomputing everything.
- **`ChangesFlowFilter.kt`** — wraps a filter with a coarse "Account state changed" signal so the ViewModel knows to re-query.
- **`FilterByListParams.kt`** — common parameters (author set, exclude muted, limit, since/until) shared across many filters.
- **`DefaultFeedOrder.kt`** — standard sort (by `createdAt` desc, plus tiebreakers for stable paging).
- **`AdditiveComplexFeedFilter.kt`** — `abstract class AdditiveComplexFeedFilter<T, U> : FeedFilter<T>()`: like `AdditiveFeedFilter` but the incoming items (`Set<U>`) are a differenttype than the list rows (`T`).
- **`FilterByListParams.kt`** — common parameters (top-nav filter, exclude muted, since/until) shared across many filters.
- **`DefaultFeedOrder.kt`** — standard comparators (`createdAt` desc + id tiebreaker for stable paging) for `Note`, `Event`, and `Card`.
Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities, etc.) live in feature subfolders under `amethyst/.../ui/screen/loggedIn/*/` — each extends `FeedFilter` or `AdditiveComplexFeedFilter`.
Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities, etc.) live in feature`dal/` subfolders under `amethyst/.../ui/screen/loggedIn/*/` — each extends `FeedFilter`, `AdditiveFeedFilter`, or `AdditiveComplexFeedFilter`. Desktop has its own in `desktopApp/.../feeds/DesktopFeedFilters.kt`.
## Adding a New Feed
1.**Define the filter.** Extend `AdditiveComplexFeedFilter<Note, Set<HexKey>>` (or plain `FeedFilter<Note>` if additivity doesn't matter). Implement:
1.**Define the filter.** Extend `AdditiveFeedFilter<Note>` (or plain `FeedFilter<Note>` if additivity doesn't matter; `AdditiveComplexFeedFilter<T, U>` if incoming items differ in type from list rows). Implement:
-`feed()` — synchronous scan over `LocalCache` / `Account` state producing an ordered list.
-`limit()` — pagination hint.
- If using `AdditiveComplexFeedFilter`: `applyFilter(collection: Set<Note>): Set<Note>` and `sort(collection: Set<Note>): List<Note>`.
- If additive: `applyFilter(collection: Set<Note>): Set<Note>` and `sort(collection: Set<Note>): List<Note>`.
2.**Pick or write a ViewModel.** If the feed's membership shifts often (bookmarks, notifications), extend `ListChangeFeedViewModel`. Otherwise `FeedViewModel`.
3.**Wire invalidation.** The ViewModel must observe the right `Account` flows + `LocalCacheFlow` so it re-queries when state changes.
4.**Render.** In the composable, collect `viewModel.feedState.feedContent` and render with a `LazyColumn { items(..., key = { it.id }) { NoteCompose(it) } }`.
-`FeedFilter` and the concrete filters currently live in `amethyst/.../ui/dal/` — **Android-only**. Desktop has parallel filters in `desktopApp/.../feeds/`.
-ViewModels are in `commons/commonMain/` — **shared**. That's the boundary: filter is Android (could be extracted), ViewModel is shared.
- When porting a new feed, extract the filter to a KMP-friendly location only if both platforms need it.
-The filter **base classes** (`FeedFilter`, `AdditiveFeedFilter`, `ChangesFlowFilter`) and feed state (`FeedContentState`) are in `commons/.../ui/feeds/` — **shared**. ViewModels are in `commons/.../viewmodels/` — **shared**.
-The **concrete** filters are platform-local: Android's in `amethyst/.../ui/screen/loggedIn/*/dal/`, Desktop's in `desktopApp/.../feeds/`. `amethyst/.../ui/dal/` keeps Android-only helpers (`AdditiveComplexFeedFilter`, `FilterByListParams`, `DefaultFeedOrder`) plus back-compat typealiases.
- When porting a feed, share the concrete filter only if both platforms need identical inclusion rules.
- **`feedKey()` is used as a cache key.** Two different semantic feeds must produce different keys, otherwise their state cross-contaminates.
- **Additive updates must stay consistent with the full recompute.** If `applyFilter` accepts a note that `feed()` wouldn't include, UX drifts.
- **Paging isn't free** — use `limit()` and `since/until` in `FilterByListParams` rather than trimming a giant scan.
- **Notifications feed is special** — it inspects `Account.followListFlow` and `LocalCache` deletions to hide muted/deleted content; always run through `FilterByListParams.exclude*` paths rather than filtering post-hoc.
- **Notifications feed is special** — it inspects the follow/mute state (`account.kind3FollowList.flow`, `account.hiddenUsers`) and `LocalCache` deletions to hide muted/deleted content; always run through the `FilterByListParams`exclusion paths rather than filtering post-hoc.
@@ -7,11 +7,12 @@ Step-by-step recipe for composing a new feed. Assume the feed shows `Note`s filt
| If… | Use |
|-----|-----|
| Membership is stable (e.g. "my follows") and you re-compute on change | `FeedFilter<Note>` |
| New notes arrive one at a time and should slot into the list incrementally | `AdditiveComplexFeedFilter<Note, Set<Note>>` |
| New notes arrive one at a time and should slot into the list incrementally | `AdditiveFeedFilter<Note>` |
| Incoming items are a different type than the list rows | `AdditiveComplexFeedFilter<T, U>` (Android-only) |
| The feed is a simple list that changes frequently (e.g. bookmarks, lists) | `FeedFilter<Note>` + `ListChangeFeedViewModel` |
| The feed is a DM thread | `ChatroomFeedViewModel` (already provides filter machinery) |
All live in `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/`.
The bases live in `commons/src/commonMain/.../commons/ui/feeds/`; `AdditiveComplexFeedFilter` and the `FilterByListParams` / `DefaultFeedOrder` helpers in `amethyst/src/main/java/.../ui/dal/`.
## 2. Write the Filter
@@ -19,7 +20,7 @@ All live in `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/`.
@@ -15,6 +15,23 @@ 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
## 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.
What this means for this skill:
1.**The raw on-disk diff is the candidate set.** A key missing from a locale file is either genuinely untranslated *or* a source-identical entry Crowdin stripped. Both are reported; the human decides which to skip. The Crowdin web UI ("N untranslated") is the ground truth for what genuinely needs work.
2.**Source-identical entries are a small, recognizable minority.** Brand terms (`Nowhere X`), single-word loanwords (`Apps` / `Feed` / `Issues`), and bare version/format strings (`v%1$s`) are the usual cases. Skip these by inspection rather than translating them to something identical.
3.**Don't add source-identical fallbacks.** Android falls back to `values/strings.xml` at runtime, so a key intentionally kept as English already renders correctly, and Crowdin's next sync would strip a local duplicate anyway.
> **Historical note:** an earlier version of this skill tried to auto-filter the
> candidate list with a git "sync-timestamp" heuristic (skip any key added before
> the last `New Crowdin translations` commit). It was **dropped** because it
> produced false negatives: a key added shortly before an export that translators
> simply hadn't reached yet is genuinely missing, but the heuristic classified it
> as "Crowdin already decided." Trust the raw diff + the Crowdin UI instead.
## Target Locales
The default set of locales (unless the user specifies otherwise):
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.
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.).
```bash
# Extract translatable keys from default (exclude translatable="false")
# Strings: extract translatable keys from default (exclude translatable="false")
This gives the list of missing key names. Do NOT diff each locale separately — assume the same keys are missing in all target locales.
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":
The combined `strings + plurals` total should line up with the Crowdin web UI's untranslated count for that locale. If it does, the raw diff is your actionable set (minus any source-identical entries you skip by inspection — see Background).
### 3. Get English values for missing keys
For each missing key, extract its English value:
For each missing key, extract its English value. `<string>` is a single line; `<plurals>` is a multi-line block — handle each appropriately.
```bash
# For each missing key, extract the full line from default strings.xml
# Missing <string>: full line from default strings.xml
### 4. Audit missing strings for plural-shaped patterns
@@ -74,7 +135,14 @@ Before presenting results, **scan the missing English strings** for two red-flag
1.**Hardcoded `"1"` next to a noun.** A new English string like `"1 reply"`, `"1 follower"`, or `"1 minute ago"` almost always belongs in a `<plurals>` resource — not a `<string>`. Hardcoding `1` in English forces every translator to either also hardcode `1` (breaking languages where the `one` category covers other numbers, e.g. some Slavic languages) or to silently change the meaning.
2.**A `%d` / `%1$d` placeholder in a clearly singular/plural sentence** (e.g. `"%1$d reply"`, `"%d follower"`). Even though the placeholder is parameterised, English-only `one`/`other` agreement won't survive translation into languages that need `few`/`many`.
Also **audit existing `<plurals>` resources** for the same anti-pattern — any locale's `quantity="one"` item that hardcodes the literal `1` (instead of using a `%d` / `%1$d` placeholder) is broken for languages where the `one` CLDR category covers more than just `n=1` (Russian, Ukrainian, Croatian, etc.). Flag and offer to fix:
Also **audit existing `<plurals>` resources** for two anti-patterns:
1.**`quantity="one"` items that hardcode the literal `1`** (instead of using a `%d` / `%1$d` placeholder) — broken for languages where the `one` CLDR category covers more than just `n=1` (Russian, Ukrainian, Croatian, etc.).
2.**`quantity="zero"` items in any locale that doesn't natively use the `zero` CLDR category** — i.e. **everything except Arabic (`ar`) and Welsh (`cy`)**. ICU/CLDR maps `count=0` to `other` for English and all the locales we ship to (cs, de, pt-BR, sv, etc.), so `<item quantity="zero">` is **dead code** there: `getQuantityString(id, 0)` will pick `other`, never the zero entry, and the visible runtime string ends up `"…0 items"` instead of the intended `"…no items"`.
If a UX genuinely wants special "no items" wording at count=0, that has to be a call-site `if (count == 0)` branch to a separate `<string>`, **not** a `quantity="zero"` plural item.
Flag and offer to fix:
```bash
# Scan every locale's strings.xml for <item quantity="one"> entries that
@@ -96,6 +164,27 @@ for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*
done
```
Then scan for dead `quantity="zero"` entries. CLDR's `zero` category is integer-bearing only in **Arabic (`ar`)** and **Welsh (`cy`)**. In every other locale, count=0 falls through to `other`, so a `<item quantity="zero">` entry is dead and likely a translator/author bug (or it silently never fires):
```bash
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml;do
# Skip Arabic and Welsh — they natively use the zero category.
text = $0; sub(/^[^>]*>/, "", text); sub(/<.*$/, "", text)
print file ": <plurals name=\"" name "\"> zero=\"" text "\""
}
/<\/plurals>/ { in_plurals = 0 }
'"$f"
done
```
For each hit, warn the user that the entry is unreachable in that locale. The fix is to **remove the `<item quantity="zero">`** and, if the UX wanted distinct wording for count=0, add a separate `<string>` plus an `if (count == 0)` branch at the call site (see "Plurals: handle with care" below).
Quick scan over the missing keys:
```bash
@@ -147,6 +236,14 @@ When adding or proposing **`<plurals>`** entries, follow these rules:
- German / Swedish / Brazilian Portuguese: `one`, `other`
- When a missing string contains a count placeholder and is conceptually a singular/plural pair, **flag it before translating** — it may belong as a `<plurals>` resource rather than a single `<string>`. Surface this to the user before proposing translations.
- **Do not use `quantity="zero"` outside Arabic (`ar`) and Welsh (`cy`).** CLDR's `zero` category is integer-bearing only in those two languages. Android calls `PluralRules.select(0)` for the device locale; in English/German/Czech/Polish/Russian/Swedish/Portuguese/etc. it returns `other`, so the explicit `<item quantity="zero">` is never picked at runtime and the user sees `"…0 items"` instead of the intended wording. If the design calls for "no items" at count=0, model it as a separate `<string>` and an `if (count == 0)` branch at the call site:
- Reference: [Android `<plurals>` docs](https://developer.android.com/guide/topics/resources/string-resource#Plurals) and [CLDR plural rules](https://unicode-org.github.io/cldr-staging/charts/latest/supplemental/language_plural_rules.html).
**Then ask the user:** "Would you like me to translate these missing strings into [list of target locales]?"
@@ -161,8 +258,11 @@ When adding translated strings to locale files:
## Common Mistakes
- **Forgetting `translatable="false"`** — these should never appear in locale files
- **Not checking string-arrays/plurals** — only checking `<string>` misses other resource types
- **Diffing each locale separately** — only diff against `cs-rCZ`; assume the same keys are missing everywhere
- **Diffing only `<stringname=`** — `<plurals>` is a separate resource type; a source `<plurals>` missing from a locale will never show up in a `<string>` diff. Always run the diff twice (once per resource type) as shown in Step 2. The same goes for `<string-array>` if the project uses it.
- **Trusting a git "sync-timestamp" heuristic to pre-filter the list** — this skill used to skip keys added before the last `New Crowdin translations` commit, on the theory that Crowdin had already "decided" them. It was dropped: a key added shortly before an export that translators hadn't reached yet is genuinely missing, so the heuristic silently dropped real work. Use the raw on-disk diff and reconcile against the Crowdin web UI's untranslated count instead.
- **Adding source-identical fallbacks locally** — they get overwritten on the next Crowdin sync. Android falls back to `values/strings.xml` at runtime anyway, so a key intentionally kept as English already renders correctly. Skip these by inspection (brand terms, loanwords, `v%1$s`-style strings); don't translate them to an identical value.
- **Skipping per-locale diffs when only diffing cs-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.
- **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`)
- **Copying English's `one`/`other` set into every locale** — each language must include all CLDR plural categories it uses (e.g. Czech needs `one`, `few`, `many`, `other`)
- **Using `<itemquantity="zero">` to special-case count=0** — outside Arabic and Welsh, this entry is unreachable: ICU/CLDR maps 0 → `other`, so the runtime never picks the zero item and the user sees `"…0 items"`. Special-case at the call site with a separate `<string>` instead.
@@ -5,11 +5,11 @@ description: Build optimization, dependency resolution, and multi-module KMP tro
# Gradle Expert
Build system expertise for AmethystMultiplatform's 4-module KMP architecture. Focus: practical troubleshooting, dependency resolution, and project-specific optimizations.
Build system expertise for AmethystMultiplatform's 10-module KMP architecture (`amethyst`, `benchmark`, `quartz`, `geode`, `commons`, `quic`, `nestsClient`, `desktopApp`, `cli`, `quic-interop` — see `settings.gradle.kts`). Focus: practical troubleshooting, dependency resolution, and project-specific optimizations.
## Build Architecture Mental Model
Think of this project as **4 layers**:
The core app stack is **4 layers** (the other modules hang off it: `cli` and `geode` are JVM apps over `commons`/`quartz`, `nestsClient` sits on `quic`, `benchmark` and `quic-interop` are test harnesses):
```
┌─────────────┬─────────────┐
@@ -165,11 +165,11 @@ implementation(libs.jna)
**The problem:** Two Compose ecosystems (Multiplatform + AndroidX) must align, or duplicate classes.
**Current project config:**
**Current project config** (always re-check `gradle/libs.versions.toml` — these drift):
```toml
composeMultiplatform="1.9.3"# Plugin + runtime
composeBom="2025.12.01"# AndroidX Compose BOM
kotlin="2.3.0"
composeMultiplatform="1.11.1"# Plugin + runtime
composeBom="2026.05.01"# AndroidX Compose BOM
kotlin="2.3.21"
```
**Rule:** Compose Multiplatform version must be compatible with Kotlin version. Check: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html
Two build inputs are **generated by tools but committed to the repo**, so a
normal build or release does **not** run either — Gradle just consumes the
checked-in output. You only regenerate them under the specific conditions below,
and each has its own guide:
| Artifact | Committed at | Regenerate when | Guide |
|---|---|---|---|
| **Material Symbols subset font** | `commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf` | You add/remove a `MaterialSymbol("\uXXXX")` codepoint in `MaterialSymbols.kt`, or bump the upstream font | [`tools/material-symbols-subset/README.md`](tools/material-symbols-subset/README.md) — run `./tools/material-symbols-subset/subset.sh` |
| **Arti (Tor) native libs** | `amethyst/src/main/jniLibs/*.so` | You update the pinned Arti version, change the JNI wrapper, or want to reproduce the binaries | [`tools/arti-build/README.md`](tools/arti-build/README.md) |
> **Material Symbols is mandatory after icon changes.** The bundled font is a
> ~210-glyph subset; a new codepoint that isn't in it renders as tofu (□) at
> runtime. Regenerate and commit the `.ttf` alongside the `MaterialSymbols.kt`
> change. Reusing an existing codepoint needs no regeneration.
Both tools have their own prerequisites (`fonttools`/`brotli` for the font; a
Rust toolchain + Android NDK 25+ for Arti) documented in their READMEs — they
are **not** required to build Amethyst from the committed sources.
| `SIGNING_PASSWORD` | Passphrase for that GPG key | Same |
| `MAC_CERTIFICATE_P12` | Base64 of your **Apple Developer ID Application** cert (`.p12`, includes the private key) | Signs the macOS desktop **DMG** and the macOS **amy** jlink tarball |
| `MAC_CERTIFICATE_PASSWORD` | Password set when exporting the `.p12` | Imports the cert into the CI keychain |
| `MAC_SIGN_IDENTITY` | Full identity string, e.g. `Developer ID Application: Your Name (TEAMID)` | The `codesign` identity to sign with |
| `MAC_NOTARY_APPLE_ID` | Apple ID email of the notarization account | Apple notarization (`notarytool`) |
| `MAC_NOTARY_PASSWORD` | **App-specific** password for that Apple ID (not the login password) | Same |
| `MAC_NOTARY_TEAM_ID` | 10-char Apple Developer **Team ID** | Same |
| `HOMEBREW_TOKEN` | PAT for `Homebrew/homebrew-cask` | Desktop cask bump (stable tags) |
| `WINGET_TOKEN` | PAT for `microsoft/winget-pkgs` | Desktop winget bump (stable tags) |
| `CROWDIN_PERSONAL_TOKEN`, `CROWDIN_PROJECT_ID` | Crowdin API creds | Translation sync (separate workflow, not the release) |
Note the **three distinct signing identities** people often conflate:
`SIGNING_KEY` + `KEY_*` is the **Android keystore**; `SIGNING_PRIVATE_KEY` +
`SIGNING_PASSWORD` is the **GPG key** for Maven Central; `MAC_CERTIFICATE_*` +
`MAC_SIGN_IDENTITY` + `MAC_NOTARY_*` is the **Apple Developer ID** for the macOS
desktop DMG. They are unrelated — each comes from a different authority.
The macOS signing secrets are **optional**: if `MAC_CERTIFICATE_P12` is unset
the release workflow still builds the DMG **and** the macOS `amy` tarball, just
**unsigned** (the previous behavior). Provision all six to switch signing +
notarization on for both. Obtaining them requires Apple Developer Program
membership ($99/yr). The same one certificate signs both artifacts.
The macOS `amy` tarball is the jlink image (bundled JRE), so signing it means
codesigning every Mach-O binary in that runtime with hardened-runtime
entitlements (`cli/packaging/macos/amy.entitlements` — needed so the JVM can
load the secp256k1 native library it extracts at runtime). A loose `.tar.gz`
cannot be **stapled** (Apple's `stapler` only handles `.app`/`.dmg`/`.pkg`), so
Gatekeeper verifies notarization **online** on first run — fine for a CLI.
Note the Homebrew-core jvm bundle (`amy-<version>-jvm.tar.gz`) is **not** signed:
Homebrew removes the quarantine attribute on its own downloads.
> **Validated (Developer ID `D77MCV9NZ7`):** signing every Mach-O in the bundled
> JRE with hardened runtime + `amy.entitlements` lets `amy init` derive a key via
> secp256k1 with no library-validation crash. Dropping `disable-library-validation`
> reproduces `UnsatisfiedLinkError: … different Team IDs` on the runtime-extracted
> `libsecp256k1-jni.dylib` — so that entitlement is load-bearing, not decorative.
>
> **Open risk — embedded jar natives.** The notary service unpacks `lib/*.jar`
> recursively and checks every Mach-O for a signature + hardened runtime. Our
> sign loop only touches loose files, so 9 unsigned natives ride along inside
> jars on a macOS build: `secp256k1` (1, required at runtime), `jna` (2),
> `sqlite` (2), and `skiko` (4, dead weight — Compose UI the CLI never renders).
> Whether `notarytool` returns `Accepted` or `Invalid` on these is **unverified**
> (the local validation had no notary creds). **Decide it with one run:** set the
> six `MAC_*` secrets and trigger `create-release.yml` via `workflow_dispatch`
> with `dry_run=true` — the sign+notarize step runs regardless of `dry_run` and
> now prints the per-file notary log on a non-`Accepted` verdict. If it comes
> back `Invalid`, the fix is to codesign the dylibs *inside* those jars before
> zipping (and/or strip the unused `skiko`/Compose jars from the CLI image — the
> `:commons` core/ui split the size budget already flags). The **desktop** app
> bundles the same jars through Compose/jpackage notarization, so run a desktop
> dry-run too; its in-jar handling differs and is likewise unverified.
Generating the values:
```bash
# Android keystore → base64 for SIGNING_KEY (one line, no wrapping)
# MAC_NOTARY_PASSWORD is an app-specific password from https://appleid.apple.com
# (Sign-In and Security -> App-Specific Passwords), NOT your Apple ID login.
```
`SONATYPE_USERNAME`/`SONATYPE_PASSWORD` are a **user token** from
<https://central.sonatype.com> (Account → Generate User Token), not your login.
A fork that doesn't publish a library can drop the `Publish Quartz Lib` step and
the four Sonatype/GPG secrets.
---
## Distribution channels
One `v*` tag fans out to several channels. Which apply depends on where a fork
distributes; the official Amethyst rollout for each is in
[`RELEASE_OPS.md`](RELEASE_OPS.md).
| Channel | How it ships | Push or pull |
|---|---|---|
| **GitHub Releases** | The release workflow builds + signs all assets and attaches them to the tag's Release | Automatic (CI) |
| **Maven Central** | Same workflow runs `publishAllPublicationsToMavenCentral` for `quartz` | Automatic (CI) |
| **Google Play** | Download the signed `amethyst-googleplay-<version>.aab` from the GH Release and upload it in Play Console | **Manual push** |
| **F-Droid** | F-Droid's build server detects the new tag and **builds the `fdroid` flavor from source** per its recipe in the external [`fdroiddata`](https://gitlab.com/fdroid/fdroiddata) repo, then signs + publishes itself | **Pull (build-from-source)** |
| **Zapstore** | The [`zsp`](https://zapstore.dev/) CLI reads [`zapstore.yaml`](zapstore.yaml) and publishes a Nostr software-release event signed with the app's nsec | **Manual push (Nostr)** |
| **Homebrew + Winget** | `bump-homebrew.yml` / `bump-winget.yml` open version-bump PRs on stable tags | Automatic (CI) |
Two channels need the build to stay split into product flavors (see
`amethyst/build.gradle.kts` → `productFlavors`):
- **`play`** carries Firebase / Google Play Services (push notifications, ML
Kit, etc.) → the Google Play AAB.
- **`fdroid`** swaps those for UnifiedPush and no-op/open-source
implementations (`amethyst/src/fdroid/…`) so the build is free of proprietary
dependencies → what F-Droid builds and what Zapstore distributes.
**F-Droid is pull, not push.** We never upload to F-Droid; its server builds our
tagged source. Keeping the `fdroid` flavor proprietary-free and the
`fastlane/metadata/android/` descriptions current is all that's required. F-Droid
Amethyst is free, open-source software (MIT License — see `LICENSE`). It is not a service. There is no Amethyst server, no Amethyst account, and the developer has no access to data stored on your device.
The Amethyst app for Android does not collect or process any personal information from its users.
Amethyst lets you browse content from third-party Nostr **relays** that you choose. Those relays host the content. They are independent of Amethyst, with their own operators and their own policies.
The app is used to browse third-party Nostr servers (called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy and terms of use that can be viewed through the app or through that server's website. The developers of this open-source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them.
This document explains what data leaves your phone, who can see it, and the standards that apply to use of the app.
The app may collect a per-device token, your public key, and a preferred Relay to connect to and provide push notification services through Google's Firebase Cloud Messaging. Other than that, the data from connected accounts is only stored locally on the device when it's required for the functionality and performance of Amethyst. This data is strictly confidential and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app.
## Privacy
Amethyst offers several options for uploading pictures and videos to post online. You can choose the server at your discretion. Similar to relays, such services are independent of the app and have their own privacy policy and terms of use.
### Data sent off-device
### Privacy with Relay services
Using the app causes the following data to leave your phone:
Your Internet Protocol (IP) address is exposed to the relays you connect to. If you want to improve your privacy, consider utilizing a service that masks your IP address (e.g., a VPN) from trackers online.
- **Nostr events** you publish, sent to the relays you have configured.
- **Subscriptions** (filters describing what you want to read), sent to those relays.
- **Media uploads** (images, audio, video), sent to the media server you select.
- *(Google Play build, push notifications enabled)* a per-device push token, your public key, and a preferred relay, registered with Google Firebase Cloud Messaging so a notification proxy can wake the app.
- *(F-Droid build, push notifications enabled)* a per-device token registered with whichever UnifiedPush distributor you install (e.g. ntfy).
The relay can also see which public keys you are using and what information you are requesting from the network. Your public key is tied to your IP address and your relay filters.
The developer does not run any server that aggregates or stores this data.
Relays have all your data in raw text. They know your IP, your name, your location (guessed from IP), your pub key, all your contacts, and other relays, and can read every action you do (post, like, boost, quote, report, etc) with the exception of the content inside Private Zaps and Private DMs.
### Data stored on your device
While the content of direct messages (DMs) is only visible to you and your DM Nostr counterparty, everyone can see when you and your counterparty are DM-ing each other. Image uploads in the DM screen use one of the chosen image servers and simply paste the image link into the DM text. Your uploaded pictures are available to anyone with that direct link.
Configuration, cached events, keys, drafts, and other operational data live in the app's local storage. Other apps cannot read it on a standard, non-rooted Android device. You can wipe it by clearing the app's storage or uninstalling.
### Visibility & Permanence of Your Content on Nostr Relays
### What relays can see
#### Information Visibility
A relay you connect to sees:
Content that you share can be shared with other relays by any user of the network.
The information you share is publicly visible to anyone reading from relays that have access to your information. Your information may also be visible to Nostr users who do not share relays with you.
- Your IP address (or the Tor exit node when using it).
- Your public key.
- The events you publish (posts, reactions, reposts, reports, etc.).
- The filters you subscribe to.
#### Information Permanence
A relay does **not** see the plaintext of:
Information shared on Nostr should be assumed permanent for privacy purposes. There is no way to guarantee deleting or editing any content once posted.
- Private Direct Messages (encrypted to the recipient under NIP-17 / NIP-44).
- Private Zaps.
## Child safety standards
A relay can still see *that* you and another user are exchanging DMs even though it cannot read them. To reduce what a relay can correlate to you, route the app over a VPN or Tor.
Amethyst does not knowingly collect information from children. The app has no age verification because it collects no personal information from anyone. The application is 17+. We rely on Google Play's age verification to make sure the user downloading the app is an adult. Since we do not control which relays the user connects to, there is no content moderation beyond the standard block post, block account, and report post and/or account that will hide the content from the user.
### Media uploads
Uploads go to the media server you select. That server is independent of Amethyst and has its own policy. Anyone holding the resulting link — including media attached to a DM — can fetch the file.
### Public content is effectively permanent
Anything you publish to a relay can be copied to other relays or clients. Once published, you should assume it cannot be reliably deleted from the network.
## Child Safety Standards
These are the published Child Safety Standards for **Amethyst**, the Android Nostr client published on Google Play by **Vitor Pamplona**. They are published under Google Play's Child Safety Standards policy.
They are a community standard, not a license restriction. Amethyst's source code remains licensed under the MIT License in `LICENSE`.
### Prohibition
Using Amethyst to create, upload, share, solicit, or distribute child sexual abuse and exploitation (CSAE) material — including child sexual abuse material (CSAM) — or to groom, exploit, or harm a minor is prohibited and is illegal in essentially every jurisdiction.
### In-app tools
Amethyst provides:
- **Report Post** and **Report Account** — publish a signed Nostr report (including the "Illegal Content" reason) so relays and other clients can act on it.
- **Block Post** / **Block Account** — hide content locally on your device.
- **Block Relay** — add a relay to your NIP-51 Blocked Relay List so the app stops fetching from or publishing to it. This is the strongest tool the app offers against a relay that refuses to moderate.
- **Mute Words / Hashtags** — filter unwanted content from your feeds.
### Addressing CSAM
Amethyst does not host content, so the app cannot remove CSAM. Only the relay hosting the content can remove it. In the United States, 18 U.S.C. §2258A makes hosting providers — not viewer applications — the entities required to report to the National Center for Missing & Exploited Children (NCMEC).
If you encounter CSAM through Amethyst:
1. Report the content in-app and select "Illegal Content."
2. Add the hosting relay to your Blocked Relay List.
3. Report directly to **NCMEC** at https://report.cybertip.org/ (United States) or to an **INHOPE** hotline at https://www.inhope.org/ (other jurisdictions). These bodies can compel the hosting provider to act.
4. You may also email **amethyst@vitorpamplona.com** with the relay URL and event ID. The developer cannot remove content from third-party relays, but may forward the report to relay operators it is in contact with and may stop recommending the offending relay in any list shipped with the app.
### Compliance
Amethyst is distributed under Google Play's Child Safety Standards policy and applicable law. Obligations attached to the **hosting** of content rest with relay operators.
### Age rating
Amethyst's Google Play listing is rated 17+. The app does not request or store age information.
## Terms of Use
### For versions downloaded from Google's Play Store
### Google Play build
You cannot use the Amethyst app for Android to submit Objectionable Content to relays. Objectionable Content includes but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary rights, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling.
You agree not to use the Google Play build of Amethyst to submit Objectionable Content to relays. Objectionable Content includes:
### For versions downloaded from F-Droid
- Sexually explicit material.
- Obscene, defamatory, libelous, slanderous, violent, or unlawful content.
- Content that infringes third-party rights (copyright, trademark, privacy, publicity).
We do not control the distribution of the application in F-Droid. Legal matters should be resolved between the user and F-Droid.
These Terms apply only to the Google Play distribution of Amethyst.
## Other Notes
### F-Droid and other source-built distributions
We reserve the right to modify this Privacy Policy and Terms of Use at any time. Any modifications to this document will be effective upon our posting of the new terms and/or upon implementation of the new changes on the Service (or as otherwise indicated at the time of posting). In all cases, your continued use of the app after the posting of any modified Privacy Policy and Terms of Use indicates your acceptance of the terms of the modified Privacy Policy and/or Terms of Use.
The MIT License in `LICENSE` is the only instrument governing your right to use, study, modify, and redistribute the software. No additional terms are imposed on these builds. Any dispute over distribution through F-Droid is between you and F-Droid.
If you have any questions about Amethyst or this privacy policy, you can send a message to amethyst@vitorpamplona.com
## Updates
This document may change. The current version is published at https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md.
This is the **operational checklist the Amethyst team follows to ship a
release** — the account-specific, push-the-buttons side of cutting a version.
The *generic* build/release mechanics (how the CI pipeline works, the asset
naming contract, the secret names a fork must set, desktop packaging) live in
[`BUILDING.md`](BUILDING.md). Read that first; this doc only covers what is
specific to shipping the official Amethyst artifacts.
> Forks: you do **not** need this file. `BUILDING.md` has everything you need to
> build and release your own fork. This describes our accounts and channels.
---
## At a glance
A release is one tag push that fans out to five distribution channels:
| Channel | Mechanism | Who pushes |
|---|---|---|
| **GitHub Releases** | Automatic — the `Create Release Assets` workflow builds + signs everything on the `v*` tag | CI |
| **Google Play** | **Manual** — download the signed AAB from the GH Release, upload in Play Console | Maintainer |
| **F-Droid** | **Pull** — F-Droid's build server builds the `fdroid` flavor from source when it sees the new tag | F-Droid (we just maintain the recipe + metadata) |
| **Zapstore** | `zsp publish` reads `zapstore.yaml`, signs a Nostr release event with Amethyst's nsec | Maintainer |
| **Homebrew + Winget** | Automatic — `bump-homebrew.yml` / `bump-winget.yml` fire on stable tags | CI |
Maven Central (the `quartz` library) also publishes automatically from the same
workflow.
---
## 1. Pre-tag checklist
1.**Bump the version** in `gradle/libs.versions.toml` — both keys:
```toml
app = "1.12.1" # semver, drives every module + the tag
appCode = "449" # Android versionCode, monotonic — must increment
```
That single edit propagates to Android (`versionName`/`versionCode`),
Desktop & CLI (`packageVersion`), `quartz` (Maven version) and `geode`
(`RelayInfo.VERSION`). Nothing else hardcodes the version.
2. **Write the changelog** as `docs/changelog/vMAJOR.MINOR.PP.md` (zero-padded,
e.g. `v1.12.01.md`) and add it to `docs/changelog/README.md`. Follow the
house style: plain text, short verb-first sentences.
3. **Publish the release-notes note on Nostr** with Amethyst's account and paste
It registers devices, watches their NIP-65 inbox / NIP-17 DM relays, and sends
wake-up pushes.
- **`play` flavor** → push via this server (Firebase/FCM).
- **`fdroid` flavor** → UnifiedPush through a distributor app the user installs
(e.g. ntfy); it does **not** use our server.
- Both are complemented by the on-device always-on `NotificationRelayService`
(see [`PULL_NOTIFICATION.md`](PULL_NOTIFICATION.md)), which keeps the user's
relay connections alive without any push server at all.
The push server has its **own repo, deploy, and release cadence** — a normal app
release does **not** redeploy it. Coordinate a server deploy only when the app
changes the registration/push contract (token format, payload, or endpoint), so
the running server stays compatible with the shipped app.
<!-- TODO(maintainer): document the push-server deploy steps + hosting, and
which app-side changes require a coordinated push-server deploy. -->
---
## 5. Secrets ownership & rotation
The workflow's required secrets and what they sign are inventoried generically
in [`BUILDING.md` § Secrets](BUILDING.md#secrets-the-ci-needs). Amethyst-specific
ownership:
| Secret(s) | Protects | Rotation |
|---|---|---|
| `SIGNING_KEY`, `KEY_ALIAS`, `KEY_STORE_PASSWORD`, `KEY_PASSWORD` | The **Android upload keystore** — losing/leaking it is the worst case; Play app signing identity | Keep the keystore backed up offline; never rotate casually (Play upload key reset is a support process) |
| `SONATYPE_USERNAME`, `SONATYPE_PASSWORD` | Maven Central namespace `com.vitorpamplona` | On compromise |
| `SIGNING_PRIVATE_KEY`, `SIGNING_PASSWORD` | The **GPG key** signing Maven artifacts | Per GPG key expiry |
| **C** | Receive + display: `OnchainSection` in `WalletScreen` (address + live balance), Esplora sync, `LocalCache.consume(OnchainZapEvent)`, `updateZapTotal` fold-in, kind-list edits in all 7 in-scope filter files | **Shipped** |
| **A.2** | Quartz foundation (send side): BIP-174 PSBT codec, BIP-341 sighash, `OnchainZapBuilder`, `signPsbt` on the `NostrSigner` hierarchy. All crypto validated against BIP-341 wallet test vectors (31 tests). | **Shipped** |
| **D** | Send flow: `OnchainZapSender` orchestrator, `Account.sendOnchainZap`, `OnchainZapSendDialog`, "Send" button on the wallet `OnchainSection`. | **Shipped** |
| **B** | NIP-55 `sign_psbt` Intent + ContentResolver contract, wired through `NostrSignerExternal.signPsbt`. Works once the external signer app (Amber etc.) ships `sign_psbt` support — older signers reply with no `result`, surfaced as a send failure. | **Shipped** |
| `OnchainZapSender` populates `block` / `proof` after broadcast | ❌ gap | `commons/.../onchain/OnchainZapSender.kt:209-214` (calls `build()` / `buildProfileZap()` with no SPV tags) |
| `OnchainBackend.getMerkleProof(txid)` to source the proof on send | ❌ gap | `chain/OnchainBackend.kt` |
| Merkle-proof parser + walker | ❌ gap | new `verify/MerkleProofVerifier.kt` |
| `OnchainZapVerifier` prefers inline proof when present | ❌ gap | `verify/OnchainZapVerifier.kt` |
| Validated `header.merkleRoot` lookup by block hash | ❌ gap | depends on the headers-explorer plan (S1) at `quartz/plans/2026-05-08-local-headers-explorer.md` |
### Send-side: Design B (two-publish)
`OnchainZapSender` publishes the `kind:8333` receipt **immediately** after
`backend.broadcast(rawTxHex)` returns, when confirmations = 0. The chosen
design keeps that instant UX and adds a second publish after the tx
confirms:
1. Today's path stays. Publish `kind:8333` right after broadcast, with
`i`/`p`/`amount`/`alt` only — no `block`, no `proof`.
2. A new post-confirmation worker watches the tx via the backend. When it
2. If the inline path fails verification (bad proof, missing header,
tx-mismatch), the event is treated as if `block` / `proof` were
absent — fall back to the existing Esplora path. **Never** treat a
failed SPV proof as a hard-reject of the whole event; that would let
a buggy producer poison legitimate zaps.
3. If neither tag is present (old-shape events), the existing
`backend.getTx()` path runs unchanged.
The end state: a strict-mode user with HTTP fallback disabled can verify
any zap whose sender included `block` + `proof`, and only those.
### Phased delivery for the SPV work
| Phase | Deliverable | Depends on | Effort |
|---|---|---|---|
| **G.1** | `MerkleProofVerifier` in `nipBCOnchainZaps/verify/`; test vectors per the resolved spec encoding | spec encoding locked | ½ d |
| **G.2** | `OnchainZapVerifier` consumes inline proof, falls back on failure; needs `LocalHeadersBitcoinExplorer.byHash()` from S1 | S1 Phase 2 (storage) | ½ d |
| **G.3** | `OnchainBackend.getMerkleProof(txid)` interface + `EsploraBackend` impl (`/tx/{txid}/merkle-proof`) | — | ½ d |
| **G.4** | Post-confirmation worker + second-publish flow in `OnchainZapSender` | G.3 | 1 d |
| **G.5** | Dedupe by `(txid, target)` preferring SPV-attached variant; `Note.zapsAmount` only counts the winner | — | ½ d |
| `ConcurrentHashMap` | 4 (`ChessRelayFetchHelper.kt`, `ChessEventCollector.kt`, `ComposeSubscriptionManager.kt`, `MutableComposeSubscriptionManager.kt`) | `androidx.collection.MutableScatterMap` (KMP) — synchronization most likely already provided by enclosing scope; audit per file |
| `SortedSet` + `ConcurrentSkipListSet` | 2 (`EventListMatchingFilter.kt`, `NoteListMatchingFilter.kt`) | Switch to `mutableListOf` + sort-on-access, or `androidx.collection.MutableScatterSet` with manual order |
| `BigDecimal` | 1 (`Note.kt`) | Either KMP bignum lib (`com.ionspin:bignum`) or move the BigDecimal-using helper to `jvmAndroid` and stub on iOS |
| `java.io.File` | 1 (`MediaContentModels.kt`) | Replace with `String` path, or `okio.Path` |
| `java.net.URI` / `MalformedURLException` | 2 (`RichTextParser.kt`, `UrlInfoItem.kt`) | KMP URL lib (`io.ktor:ktor-http`) or stay JVM via expect/actual `parseUrl()` |
| `java.nio.charset.Charset` | 1 (`HtmlCharsetParser.kt`) | `kotlin.text.Charsets` for UTF-8/16; for arbitrary charsets, expect/actual |
| `:nestsClient` project dep | 2 (`NestViewModel.kt`, `ActiveSubscription.kt`) | Move both files to `jvmAndroid` source set (audio rooms are Phase 5 anyway) |
| `com.halilibo.richtext.*` | 1 (`RenderMarkdown.kt`) | Verify iOS artifact; if missing, move to `jvmAndroid` until Phase 3 markdown decision |
**Files that look scary but aren't:**
- 183 files import `androidx.compose.*` — these all map to JetBrains Compose
Multiplatform's iOS artifacts (identical package paths). No work needed.
- 7 files import `androidx.lifecycle.*` — KMP since 2.8.0. No work needed.
- 0 files import `coil3.network.okhttp` (Coil network is already isolated).
- 0 files import `javax.*` or `android.*` directly from commonMain.
| Signer | Where the private key lives | Sign call latency | Needs user interaction? |
|---|---|---|---|
| **`NostrSignerInternal`** | In-process keypair, loaded at login | Synchronous, microseconds | No |
| **`NostrSignerRemote`** (NIP-46 bunker) | Remote process — a wallet app, browser tab, separate device | Network round-trip via relays, seconds | Yes — the bunker app pops a confirmation on the user's other device |
| **`NostrSignerExternal`** (NIP-55, e.g. Amber) | Another Android app on the same device | Bound-service IPC + activity bounce | Yes — Amber shows an activity in the foreground asking the user to approve |
Each signer surfaces the same `suspend fun sign(...)` API. The difference is
**what happens to the foreground UI** while the sign is in flight.
## What App Functions gives us to work with
From the alpha09 artifact (`androidx.appfunctions:appfunctions-service`):
- **Suspending dispatch.** `executeFunction` is a suspend function — a slow
signer (NIP-46 round-trip) doesn't block the system shell.
> Every Amethyst screen → one AppFunction verb. The verb invokes the
> same `*FeedFilter` the screen uses, runs `feed()` against
> `LocalCache`, and projects the result into a Gemini-friendly
> `NoteHit` / `ProfileHit` / etc.
This keeps the agent surface in sync with what the user sees, with no
duplicate filtering logic.
## Why this works
*`*FeedFilter.feed()` is stateless and idempotent — it reads
`LocalCache` (a global singleton) and `account` state. Safe to
invoke from any thread, any process state, no UI lifecycle
required.
*`AccountFeedContentStates` itself is owned by `AccountViewModel`,
but we don't need the precached state — we just need the filter
class. Invoking it on each AppFunction call is acceptable (a few
ms even on large caches).
* The catch: `LocalCache` only contains what the foreground app
subscriptions have already fetched. If the user hasn't opened
Amethyst in days, the cache may be sparse. Acceptable trade-off:
the agent reflects "what's on your screen now", not "what exists
on Nostr right now". For freshness, the user can open the app or
the verb can fall back to a relay drain.
## Existing verb → feed mapping
| AppFunction verb | Feed source | Notes |
|---|---|---|
| `getFeedDigest` | `HomeNewThreadFeedFilter` | Matches the home page (new threads only, all kinds, mute-filtered) |
| `getRecentFromFollows` | direct `INostrClient.fetchAll` (kind:1 only) | Pure kind:1 from kind:3 follows. Different shape than home — keeping both: `getRecentFromFollows` is fast / always fresh, `getFeedDigest` is "what's on my screen" |
| `getMyMentions` | direct relay drain | Could move to `NotificationFeedFilter` |
| `getRecentDms` | direct relay drain + decrypt | Could move to `ChatroomListKnownFeedFilter` / `ChatroomListNewFeedFilter` |
| `getLiveStreams` | direct relay drain | Could move to `LiveStreamsFeedFilter` |
| `searchArticles` | direct relay drain (NIP-50) | Read-side only; users already in cache via `ArticlesFeedFilter` could be merged |
## Unmapped feeds (proposed verbs)
These all have existing `FeedContentState`s. Adding a verb each is
~30 lines of glue.
| Screen | FeedContentState | Proposed verb name | User intent |
|---|---|---|---|
| Home — replies | `homeReplies` | `getRecentReplies` | "what conversations am I in?" |
| Home — everything | `homeEverything` | `getEverythingFeed` | "the full firehose of my follows" |
| Home — live | `homeLive` | `getLiveActivityFromFollows` | "what's live from my follows?" |
| Video | `videoFeed` | `getVideoFeed` | "show me Nostr videos" |
| Pictures | `picturesFeed` | `getPictureFeed` | "what photos are people posting?" |
| Shorts | `shortsFeed` | `getShortVideoFeed` | NIP-71 short video |
| Long-form (your follows) | `longsFeed` | `getLongFormFromFollows` | "what articles are my follows publishing?" |
**Branch:**`feat/avif-support` (based on `d1610bf97`, origin/main = upstream/main)
**Author:** davotoula
**Manual test plan:**`~/docs/amethyst/2026-05-26-avif-manual-test-plan.md`
---
## 1. Goal
Make AVIF (still and animated) a first-class image format in Amethyst, working on every surface where any other image format works — uploads, feeds, profiles, DMs, emoji packs, reactions, gallery, caching — with a graceful no-crash fallback on Android API <31wheretheplatformdecodercannothandleAVIFatall.
Allupload-pipelinePRsconvergedonroughlythesamefix.Theywererejectedfor**lack of manual on-device verification**,notforbeingwrong.Wewilldrawinspirationfrom#3016'sdiffstructurebutwritetheproductioncodefromscratchoncurrentmain.
**AVIF is not a new subsystem.**ItistheactofmakingAVIFbehavelikeanimatedWebPeverywhere.Therightmentalmodel:findeveryplaceWebP/GIFgetsspecial-casedandmakesureAVIFlandsinthesamebranch.
DesktopusesCoil-JVMwhichdoes**not**include`AnimatedImageDecoder`(noplatform`ImageDecoder`outsideAndroid).AnimatedAVIFonDesktopwilllikelyshowfirst-frameonly.StillAVIF:unknown—mayrequireadditionalCoildecoder.**Verify, then document as known limitation if confirmed.**DonotaddJVMlibavifbindinginthisPR.
**Do not introduce a parallel `AnimatedUrlImage` composable.**ThatwasPR#2825'smistakeandthereasonitwasclosed.Anynewabstractionmustbeadditivetoexistingcomposables,notareplacement.
**Regenerating fixtures** (only if you change the fixtures themselves):
```bash
brew install libavif ffmpeg exiftool # one-time
# See Task 1 in 2026-05-27-avif-instrumented-tests-plan.md for the full generation recipe.
```
---
## 10. Headline proof artifact
Per §2.5 of the manual test plan: generate `emote-64.avif`, upload via the app to a NIP-96 or Blossom server, build a NIP-30 emoji pack event (kind 30030) referencing it from the **Amethyst tester** account, attach to the account, compose a note using `:emote_name:`, react to a note with it. Record a 30-second screen capture.
This becomes the headline artifact in the PR description — direct answer to RedNumber1's original ask ("animated AVIFs for my emotes").
---
## 11. Known limitations to document in the PR
- **Android API <31:**AVIFdoesn'trender—neither`AnimatedImageDecoder`(Android8.0/API26doesn'thaveit)nor`BitmapFactory`candecodeAVIF.VerifiedonAPI26emulator:postscontainingAVIFURLsshowtheURLasaclickablelinkinlineinthenotebody,nocrash,noUIhang.WhenscrollinganAVIFpostintoview,Coilbrieflyrenderstheblurhashplaceholder(via`BlurHashFetcher`)beforethefaileddecodetransitionstothelinkfallback—that'sasmallUXwin,notaregression.Avatarslotsfallbacktorobohash.NoJNIlibavifshipped.
| `service/uploads/MediaMimeTypesTest.kt` | `amethyst/src/test/` (JVM unit) | `isAvif()` and `extensionFromMimeType()` — pure-Kotlin helpers. Covers BlossomUploader/NIP-96 extension-fallback regression (commit `df84475d4`) by testing the helper both call sites delegate to. |
| `service/images/ThumbnailDiskCacheAvifInstrumentedTest.kt` | `amethyst/src/androidTest/` | Commit `5a760c046` — `generateFromFile()` returns `false` and writes nothing to cache for animated AVIF; returns `true` and produces a cached Bitmap for still AVIF. Public API is friendly — no seam needed. |
| `service/uploads/AvifMetadataStripperPoisonedFixtureInstrumentedTest.kt` | `amethyst/src/androidTest/` | Commit `b9112550b` — `MetadataStripper.strip(...)` throws `AvifMetadataNotVerifiableException` on the EXIF-poisoned fixture. Verifies the fail-closed contract that the ViewModel error-string binding depends on. (The ViewModel→string-resource wiring at `NewUserMetadataViewModel.kt:223` is single-line and compile-checked; not separately tested.) |
## 4. Explicitly out of scope
- Compose UI tests for slider-hide (`ImageVideoDescription.kt`, `ChatFileUploadDialog.kt`), avatar
`contentScale` default (`RobohashAsyncImage.kt`), or animate-profile-pic-by-URL-extension.
- A CI workflow change. The existing `build.yml` runs no emulator step; this design does not add one.
- Desktop/JVM upload-pipeline instrumented tests in `commons/jvmMain/`.
- iOS test coverage in `iosTest/`.
- Performance benchmarks.
## 5. Run instructions
Local-only, no CI. Recommended: API 35 emulator (primary), API 28 second pass to verify
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.