Compare commits

...

1627 Commits

Author SHA1 Message Date
Vitor Pamplona
3d26a3d818 v.1.12.5
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:55:37 -04:00
Vitor Pamplona
61defaa2e9 fix(ci): resolve the exact Developer ID common name for Compose signing
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>
2026-06-19 12:54:56 -04:00
Vitor Pamplona
c006831f9c v.1.12.4
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:32:52 -04:00
Vitor Pamplona
e82237840a fix(ci): point Compose macOS signing at the CI keychain explicitly
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>
2026-06-19 12:32:46 -04:00
Vitor Pamplona
7096819f3b v.1.12.3 2026-06-19 11:13:00 -04:00
Vitor Pamplona
ee58355e6e fix(ci): sign macOS Mach-O natives embedded in bundled jars before notarization
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>
2026-06-19 10:42:06 -04:00
Vitor Pamplona
8ef4e42c16 Merge pull request #3278 from nrobi144/feat/desktop-relay-latency-health
feat(desktopApp): relay latency health — tracking, classifier & dashboard UI
2026-06-19 10:22:07 -04:00
Vitor Pamplona
b8312f023a v.1.12.2 2026-06-19 09:22:33 -04:00
David Kaspar
47ac693d96 Merge pull request #3279 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-19 10:59:54 +01:00
Crowdin Bot
648f3f2bd4 New Crowdin translations by GitHub Action 2026-06-19 09:26:34 +00:00
David Kaspar
29d6bc308e Merge pull request #3260 from nrobi144/worktree-fix-desktop-macos-keychain-proguard
fix(desktop): macOS forced re-login on cold boot — ProGuard strips java-keyring backend
2026-06-19 10:24:49 +01:00
nrobi144
49929a4afa build(desktop): add osxkeychain.so regression guard task — fail release if stripped
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>
2026-06-19 10:35:13 +03:00
Róbert Nagy
5e3fea94a1 Merge branch 'main' into feat/desktop-relay-latency-health 2026-06-19 10:28:07 +03:00
Vitor Pamplona
552cd5d7e1 Merge pull request #3277 from vitorpamplona/claude/modest-planck-o0fy3v
Roadstr: visual overhaul with map hero and category pills
2026-06-18 19:45:19 -04:00
Claude
0a3ce4535e refactor: limit road event feed inclusion to geohash only
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
2026-06-18 23:42:28 +00:00
Claude
e93dffa9b5 feat: include road events (1315/1316) in hashtag and geohash feeds
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
2026-06-18 23:36:09 +00:00
Claude
f0ea68f440 fix: night map preserves hue instead of crushing to black
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
2026-06-18 23:24:30 +00:00
Claude
2d563aba07 feat: make the road event map follow the app's dark theme
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
2026-06-18 23:15:38 +00:00
Claude
bdc0c19257 fix: give road event comment an explicit on-surface color
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
2026-06-18 23:08:48 +00:00
Claude
ca11b0a37b Revert "fix: readable pill text via WCAG contrast, not a luminance threshold"
This reverts commit 595d12f81b.
2026-06-18 23:06:47 +00:00
Claude
595d12f81b fix: readable pill text via WCAG contrast, not a luminance threshold
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
2026-06-18 23:00:55 +00:00
Claude
2b02a1341f feat: modernize road event cards with a map-hero + floating pill
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
2026-06-18 22:52:35 +00:00
Claude
aee2f77654 refactor: drop redundant expiry/age row from road event cards
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
2026-06-18 22:47:44 +00:00
Claude
2e32660d9b feat: roadstr-accurate road event rendering (kind 1315/1316)
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
2026-06-18 22:38:07 +00:00
Vitor Pamplona
bdb16bfc2c Merge pull request #3276 from vitorpamplona/claude/wizardly-mendel-o3ar8d
Add resumable FTS reindex for NIP-50 search index rebuilds
2026-06-18 18:35:38 -04:00
Claude
af1e34a34d fix(quartz): clamp non-positive FTS reindex batch size
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
2026-06-18 22:32:51 +00:00
Claude
b419db4fe0 feat(quartz): make FTS reindex pausable/resumable for large stores
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
2026-06-18 22:16:48 +00:00
Vitor Pamplona
c84333c753 Merge pull request #3275 from vitorpamplona/fix/tor-circuit-dead-warm-reset
fix(tor): warm-reset on dead-circuit self-heal instead of wiping state
2026-06-18 18:14:17 -04:00
Vitor Pamplona
048aa821b6 fix(tor): warm-reset on dead-circuit self-heal instead of wiping state
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>
2026-06-18 18:08:17 -04:00
Claude
31a375034a fix: make road event location map square (1:1 aspect ratio)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017tYbcy4UGWxqQbcycyL7Yd
2026-06-18 21:52:29 +00:00
Claude
0c67f63bad feat(quartz): add FTS reindex to SQLite and filesystem event stores
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
2026-06-18 21:49:06 +00:00
Claude
a9ce0df8f2 feat: render road event (kind 1315/1316) location on an OSM map
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
2026-06-18 21:36:56 +00:00
Vitor Pamplona
84976b0a3e Merge pull request #3274 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-18 17:10:05 -04:00
Crowdin Bot
4ef7828a1c New Crowdin translations by GitHub Action 2026-06-18 21:09:17 +00:00
Vitor Pamplona
2d82fd7233 Merge pull request #3269 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-18 17:08:45 -04:00
Crowdin Bot
9fcc6bd0a9 New Crowdin translations by GitHub Action 2026-06-18 21:07:14 +00:00
Vitor Pamplona
976b8f72b8 Merge pull request #3273 from vitorpamplona/claude/thread-note-collapse-zwnjqb
Add reply collapsing to thread view
2026-06-18 17:06:52 -04:00
Vitor Pamplona
294be62fec Merge pull request #3272 from vitorpamplona/claude/stoic-lovelace-qv7vy8
Add Roadstr road event support (kinds 1315/1316)
2026-06-18 17:05:08 -04:00
Vitor Pamplona
e74b5ac308 Merge pull request #3271 from vitorpamplona/claude/quartz-fts-indexing-review-29o8us
Implement NIP-50 full-text search for additional event kinds
2026-06-18 17:03:15 -04:00
Claude
2d7c30facc style: always show the collapsed reply count, including "+0 replies"
Render the count on every collapsed card for visual consistency, so leaf
replies show "+0 replies" instead of just the chevron.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hwQiQbgboo8LScPdDHDJn
2026-06-18 20:59:11 +00:00
Claude
ffe3af1035 feat: index direct message content (NIP-17 ChatMessageEvent)
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
2026-06-18 20:58:11 +00:00
Claude
4662cb75d0 refactor(roadstr): match NIP-88 polls architecture; register in LocalCache
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
2026-06-18 20:57:41 +00:00
Claude
a7bf23a00d feat: index zap comments (LnZap request/receipt, onchain zap, nutzap)
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
2026-06-18 20:54:11 +00:00
Vitor Pamplona
2258a0ee2d Merge pull request #3270 from vitorpamplona/claude/tender-mccarthy-lr70hd
Fix NostrClientRepeatSubTest to handle timing-dependent EOSE counts
2026-06-18 16:46:31 -04:00
Claude
d5835f730c style: always stack the collapsed reply count on two lines
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
2026-06-18 20:41:54 +00:00
Claude
2019c47fdb style: center the collapsed reply count label
Center-align the wrapped "+N replies" text so the count sits centered over
the word; it stays vertically centered via the row's alignment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hwQiQbgboo8LScPdDHDJn
2026-06-18 20:38:32 +00:00
Claude
bb1d034be9 Merge remote-tracking branch 'origin/main' into claude/thread-note-collapse-zwnjqb 2026-06-18 20:32:05 +00:00
Claude
e45fbccc4e style: match collapsed reply avatar spacing to NoteComposeLayout
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
2026-06-18 20:28:27 +00:00
davotoula
2a7493019b fix: remove literal backslash in DM relay-incomplete label
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>
2026-06-18 22:25:34 +02:00
Claude
9bccf57f4e style: let the collapsed reply count wrap onto multiple lines
Constrain the "+N replies" label width and end-align it so it can wrap,
reducing the horizontal space the right-side cluster takes from the content.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hwQiQbgboo8LScPdDHDJn
2026-06-18 20:23:51 +00:00
Claude
61255198b3 feat(roadstr): add kind 1315/1316 road event support
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
2026-06-18 20:22:08 +00:00
Claude
340916aa86 feat: make polls, code snippets, audio, git/torrent replies & interest sets searchable
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
2026-06-18 20:21:08 +00:00
Claude
946c31bc73 style: reserve a 55dp slot for the collapsed reply avatar
Keep the collapsed avatar at 40dp but center it inside a 55dp-wide box so the
author name and content line up with the expanded NoteCompose layout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hwQiQbgboo8LScPdDHDJn
2026-06-18 20:20:45 +00:00
Claude
c5c5ef706f fix(quartz): de-flake NostrClientRepeatSubTest re-subscription test
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
2026-06-18 20:16:02 +00:00
Claude
2b715c8ce5 style: shrink collapsed reply avatar to Size40dp
Use a 40dp avatar on collapsed thread cards — a middle ground between the
initial compact size and the full expanded NoteCompose avatar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hwQiQbgboo8LScPdDHDJn
2026-06-18 20:09:35 +00:00
Claude
9a577e13b8 feat: show hidden reply count on collapsed thread cards
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
2026-06-18 20:07:35 +00:00
Claude
79f6bf1b7c fix: drop label prefix from WikiNoteEvent FTS content
Missed in the earlier label-removal pass. Index bare title/summary/content.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
2026-06-18 20:07:23 +00:00
Claude
0c4b4a9902 fix: keep collapsed reply avatar at the full Size55dp
Match the avatar size used by the expanded NoteCompose so the picture stays
the same size when a thread reply is collapsed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hwQiQbgboo8LScPdDHDJn
2026-06-18 20:01:19 +00:00
Claude
d184e4369c feat: make remaining text-bearing events searchable
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
2026-06-18 19:59:45 +00:00
Vitor Pamplona
3948c8dbd1 Merge pull request #3266 from vitorpamplona/claude/relay-connections-background-03x58e
Fix lifecycle-aware subscriptions and notification relay throttling
2026-06-18 15:42:28 -04:00
Vitor Pamplona
f31ab08e25 Merge pull request #3268 from vitorpamplona/claude/scroll-sensitivity-nav-statusbar-g2jo95
Add hysteresis to status bar hide/show and dampen bar reveal
2026-06-18 15:42:09 -04:00
Claude
d87de452e6 refactor: drop structural labels from FTS indexable content
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
2026-06-18 19:41:09 +00:00
Claude
e400a48dbf feat: index parsed JSON fields of profile/channel/app-handler events
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
2026-06-18 19:33:10 +00:00
Vitor Pamplona
0d8816eda0 Merge pull request #3267 from vitorpamplona/claude/modernize-quote-repost-buttons-05r689
Refactor boost popup UI with reusable BoostActionChip component
2026-06-18 15:17:58 -04:00
Vitor Pamplona
dc31f04a92 Merge pull request #3265 from vitorpamplona/claude/beautiful-bell-145xan
Fix public chat relay selection for channel messages
2026-06-18 15:11:02 -04:00
Claude
400a06f62b revert(relay): leave RelayPool's connected set to onDisconnected
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
2026-06-18 19:04:22 +00:00
Claude
6e7d731979 feat: require at least one relay to create or edit a public chat
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
2026-06-18 19:01:55 +00:00
Claude
77834cfd92 fix(relay): derive connected set from pool state instead of patching it
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
2026-06-18 18:54:06 +00:00
Claude
a689d5cb19 revert: keep original event_fts table design, drop FTS migration
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
2026-06-18 18:47:37 +00:00
Claude
1027324da8 perf: run post-migration FTS reindex in the background
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
2026-06-18 18:42:09 +00:00
Claude
85e6abd17b chore(relay): drop speculative removeAllRelays connected-set clear
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
2026-06-18 18:32:45 +00:00
Vitor Pamplona
d6fcc7abbf Merge pull request #3264 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-18 14:16:08 -04:00
Claude
738589fe2f chore(relay): remove diagnostic logging, restore 30s unsubscribe grace
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
2026-06-18 18:15:32 +00:00
Claude
dbfd2c4c43 fix(notif): throttle relay-count updates to dodge Android's rate limit
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
2026-06-18 17:53:39 +00:00
Crowdin Bot
e066bd8a5b New Crowdin translations by GitHub Action 2026-06-18 17:37:54 +00:00
Claude
bbd84664d9 Merge remote-tracking branch 'origin/main' into claude/relay-connections-background-03x58e 2026-06-18 17:37:26 +00:00
Vitor Pamplona
2f602a29bc fix(tor): seed hasEverBootstrapped from on-disk guard sample
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>
2026-06-18 13:33:51 -04:00
Claude
c4889c9ae2 Merge remote-tracking branch 'origin/main' into claude/relay-connections-background-03x58e 2026-06-18 17:33:09 +00:00
Claude
8f6074e85b feat: index natural-language fields of more event kinds for FTS
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
2026-06-18 16:34:27 +00:00
Claude
edae6d0520 feat(notif): distinguish foreground vs background relay count in popup
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
2026-06-18 16:30:06 +00:00
Claude
b732cbaf59 chore: temporary diagnostic logging for public chat relay targeting
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
2026-06-18 15:56:57 +00:00
Claude
17073e7cfa debug(relay): trace the notification popup's connected-count updates
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
2026-06-18 15:53:59 +00:00
Claude
6a2d8baf43 refactor: align event_fts rowid with event_headers.row_id
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
2026-06-18 15:51:06 +00:00
Claude
840868d315 fix: fall back to observed relays when public chat declares an empty relay list
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
2026-06-18 15:42:52 +00:00
Claude
5d2bbfe661 style(relay): spotless import ordering in BaseEoseManager
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae
2026-06-18 15:35:11 +00:00
Claude
87c4077f94 fix(relay): detect background via LifecycleEventObserver, not bg-dispatched flow
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
2026-06-18 15:34:56 +00:00
Claude
6e579e469f fix: broadcast public chat messages when channel declares no relays
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
2026-06-18 14:53:12 +00:00
Claude
63da2e5b71 debug(relay): log per-assembler relay count on invalidation
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
2026-06-18 14:31:07 +00:00
Claude
43b28a5cf4 fix(relay): prune connected set when a relay leaves the pool
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
2026-06-18 13:55:52 +00:00
Claude
a15fe94e2f debug(relay): log connected-set vs pool cache after updatePool
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
2026-06-18 13:37:15 +00:00
Claude
5b996d62e8 feat(ui): show both fork states in boost popup preview
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
2026-06-18 13:24:13 +00:00
Vitor Pamplona
24e9cf2aaa Merge pull request #3263 from vitorpamplona/claude/gracious-archimedes-wfvcri
Add macOS code signing + notarization for DMG and amy CLI
2026-06-18 09:14:25 -04:00
Claude
87c16afe1d refactor: move amy.rb into cli/packaging/homebrew (was root packaging/)
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
2026-06-18 13:13:43 +00:00
davotoula
c9e8c84524 fix(l10n): list base sw in locales_config to match Swahili consolidation
The Swahili translation now lives at the base values-sw resource (the
sw-KE/sw-TZ dirs were consolidated away)
2026-06-18 11:44:00 +02:00
David Kaspar
468966f57b Merge pull request #3262 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-18 11:42:15 +02:00
David Kaspar
1dfa24390d Merge branch 'main' into l10n_crowdin_translations 2026-06-18 11:42:06 +02:00
Crowdin Bot
97e1a9bebf New Crowdin translations by GitHub Action 2026-06-18 09:40:57 +00:00
David Kaspar
b73890ac96 Merge pull request #3261 from nrobi144/feat/desktop-launch-optimization
feat(desktop): launch optimization foundation + icon-decode/relay-bootstrap fixes
2026-06-18 11:38:59 +02:00
nrobi144
19efb6a28e feat(desktopApp): surface relay latency in dashboard + popup + banner
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>
2026-06-18 12:11:26 +03:00
nrobi144
a07c9ac7cc feat(commons,desktop): wire latency tracker into RelayHealthStore + persistence
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>
2026-06-18 12:11:25 +03:00
nrobi144
96371715f4 feat(commons): per-relay latency tracker + slow-relay classifier
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>
2026-06-18 12:11:25 +03:00
nrobi144
a1d7889be3 revert(desktop): drop ProGuard keep-rule swap — binary PoW refuted the strip hypothesis
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>
2026-06-18 12:11:03 +03:00
nrobi144
96752bd21b docs(desktop): mark all in-scope launch-opt phases complete in the plan
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.
2026-06-18 12:10:58 +03:00
nrobi144
48a8178c98 feat(desktop): App() Compose UI smoke tests + Phase 5.2 regression tests
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.
2026-06-18 12:09:41 +03:00
David Kaspar
24655a8a89 Merge pull request #3259 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-18 11:01:28 +02:00
Crowdin Bot
a81b2b9994 New Crowdin translations by GitHub Action 2026-06-18 08:41:19 +00:00
David Kaspar
4d8e3063c4 Merge pull request #3258 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-18 10:39:27 +02:00
nrobi144
123c055828 fix(desktop): macOS forced re-login on cold boot — repair ProGuard keep rules for java-keyring
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>
2026-06-18 11:31:29 +03:00
nrobi144
d10c875d88 docs(desktop): refresh launch-opt plan pending list with App() blocker
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.
2026-06-18 11:14:28 +03:00
nrobi144
d2d044a634 refactor(desktop): relax App() torManager param to ITorManager interface
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).
2026-06-18 11:13:54 +03:00
nrobi144
b14ee5ec5c feat(desktop): in-process relay seam, launch benchmark, bootstrap-gate removal
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.
2026-06-18 11:10:14 +03:00
Claude
0c3711ea15 feat(ui): modernize the quote/repost choice popup
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
2026-06-18 03:54:14 +00:00
Claude
1bef6ab2ed fix: index poll option text cleanly in ZapPollEvent FTS
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
2026-06-18 03:48:07 +00:00
Claude
d4c3b83295 feat: collapse thread replies on click in thread view
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
2026-06-18 03:42:07 +00:00
Claude
a349ebbbc6 feat: reduce sensitivity for bringing back nav and status bar on scroll
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
2026-06-18 03:03:29 +00:00
Claude
a32b349b04 debug(relay): trace background sub teardown + drop unsubscribe grace to 0
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
2026-06-18 02:43:48 +00:00
Claude
cb5498ae4c perf(cli): drop Compose UI render stack from the amy runtime image
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
2026-06-18 01:39:08 +00:00
Crowdin Bot
468216ae1b New Crowdin translations by GitHub Action 2026-06-18 00:56:31 +00:00
Vitor Pamplona
e1ec0b6823 Adds Bitcoin Sikho as a Designer to the contributors. 2026-06-17 20:51:08 -04:00
Claude
3c422b1824 ci(cli): surface notary log on non-Accepted; record signing validation
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
2026-06-18 00:49:26 +00:00
Claude
d10e6c4d81 feat(cli): codesign + notarize the macOS amy tarball
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
2026-06-18 00:21:58 +00:00
Claude
f24fc10212 feat(cli): publish no-JRE jar bundle + Homebrew-core formula for amy
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
2026-06-18 00:14:09 +00:00
Vitor Pamplona
3a1cbd8c37 Merge pull request #3257 from vitorpamplona/claude/nice-maxwell-cb026o
Cashu: surface and help evacuate coins from untrusted mints
2026-06-17 19:55:50 -04:00
Vitor Pamplona
a700db6aa9 Merge pull request #3256 from vitorpamplona/claude/beautiful-shannon-75i3oc
Bump dependency versions in gradle/libs.versions.toml
2026-06-17 19:39:02 -04:00
Claude
644ccaedd5 chore(deps): bump stable dependency versions
Update to latest stable releases:
- Compose BOM 2026.05.01 -> 2026.06.00 (Compose core patch 1.11.2 -> 1.11.3)
- AndroidX Lifecycle 2.10.0 -> 2.11.0
- Firebase BOM 34.14.1 -> 34.15.0 (firebase-messaging 25.0.2 -> 25.1.0)
- google-services plugin 4.4.4 -> 4.5.0
- kotlin-test 2.3.21 -> 2.4.0 (align with kotlin 2.4.0)
- KSP 2.3.8 -> 2.3.9
- Spotless 8.6.0 -> 8.7.0

All stable, no breaking changes affecting Amethyst: no Navigation3 usage
(Lifecycle 2.11 nav decorator break N/A), Spotless pins ktlint 1.7.1 (no
reformat churn), and Firebase FCM deprecations are warnings only (no
allWarningsAsErrors). Verified: spotlessCheck, quartz compile + jvmTest pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDjG6Lgp4SmGJ9vuD7XwDD
2026-06-17 23:19:27 +00:00
Claude
c81dbb0127 feat(desktop): wire macOS Developer ID signing + notarization
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
2026-06-17 23:06:07 +00:00
Claude
cca371c1f6 fix(cashu): recover from seed across held mints, not just configured
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
2026-06-17 23:00:25 +00:00
Claude
f9c8ce0213 feat(cashu): let users move coins off an unconfigured mint
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
2026-06-17 22:38:02 +00:00
Claude
dbe1757ee9 feat(cashu): highlight balances held at unconfigured mints
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
2026-06-17 22:38:02 +00:00
Claude
e1c3ebbab3 feat(cashu): surface all token-holding mints and sync them on wallet open
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
2026-06-17 22:38:02 +00:00
Vitor Pamplona
4d7b88aa3f Merge pull request #3253 from vitorpamplona/claude/awesome-franklin-fhhs16
Cashu wallet: split recommendations into dedicated screen, add danger zone
2026-06-17 18:37:24 -04:00
Claude
e8fdbc5532 fix(cashu): address pre-merge audit findings
- 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
2026-06-17 22:32:15 +00:00
Claude
d56f3a675d fix(cashu): keep Verify on current mints; reorder settings hub
- 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
2026-06-17 22:32:13 +00:00
Claude
c340fd05cf feat(cashu): reframe edit-wallet as mint editor; move Verify to suggestions
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
2026-06-17 22:32:11 +00:00
Claude
f970f92837 refactor(cashu): move mint recommendations to its own screen
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
2026-06-17 22:32:09 +00:00
Claude
6321780126 feat(cashu): move nutzap-key rotation to the Danger Zone
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
2026-06-17 22:32:07 +00:00
Claude
7252726950 feat(cashu): add mints directly from browser, verify mints in the list
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
2026-06-17 22:32:04 +00:00
Claude
f3ac2b14d1 style(cashu): make wallet settings danger zone red
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
2026-06-17 22:32:02 +00:00
Claude
b08f904ff3 test(geode): match renamed relay NAME "Geode" in NIP-11 assertion
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
2026-06-17 22:32:00 +00:00
Claude
88a1755ae0 feat(cashu): add stop-receiving-nutzaps and delete-wallet actions
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
2026-06-17 22:31:57 +00:00
Vitor Pamplona
93186a559c Merge pull request #3255 from vitorpamplona/claude/great-edison-nf92b4
fix(cashu): NUT-02 per-keyset input fees in melt/swap (fixes coinos send-LN)
2026-06-17 18:20:19 -04:00
Vitor Pamplona
8282c5f609 Merge pull request #3254 from vitorpamplona/claude/beautiful-hawking-qfcq4w
Fix lone surrogates in truncated strings (emoji safety)
2026-06-17 18:15:37 -04:00
Vitor Pamplona
af9e2e8079 Merge pull request #3252 from vitorpamplona/claude/eloquent-fermi-4tpx8y
docs: add Zapstore metadata and publishing relay guidance
2026-06-17 18:08:21 -04:00
Claude
ae678b219b docs(release-ops): swap relay.nostr.band for vitor.nostr1.com in zsp example
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VK27apdHs4Yzxa54qx44oJ
2026-06-17 22:07:13 +00:00
Claude
3ae9532efe fix: don't split surrogate pairs when truncating alt/summary tags
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
2026-06-17 22:04:19 +00:00
Claude
d6e1af20de Merge remote-tracking branch 'origin/main' into claude/great-edison-nf92b4 2026-06-17 22:03:38 +00:00
Claude
2cbdfe749b refactor(cashu): migrate token-redeem melt to NUT-05
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
2026-06-17 21:42:31 +00:00
Claude
09e4d93e35 chore(zapstore): add icon + supported_nips and document publishing relays
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
2026-06-17 21:41:54 +00:00
Claude
d9a9ecdc05 fix(cashu): reserve the melt's NUT-02 input fee in send-LN swap-down
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
2026-06-17 21:28:50 +00:00
Vitor Pamplona
5dc1e0ccf5 Merge pull request #3251 from vitorpamplona/claude/hopeful-cori-qpq0ug
Add music playlist composer screen and edit support
2026-06-17 17:25:42 -04:00
Claude
3b9cca19cf feat: add "Mine" option to music + playlist feeds; fix cover in editor
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
2026-06-17 21:14:55 +00:00
Claude
32a30453f3 fix(cashu): price NUT-02 input fees per each input proof's own keyset
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
2026-06-17 21:06:46 +00:00
Claude
3ee699c555 feat: manage tracks (reorder/remove) inside the playlist editor
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
2026-06-17 20:33:54 +00:00
Vitor Pamplona
20e1b933ce Merge pull request #3248 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-17 16:28:40 -04:00
Crowdin Bot
378d022e85 New Crowdin translations by GitHub Action 2026-06-17 20:19:17 +00:00
Vitor Pamplona
d820db9146 Merge pull request #3250 from vitorpamplona/claude/compassionate-tesla-j373vv
Cashu: avoid flickering invoice dialog during payment polling
2026-06-17 16:16:48 -04:00
Vitor Pamplona
a57c255ac5 Merge pull request #3247 from vitorpamplona/claude/laughing-euler-qmgmd9
Add direct image file sharing without preview dialog
2026-06-17 16:05:20 -04:00
Vitor Pamplona
7cadb6b774 Merge pull request #3249 from vitorpamplona/fix/dm-known-sender-classification
fix(chat): track chatroom senders so followed-contact DMs land in Known
2026-06-17 15:59:43 -04:00
Vitor Pamplona
6ec65042e2 fix(chat): track chatroom senders so followed-contact DMs land in Known
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>
2026-06-17 15:57:11 -04:00
Vitor Pamplona
710ca2c92b Merge pull request #3246 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-17 15:49:26 -04:00
Claude
af8fffb989 feat: full-screen music playlist composer with cover image and metadata
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
2026-06-17 19:43:34 +00:00
Claude
3844b6471d fix: show image thumbnail in share sheet for Share as Image
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
2026-06-17 19:30:26 +00:00
Claude
2684ffef54 fix: keep Cashu receive invoice on screen during mint polling
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
2026-06-17 19:30:20 +00:00
Claude
aead3c611d feat: add Share as Image (local file) and rename upload flow to Share as Image Url
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
2026-06-17 18:56:56 +00:00
Crowdin Bot
158af6bafd New Crowdin translations by GitHub Action 2026-06-17 15:29:35 +00:00
Vitor Pamplona
fa5d126ba7 Merge pull request #3245 from vitorpamplona/release/1.12.1
Release 1.12.1
2026-06-17 11:27:32 -04:00
Vitor Pamplona
1437d1e0b0 deleting unnecessary file 2026-06-17 11:21:58 -04:00
Vitor Pamplona
0c59bb89ee docs: complete the build/release guide and add RELEASE_OPS
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>
2026-06-17 11:20:36 -04:00
Vitor Pamplona
02ee493502 chore(release): 1.12.1
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>
2026-06-17 11:20:18 -04:00
Vitor Pamplona
270d229d0a build: drive all module versions from the catalog
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>
2026-06-17 11:19:52 -04:00
Vitor Pamplona
af27b7750e Fixes versions in the Skill file 2026-06-17 10:34:00 -04:00
Vitor Pamplona
06cba4e1db Merge pull request #3244 from vitorpamplona/claude/lucid-turing-atp9z3
Support measurement system detection on Android API <28
2026-06-17 10:30:50 -04:00
David Kaspar
61706c167a Merge pull request #3243 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-17 16:13:56 +02:00
Crowdin Bot
b608e82cde New Crowdin translations by GitHub Action 2026-06-17 14:12:23 +00:00
Claude
ef3c11da15 fix: guard ICU getMeasurementSystem behind API 28 check
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
2026-06-17 14:09:38 +00:00
davotoula
92ffc76bb0 update cz, se, pt, de 2026-06-17 16:03:47 +02:00
Vitor Pamplona
940458d767 Merge pull request #3242 from vitorpamplona/claude/gallant-bardeen-78e9n1
Add Health Connect integration for workout suggestions
2026-06-17 09:15:24 -04:00
Claude
c0df0048bd feat: render workout notes through the kind-1 text pipeline
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
2026-06-17 12:29:51 +00:00
Vitor Pamplona
9ca1a14145 Merge pull request #3240 from davotoula/fix/immersive-system-bars
Hide Android system bars in immersive scroll and full-screen media
2026-06-17 08:22:44 -04:00
Vitor Pamplona
1e8fdfdf2e Merge pull request #3241 from davotoula/l10n/swahili-base
Promote Kenyan Swahili to the base resource qualifier
2026-06-17 08:21:57 -04:00
davotoula
0a7b8300df fix(l10n): promote Kenyan Swahili to the base resource qualifier 2026-06-17 13:13:04 +02:00
David Kaspar
77ff7945d6 Merge pull request #3237 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-17 13:07:26 +02:00
davotoula
f920ab0c6f Code review:
- 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)
2026-06-17 11:40:43 +02:00
davotoula
67764300a8 feat: hide OS system bars during full-screen media
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.
2026-06-17 11:24:50 +02:00
davotoula
04d7316e34 feat: hide OS status bar during scroll immersive mode 2026-06-17 11:24:17 +02:00
nrobi144
48726ee4df docs(desktop): mark phases 1.1/1.2/1.3/5.1 complete in launch-opt plan
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.
2026-06-17 11:41:29 +03:00
nrobi144
b338d7db4b feat(desktop): collapse icon decoding to a single memoized lazy
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.
2026-06-17 11:40:22 +03:00
nrobi144
ff55898ab9 feat(desktop): launch-optimization plan + test pyramid foundation
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.
2026-06-17 11:36:02 +03:00
Claude
5179757019 feat: render workout notes and show metrics in the viewer's units
- 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
2026-06-17 03:15:13 +00:00
Claude
d8c66a4a25 feat: default workout distance unit to the phone's measurement system
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
2026-06-17 03:08:43 +00:00
Claude
d4c7577828 feat: record the source app/device name from Health Connect
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
2026-06-17 02:40:00 +00:00
Claude
89ab47c0ec fix: tighten New Workout composer vertical spacing
Reduce the section spacing from 16dp to 12dp so the gaps around the activity
chip rows aren't oversized.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgqQKHSewRHVM8vSCLt9P
2026-06-17 01:46:14 +00:00
Claude
631c110a4f fix: center the activity type chips in the New Workout composer
The activity FilterChip FlowRow was start-aligned; center it under the hero
with Arrangement.spacedBy(.., Alignment.CenterHorizontally).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgqQKHSewRHVM8vSCLt9P
2026-06-17 01:36:41 +00:00
Claude
ee1a7aec2f feat: move Health Connect entirely into the New Workout composer
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
2026-06-17 01:15:51 +00:00
Crowdin Bot
e5353703e3 New Crowdin translations by GitHub Action 2026-06-17 01:10:46 +00:00
Vitor Pamplona
ae3883052b Merge pull request #3235 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-16 21:09:00 -04:00
Claude
63aa4102d0 feat: keep only the connect prompt on the feed; workouts list in New Workout
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
2026-06-17 01:03:54 +00:00
Claude
a60d2b6c1c feat: drop the Health Connect feed banner; keep it in New Workout only
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
2026-06-17 00:52:36 +00:00
Claude
125947bf85 feat: modernize the New Workout composer
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
2026-06-17 00:34:37 +00:00
Claude
ad1d28d0a6 chore: remove Health Connect workout detection plan doc
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgqQKHSewRHVM8vSCLt9P
2026-06-17 00:21:48 +00:00
Claude
692583cac2 feat: limit the feed workout banner to today's workouts
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
2026-06-16 23:52:09 +00:00
Crowdin Bot
66b0428cb2 New Crowdin translations by GitHub Action 2026-06-16 23:48:41 +00:00
Vitor Pamplona
fc35f5afe3 Merge pull request #3236 from vitorpamplona/claude/fervent-pascal-dy03ve
Add share-as-image feature for notes
2026-06-16 19:46:32 -04:00
Claude
397cf7d302 feat: add a recent-workouts carousel to the New Workout composer
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
2026-06-16 23:46:11 +00:00
Claude
7bad2a09b3 fix: use the Add icon on the New Workout FAB for consistency
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
2026-06-16 23:46:09 +00:00
Claude
7bed12b508 chore: add diagnostic logging to Health Connect workout reads
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
2026-06-16 23:34:43 +00:00
Claude
a9cb2f464b fix: stop the connect card from flashing on every screen open
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
2026-06-16 23:29:12 +00:00
Claude
fdf7682409 feat: re-scan Health Connect when the app resumes
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
2026-06-16 23:23:43 +00:00
Claude
0e1233f760 fix: keep Health Connect prefs off the main thread (StrictMode)
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
2026-06-16 23:17:25 +00:00
Claude
723ba29006 feat: scroll the workout suggestion banner with the feed
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
2026-06-16 23:14:20 +00:00
Claude
b0bcadc6b3 fix: add Android 14+ Health Connect permission rationale activity-alias
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
2026-06-16 23:11:35 +00:00
Claude
03e8f86a83 fix: align Health Connect import with RUNSTR (active calories + title)
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
2026-06-16 23:07:42 +00:00
Claude
fa2dd03be9 feat: polish share-as-image screen
- 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
2026-06-16 23:02:33 +00:00
Claude
65d7eab85c feat: polish the Health Connect workout suggestion banner
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
2026-06-16 22:51:48 +00:00
Claude
1c5c6d8a6b feat: add Compose setting to disable Health Connect workout suggestions
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
2026-06-16 22:33:05 +00:00
Vitor Pamplona
230c247909 fix(desktop): consistent macOS .icns + multi-size Windows .ico
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>
2026-06-16 18:29:03 -04:00
Claude
6de43dec2d feat: detect workouts from Health Connect and suggest a kind 1301 post
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
2026-06-16 22:19:58 +00:00
Vitor Pamplona
3a21df7775 fix(desktop): exclude leaked kotlinx-coroutines-test from release dmg
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>
2026-06-16 17:52:48 -04:00
Claude
1894d5f4b6 refactor: make share-as-image a screen with an Image preview
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
2026-06-16 21:48:06 +00:00
Vitor Pamplona
adcf71d43e Merge pull request #3234 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-16 17:35:22 -04:00
Crowdin Bot
2f09427ac6 New Crowdin translations by GitHub Action 2026-06-16 21:34:40 +00:00
Claude
a8a33e830c feat: share any note as an uploaded image
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
2026-06-16 21:33:06 +00:00
Vitor Pamplona
172a8da125 Merge pull request #3233 from vitorpamplona/fix/tor-active-transition-race
fix(tor): set Active deterministically so the bootstrap callback can't race it
2026-06-16 17:32:42 -04:00
Vitor Pamplona
b7dadf8ec7 fix(tor): set Active deterministically so the bootstrap callback can't race it
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>
2026-06-16 17:29:41 -04:00
Vitor Pamplona
0572b0091c v1.12.0 2026-06-16 14:38:13 -04:00
Vitor Pamplona
242904afd2 Merge 2026-06-16 14:33:03 -04:00
Vitor Pamplona
98204eb8ff docs(changelog): cover Tor reliability + other recent v1.12.0 work
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>
2026-06-16 14:19:22 -04:00
davotoula
97e6bfb53f Merge remote-tracking branch 'upstream/main' into main-upstream 2026-06-16 20:12:07 +02:00
Vitor Pamplona
7ae356443c Merge pull request #3232 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-16 14:09:15 -04:00
davotoula
6a497ded78 fix logging of e
use lambda
2026-06-16 20:08:03 +02:00
Crowdin Bot
65b115668b New Crowdin translations by GitHub Action 2026-06-16 17:55:35 +00:00
Vitor Pamplona
bc605095e0 Merge pull request #3229 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-16 13:53:52 -04:00
Vitor Pamplona
707528dbf7 Merge pull request #3231 from vitorpamplona/feat/tor-circuit-health-selfheal
feat(tor): self-heal when Tor is Active but every circuit is dead
2026-06-16 13:53:43 -04:00
Vitor Pamplona
329333a396 Merge pull request #3230 from vitorpamplona/perf/tor-preserve-consensus-cache
perf(tor): stop wiping Arti's consensus cache on every start
2026-06-16 13:53:22 -04:00
Vitor Pamplona
9620ce3813 Merge pull request #3228 from vitorpamplona/fix/tor-lifecycle-race
fix(tor): bound Arti bootstrap with a 60s timeout + repeatable network test suite
2026-06-16 13:53:08 -04:00
Vitor Pamplona
d778e8bc4f Merge branch 'main' into fix/tor-lifecycle-race 2026-06-16 13:53:01 -04:00
Crowdin Bot
63f2e86ea3 New Crowdin translations by GitHub Action 2026-06-16 17:49:41 +00:00
Vitor Pamplona
d9066572cd test(tor): add repeatable on-device networking scenario suite
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>
2026-06-16 13:44:06 -04:00
davotoula
67a39ac51e update cs,sv,de,pt 2026-06-16 19:39:20 +02:00
Vitor Pamplona
829afba141 fix(tor): require a sustained failure streak before circuit self-heal
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>
2026-06-16 13:32:42 -04:00
Vitor Pamplona
eaf07fd4ad feat(tor): self-heal when Tor is Active but every circuit is dead
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>
2026-06-16 12:10:56 -04:00
Vitor Pamplona
4f668f6175 perf(tor): stop wiping Arti's consensus cache on every start
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>
2026-06-16 11:57:50 -04:00
Vitor Pamplona
40921b80d2 Merge pull request #3227 from vitorpamplona/claude/eager-keller-9a4exu
Eagerly preload thread for replies visible in feed
2026-06-16 11:06:30 -04:00
Vitor Pamplona
63ef0a3fb6 Merge pull request #3226 from vitorpamplona/claude/upbeat-hypatia-klvd5d
Redesign workout display with hero metric and stats grid
2026-06-16 11:04:55 -04:00
Vitor Pamplona
bfb41b8d3d Merge pull request #3224 from vitorpamplona/fix/tor-lifecycle-race
fix: Tor lifecycle race + bounded bootstrap so a hostile network can't wedge Tor
2026-06-16 10:46:26 -04:00
Vitor Pamplona
69b8ba9f23 fix: bound Arti bootstrap with a 60s timeout so a hostile network can't wedge Tor
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>
2026-06-16 10:41:09 -04:00
Vitor Pamplona
bdfc56cf23 fix: serialize Arti lifecycle so a reset can't destroy a bootstrapping client
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>
2026-06-16 10:06:59 -04:00
Vitor Pamplona
ff2de15b20 Merge pull request #3223 from vitorpamplona/fix/gate-tor-relays-until-ready
Gate Tor-routed relay dials until Tor is ready
2026-06-16 09:44:53 -04:00
Vitor Pamplona
be5cfc1501 fix(commons): stop RelayHealthStoreCloseTest livelocking the test suite
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>
2026-06-16 09:37:55 -04:00
Claude
543419c3ef refactor: let ThreadFilterSubAssembler own root resolution for reply preload
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.
2026-06-16 13:04:14 +00:00
Vitor Pamplona
7b34438b04 fix: gate Tor-routed relay dials until Tor's SOCKS port is ready
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>
2026-06-16 08:41:06 -04:00
Vitor Pamplona
085e2466e6 Merge pull request #3221 from nrobi144/fix/relay-health-threading-and-sleep-resume
fix: RelayHealthStore threading + sleep-resume socket recovery (follow-up to #3186)
2026-06-16 08:40:37 -04:00
nrobi144
552540e77d fix(desktopApp): move sleep/resume detection out of Quartz
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.
2026-06-16 09:59:31 +03:00
Claude
c7f5ab83b0 feat: pre-load reply threads from the feed
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.
2026-06-16 00:26:13 +00:00
Claude
830e340ed8 feat: hero metric and fixed grid for workout display
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.
2026-06-16 00:14:44 +00:00
Claude
e39369da80 feat: enrich workout display with source, splits-style metrics
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.
2026-06-15 22:47:35 +00:00
Claude
988af90763 feat: render Workout feed with regular NoteCompose
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.
2026-06-15 22:43:56 +00:00
Vitor Pamplona
72c0558bb9 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2026-06-15 16:11:10 -04:00
Vitor Pamplona
d475d041c8 Final version of change log 2026-06-15 15:25:25 -04:00
David Kaspar
23bdd952b4 Merge pull request #3222 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-15 17:59:39 +02:00
Crowdin Bot
17e8e1c8b2 New Crowdin translations by GitHub Action 2026-06-15 15:50:36 +00:00
Vitor Pamplona
3b233924ce Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2026-06-15 11:46:10 -04:00
Vitor Pamplona
fcaa7ba67c Merge branch 'main' into fix/relay-health-threading-and-sleep-resume 2026-06-15 11:41:28 -04:00
Vitor Pamplona
c3f82359db Merge pull request #3219 from davotoula/fix/relay-reconnect-backoff
Don't reset relay reconnect backoff on momentary connections
2026-06-15 11:41:06 -04:00
Vitor Pamplona
7b0e8b7117 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2026-06-15 10:46:15 -04:00
Vitor Pamplona
0cfc324d87 Removes Threading checks on Commons since Main Threads don't exist over there.
Remove warnings
2026-06-15 10:41:40 -04:00
nrobi144
b91519157e fix(commons,quartz): RelayHealthStore threading + sleep-resume socket recovery
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.
2026-06-15 13:21:32 +03:00
davotoula
33a7ef3be5 Code review:
- harden relay backoff fields for cross-thread access
- reuse EmptyConnectionListener in backoff test
- extract shared relay-client test fakes
2026-06-14 20:36:59 +02:00
Vitor Pamplona
4552928360 Merge pull request #3216 from davotoula/fix/ios-test-uikit-prebuilt-cache
Restore iOS test native-cache workaround dropped in dependency bump
2026-06-14 12:33:26 -04:00
davotoula
312f64dcc3 fix: don't reset relay reconnect backoff on momentary connections
A relay that accepts the WebSocket handshake and then immediately resets
the connection (e.g. essayist.decentnewsroom.com) defeated the exponential
reconnect backoff.
2026-06-14 18:25:26 +02:00
Vitor Pamplona
c146b97a3f Merge pull request #3217 from vitorpamplona/claude/ipv6-url-parsing-xy4mvg
fix: parse IPv6-literal URLs in post content
2026-06-14 12:23:38 -04:00
Claude
b565c37277 fix: parse IPv6-literal URLs in post content
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.
2026-06-14 15:59:32 +00:00
davotoula
0459f29be2 fix(commons): restore iOS test native-cache workaround dropped in dep bump
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
2026-06-14 17:48:03 +02:00
David Kaspar
2458a59e07 Merge pull request #3215 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-14 17:38:01 +02:00
Crowdin Bot
2fa393cfb6 New Crowdin translations by GitHub Action 2026-06-14 15:37:10 +00:00
David Kaspar
4ae9d967f5 Merge pull request #3212 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-14 17:35:07 +02:00
Crowdin Bot
fe08e79c68 New Crowdin translations by GitHub Action 2026-06-14 15:01:18 +00:00
Vitor Pamplona
6cc62f13d8 Merge pull request #3214 from vitorpamplona/claude/changelog-v1-11-at3e12
docs: Add v1.12.0 release notes
2026-06-14 10:59:25 -04:00
davotoula
cddaaec445 update cz, se, pt, de 2026-06-14 14:49:16 +02:00
Claude
dbe7a228a0 docs: tighten v1.12.0 highlights to flagship features
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
2026-06-14 02:43:30 +00:00
Claude
5fa1d615bf docs: correct v1.12.0 descriptions against PR bodies + verify kinds/NIPs
Verified every event kind and NIP number against quartz code. Read the
feature PR descriptions and fixed inaccuracies:
- Audio visualizer styles (Classic/Spectrum Bars/Color Waves/Radial Ring/
  Aurora Glow/Static/Off; audio-only notes; FFT from decoded audio)
- Podcasts elevated to a new NIP-F4 feature (feeds, favorites, inline player)
- Workouts: create/publish + feed filters (RUNSTR dialect)
- NIP-14 group DMs: subjects + multi-recipient creation + read/mute tracking
- Share to DM reframed as the Android 'Send as DM' share-sheet target
- Cashu wallet: mint-from/melt-to Lightning, send/receive tokens
- CLINK acronym expanded

https://claude.ai/code/session_01Y8cDkDRV48GYdQd3CR4kuV
2026-06-14 00:25:47 +00:00
Claude
344749c007 docs: reconcile v1.12.0 changelog against merged PRs
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
2026-06-13 23:22:09 +00:00
Claude
5cdc21a7c0 docs: fully itemize v1.12.0 changelog for phone reading
Break every multi-clause bullet into short sub-items so each line is one
idea, optimized for mobile reading.

https://claude.ai/code/session_01Y8cDkDRV48GYdQd3CR4kuV
2026-06-13 23:13:39 +00:00
Claude
b98c226e19 docs: complete v1.12.0 changelog after full commit-by-commit audit
Read all 352 non-merge commits since v1.11.0 and added the remaining
gaps: GPLv3 TarsosDSP -> in-house pitch shifter (licensing/voice
anonymizer), live-stream chat relay fallback, DM history-loading fixes,
legacy NIP-71 video d-tag, media-server reset, account cache cleanup,
poll option text, and the Material Symbols Cashu icon.

https://claude.ai/code/session_01Y8cDkDRV48GYdQd3CR4kuV
2026-06-13 23:06:42 +00:00
Claude
a6868a1083 docs: add Cashu wallet, Music, Software Apps, audio visualizer to v1.12.0
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
2026-06-13 23:02:44 +00:00
Claude
4be9cfc687 docs: add missing features to v1.12.0 changelog
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
2026-06-13 22:57:03 +00:00
Claude
26eea1d183 docs: split remaining multi-sentence items into sub-lists in v1.12.0 changelog
https://claude.ai/code/session_01Y8cDkDRV48GYdQd3CR4kuV
2026-06-13 22:54:43 +00:00
Claude
eb4524ec58 docs: use template Translations section (Crowdin credits) in v1.12.0 changelog
https://claude.ai/code/session_01Y8cDkDRV48GYdQd3CR4kuV
2026-06-13 22:51:28 +00:00
Claude
7695fe6ee9 docs: shorten sentences and add sub-lists in v1.12.0 changelog
https://claude.ai/code/session_01Y8cDkDRV48GYdQd3CR4kuV
2026-06-13 22:45:50 +00:00
Claude
a2f331eb65 docs: add v1.12.0 changelog covering work since v1.11.0
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
2026-06-13 22:40:03 +00:00
Vitor Pamplona
c7e25c5a9e Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2026-06-13 18:18:35 -04:00
Vitor Pamplona
a75b60135a fix: adapt Blossom interceptor test to OkHttp 5 Interceptor.Chain
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>
2026-06-13 18:17:42 -04:00
Vitor Pamplona
ece7194456 Updates all dependencies 2026-06-13 18:10:13 -04:00
Vitor Pamplona
8dc24d6574 Merge pull request #3211 from mstrofnone/feat/desktop-namecoin-diagnostics
desktop(namecoin): port DiagnosticCard from Android settings
2026-06-13 18:08:46 -04:00
mstrofnone
96943fb0d1 desktop(namecoin): port DiagnosticCard from Android settings
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.
2026-06-14 08:01:59 +10:00
Vitor Pamplona
f2a48f87ca Finish up the template 2026-06-13 17:50:51 -04:00
Vitor Pamplona
a47f952f6e Merge pull request #3210 from vitorpamplona/claude/exciting-pascal-1yw9mv
Reorganize changelog into versioned files
2026-06-13 15:39:17 -04:00
Claude
81128ba74a docs: split CHANGELOG into per-version files under docs/changelog
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.
2026-06-13 18:24:10 +00:00
Vitor Pamplona
48a9ad8897 Merge pull request #3209 from davotoula/fix/media-server-default-reset
Stop media-server default from resetting on every launch
2026-06-13 14:10:56 -04:00
Vitor Pamplona
60d9917bd3 Merge pull request #3208 from davotoula/fix/relaystat-deadcode-3186
Remove write-only RelayStat liveness fields
2026-06-13 14:09:19 -04:00
davotoula
5551f990a5 fix(quartz): remove write-only RelayStat liveness fields
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.
2026-06-13 19:43:02 +02:00
davotoula
0f1b4c9597 fix: stop media-server default from resetting on every launch 2026-06-13 19:20:08 +02:00
Vitor Pamplona
92249b1481 Merge pull request #3207 from vitorpamplona/claude/blissful-feynman-spfyyx
Migrate notification filter from Global to Selected on first load
2026-06-13 12:05:26 -04:00
Claude
cdb56f63e2 fix(notifications): migrate legacy Global filter to Curated once
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.
2026-06-13 15:49:35 +00:00
Vitor Pamplona
72af326936 Merge pull request #3206 from vitorpamplona/claude/modest-meitner-nejynq
Fix test name grammar in LnZapRequestAnonTagTest
2026-06-13 11:47:51 -04:00
Claude
1f8ab1387a fix: remove comma from Kotlin Native test name in LnZapRequestAnonTagTest
Kotlin/Native (iOS) disallows commas in backtick-quoted function names,
which broke the iosSimulatorArm64MainKlibrary task.
2026-06-13 15:44:52 +00:00
Vitor Pamplona
5738c25c79 Merge pull request #3205 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-13 11:44:12 -04:00
Crowdin Bot
6be32b4719 New Crowdin translations by GitHub Action 2026-06-13 15:30:23 +00:00
Vitor Pamplona
956d5d6fb5 Merge pull request #3199 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-13 11:28:48 -04:00
Vitor Pamplona
fcb4b7a9ad Merge pull request #3202 from vitorpamplona/claude/sweet-shannon-smjotq
Redesign activity cards (reactions, zaps, nutzaps) with unified UI
2026-06-13 11:28:38 -04:00
Crowdin Bot
891b0b4f57 New Crowdin translations by GitHub Action 2026-06-13 15:10:25 +00:00
Vitor Pamplona
cdb0c47b39 Merge pull request #3204 from vitorpamplona/claude/trusting-heisenberg-4iu49o
Add show button to hidden words list
2026-06-13 11:08:53 -04:00
Vitor Pamplona
c2b9391239 Merge pull request #3203 from vitorpamplona/claude/modernize-post-notify-tags-ogz2h1
Refactor notification chips UI with Material 3 components
2026-06-13 11:07:58 -04:00
Claude
72ecb98749 fix: add spacing before the notify add-user search field
The search field shown after tapping Add sat flush against the notify
chips row. Add a standard vertical spacer so it gets a consistent gap.
2026-06-13 15:00:05 +00:00
Claude
1709d5a30a fix: replies to likes/zaps no longer pull in the liked post's thread
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
2026-06-13 14:57:46 +00:00
Claude
6fcbecb293 fix: make notify chip row spacing match horizontal spacing
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.
2026-06-13 14:48:50 +00:00
Claude
6a2033fbb4 feat: show per-row unblock button on Hidden Words screen
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.
2026-06-13 14:46:30 +00:00
Claude
45ce6f2815 fix: render notify chip avatar via leadingIcon to avoid circle clip
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.
2026-06-13 14:38:40 +00:00
Claude
82daed3c55 fix: match vertical chip spacing to horizontal in notify row 2026-06-13 14:36:26 +00:00
Claude
d7a552770f feat: modernize notify-tag chips in new post screens
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).
2026-06-13 03:38:32 +00:00
Vitor Pamplona
eff826f3d5 Merge pull request #3201 from vitorpamplona/claude/beautiful-ride-n6y3s2
Support private notes: gift-wrap kind-1 replies and posts to p-tagged users
2026-06-12 21:17:55 -04:00
Claude
953f1fe66c feat: add design-time previews for the activity cards
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
2026-06-12 23:37:43 +00:00
Claude
5e75fd1aae feat: render likes, zaps, and nutzaps as gradient activity cards like onchain zaps
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
2026-06-12 23:00:42 +00:00
Claude
74d394a7f7 Merge remote-tracking branch 'origin/main' into claude/beautiful-ride-n6y3s2
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
2026-06-12 22:52:57 +00:00
Claude
0b5f926dd8 docs: TODO for gift-wrap deletion requests (recipient-authored kind 5)
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
2026-06-12 22:52:15 +00:00
Claude
cb9580941c Revert "feat: modernize reaction and zap cards into activity cards"
This reverts commit 11b991ef50.
2026-06-12 22:16:42 +00:00
Claude
11b991ef50 feat: modernize reaction and zap cards into activity cards
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
2026-06-12 21:56:14 +00:00
Claude
d271c3fe4b fix: replies to likes and zaps now reach the Curated notification feed
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
2026-06-12 21:23:33 +00:00
Claude
46b2147598 Merge remote-tracking branch 'origin/main' into claude/sweet-shannon-smjotq 2026-06-12 19:52:34 +00:00
Claude
0948c0d467 feat: reactions and zaps anchor their own thread; drop chip long-press
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
2026-06-12 19:44:09 +00:00
Vitor Pamplona
3339577c37 Merge pull request #3200 from vitorpamplona/claude/laughing-tesla-va4nvi
Improve NIP-82 software app UI with author info and screenshots
2026-06-12 15:43:28 -04:00
Claude
bb43fd97b8 feat: open app screenshots fullscreen via ZoomableContentView
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
2026-06-12 19:31:31 +00:00
Claude
a699bcd1c4 feat: zap renderings embed the zapped post, like reactions and reposts do
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
2026-06-12 19:28:09 +00:00
Claude
eb5f7d5434 feat: single click on like/zap chips opens the event's thread view
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
2026-06-12 19:27:35 +00:00
Claude
8c7430038d fix: letter-avatar fallback for app icons while loading or on failure
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
2026-06-12 18:19:20 +00:00
Vitor Pamplona
bf07917260 Merge pull request #3196 from vitorpamplona/claude/nice-ptolemy-9153uk
Add Selected filter mode for curated notifications
2026-06-12 14:07:38 -04:00
Claude
7072da5286 feat: show screenshots in app feed cards, drop platform chips
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
2026-06-12 18:07:24 +00:00
Claude
7743d2a82a feat: show 'by <author>' under the app name in feed cards and detail header
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
2026-06-12 18:05:44 +00:00
Vitor Pamplona
c6ac3bebed Merge pull request #3197 from davotoula/fix/relay-log-diagnostics
Make relay failure logs diagnosable (exception class on null message, correct NIP-11 error label)
2026-06-12 13:42:31 -04:00
David Kaspar
83a32c685a Merge pull request #3198 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-12 19:41:42 +02:00
Crowdin Bot
7f0a902ece New Crowdin translations by GitHub Action 2026-06-12 17:37:46 +00:00
David Kaspar
efe23db553 Merge pull request #3195 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-12 19:35:45 +02:00
Vitor Pamplona
e0e21f4562 Merge pull request #3192 from vitorpamplona/claude/eager-ptolemy-rwso2r
Add NIP-89 app recommendation management UI
2026-06-12 13:33:38 -04:00
davotoula
d2f0b717ba Code review:
- apply exception-class fallback to connect() too
- dedup message construction
2026-06-12 19:30:09 +02:00
davotoula
16e5ddbc5f fix(amethyst): report NIP-11 fetch failures as FAIL_TO_REACH_SERVER 2026-06-12 19:30:09 +02:00
davotoula
91a003d72d fix(quartz): include exception class in relay failure logs when message is null 2026-06-12 19:30:09 +02:00
Claude
1ea0b91ff1 feat: relabel the curated notification filter from Selected to Curated
https://claude.ai/code/session_013VDWpD8Dr6sBF7tBEUZGpg
2026-06-12 17:30:02 +00:00
Claude
b9a1d198c2 feat: show app author on the app detail screen
Adds a clickable author row (profile picture + username) below the
app header, matching the owner row pattern used by community and
long-form screens.

https://claude.ai/code/session_01RJSke6d4dpSZgdtXAmGMmp
2026-06-12 17:26:27 +00:00
Crowdin Bot
bd8f4cdb48 New Crowdin translations by GitHub Action 2026-06-12 17:23:00 +00:00
Claude
24ef184de5 fix: start the app recommendations flow eagerly to pin notes in the soft cache
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
2026-06-12 17:22:57 +00:00
Vitor Pamplona
5d1e9d3a6e Merge pull request #3194 from vitorpamplona/claude/vibrant-feynman-awdqs4
Exclude metadata tags from text search
2026-06-12 13:21:11 -04:00
Claude
ac7f55eb3e refactor: extract NIP-89 recommendation logic into AppRecommendationsState
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
2026-06-12 15:40:15 +00:00
Claude
5263e3b1d9 fix: exclude p, e, a, and alt tags from note text search matching
Their values are ids or descriptions of other events, not content of
the event itself, so they shouldn't make an event match a text search.

https://claude.ai/code/session_01YMs6aXuvs5NaYjzyPH6Zqj
2026-06-12 15:37:16 +00:00
Claude
41055c7769 fix: align app detail screen with thread UI conventions
- 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
2026-06-12 15:36:35 +00:00
Vitor Pamplona
ee95312700 Merge pull request #3193 from vitorpamplona/claude/nifty-brown-egcooi
Add --payer-data flag to offer request command
2026-06-12 11:30:20 -04:00
Claude
cbe4b030e1 refactor: expose my 31989s as a LocalCache-observed StateFlow on Account
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
2026-06-12 15:29:30 +00:00
Claude
18c21ad51b feat(cli): add --payer-data to amy offer request
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
2026-06-12 15:18:45 +00:00
Claude
3fc19ce023 refactor: model the curated notification mode as TopFilter.Selected
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
2026-06-12 15:15:16 +00:00
Claude
eed490f579 fix: open app definitions by address instead of version event id
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
2026-06-12 15:08:42 +00:00
Claude
7f39a18a02 fix: exclude client tag from note text search matching
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
2026-06-12 15:07:13 +00:00
Claude
36d1b9b506 feat: split notifications Global into Selected (curated) and a raw Global
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
2026-06-12 15:00:53 +00:00
Vitor Pamplona
1927ce1ef0 Merge pull request #3191 from vitorpamplona/claude/elegant-allen-9mt4he
Unify payment card UI with PaymentCard component
2026-06-12 10:59:49 -04:00
David Kaspar
f161cfce77 Merge pull request #3190 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-12 16:56:31 +02:00
Claude
4b47da9c5d feat: 'by author' chip on app items + stable scroll on return to the editor
- 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
2026-06-12 14:44:05 +00:00
Crowdin Bot
061da30553 New Crowdin translations by GitHub Action 2026-06-12 13:33:32 +00:00
Vitor Pamplona
b37e62cd62 Merge pull request #3186 from nrobi144/feat/unhealthy-relay-review
feat(desktop): unhealthy-relay review banner + popup
2026-06-12 09:31:34 -04:00
Claude
7605a5659a revert: drop the kind 31989 search result collapsing
Restores SearchBarViewModel to return search results unmodified.

https://claude.ai/code/session_015dX5vWqvXUYD8rzPYX8vTB
2026-06-12 13:28:38 +00:00
Vitor Pamplona
da8dca29d7 Merge pull request #3188 from davotoula/feat/onchain-failure-translations
Localize onchain zap send UI and failure messages
2026-06-12 09:20:18 -04:00
davotoula
a593ec35f4 Code review:
- 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
2026-06-12 14:05:12 +02:00
davotoula
97f9cca43e feat(l10n): localize onchain zap send dialog and failure messages 2026-06-12 14:04:55 +02:00
davotoula
7265a9da36 refactor: extract duplicated string literals into constants (sonar) 2026-06-12 14:04:47 +02:00
David Kaspar
078ceac846 Merge pull request #3187 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-12 10:22:48 +02:00
Crowdin Bot
bbd039cd74 New Crowdin translations by GitHub Action 2026-06-12 08:21:53 +00:00
davotoula
5bfe20b673 feat: add cs, de, sv, pt-BR translations for CLINK, Send Payment, workouts, and pin strings
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>
2026-06-12 10:16:11 +02:00
nrobi144
a46a72a89f feat(desktop): unhealthy-relay review banner + popup
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.
2026-06-12 06:40:46 +03:00
Claude
ae993068aa fix: NIP-89 UI polish — chip alignment, compact button, list quality, 31989 rendering and search dedup
- 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
2026-06-12 00:52:43 +00:00
Claude
275c53ad7b feat: modernize inline payment cards and surface their descriptions
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
2026-06-12 00:05:30 +00:00
Claude
c3f59a92ba feat: tiered ordering for the app recommendations editor
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
2026-06-11 23:59:52 +00:00
Claude
917e38c19f fix: make rumor wrap rebroadcast reachable in both audit edge cases
- 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
2026-06-11 23:56:44 +00:00
Claude
b9595d91a9 Merge remote-tracking branch 'origin/main' into claude/sweet-shannon-smjotq 2026-06-11 23:55:00 +00:00
Claude
38b16f0328 feat: editable NIP-89 app recommendations on profile + richer app cards
- 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
2026-06-11 23:37:27 +00:00
Claude
96eaefda9b fix: tie rumor host lifetime to the Note — replace the RumorHosts index
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
2026-06-11 23:35:25 +00:00
Vitor Pamplona
d91988b26b Merge pull request #3185 from vitorpamplona/claude/focused-einstein-6jjqmj
Add unified profile payment screen with multi-rail support
2026-06-11 19:14:43 -04:00
Claude
98d8a73337 fix(profile): move the payment rail chips below the identity claims
The chip row now renders after the website link and external identities
(GitHub/Twitter/etc.) instead of between NIP-05 and the website.
2026-06-11 23:07:05 +00:00
Claude
86cd1315c2 fix(profile): equalize wrapped-row spacing across all Send Payment chip groups
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.
2026-06-11 23:05:39 +00:00
Claude
a311ffec22 fix(profile): wrap spacing on Send Payment chip rows + cashu mark on its chips
- 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.
2026-06-11 22:34:39 +00:00
Vitor Pamplona
1e23b14ff2 Merge pull request #3184 from vitorpamplona/claude/beautiful-turing-j0czsm
Add NIP-101e fitness workout support (Kind 1301)
2026-06-11 18:26:36 -04:00
Claude
3e9fce1858 refactor: replace WrappedEvent host tracking with the RumorHosts index
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
2026-06-11 22:18:50 +00:00
Claude
5635f89f3a feat(profile): drop Receive-on detail line; long-press chips copy destinations
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.
2026-06-11 22:15:07 +00:00
Claude
dcb6c6a2ba fix(profile): render rail and payment-target chips in one FlowRow
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.
2026-06-11 22:09:42 +00:00
Claude
1883f1b032 refactor: full-screen NewWorkout route + audit fixes
- 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
2026-06-11 22:09:12 +00:00
David Kaspar
8b9176bc02 Merge pull request #3179 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-12 00:08:29 +02:00
Claude
adb59eb276 fix(profile): tint the cashu chip icon and brighten its purple
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.
2026-06-11 22:06:43 +00:00
Crowdin Bot
344797ce87 New Crowdin translations by GitHub Action 2026-06-11 22:01:40 +00:00
Claude
e7b4f8edbb feat(profile): unified payment rail chips on the profile header
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
2026-06-11 21:59:47 +00:00
Vitor Pamplona
ae17c2378b Merge pull request #3183 from vitorpamplona/claude/friendly-lovelace-aw7vfj
Add pinned chatrooms feature with NIP-78 sync
2026-06-11 17:59:24 -04:00
Claude
afa341f2fc fix(profile): rename rail selector label from 'Pay with' to 'Receive on'
The rail chips describe how the recipient receives the funds, not which
wallet pays — that's the 'Pay from' row below.
2026-06-11 21:51:09 +00:00
Claude
4361f95a17 feat: NIP-101e workout records (kind 1301) + Workouts feed screen
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
2026-06-11 21:48:32 +00:00
Claude
a3a938253f feat(chat): sync pinned DM rooms via the NIP-78 AppSpecificData event
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
2026-06-11 21:47:17 +00:00
Vitor Pamplona
3044e430ab Merge pull request #3182 from vitorpamplona/claude/awesome-gauss-hd9xjn
Add kotlinx-serialization support for CLINK protocol DTOs
2026-06-11 17:47:16 -04:00
Claude
9999d92bca fix: register CLINK DTO serializers in KotlinSerializationMapper for native targets
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
2026-06-11 21:33:49 +00:00
Claude
e62bef8610 perf(chat): defer pinned-room state reads to the row and menu slots
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
2026-06-11 21:22:25 +00:00
Claude
23605ef630 feat(profile): 'Pay from' wallet selector on the Send Payment screen
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
2026-06-11 21:18:11 +00:00
Claude
02e0d9a4be feat(profile): pay bitcoin payment targets through the in-app on-chain wallet
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
2026-06-11 21:01:07 +00:00
Claude
fa25f22f5f Merge remote-tracking branch 'origin/main' into claude/beautiful-ride-n6y3s2 2026-06-11 20:54:50 +00:00
Claude
1bb48e25df feat: broadcast private rumors as their delivering gift wrap
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
2026-06-11 20:53:54 +00:00
Claude
dd9ee0b5c6 fix: close audit findings — report leak, model-level rumor guards, hide share/bookmarks on private notes
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
2026-06-11 20:53:54 +00:00
Claude
7985377a38 feat: private un-react via gift-wrapped deletions + force-private zaps on private notes
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
2026-06-11 20:53:53 +00:00
Claude
880995e17c feat: editable Notify block — pick receivers for private posts
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
2026-06-11 20:53:52 +00:00
Claude
0fc81cf79d feat: compose private replies and private posts via NIP-17 gift wraps
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
2026-06-11 20:53:52 +00:00
Claude
7e7d8e5325 feat: private reactions on unsealed rumors + leak prevention for private notes
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
2026-06-11 20:53:51 +00:00
Vitor Pamplona
e8ed9379b8 Merge pull request #3181 from vitorpamplona/claude/focused-knuth-4zi90p
Fix ThumbnailDiskCache to recreate cache dir if cleared at runtime
2026-06-11 16:45:43 -04:00
Vitor Pamplona
77a26ed131 Merge pull request #3180 from vitorpamplona/claude/dazzling-sagan-1c0ncz
Optimize string resource loading in WalletScreen
2026-06-11 16:40:07 -04:00
Claude
9ebee56dba fix: recreate thumbnail cache dir before writing temp file
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
2026-06-11 20:16:37 +00:00
Claude
4563bf872d fix(profile): harden Send Payment screen after audit
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
2026-06-11 20:15:05 +00:00
Claude
6775ad8e62 docs: add RUNSTR interop research plan (kind 1301 + related events)
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
2026-06-11 20:12:30 +00:00
Claude
38023dac45 fix: resolve iOS test-name compile error and LocalContext lint error
- 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
2026-06-11 20:10:20 +00:00
Vitor Pamplona
6c8dea755d Merge pull request #3178 from vitorpamplona/claude/kind-lamport-dwtzh8
Marmot: seed subscription since from stored messages on restart
2026-06-11 16:08:39 -04:00
Claude
73a63cf43a fix: address audit findings on the MLS unread/restore changes
- 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.
2026-06-11 20:05:25 +00:00
Claude
7d7f2f275f refactor(quartz): dedicated NutzapEvent.buildToUser for profile nutzaps
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
2026-06-11 19:48:56 +00:00
Claude
f2702f9299 feat(profile): unified Send Payment screen for lightning, clink, on-chain and cashu zaps
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
2026-06-11 19:28:02 +00:00
Claude
67ea7c5c95 Merge remote-tracking branch 'origin/main' into claude/sweet-shannon-smjotq 2026-06-11 19:01:36 +00:00
Claude
438f37a1ad Merge remote-tracking branch 'origin/main' into claude/kind-lamport-dwtzh8 2026-06-11 19:00:39 +00:00
Claude
0c9a7ed9ee Merge remote-tracking branch 'origin/main' into claude/friendly-lovelace-aw7vfj 2026-06-11 19:00:33 +00:00
Claude
9ce3b91f81 feat: route replies to private zaps into the sender's DM room
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
2026-06-11 19:00:32 +00:00
Vitor Pamplona
675580640d Merge pull request #3177 from vitorpamplona/claude/trusting-mayer-6o0yd5
Implement CLINK (Common Lightning Interface for Nostr Keys)
2026-06-11 14:42:25 -04:00
Claude
d242eb62aa Merge remote-tracking branch 'origin/claude/trusting-mayer-6o0yd5' into claude/trusting-mayer-6o0yd5 2026-06-11 18:18:57 +00:00
Claude
990c5afe99 Merge remote-tracking branch 'origin/main' into claude/trusting-mayer-6o0yd5
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt
2026-06-11 18:15:04 +00:00
Vitor Pamplona
44a6ab6bd4 fix(clink): keep offer/debit payments on clearnet + short subscription id
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>
2026-06-11 14:00:32 -04:00
Vitor Pamplona
f9f7de3ed0 feat(tor): money-operations relay category
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>
2026-06-11 13:35:11 -04:00
Vitor Pamplona
d1bd5734cd fix(relay): rebuild sockets opened on the wrong transport
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>
2026-06-11 13:34:49 -04:00
Vitor Pamplona
226ceee6df Merge pull request #3175 from nrobi144/feat/desktop-vlcj-to-kdroidfilter
feat(desktop): replace vlcj (GPLv3) with kdroidFilter ComposeMediaPlayer + JCodec/FFmpeg
2026-06-11 12:11:53 -04:00
nrobi144
eae1ed88ec fix(desktop,media): address PR #3175 review findings
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.
2026-06-11 17:46:12 +03:00
David Kaspar
013b029c86 Merge pull request #3176 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-11 16:36:23 +02:00
Crowdin Bot
aa63d01b32 New Crowdin translations by GitHub Action 2026-06-11 11:47:22 +00:00
David Kaspar
80b9c91c59 Merge pull request #3174 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-11 13:45:08 +02:00
Róbert Nagy
e3441157ad Merge branch 'main' into feat/desktop-vlcj-to-kdroidfilter 2026-06-11 13:50:42 +03:00
nrobi144
704f4f44ee feat(desktop): replace vlcj with kdroidFilter ComposeMediaPlayer + JCodec/FFmpeg
Drops uk.co.caprica:vlcj 4.8.3 (GPL-3.0) from desktopApp and replaces it
with an MIT-dominant stack:

- Video / audio playback: io.github.kdroidfilter:composemediaplayer:0.10.0
  (MIT) — OS-native backends (Media Foundation on Windows, AVFoundation on
  macOS, GStreamer on Linux). First-class Compose VideoPlayerSurface.
- Thumbnail extraction: org.jcodec:jcodec(+javase):0.2.5 (BSD-2) primary
  H.264 path, raw ProcessBuilder FFmpeg fallback for HEVC / VP9 / AV1 /
  HLS / non-faststart MP4.
- Binary SPDX: MIT AND LGPL-2.1-or-later AND BSD-2-Clause AND Apache-2.0.
  rpmLicenseType updated accordingly (previously misdeclared as MIT
  while shipping GPLv3 vlcj).

Code changes:
- Deleted: VlcjPlayerPool, MacOsVlcDiscoverer, BundledVlcDiscoverer,
  VlcResourceResolver
- Rewrote: GlobalMediaPlayer (kdroidFilter engine + snapshotFlow-based
  state sync into the preserved MediaPlaybackState contract);
  VideoThumbnailCache (JCodec → ProcessBuilder ffmpeg cascade, with a
  hard 4 MiB download cap, Content-Type sniff to reject HTML error
  pages, and cleanup of zero-byte cache entries); DesktopVideoPlayer
  (mounts VideoPlayerSurface for the active URL, codec/network error
  UX with "Open in default player" fallback)
- Updated: NowPlayingBar + GlobalFullscreenOverlay to render
  VideoPlayerSurface directly (drops the videoFrame ImageBitmap relay)
- Main.kt: drops vlcj pre-init / shutdown calls (kdroidFilter lazy-loads
  natives + registers its own shutdown hook on Windows)

Build / packaging:
- Removes ir.mahozad.vlc-setup plugin + vlcSetup{} block + the per-OS
  bundled VLC tree + the -Dvlc.plugin.path JVM arg
- Adds NOTICE.md + per-component LICENSE-*.txt under appResources/common
  (LGPL-2.1 license text is a placeholder — replace with verbatim FSF
  text before release)
- Adds per-OS LGPL FFmpeg drop-in directories with README pointing at
  the recommended LGPL binary source (osxexperts.net / Crigges Windows
  LGPL build)
- Adds Flathub manifest skeleton (Gitnuro-style: org.freedesktop.Platform
  24.08 + openjdk21 extension + org.freedesktop.Platform.ffmpeg-full
  add-extension for patent codecs)
- AppRun: drops VLC LD_LIBRARY_PATH / VLC_PLUGIN_PATH env wiring

Verified on macOS arm64:
- ./gradlew :desktopApp:compileKotlin                BUILD SUCCESSFUL
- ./gradlew :desktopApp:test                         BUILD SUCCESSFUL
- ./gradlew :desktopApp:spotlessApply                clean
- Smoke launch: no VLC/vlcj/libvlc log lines, kdroidFilter native
  library extracts to ~/.cache/composemediaplayer/native/, thumbnail
  cache populates at ~/.cache/amethyst-desktop/video-thumbs/ with the
  4 MiB cap enforced
- H.264 MP4 playback (active + thumbnail extraction) confirmed
- VP9-in-WebM playback fails on macOS as AVFoundation cannot decode it —
  expected codec gap; surfaced via PlaybackErrorMessage + "Open in
  default player" handoff in DesktopVideoPlayer

Docs:
- docs/plans/2026-06-11-feat-replace-vlcj-with-kdroidfilter-plan.md
- docs/plans/2026-06-11-vlcj-replacement-testing-sheet.md
2026-06-11 13:37:33 +03:00
Claude
049db06844 feat: pin DM conversations to the top of the chat list
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
2026-06-11 01:36:16 +00:00
Claude
7050264d70 fix: clear cachedAccounts entry when an account is deleted
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
2026-06-11 01:36:03 +00:00
Claude
0bfd2f8bc2 test: move MarmotManagerLeaveRejoinTest to jvmTest so CI actually runs it
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.
2026-06-11 01:20:59 +00:00
Claude
c6b09c4b53 fix: anonymous profile zaps were encrypted as private zaps; nutzap chips reply on long-press
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
2026-06-10 23:41:26 +00:00
Crowdin Bot
ea74be65cf New Crowdin translations by GitHub Action 2026-06-10 23:30:11 +00:00
Vitor Pamplona
2e26823f3d Merge pull request #3166 from davotoula/fix/nip71-legacy-video-addressable-dtag
Compute legacy NIP-71 video addresses with their d tag
2026-06-10 19:28:27 -04:00
Vitor Pamplona
765572b25b Merge pull request #3173 from vitorpamplona/claude/lucid-bardeen-tiedwa
Audit & refresh skill library, docs, and hooks (Phase 2–3)
2026-06-10 19:28:18 -04:00
Claude
c503af0f5a docs: fix stale signer, NIP-19, NIP-44, and EventStore claims in skills
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
2026-06-10 23:21:30 +00:00
Claude
2eb6510eec fix: stop refetching the full MLS kind:445 backlog on every restart
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).
2026-06-10 23:03:48 +00:00
Claude
e4ce7b887d feat: support replying to zaps from the notification screen
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
2026-06-10 22:55:25 +00:00
Claude
af7d61f361 fix: persist MLS chat unread state across app restarts
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).
2026-06-10 22:08:51 +00:00
Claude
8209f4416a docs: fix stale claims in Claude skills against current code
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
2026-06-10 22:01:29 +00:00
Claude
f2cce3dc87 fix(clink): run offer/debit payer crypto off the Main thread
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
2026-06-10 21:40:53 +00:00
Claude
5aab19e8da feat(clink): copy-offer button on the offer card title
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
2026-06-10 21:36:27 +00:00
Claude
d0af07be02 feat(cli): offer discover <nip05> — resolve a profile offer via NIP-05
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
2026-06-10 21:30:12 +00:00
Claude
2635bd90a5 feat(cli): zap --with <ndebit> settles the invoice via CLINK debit
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
2026-06-10 21:27:36 +00:00
Claude
e77658c292 feat(cli): close CLINK parity gaps — profile offer, follow, offer pay, GFY detail
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
2026-06-10 21:23:51 +00:00
Claude
5dadff0315 fix(clink): label the offer chip/card 'CLINK Offer'
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
2026-06-10 20:58:54 +00:00
Claude
01cb45cb31 feat(clink): render profile offer as a tappable chip, not an always-on card
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
2026-06-10 20:55:21 +00:00
Claude
f725b2ee39 chore: prune Claude config for Fable 5 and fix stale skill metadata
- 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
2026-06-10 20:00:43 +00:00
Claude
1490e7a030 fix(zap): thread-safe progress accumulator for both pay rails
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
2026-06-10 19:16:59 +00:00
Claude
7e7898bf77 refactor(clink): audit follow-ups — consistent error detail, non-null priceType, budget guard
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
2026-06-10 18:55:57 +00:00
David Kaspar
08f6c63d02 Merge pull request #3172 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-10 20:50:49 +02:00
Crowdin Bot
b94803b701 New Crowdin translations by GitHub Action 2026-06-10 18:49:25 +00:00
davotoula
9fd53b6461 feat: add cs, de, sv, pt-BR translations for chat history and reply search strings
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:45:28 +02:00
David Kaspar
84c1f4f533 Merge pull request #3169 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-10 20:38:46 +02:00
Crowdin Bot
e1835e5584 New Crowdin translations by GitHub Action 2026-06-10 18:38:37 +00:00
Vitor Pamplona
1d98583f28 Merge pull request #3171 from vitorpamplona/claude/intelligent-newton-8qgvo8
Extract Marmot group message composer to ViewModel
2026-06-10 14:35:50 -04:00
davotoula
572f4005e1 test: guard kind-range vs class-hierarchy invariant in EventFactory
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.
2026-06-10 20:33:22 +02:00
davotoula
d90574c4e9 refactor: rename ReplaceableVideoEvent to AddressableVideoEvent 2026-06-10 20:32:57 +02:00
Vitor Pamplona
3393469a41 Merge pull request #3170 from vitorpamplona/claude/upbeat-einstein-fn5a3j
fix: treat kind-9 ChatEvent as a chat kind for inline chat-style quotes
2026-06-10 14:30:51 -04:00
Claude
33f1d20a21 fix(clink): mirror NWC's split progress on the debit zap rail
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
2026-06-10 18:29:46 +00:00
Claude
51eecbc557 Merge remote-tracking branch 'origin/main' into claude/intelligent-newton-8qgvo8 2026-06-10 18:26:08 +00:00
Claude
908bc58190 test: lock in reorder-only semantics of mention priority ranking
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.
2026-06-10 18:25:57 +00:00
Vitor Pamplona
33f305b58e Merge pull request #3167 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-10 14:23:36 -04:00
Claude
14cb06d081 fix: treat kind-9 ChatEvent as a chat kind for inline chat-style quotes
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
2026-06-10 18:23:15 +00:00
Vitor Pamplona
5e6c8ce641 Merge pull request #3168 from vitorpamplona/claude/upbeat-einstein-fn5a3j
Add InlineQuoteRenderer strategy for customizing quoted notes
2026-06-10 14:23:08 -04:00
Claude
15b12e7f13 refactor(clink): make the debit zap rail fire-and-forget like NWC
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
2026-06-10 17:54:09 +00:00
Claude
fde5818044 refactor: extract MarmotNewMessageViewModel for the MLS composer
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.
2026-06-10 17:43:30 +00:00
Claude
6f79779885 feat: mark conversation participants with an 'In this chat' chip in mention suggestions
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.
2026-06-10 17:40:52 +00:00
Claude
61387ba12c docs(clink): record interop review + spec-conformance pass results
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
2026-06-10 17:40:29 +00:00
Claude
c5147b1203 fix: treat kind-9 ChatEvent as a chat kind for inline chat-style quotes
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
2026-06-10 17:37:33 +00:00
Claude
b79ab1214c refactor: address review findings on mention tagging and suggestions
- 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.
2026-06-10 17:24:27 +00:00
Claude
f4e0bcf73d fix(clink): spec-conformance hardening (k1 length, frequency, description, GFY detail)
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
2026-06-10 16:44:00 +00:00
Claude
429aa4f4a6 fix: skip chat reply row when the reply is already cited inline in the message
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
2026-06-10 16:37:56 +00:00
Crowdin Bot
0800eecbe0 New Crowdin translations by GitHub Action 2026-06-10 16:36:02 +00:00
Claude
bf0fa0c57e feat: rank conversation participants first in @-mention suggestions
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
2026-06-10 16:34:09 +00:00
Vitor Pamplona
b2ac311539 disabling relay logs for DMs 2026-06-10 12:30:49 -04:00
Vitor Pamplona
1e594a62c9 Removing warning 2026-06-10 12:30:49 -04:00
Claude
c78ba27c86 test(clink): correct provenance of the default-offer interop vector
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
2026-06-10 16:15:25 +00:00
Claude
eb7b3bad07 test(clink): golden wire-shape fixtures from the public-domain specs
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
2026-06-10 15:46:51 +00:00
Claude
84e91833c4 feat: add @-mention user search and tagging to Marmot group chat composer
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
2026-06-10 15:35:03 +00:00
Claude
5e465410b2 test(clink): add clink-demo DEFAULT_NOFFER interop vector
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
2026-06-10 15:26:03 +00:00
Claude
551705d14a refactor: render quote-mention leftover chars in DisplayFullNote, not the quote renderer
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
2026-06-10 15:21:18 +00:00
davotoula
638486ea1f Compute legacy NIP-71 video addresses with their d tag:
Extend BaseAddressableEvent instead so dTag() reads the real `d` tag.
2026-06-10 17:20:14 +02:00
Claude
f9ed2e0ab7 feat(clink): close ecosystem interop gaps (manage list, TLV3, NIP-05, receipts, payer privacy)
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
2026-06-10 15:16:44 +00:00
Claude
c88344c4c6 feat: render chat messages quoted inside chat bubbles with the chat reply design
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
2026-06-10 14:59:36 +00:00
Claude
fdc09ad039 perf(clink): cache parsed NOffer for NIP-05 lookups + case-insensitive key
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
2026-06-10 14:30:02 +00:00
Claude
33b3eb5b6f docs(clink): record final implementation state, audit results, spec-vs-SDK notes
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
2026-06-10 07:18:02 +00:00
Claude
545018a6a3 test(cli): clink-headless harness for amy offer/debit info
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).
2026-06-10 07:01:43 +00:00
Claude
904032cea8 feat(cli): amy debit command for CLINK debits (info + pay + budget)
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.
2026-06-10 06:44:45 +00:00
Claude
7d9a42a51b feat(cli): amy offer command for CLINK offers (info + request)
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.
2026-06-10 06:03:07 +00:00
Claude
2bd18eb50d test(clink): regression tests for the audit fixes
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.
2026-06-10 05:10:54 +00:00
Claude
488d459984 harden(clink): no self-decrypt fallback + #p on response filters
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).
2026-06-10 04:32:51 +00:00
Claude
3968790db1 fix(clink): audit fixes — unsigned offer price, Manage shape, NIP-05 cache
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.
2026-06-10 04:20:47 +00:00
Claude
b74d769f60 fix(clink): honor default payment source in App Functions + main-safe debit callback
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.
2026-06-10 03:43:33 +00:00
Claude
a481fa5bea fix(clink): harden payer round-trips + two review findings
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.
2026-06-10 03:13:54 +00:00
Claude
084c7be23a feat(clink): debit spending budgets (one-time + recurring)
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.
2026-06-10 02:37:46 +00:00
Claude
7bac8cfd46 refactor(clink): clink_version as a shared ClinkVersionTag class
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.
2026-06-10 01:41:04 +00:00
Claude
03b091d3ec refactor(clink): model event tags via PTag/ETag classes + builder DSL
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.
2026-06-10 01:26:06 +00:00
Claude
c1a0e707a0 refactor(clink): clink_offer metadata via ClinkOfferTag + DSL builder
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.
2026-06-10 01:10:49 +00:00
Claude
57850c3bcf revert(clink): drop zap requests from the offer card
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.
2026-06-10 00:43:51 +00:00
Claude
46225bf5b4 feat(clink): post-level zaps for offers + enable offer zaps in the feed
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.
2026-06-10 00:37:19 +00:00
Claude
c24215c836 feat(clink): follow a moved offer (Expired or Moved, code 3)
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.
2026-06-10 00:18:58 +00:00
Claude
6b9184bf50 feat(clink): read + surface a profile's noffer (kind-0 + NIP-05)
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.
2026-06-09 23:39:36 +00:00
Vitor Pamplona
e41169efbf Merge pull request #3164 from vitorpamplona/claude/focused-faraday-z9zs87
Work around Compose ui-uikit prebuilt cache linking failure
2026-06-09 19:29:00 -04:00
Claude
def5cbc4e9 feat(clink): let users set a noffer on their profile
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.
2026-06-09 23:28:26 +00:00
Claude
50b4ed8c1e feat(clink): kind-0 clink_offer metadata field
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.
2026-06-09 23:22:36 +00:00
Claude
db2f65d300 feat(clink): make offer payments zappable
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.
2026-06-09 23:13:26 +00:00
Claude
94ee192641 feat(clink): support variable-amount offers in the offer card
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.
2026-06-09 23:00:12 +00:00
Claude
84497bbd47 perf(commons): index URLs by first char in fixMissingSpaces
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).
2026-06-09 22:52:00 +00:00
Claude
503f33eb2e feat(clink): pay offer/invoice cards via default source, confirmed in-app
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.
2026-06-09 22:49:32 +00:00
Claude
f42cc836aa fix(commons): make fixMissingSpaces work on Kotlin/Native (iOS)
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).
2026-06-09 22:42:50 +00:00
Claude
11891ace8c feat(clink): route profile + DVM single-invoice pays through the default source
The two remaining 'pay this bolt11' sites now honor the selected default
payment source instead of the binary NWC-or-intent check:
- AccountViewModel gains payInvoiceViaClinkDebit, the single-invoice debit-rail
  counterpart of sendZapPaymentRequestFor.
- DisplayLNAddress (profile LN-address pay) and DvmContentDiscoveryScreen (DVM
  invoice pay) dispatch on defaultPaymentSource(): CLINK debit -> 21002 round
  trip, NWC -> existing pay_invoice, none -> external wallet intent.

NWC-only users are unaffected. :amethyst compiles; the debit payout path stays
untested end-to-end.
2026-06-09 22:31:26 +00:00
Claude
002cd3cceb feat(clink): add CLINK debit as a payment source in the Wallet screen
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.
2026-06-09 22:24:11 +00:00
Claude
7533c9f34f feat(clink): route zap payments through the selected default source
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.
2026-06-09 22:16:52 +00:00
Claude
71dc036889 feat(clink): persist debit wallets + unify zap default payment source
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.
2026-06-09 22:06:22 +00:00
Claude
f0276f1e04 feat(clink): debit payment-source model + unified default resolver
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.
2026-06-09 21:52:05 +00:00
Claude
9671fc4d04 fix(commons): disable native cache for iOS test binary to fix ui-uikit link
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.
2026-06-09 21:44:11 +00:00
Claude
a7066784d4 feat(clink): render noffer offers as a payable card in notes
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.
2026-06-09 21:19:45 +00:00
Vitor Pamplona
a7e892dc7c Merge pull request #3160 from davotoula/chore/remove-tarsosdsp-gpl
Replace GPLv3 TarsosDSP with an in-house pitch shifter
2026-06-09 17:09:11 -04:00
Vitor Pamplona
d8a37dfb0d Merge pull request #3163 from vitorpamplona/claude/nifty-cray-h4ctiu
Add NIP-14 group subjects and improve DM group creation UX
2026-06-09 17:08:53 -04:00
Claude
62e522ccc4 feat(clink): detect noffer pointers in rich-text as ClinkOfferSegment
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.
2026-06-09 20:58:58 +00:00
Claude
85f9998463 revert: drop ChatroomList.changes flow and the screen's reactive use
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
2026-06-09 20:57:45 +00:00
Vitor Pamplona
ff71bc1401 Merge pull request #3162 from vitorpamplona/claude/trusting-archimedes-hduulo
Replace String.format with toString(16).padStart in test helpers
2026-06-09 16:56:30 -04:00
Claude
a6e9439c6d fix: use multiplatform hex padding in room state tests
String.format is JVM-only and broke the iOS Klib compile of commonTest.
Replace "%064x".format(...) with toString(16).padStart(64, '0').
2026-06-09 20:45:17 +00:00
Claude
ef7658ae09 test(clink): add cross-impl interop vectors from @shocknet/clink-sdk
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.
2026-06-09 20:39:45 +00:00
Vitor Pamplona
39d36441d4 Merge pull request #3159 from vitorpamplona/claude/brave-lovelace-d1rap2
fix(commons): make commonTest compile for iOS targets
2026-06-09 16:24:54 -04:00
Claude
c619338204 feat(clink): add CLINK client and server facades
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).
2026-06-09 20:24:48 +00:00
Vitor Pamplona
30dcd3aead Merge pull request #3161 from vitorpamplona/claude/friendly-mayer-g3lhj1
Count NIP-18 quote-reposts as boosts of quoted notes
2026-06-09 16:20:52 -04:00
Claude
ed37020fbf ci: run shared commonTest on iOS for :commons
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.
2026-06-09 20:20:29 +00:00
Claude
8fa06f525f feat(clink): add CLINK request/response event kinds and DTOs
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).
2026-06-09 20:19:17 +00:00
Claude
1fbefb2efe feat(desktop): bring group DM parity with Android
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
2026-06-09 20:16:55 +00:00
Claude
10f369c43c feat(model): count NIP-18 quote-reposts in the repost counter
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.
2026-06-09 20:15:19 +00:00
davotoula
26fc0ea7bf docs(claude): require a license check when adding any dependency 2026-06-09 22:10:55 +02:00
Claude
41b1b63482 feat(clink): add CLINK bech32 pointer types and parser
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.
2026-06-09 20:04:00 +00:00
David Kaspar
445702a34a Merge pull request #3158 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-09 22:02:02 +02:00
davotoula
66c345717c Code review fixes:
- honor cancellation in voice anonymizer
- reuse shared Hann window and drop vestigial progress callback
2026-06-09 22:00:29 +02:00
davotoula
190316eb28 Replace GPLv3 TarsosDSP with in-house pitch shifter 2026-06-09 21:59:50 +02:00
Claude
5f41907149 docs(clink): add CLINK protocol implementation plan
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).
2026-06-09 19:44:21 +00:00
Claude
fc4f7a03fc fix(commons): make commonTest compile for iOS targets
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.
2026-06-09 19:26:04 +00:00
Crowdin Bot
673a12452d New Crowdin translations by GitHub Action 2026-06-09 19:08:10 +00:00
Vitor Pamplona
3dfe418150 Merge pull request #3151 from vitorpamplona/claude/relay-message-pagination-LMqSQ
DM history: per-relay backward paging, live-tail split + prune-aware window realignment
2026-06-09 15:05:58 -04:00
Vitor Pamplona
696be6181e Merge pull request #3157 from davotoula/fix/latex-rendering
Inline LaTeX — render wrapped equations, upgrade renderer, baseline alignment
2026-06-09 15:05:31 -04:00
Vitor Pamplona
39eb25bc17 fix(commons): show "N relays" on every history marker count chip
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>
2026-06-09 14:17:07 -04:00
David Kaspar
022aec6474 Merge pull request #3156 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-09 20:13:51 +02:00
Vitor Pamplona
75315095ee refactor(commons): collapse pager status flows into one PagingStatus snapshot
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>
2026-06-09 14:13:26 -04:00
davotoula
c8de54d1c2 fix(math): align inline equations to the text baseline 2026-06-09 19:51:31 +02:00
davotoula
8097e6c52d fix(math): upgrade LaTeX renderer to maintained rikkahub jlatexmath fork 2026-06-09 19:51:31 +02:00
davotoula
711ca547b5 fix(math): render inline equations wrapped in opening punctuation 2026-06-09 19:51:31 +02:00
Vitor Pamplona
b21aecdfca Improves markers 2026-06-09 13:47:46 -04:00
Claude
d6603baf1a Merge remote-tracking branch 'origin/main' into claude/relay-message-pagination-LMqSQ 2026-06-09 12:54:51 +00:00
Crowdin Bot
e9e5ce4152 New Crowdin translations by GitHub Action 2026-06-09 11:41:09 +00:00
Vitor Pamplona
fb31d5440a Merge pull request #3155 from nrobi144/feat/desktop-image-compression
feat(desktop): image compression + preview gate, plus lightbox URL hover/copy
2026-06-09 07:39:21 -04:00
nrobi144
c21317912e fix(commons): make UploadOrchestrator backward-compatible for non-image uploads
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.
2026-06-09 12:25:41 +03:00
nrobi144
194a109a73 feat(desktop): copy Blossom URL on image click + hover tooltip + snackbar
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).
2026-06-09 11:42:01 +03:00
nrobi144
19e5b3f8de fix(desktop): rename preview dialog Cancel -> Back
"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.
2026-06-09 11:42:01 +03:00
nrobi144
40d9fe6d97 fix(commons): two crash bugs in ImageReencoder/CompressionException
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.
2026-06-09 11:42:01 +03:00
nrobi144
150117241b fix(desktop): preview "skip" now means "upload original", not "drop"
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.
2026-06-09 11:42:01 +03:00
nrobi144
b73f7fe4e2 feat(desktop): per-row skip toggle + explicit metadata-strip status in preview
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.
2026-06-09 11:42:01 +03:00
nrobi144
0f5eae4906 feat(desktop): preview-then-publish gate for image uploads
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.
2026-06-09 11:42:01 +03:00
nrobi144
62260e9aed fix(desktop): widen compose dialog + lock selector labels to one line
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.
2026-06-09 11:42:01 +03:00
nrobi144
6d6270a8e8 fix(desktop): unify options-row controls + restore HIGH quality preset
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.
2026-06-09 11:42:00 +03:00
nrobi144
9aad12804a feat(desktop): fail-loud confirm dialog on compression failure
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.
2026-06-09 11:42:00 +03:00
nrobi144
4c1464bfb3 fix(desktop): route clipboard-paste temp files through AmethystTempDir
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.
2026-06-09 11:42:00 +03:00
nrobi144
0a01790726 feat(desktop): per-post compression quality + batch progress in compose
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.
2026-06-09 11:42:00 +03:00
nrobi144
38eacee53c feat(desktop): add Image Compression settings panel
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.
2026-06-09 11:42:00 +03:00
nrobi144
b6f2a0e48d feat(desktop): add ImageCompressionStore for persisted compression settings
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.
2026-06-09 11:42:00 +03:00
nrobi144
21577d9576 feat(commons): wire ImageReencoder into UploadOrchestrator
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).
2026-06-09 11:42:00 +03:00
nrobi144
e6711845a0 feat(commons): add ImageReencoder + AmethystTempDir for image uploads
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.
2026-06-09 11:41:59 +03:00
nrobi144
3127a57329 chore(plans): move image compression plan to desktopApp/plans/
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.
2026-06-09 11:41:59 +03:00
nrobi144
0a4550af26 feat(commons): add image format sniffer + compression types
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.
2026-06-09 11:41:59 +03:00
nrobi144
e17f04eb54 build(commons,cli): add Thumbnailator + force AWT headless for image compression
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.
2026-06-09 11:41:59 +03:00
nrobi144
d9c39b6065 docs(plans): add desktop image compression plan
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).
2026-06-09 11:41:59 +03:00
Vitor Pamplona
919e45eff1 Merge pull request #3154 from vitorpamplona/claude/equation-rendering-klegT
fix(math): center inline equations on the text line
2026-06-08 21:21:49 -04:00
Claude
65b74fe150 fix(math): center inline equations on the text line
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.
2026-06-09 01:20:13 +00:00
Vitor Pamplona
8afdfba1c2 Merge pull request #3153 from vitorpamplona/claude/equation-rendering-klegT
fix(math): collapse over-escaped backslashes in inline equations
2026-06-08 20:57:56 -04:00
Claude
be06b7335b fix(math): collapse over-escaped backslashes in inline equations
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.
2026-06-09 00:52:47 +00:00
Vitor Pamplona
dd88ae3167 Merge pull request #3152 from vitorpamplona/claude/equation-rendering-klegT
Add LaTeX math rendering for $...$ and $$...$$ equations
2026-06-08 20:36:11 -04:00
Vitor Pamplona
faa058c97d Merge pull request #3150 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-08 20:25:37 -04:00
Crowdin Bot
d23b69cc9b New Crowdin translations by GitHub Action 2026-06-08 23:33:06 +00:00
Vitor Pamplona
159f8c92c0 Merge pull request #3149 from davotoula/audio-square-player
Square the feed audio player
2026-06-08 19:31:20 -04:00
Vitor Pamplona
aa6b9bc53e refactor(dm): centralize the DM history-window boundary + log prune rewinds
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>
2026-06-08 17:59:04 -04:00
davotoula
14b5e9043d Code review:
- refactor(audio): tighten visibility, close track-listener race, reorder isAudio param
- refactor(audio): simplify audioSquare guard and dedup the audio-track listener
2026-06-08 23:37:52 +02:00
Claude
b8ac919acd test(richtext): guard math/link/image/hashtag adjacency
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.
2026-06-08 21:27:15 +00:00
davotoula
0feac36af2 feat(audio): square the feed audio player so controls get room
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.
2026-06-08 22:48:18 +02:00
Vitor Pamplona
96ff5316dc fix(dm): realign the per-relay download window when DMs are pruned
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>
2026-06-08 16:03:50 -04:00
David Kaspar
8a0fadbd07 Merge pull request #3148 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-08 21:32:43 +02:00
Crowdin Bot
8567a4b9e8 New Crowdin translations by GitHub Action 2026-06-08 19:21:32 +00:00
davotoula
d6586fe898 update cs,sv,de,pt 2026-06-08 21:17:50 +02:00
Claude
1e76705c87 Merge remote-tracking branch 'origin/main' into claude/relay-message-pagination-LMqSQ
# Conflicts:
#	commons/src/commonMain/composeResources/values/strings.xml
2026-06-08 18:15:56 +00:00
Vitor Pamplona
8c6b94fc6f Merge pull request #3147 from davotoula/audio-visualiser
Audio visualiser for audio notes
2026-06-08 12:33:16 -04:00
David Kaspar
6bf9f0e929 Merge pull request #3146 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-08 18:02:31 +02:00
davotoula
96237535ec Delay live visualizer to compensate output latency
- fix(audio): delay live visualizer to compensate output latency
- feat(audio): route-aware visualizer delay (wired vs Bluetooth)
- docs(audio): explain the hardcoded visualizer delay; note auto-detect was rejected
2026-06-08 17:46:58 +02:00
davotoula
0e3c597b96 Final code review
- 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()
2026-06-08 17:46:58 +02:00
davotoula
40642fbcad Manual visual tuning
fix(audio): make Waves & Aurora spectrum-shaped and bounded to the canvas
2026-06-08 17:46:58 +02:00
davotoula
da9ac36957 Code review and manual testing fixes
refactor(audio): narrow sink visibility, exhaustive style when, clarifying docs
fix(audio): bound tap registry, uniform blurhash scrim, robust fullscreen sizing, settings preview
refactor(audio): dealias log bins, static renderer, opt-in clock, path reuse, hue util
fix(audio): persist visualizer choice via NIP-78 (publish on change)
perf(audio): reuse FFT/window buffers to cut audio-thread allocations
feat(audio): fill height in fullscreen, fixed strip in feed; punchier aurora
2026-06-08 17:46:58 +02:00
davotoula
be6acabdee Add audio-visualizer settings
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
2026-06-08 17:46:58 +02:00
davotoula
032e1bf7fb Add audio visualisers
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
2026-06-08 17:46:58 +02:00
Claude
b4e2b5f651 refactor: cleaner math/parser integration via space-split tokens
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
2026-06-08 15:31:46 +00:00
Claude
1096647191 feat: render LaTeX math in notes with $...$ and $$...$$ delimiters
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
2026-06-08 14:47:56 +00:00
Crowdin Bot
56710c63bd New Crowdin translations by GitHub Action 2026-06-08 14:33:36 +00:00
Vitor Pamplona
46e29ad3f8 Merge pull request #3145 from vitorpamplona/claude/poll-text-persistence-hFoho
Unify poll and zap poll options into single state map
2026-06-08 10:31:23 -04:00
Vitor Pamplona
aa670a4587 Merge branch 'main' into claude/poll-text-persistence-hFoho 2026-06-08 10:31:15 -04:00
Vitor Pamplona
f05500792c Merge pull request #3144 from davotoula/fix/resilient-profile-metadata
Resilient profile metadata (birthday)
2026-06-07 17:36:11 -04:00
Vitor Pamplona
021361a72e Merge pull request #3143 from davotoula/feat/render-birdstar-birdex
Minimal first-class rendering for Birdstar "Birdex" species collections (kind 12473)
2026-06-07 17:22:08 -04:00
davotoula
0107808ef6 Code review:
- Expose a nullable descriptor
- Log the JSON element kind instead of the raw, network-sourced value.
- drop birthday happy-path tests duplicated by UpdateMetadataTest
2026-06-07 23:18:57 +02:00
davotoula
39531b85fb fix(metadata): tolerate non-spec birthday so it can't drop the profile 2026-06-07 23:05:11 +02:00
David Kaspar
47f346879c Merge pull request #3142 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-07 22:56:39 +02:00
davotoula
fba4b933b0 Code review:
- 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.
2026-06-07 22:37:05 +02:00
davotoula
98ff13b83f feat(birdstar): render Birdex species collections (kind 12473) 2026-06-07 22:18:29 +02:00
Claude
e9ff46ac46 fix(dm): gate forwarded callbacks on the bound scope (single-active orchestrator)
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.
2026-06-07 14:48:40 +00:00
Claude
6aaed71eea fix(dm): widen the per-relay silence window so a slow Tor connect isn't "stalled"
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.
2026-06-07 14:48:38 +00:00
Crowdin Bot
cdb76e448c New Crowdin translations by GitHub Action 2026-06-07 12:21:45 +00:00
Vitor Pamplona
856ff3293b Merge pull request #3141 from nrobi144/fix/nip46-bunker-double-resume
fix(quartz): NIP-46 bunker double-resume + retry id-reuse races
2026-06-07 08:19:58 -04:00
nrobi144
8b9875d9cc fix(quartz): NIP-46 bunker double-resume + retry id-reuse races
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\`
2026-06-07 14:36:29 +03:00
Claude
fd8dd80172 fix(dm): show parked relays on the paused history card + make the sync marker tappable
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.
2026-06-06 23:00:03 +00:00
Claude
27d03b4a54 fix: share poll option text between zap and non-zap polls
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.
2026-06-06 22:46:36 +00:00
David Kaspar
09a166c7c0 Merge pull request #3140 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-06 16:20:18 +02:00
Crowdin Bot
21f5acec51 New Crowdin translations by GitHub Action 2026-06-06 14:19:43 +00:00
David Kaspar
f7945d7862 Merge pull request #3139 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-06 16:18:23 +02:00
davotoula
a4f883eaa8 fix(scheduling): route always-on prompt to Notification Settings 2026-06-06 16:14:09 +02:00
Crowdin Bot
68d2d68edf New Crowdin translations by GitHub Action 2026-06-06 13:15:23 +00:00
Vitor Pamplona
e2b1b03d84 Merge pull request #3137 from davotoula/feat/agora-fundraiser-kind-33863
Render Agora fundraiser campaigns (kind 33863)
2026-06-06 09:13:43 -04:00
Vitor Pamplona
8379134179 Merge pull request #3138 from nrobi144/fix/desktop-feed-reply-context
feat(desktop): reply context in feeds + profile Replies tab
2026-06-06 09:13:19 -04:00
nrobi144
3a21abb0f7 fix(desktop): load parent-author metadata and make parent embed clickable
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>
2026-06-06 15:27:11 +03:00
nrobi144
0c681ecd9f fix(desktop): restore inter-word spaces in rich text with mentions/hashtags
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>
2026-06-06 15:27:11 +03:00
nrobi144
a23dbb2a16 fix(desktop): tighten profile Replies tab to marked replies only
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>
2026-06-06 15:27:11 +03:00
nrobi144
a5ac5c855e feat(desktop): add Replies tab to user profile screen
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>
2026-06-06 15:27:10 +03:00
nrobi144
ad8556a82c feat(desktop): show reply context in feeds (parent embed + label)
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>
2026-06-06 15:27:10 +03:00
davotoula
7aa04e773b Code review:
- read fundraiser value tags via shared helpers
2026-06-06 12:50:29 +02:00
davotoula
be12abcb21 feat(agora): render Agora fundraiser campaigns (kind 33863)
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).
2026-06-06 12:50:02 +02:00
Vitor Pamplona
0f915d6d68 docs(dm): reflect the quartz→commons split in the DM design doc
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>
2026-06-05 20:53:41 -04:00
Vitor Pamplona
26c0ae7f69 refactor(dm): move the paging orchestrators from quartz to commons/relayClient
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>
2026-06-05 19:52:58 -04:00
Vitor Pamplona
1751f41a68 docs(dm): keep the rest of the quartz paging package module-neutral
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>
2026-06-05 19:22:45 -04:00
Vitor Pamplona
3fde69c3a9 docs(dm): keep RelayLoadingCursors KDoc free of downstream-module concepts
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>
2026-06-05 19:18:23 -04:00
Vitor Pamplona
d836c06772 docs(dm): tighten the RelayLoadingCursors KDoc
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>
2026-06-05 19:14:19 -04:00
Vitor Pamplona
50676bcc64 docs(dm): fix stale KDoc links on RelayLoadingCursors
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>
2026-06-05 19:11:51 -04:00
Vitor Pamplona
c512bd39c4 refactor(dm): rename UntilLimitPager to RelayLoadingCursors
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>
2026-06-05 19:07:30 -04:00
Vitor Pamplona
b37f1a6e56 fix(dm): make the commons DM feed UI compile for iOS
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>
2026-06-05 18:57:41 -04:00
Vitor Pamplona
6223617179 refactor(dm): move paging cursors onto the model, drop the keyed pager
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>
2026-06-05 18:50:21 -04:00
Vitor Pamplona
910037ad8e refactor(quartz): add TimeUtils.nowMillis, drop raw System calls in pagers
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>
2026-06-05 17:48:28 -04:00
Vitor Pamplona
cd6537bfd5 refactor(dm): unify per-relay reach vocabulary + clarify Nip04 routing name
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>
2026-06-05 17:10:38 -04:00
Vitor Pamplona
e9f2f1d7aa refactor(dm): tighten visibility of internal-only paging helpers
- 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>
2026-06-05 16:45:04 -04:00
Vitor Pamplona
48333f0007 refactor(dm): drop dead pagination code with no production caller
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>
2026-06-05 16:44:56 -04:00
Vitor Pamplona
5a5a4a9c27 Merge pull request #3136 from davotoula/feat/hide-reposts-of-unsupported-kinds
Hide reposts whose boosted kind is unsupported
2026-06-05 15:12:43 -04:00
davotoula
7672282892 refactor: share isRenderableRepost via commons, apply on desktop
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.
2026-06-05 20:47:09 +02:00
davotoula
dc44802beb Code review:
- dedup boostedKind and simplify isRenderableRepost
2026-06-05 20:46:35 +02:00
davotoula
58e4a7c610 feat(feed): hide reposts whose boosted kind is unsupported 2026-06-05 20:46:13 +02:00
Claude
b672611c52 feat(dm): log the NIP-42 AUTH handshake in the DM diagnostics
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.
2026-06-05 17:55:28 +00:00
Claude
82ffd4c16b test(dm): cover the WindowLoadTracker idle backstop + its heard-from gate
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.
2026-06-05 17:35:23 +00:00
Claude
4fc24950e3 refactor(dm): cleanup + DRY/test the relay-reach gap predicate
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.
2026-06-05 17:19:06 +00:00
Claude
f631b0fbf1 docs(dm): reflect the commons UI extraction in the design doc
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.
2026-06-05 16:59:49 +00:00
Claude
ace9d20690 refactor(dm): extract the history status card to commons (shared with desktop)
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.
2026-06-05 16:58:34 +00:00
Claude
c3ed7e65c9 refactor(dm): extract relay-reach markers to commons (shared with desktop)
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.
2026-06-05 16:50:07 +00:00
Claude
3871d3bd1d docs(dm): reflect the BackwardRelayPager extraction in the design doc
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.
2026-06-05 16:28:29 +00:00
Claude
1c7e073b48 refactor(dm): wire the three history managers onto BackwardRelayPager + tests
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.
2026-06-05 16:23:30 +00:00
Claude
7ef6224c19 refactor(dm): extract per-relay paging primitives to quartz + add BackwardRelayPager
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.
2026-06-05 14:09:12 +00:00
Claude
0e4d2e96bc docs(dm): rewrite the DM pagination design doc to match the code
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)".
2026-06-05 13:19:24 +00:00
Claude
79f237ff0b Merge remote-tracking branch 'origin/main' into claude/relay-message-pagination-LMqSQ 2026-06-05 12:33:34 +00:00
Claude
813110cc29 fix(dm): distinguish 'caught up' from 'couldn't reach relays' in history/reply cards
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 👀.
2026-06-05 00:53:55 +00:00
Claude
61324aa7f0 feat(dm): tap the history card to see per-relay window positions
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.
2026-06-04 23:04:27 +00:00
Claude
a2b7eac701 feat(dm): show 'waiting on N relays' on the paused history card
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).
2026-06-04 22:57:37 +00:00
Claude
b9c2c646fd feat(dm): log a 'window settled' line when every relay is done or stalled
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.
2026-06-04 22:48:06 +00:00
Vitor Pamplona
0394ec2a75 fix(dm): drive window-limit paging off viewport visibility, not row composition
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>
2026-06-04 18:34:43 -04:00
Claude
a4368c403a fix(dm): stop the history loading card flickering between back-to-back pages
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.
2026-06-04 22:26:19 +00:00
Claude
c4b1eaaa3d fix(dm): pin the history floor so un-delivered relays don't re-page; add sentinel/bootstrap trace logs
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).
2026-06-04 21:41:07 +00:00
Vitor Pamplona
9c175259c6 Merge pull request #3135 from davotoula/feature/video-swipe-brightness-volume
Fullscreen swipe controls for brightness & volume
2026-06-04 17:35:22 -04:00
davotoula
54caab3520 Sync video mute with fullscreen volume swipe 2026-06-04 23:19:42 +02:00
davotoula
f2df23cfa6 Code review:
- fix(player): harden fullscreen swipe controls and brightness lifecycle
2026-06-04 23:19:28 +02:00
davotoula
5edfe90321 feat(player): enable brightness/volume swipe in fullscreen video 2026-06-04 23:19:08 +02:00
Claude
9ac835557a fix(dm): stop window-limit sentinels re-paging on relay connection churn
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.
2026-06-04 21:15:57 +00:00
Claude
eaddceba16 fix(dm): harden pager loop guard and foreground-only status writes
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.
2026-06-04 20:30:19 +00:00
Claude
d20b02047b feat(dm): demand-driven per-relay history paging via window-limit sentinels
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.
2026-06-04 20:08:54 +00:00
Vitor Pamplona
56240c10d2 Merge pull request #3134 from vitorpamplona/claude/compassionate-cannon-3mX7Y
Fix cache-pruning invariant for onchain zaps and nutzaps
2026-06-04 15:08:23 -04:00
Claude
d7e21fb3bd Merge remote-tracking branch 'origin/main' into claude/relay-message-pagination-LMqSQ 2026-06-04 19:04:22 +00:00
Vitor Pamplona
c6be3ee3a3 Merge pull request #3118 from mstrofnone/docs/namecoin-design-refresh
docs(namecoin): refresh NIP-05 design doc to match current main
2026-06-04 15:03:37 -04:00
Vitor Pamplona
8a156261cd Merge pull request #3132 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-04 15:03:24 -04:00
Crowdin Bot
336695dd55 New Crowdin translations by GitHub Action 2026-06-04 19:03:17 +00:00
Vitor Pamplona
38724cc3f5 Merge pull request #3133 from vitorpamplona/claude/wizardly-fermat-y7NBf
Move authenticated identity from policy to connection scope
2026-06-04 15:00:58 -04:00
Claude
7c95ba1ffd refactor: rename removal methods to match what they do
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
2026-06-04 18:59:54 +00:00
Claude
4ce5f6b1a3 feat(dm): per-relay reach markers for NIP-17, like NIP-04
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.
2026-06-04 18:59:03 +00:00
Claude
9f0ecd549f refactor(dm): page rooms-list NIP-04 history per relay; drop dead pager code
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).
2026-06-04 18:55:44 +00:00
Claude
9497739c7e docs(quartz/relay): remove auth-scope component diagram 2026-06-04 18:48:26 +00:00
Claude
60b8629a27 refactor(dm): page NIP-17 gift-wrap history per relay, like NIP-04
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.
2026-06-04 18:36:07 +00:00
Claude
a3bb6fbc10 refactor: unify Note removal into one shared unlink path
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
2026-06-04 18:35:56 +00:00
Claude
d2e64a0339 docs(quartz/relay): update component diagram to the merged design
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.
2026-06-04 18:29:52 +00:00
Claude
89f55a2509 feat(chats): show relay count and reach-back on the reply loader
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.
2026-06-04 17:42:52 +00:00
Claude
0791ae9d40 test(quartz/relay): assert authenticatedUsers is scoped per connection
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.
2026-06-04 17:42:48 +00:00
Claude
23ddeba8ec fix: sever child back-references when deleting a Note (NIP-09)
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
2026-06-04 17:42:29 +00:00
Claude
6fb735018c refactor(quartz/relay): drop redundant pubKey param from onAuthenticated/authorize
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.
2026-06-04 17:32:24 +00:00
Claude
a54d9c92a7 refactor(quartz/relay): move auth identity from policy into connection scope
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.
2026-06-04 16:45:03 +00:00
Claude
c4bfdc72dc fix: detach channel in removeFromCache to mirror deleteNote
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
2026-06-04 16:17:14 +00:00
davotoula
21f722767d refactor: use lambda Log overload for interpolated log calls 2026-06-04 18:15:17 +02:00
Claude
6943c1a056 docs(quartz/relay): component & data-flow diagram for the auth-scope plan 2026-06-04 16:03:00 +00:00
Claude
efecbc40ab style(chats): match unloaded-reply loader to the history loading card
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.
2026-06-04 16:01:32 +00:00
Claude
0008f676b6 docs(quartz/relay): plan to move auth identity from policy to connection scope 2026-06-04 15:56:36 +00:00
Claude
81701ea751 feat(chats): walk DM history to load a reply's unloaded target
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.
2026-06-04 15:47:32 +00:00
Claude
11591826f0 fix: detach onchain-zap and nutzap sources when pruning Notes from LocalCache
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
2026-06-04 15:37:44 +00:00
Claude
a98f5fddd2 refactor(quartz/relay): move authenticatedUsers off IRelayPolicy to a marker
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).
2026-06-04 15:36:40 +00:00
Claude
3d6acde472 feat(quartz/relay): thread a per-connection RequestContext into EventSource
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.
2026-06-04 15:25:39 +00:00
Claude
bd77e93818 fix(chats): surface streamed strangers in New Requests
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().
2026-06-04 15:05:18 +00:00
Claude
0077c136a5 fix: keep the DM history 'reached back' date monotonic
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
2026-06-03 23:24:59 +00:00
Vitor Pamplona
3db3037f92 seeing if this works for jitpack 2026-06-03 19:22:29 -04:00
Vitor Pamplona
b0a6baaddc Tries to fix the compilation issue with jitpack 2026-06-03 18:58:19 -04:00
Claude
7fd44155d3 Merge remote-tracking branch 'origin/main' into claude/relay-message-pagination-LMqSQ 2026-06-03 22:41:14 +00:00
Vitor Pamplona
d92c4be096 fix: recover Tor from a wedged Arti guard sample on startup
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>
2026-06-03 18:27:31 -04:00
Claude
edbdbdcc8e fix: give up on unreachable relays so the rooms list resolves
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
2026-06-03 21:52:38 +00:00
Vitor Pamplona
99ab2cb720 Merge pull request #3131 from vitorpamplona/claude/quartz-relay-ergonomics-7sTEN
Add ReqResponderServer for non-storage relays + NIP-50 search support
2026-06-03 17:45:49 -04:00
Claude
1c54a877e2 refactor(quartz): split relay server package into engine / backend / policies
The relay server package had 14 top-level files. Group by concern:

- relay/server/          engine + entry points: NostrServer, EventSourceServer,
                         RelayServerBase, RelaySession, ConnectionRegistry,
                         RelayServerListener, NegSessionRegistry
- relay/server/backend/  data plane: SessionBackend, EventSource,
                         EventSourceBackend, LiveEventStore, IngestQueue
- relay/server/policies/ policy model + impls: IRelayPolicy(+PolicyResult) and
                         RelayLimits move in alongside the existing policies
- relay/server/inprocess/ unchanged

Layering is acyclic: engine -> {backend, policies} -> nip01Core.

This moves three already-published classes (IRelayPolicy, LiveEventStore,
IngestQueue), so external quartz consumers re-import — a source-only change,
no behavior change. In-repo callers (geode) updated. Full quartz + geode
suites green; RELAY.md source map updated.

https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
2026-06-03 21:44:12 +00:00
Claude
a3b4df8e6e fix: rooms-list history stuck on 'Loading feed' after a no-progress round
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
2026-06-03 21:34:50 +00:00
Claude
66481dac05 refactor(quartz): rename ReqResponder -> EventSource; drop JwtAuthPolicy doc
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
2026-06-03 21:09:24 +00:00
Claude
98fb872005 feat: drop the rooms-list stall-gate — page to exhaustion while in view
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
2026-06-03 20:57:27 +00:00
Claude
c13dbad99d refactor(quartz): simplify relay server internals (no behavior change)
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
2026-06-03 20:43:52 +00:00
Claude
432a19f7c0 feat: label the in-stream relay markers 'Relay sync:'
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
2026-06-03 20:25:57 +00:00
Claude
9a19a0f346 refactor(quartz): remove pendingNewlyAdded — commit auth once, not commit-then-rollback
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
2026-06-03 20:25:12 +00:00
Claude
81dd2585dc fix: DM history card showed text but a blank icon when paused
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
2026-06-03 20:19:44 +00:00
Claude
a21f811e87 refactor(quartz): clarity pass on relay server internals
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
2026-06-03 20:15:06 +00:00
Claude
366d9435d7 fix(quartz): pre-merge review fixes — auth rollback, listener rename
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
2026-06-03 19:41:55 +00:00
Claude
648a00ef63 feat(quartz): add missing NIP-11 banner field to relay info document
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
2026-06-03 19:22:37 +00:00
David Kaspar
5901f2e453 Merge pull request #3130 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-03 21:06:42 +02:00
Claude
37b07057b2 refactor(quartz): enforce session-level limits through policy hooks
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
2026-06-03 18:58:20 +00:00
Claude
40b60e7bcc fix: compact relay markers + no '0 relays' flash on the DM loading card
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
2026-06-03 18:50:13 +00:00
Claude
70dcaa1b10 feat(quartz): relay limits as single source of truth + NIP-11 serving
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
2026-06-03 18:41:06 +00:00
Claude
952da685d8 feat(quartz): NIP-45 approximate COUNT — HyperLogLog construction + wire support
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
2026-06-03 18:29:01 +00:00
Crowdin Bot
bd9f927041 New Crowdin translations by GitHub Action 2026-06-03 18:21:24 +00:00
Claude
319ffb729a feat(quartz): relay connection observability + stable connection ids
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
2026-06-03 18:21:05 +00:00
davotoula
54f746edc3 feat: translate settings-search and share-to-DM strings (cs, de, sv, pt-BR)
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>
2026-06-03 20:16:36 +02:00
Claude
bcb4b2b964 fix(quartz): roll back authentication when the post-auth hook rejects
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
2026-06-03 17:45:52 +00:00
Claude
ab7aa44d21 fix: pin the convo history floor per window to stop re-REQ churn
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
2026-06-03 17:41:45 +00:00
Claude
68e49a97ba feat(quartz): Flow<Event> REQ-responder SPI + storage-free dispatch engine
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
2026-06-03 17:31:53 +00:00
Claude
e1cdd40bb5 chore: remove superseded TimeWindowPagination, refresh DM design doc
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
2026-06-03 17:16:21 +00:00
Claude
69aea29954 feat(quartz): server-side relay ergonomics — NIP-50 parser, suspend auth hook, wire helpers
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
2026-06-03 17:15:13 +00:00
Claude
5b0f2051db fix: convo NIP-04 history stuck 'loading' with 0 relays on first open
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
2026-06-03 16:48:18 +00:00
Claude
95a38111dd Merge remote-tracking branch 'origin/main' into claude/relay-message-pagination-LMqSQ 2026-06-03 15:24:39 +00:00
Vitor Pamplona
9423463f17 Merge pull request #3129 from vitorpamplona/claude/sleepy-sagan-Q2p3t
Register CommunityRulesEvent in EventFactory for kind 34551
2026-06-03 09:35:28 -04:00
Claude
3a43c4f7bb fix: register CommunityRulesEvent in EventFactory
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.
2026-06-03 13:30:52 +00:00
David Kaspar
479c694e6e Merge pull request #3128 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-03 14:35:31 +02:00
Crowdin Bot
6f645511f0 New Crowdin translations by GitHub Action 2026-06-03 12:02:06 +00:00
Vitor Pamplona
67d14fcc03 Merge pull request #3125 from nrobi144/fix/desktop-log-noise
fix: address root causes of 6 runtime log noise issues
2026-06-03 07:59:09 -04:00
Vitor Pamplona
74af87ae37 Merge pull request #3127 from davotoula/feat/settings-search
Searchable, data-driven settings screen
2026-06-03 07:58:50 -04:00
davotoula
72d538bb62 refactor(settings): address review — non-translatable keywords, symEntry helper, legal keywords
- 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>
2026-06-03 10:08:28 +02:00
davotoula
d75e80bea6 feat(settings): curated search keywords for all rows + word-prefix search matching
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.
2026-06-03 09:45:20 +02:00
David Kaspar
fc14ad6bfc Merge pull request #3126 from nrobi144/feat/desktop-new-posts-chip
feat(desktop): home-feed scroll polish + sidebar tooltips
2026-06-03 09:43:05 +02:00
davotoula
d8e4ada781 Code reviewP
- 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
2026-06-03 09:21:24 +02:00
davotoula
4446b12972 feat(settings): add settings catalog data model + filterSettings
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
2026-06-03 09:20:54 +02:00
nrobi144
599a16193a fix(desktop): collapsed sidebar — tighter ripple + hover tooltip
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.
2026-06-03 09:46:15 +03:00
nrobi144
e5b210d4e1 fix(desktop): make first-pinned-feed default actually take effect
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.
2026-06-03 09:36:18 +03:00
nrobi144
99af0f75e1 fix(desktop): default home tab to first pinned feed, not last-saved mode
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.
2026-06-03 09:31:02 +03:00
nrobi144
37662eea45 fix(desktop): port StickToTopOnPrepend to commons and apply on home feed
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.
2026-06-03 07:33:39 +03:00
nrobi144
44febcc77f feat(desktop): add Amethyst logo to Tor and account-loading splashes
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.
2026-06-03 07:31:49 +03:00
nrobi144
38a191341f fix(desktop): bump new-posts chip top margin to 16dp
Tighter 8dp gap clipped visually too close to the search header card.
2026-06-03 07:31:34 +03:00
Claude
af193e03ab refactor: tidy DM history assembler + restore per-relay diagnostics
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
2026-06-03 02:26:46 +00:00
Claude
afa02df3a0 feat: in-stream per-relay paging markers for NIP-04 conversations
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
2026-06-03 00:39:00 +00:00
Claude
cbee966bb7 feat: page each NIP-04 DM relay independently to converge on one window
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
2026-06-03 00:29:53 +00:00
Claude
07b0c87c38 fix: stop giftwrap/rooms loads completing before their REQs are sent
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
2026-06-03 00:04:09 +00:00
Claude
0d27477c4d fix: mark a DM conversation done when its last relays stop making progress
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
2026-06-02 23:43:12 +00:00
Claude
d658ae2414 fix: don't let a relay stuck before its REQ hold the DM load for 5 min
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
2026-06-02 22:31:42 +00:00
Claude
3cb6613cd7 fix: give up on DM relays that accept a REQ but never answer
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
2026-06-02 22:10:16 +00:00
Claude
0430fd176c refactor: scope NIP-04 DM filters per relay to the keys that own each relay
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
2026-06-02 21:48:06 +00:00
Claude
772355d2fc fix: don't ask a correspondent's relays for my own NIP-04 messages
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
2026-06-02 21:28:30 +00:00
Claude
5bd7b765d6 chore: log which account contributes which relays to the DM filters
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
2026-06-02 20:57:34 +00:00
Claude
5080ab08c5 fix: give up on relays that keep rejecting us so a chat can finish loading
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
2026-06-02 20:24:23 +00:00
Claude
ae62968160 fix: mark DM history exhausted only when every relay empty-EOSEs
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
2026-06-02 20:00:31 +00:00
Claude
baad77a966 refactor: key the conversation history pager by a (account, ChatroomKey) type
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
2026-06-02 19:18:38 +00:00
Claude
0f0644200d fix: don't leak DM history state across logged-in accounts
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
2026-06-02 19:11:40 +00:00
Claude
0d0857c2e9 feat: modern DM history status card with "all caught up" finish
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
2026-06-02 18:38:49 +00:00
Claude
5e67fe913f fix: page NIP-17 and NIP-04 history independently in the rooms list
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
2026-06-02 17:57:56 +00:00
Claude
030f2fdbfa Merge remote-tracking branch 'origin/main' into claude/relay-message-pagination-LMqSQ
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt
2026-06-02 14:59:28 +00:00
nrobi144
098a74ca53 feat(desktop): add "New posts" chip with slide-from-top animation
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
2026-06-02 17:16:58 +03:00
Vitor Pamplona
a4aff84897 Merge pull request #3124 from nrobi144/feat/desktop-feed-ui-refresh
feat(desktop): Feed UI refresh — inline expansion, comments, related content
2026-06-02 08:02:50 -04:00
Vitor Pamplona
3de0fdc4c5 Merge pull request #3122 from davotoula/feat/share-as-dm
Share content directly to a DM ("Send as DM" share target)
2026-06-02 08:00:35 -04:00
nrobi144
aeb49c3cac fix(desktop): address PR review findings on feed UI refresh
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>
2026-06-02 13:45:00 +03:00
Róbert Nagy
1f81a7fb25 Merge branch 'main' into fix/desktop-log-noise 2026-06-02 10:51:59 +03:00
nrobi144
2ca8eb31dc fix: address root causes of 6 runtime log noise issues
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>
2026-06-02 10:47:58 +03:00
Róbert Nagy
70636c0f9a Merge branch 'main' into feat/desktop-feed-ui-refresh 2026-06-02 10:01:10 +03:00
Claude
77d8657b62 feat: page DM history by until+limit per relay (gap-proof stop signal)
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
2026-06-02 03:54:09 +00:00
Claude
bc86813cbc fix: load older history at the start of a short conversation
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
2026-06-02 01:18:53 +00:00
Claude
793860170f feat: split DM loading into a live tail + bounded history slices
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
2026-06-01 23:43:02 +00:00
Claude
9e2e595cac feat: log NIP-04 (kind 4) REQs in the DM relay diagnostics trail
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
2026-06-01 23:12:19 +00:00
Claude
a2033499c9 fix: measure out-of-window events against the margined REQ floor
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
2026-06-01 23:06:10 +00:00
Claude
c33a10c945 fix: complete a window load only when every relay has settled
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
2026-06-01 22:59:52 +00:00
Vitor Pamplona
063da53ffd Merge pull request #3123 from vitorpamplona/claude/relaxed-edison-N0F4J
Support ephemeral signers for anonymous post uploads
2026-06-01 18:57:22 -04:00
Claude
e8a50bfa11 fix: use ephemeral signer for media uploads in anonymous posts
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.
2026-06-01 22:33:42 +00:00
Claude
5fb0cc9dd4 fix: don't declare a window load done while relays are still connecting
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
2026-06-01 22:24:16 +00:00
Claude
923ad500a7 feat: count per-load gift-wraps and flag out-of-window events
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
2026-06-01 21:44:45 +00:00
Claude
4d234788df chore: DMPagination logs for window/auto-fill behavior
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.
2026-06-01 20:55:11 +00:00
Claude
b23dcdf468 fix: conversation showed only a spinner / loaded everything (drop gap-free clip)
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.
2026-06-01 20:25:34 +00:00
Claude
9118a757e3 Merge remote-tracking branch 'origin/main' into claude/relay-message-pagination-LMqSQ 2026-06-01 19:38:01 +00:00
davotoula
ae271d0ba7 refactor(sonar): extract NOT_STARTED_MESSAGE constant in CashuWalletState
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>
2026-06-01 21:34:18 +02:00
davotoula
e6a512db42 Code review and testing fixes:
- 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
2026-06-01 21:29:41 +02:00
davotoula
8d715e5730 feat(dm-share): add ShareToDM route and attachment param on Room route 2026-06-01 21:29:41 +02:00
Vitor Pamplona
d8fba6a342 Merge pull request #3121 from vitorpamplona/claude/blissful-babbage-fFHRt
Fix inverted guard in TagArrayBuilder.addUniqueValueIfNew
2026-06-01 15:23:04 -04:00
Claude
ec5245c81e feat: consistent DM "load more" boundary in the chat (spinner only when loading)
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.
2026-06-01 19:17:21 +00:00
Claude
1c2775c6b5 fix(quartz): emit q tags again (inverted guard dropped all quotes)
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
2026-06-01 19:15:03 +00:00
Claude
0fb6f6778d refactor: one DM window, NIP-04 followers, shared listener
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.
2026-06-01 14:12:02 +00:00
Claude
46a8d1d490 feat: gap-free conversation timeline (display floor across NIP-04 + NIP-17)
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.
2026-06-01 13:57:26 +00:00
Vitor Pamplona
877d401e34 Merge pull request #3120 from vitorpamplona/claude/laughing-noether-2EmMZ
Move HTML parsing and broadcast service to commons for KMP
2026-06-01 08:12:55 -04:00
Vitor Pamplona
51503f278d Merge pull request #3119 from vitorpamplona/dependabot/github_actions/actions-bfa3075405
chore(actions): bump the actions group with 2 updates
2026-06-01 08:11:42 -04:00
nrobi144
1b17ce6975 fix(desktop): wire like and zap on comment items
- 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>
2026-06-01 12:20:01 +03:00
nrobi144
4b021351d3 fix(desktop): wire comment reactions + fix related content click navigation
- 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>
2026-06-01 12:13:33 +03:00
nrobi144
3f862637d1 fix(desktop): load comment author metadata on inline expansion
- 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>
2026-06-01 12:09:28 +03:00
nrobi144
08c7b5f214 fix(desktop): remove auto-scroll on card expansion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-01 12:08:35 +03:00
nrobi144
4fddfef5dd feat(desktop): inline card expansion in feed
- 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>
2026-06-01 12:03:16 +03:00
nrobi144
92e210a584 fix(desktop): follow pill visibility, metadata loading, reply + view all wiring
- 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>
2026-06-01 11:45:32 +03:00
nrobi144
5aa2f519e7 feat(desktop): visual overhaul of thread detail view matching Layers design
- Create CommentsCard: OutlinedCard with "Comments N" header + badge,
  "Most recent" label, reply input slot, comment items slot
- Create CommentItem: lightweight comment row with avatar, name, handle,
  time, content, Reply/Like/Zap actions (replaces heavy FeedNoteCard for replies)
- Restyle InlineReplyInput: cyan "Send" pill button instead of plain icon
- Revise RelatedContentRow: image-overlay cards (200x140dp) with AsyncImage
  background, dark gradient overlay, white title + author + zaps
- Restructure ThreadScreen: root note card → CommentsCard → Related section

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-01 11:34:18 +03:00
nrobi144
9194dac8f9 feat(desktop): related content section in thread view
- 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>
2026-06-01 11:07:31 +03:00
nrobi144
25c9cf4611 feat(desktop): share menu with copy/broadcast options
- Create ShareMenu composable with ShareMenuState
- 6 share options: Copy Text, Copy Note ID, Copy Event Link, Copy Raw JSON,
  Copy Web Link (njump.me), Broadcast
- Replace MoreVert overflow menu with Share icon + ShareMenu
- Use existing copyToClipboard helper for clipboard operations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-01 11:07:19 +03:00
nrobi144
cc1330adb6 feat(desktop): inline reply in thread view
- Create InlineReplyInput composable (avatar + TextField + Send button)
- SendState sealed interface (Idle/Sending/Error)
- Ctrl/Cmd+Enter keyboard shortcut to send
- Build kind:1 reply with NIP-10 e-tag + p-tag
- Optimistic display via localCache.consume + broadcastToAll
- Error shown inline with text preserved for retry
- Hidden for logged-out users

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-01 11:06:50 +03:00
nrobi144
1294283937 feat(desktop): follow pill in feed card header
- Add headerTrailingContent slot to NoteCard for follow pill placement
- Add FollowPill composable (FilterChip with PersonAdd icon)
- Wire follow action in FeedScreen: FollowAction.follow + broadcastToAll
- Mutex guards concurrent follows to prevent kind:3 overwrites
- Expose lastContactListEvent on DesktopLocalCache for follow operations
- Hidden for own notes, already-followed users, and logged-out users

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-01 10:31:53 +03:00
nrobi144
fd6e37b84a feat(desktop): slide-animated inline navigation with 2-level back stack cap
- 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>
2026-06-01 10:23:24 +03:00
nrobi144
144b911867 feat(desktop): move actions inside card + fix sidebar double active state
- 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>
2026-06-01 10:17:08 +03:00
dependabot[bot]
92989566cd chore(actions): bump the actions group with 2 updates
Bumps the actions group with 2 updates: [gradle/actions](https://github.com/gradle/actions) and [actions/cache](https://github.com/actions/cache).


Updates `gradle/actions` from 4 to 6
- [Release notes](https://github.com/gradle/actions/releases)
- [Commits](https://github.com/gradle/actions/compare/v4...v6)

Updates `actions/cache` from 4 to 5
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

---
updated-dependencies:
- dependency-name: gradle/actions
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-01 01:34:23 +00:00
m
9afa4a120f docs(namecoin): refresh design doc to match current main
- 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.
2026-06-01 08:26:07 +10:00
David Kaspar
cc7731b59c Merge pull request #3117 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-31 21:33:17 +02:00
Crowdin Bot
77ea119b8b New Crowdin translations by GitHub Action 2026-05-31 11:31:27 +00:00
Vitor Pamplona
7511e31d83 Merge pull request #3116 from davotoula/fix/versionname-worktree-cwd
Run git from rootDir in versionName detection so worktrees work
2026-05-31 07:29:46 -04:00
David Kaspar
16beed71e5 Merge branch 'main' into fix/versionname-worktree-cwd 2026-05-31 12:03:26 +02:00
David Kaspar
73bc81a6ba Merge pull request #3113 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-31 09:36:33 +02:00
Crowdin Bot
5adab91a78 New Crowdin translations by GitHub Action 2026-05-31 07:32:01 +00:00
davotoula
e54086d851 docs(skill): drop sync-timestamp filter from find-missing-translations
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>
2026-05-31 09:30:16 +02:00
davotoula
62a8841a18 i18n: translate Cashu wallet, top-up/nutzap, hashtag-label, podcast & music strings (cs, de, sv, pt-BR)
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>
2026-05-31 09:26:33 +02:00
Vitor Pamplona
14d7741eb6 Merge pull request #3115 from vitorpamplona/claude/gallant-planck-cL2Kq
feat(cashu): show per-mint balance, drop inline recommend button
2026-05-30 20:00:55 -04:00
Claude
40a6465f3c fix: base rooms-list window paging only on private chats
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.
2026-05-30 23:34:30 +00:00
Claude
f26c00add0 refactor(commons): make HtmlParser KMP — drop java Charset dependency
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.
2026-05-30 23:34:05 +00:00
Claude
fd7ac921d1 Merge remote-tracking branch 'origin/main' into claude/gallant-planck-cL2Kq 2026-05-30 23:27:19 +00:00
Claude
94dc759f8a feat(cashu): add standalone mint top-up screen
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.
2026-05-30 22:59:08 +00:00
Claude
58ad87c900 refactor(commons): move link-preview fetcher to commons
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.
2026-05-30 22:57:37 +00:00
Claude
cad22689d1 feat: window NIP-04 with NIP-17 in lockstep in the conversation screen
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.
2026-05-30 22:57:18 +00:00
Claude
5f1514a152 refactor(commons): move relay broadcast tracker to commons
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.
2026-05-30 22:42:05 +00:00
Claude
43de099e7e feat: scroll-driven NIP-17 loading in conversations (drop eager load-all)
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.
2026-05-30 22:41:08 +00:00
Vitor Pamplona
fc6d157cbc Merge pull request #3114 from vitorpamplona/claude/brave-franklin-4taet
Remove wallet reordering UI and drag-and-drop functionality
2026-05-30 18:38:10 -04:00
Claude
dddbb45680 feat(cashu): show per-mint balance, drop inline recommend 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.
2026-05-30 22:35:37 +00:00
Claude
58973701d0 feat: load full gift-wrap history when a conversation opens
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.
2026-05-30 22:30:26 +00:00
Claude
a70e4cebc0 fix: tidy wallet screen UI
- 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
2026-05-30 22:24:28 +00:00
Claude
e66c27e375 refactor(commons): move CLI-safe util extensions out of amethyst
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.
2026-05-30 22:23:47 +00:00
Claude
9ac7ee8026 fix: race conditions in the DM window loaders
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.
2026-05-30 22:14:30 +00:00
Claude
6f9c5bbdf0 fix: complete DM windows on quiescence, add load-entire-history button
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.
2026-05-30 22:05:57 +00:00
Vitor Pamplona
4ae606805f Merge pull request #3112 from vitorpamplona/claude/gracious-cori-uLr4P
Move NIP-51/72 decryption caches and models to commons
2026-05-30 18:04:49 -04:00
Claude
abc9cd14cf refactor: move InterestSet to commons
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
2026-05-30 21:59:26 +00:00
Claude
a38388fce5 refactor: move LabeledBookmarkList to commons
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
2026-05-30 21:52:06 +00:00
Claude
593c320004 refactor: move Mute/People/Community decryption caches to commons
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
2026-05-30 21:46:13 +00:00
Claude
d311a01964 feat: auto-fill and prefetch the rooms list with a growing DM window
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.
2026-05-30 21:44:26 +00:00
Claude
ab72427445 refactor: move HashtagListDecryptionCache to commons
Pure quartz-only decryption cache — extract to
commons/model/nip51Lists/hashtagLists. Re-points Account,
FeedDecryptionCaches, and the sibling HashtagListState.

https://claude.ai/code/session_01JFbYZdVV4QmDC4eQYvEccb
2026-05-30 21:43:55 +00:00
Claude
466086c475 refactor: move FavoriteAlgoFeedsListDecryptionCache to commons
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
2026-05-30 21:42:51 +00:00
Claude
c619d71890 refactor: move NwcWalletEntry to commons/model/nip47WalletConnect
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
2026-05-30 21:40:54 +00:00
Vitor Pamplona
34f6b2ba36 Merge pull request #3111 from vitorpamplona/claude/epic-hamilton-23225
NIP-32: Add hashtag labeling and label-based hashtag feed
2026-05-30 17:32:03 -04:00
Claude
3f4066c4b8 refactor: buildHashtagLabel takes an EventHintBundle
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
2026-05-30 21:23:56 +00:00
Vitor Pamplona
ae85bfc358 Merge pull request #3110 from vitorpamplona/claude/dreamy-galileo-YOiWa
Move utility functions and models to commons module
2026-05-30 17:13:50 -04:00
Claude
80dc2f2d91 fix: stop scroll-to-end widening from cascading the DM window
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.
2026-05-30 21:11:43 +00:00
Claude
8e7d169198 feat: route follow hashtag-labels through each follow's outbox relays
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
2026-05-30 20:59:19 +00:00
Claude
789dd4f02b refactor: dedup ByteFormatter against commons util
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
2026-05-30 20:52:12 +00:00
Claude
e930cc6ef0 feat: window NIP-04 DMs in lockstep with gift wraps in the rooms list
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
2026-05-30 20:42:06 +00:00
Claude
6eee100010 refactor: dedup CountFormatter against commons util
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
2026-05-30 20:37:20 +00:00
Claude
5d629faa05 refactor: dedup EmojiUtils against commons util
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
2026-05-30 20:37:12 +00:00
Claude
b0f9b1621a refactor: move CashuToken to commons/model/nip60Cashu
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
2026-05-30 20:37:04 +00:00
Claude
d57f8c18c6 feat: first-class NIP-32 hashtag labels on posts and in the hashtag feed
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
2026-05-30 20:32:43 +00:00
Claude
19d6c9baaa refactor: move TrustProviderListDecryptionCache to commons, rename pkg to nip85TrustedAssertions
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
2026-05-30 20:15:36 +00:00
Claude
47a70b4fa3 refactor: move OwnedEmojiPack to commons
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
2026-05-30 20:15:28 +00:00
davotoula
908eedb831 fix(amethyst): set git cwd in versionName branch detection so worktrees work
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>
2026-05-30 21:59:35 +02:00
Vitor Pamplona
b6361f133c Merge pull request #3109 from vitorpamplona/claude/great-knuth-sJVYl
Reorganize commons packages: NIP-64 chess, NIP-AC WebRTC, and model caches
2026-05-30 15:55:59 -04:00
Claude
6b2265273a docs(commons): add target package hierarchy + naming rules for the migration
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
2026-05-30 19:51:38 +00:00
Claude
d7aa307b9c docs(commons): add Amethyst→commons migration plan
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
2026-05-30 19:36:22 +00:00
Claude
976c650b2f fix: detect gift-wrap REQs by kinds array, drop EOSE noise
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
2026-05-30 19:35:29 +00:00
Claude
d8899eedf8 refactor(commons): move feature-specific UI out of ui/ into <feature>/ui
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
2026-05-30 19:02:55 +00:00
Claude
79a9bf78f8 refactor(commons): name single-NIP feature packages after their quartz NIP
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
2026-05-30 18:46:36 +00:00
David Kaspar
1f1abbbc58 Merge pull request #3104 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-30 20:30:17 +02:00
Crowdin Bot
6b48b55920 New Crowdin translations by GitHub Action 2026-05-30 17:48:08 +00:00
davotoula
70f72d4604 fix(sonar-s1871): match ChannelMessage/Metadata explicitly, not via IsInPublicChatChannel
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
2026-05-30 19:43:03 +02:00
Claude
fca99b68be chore: scope DM diagnostics to gift-wrap relays only
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
2026-05-30 17:10:27 +00:00
Claude
b0c6ffb821 refactor(commons): consolidate package taxonomy + add architecture doc
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
2026-05-30 17:03:57 +00:00
Claude
3ca40ca5ef chore: add DM relay diagnostics timeline (tag: DMPagination)
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
2026-05-30 16:28:50 +00:00
Claude
65b5ad48f6 chore: log cold-boot DM load start and completion
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
2026-05-30 15:48:14 +00:00
Claude
b6e3716711 chore: add DMPagination debug logs for the DM time window
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
2026-05-30 15:27:41 +00:00
Claude
5436ac07be Merge remote-tracking branch 'origin/main' into claude/relay-message-pagination-LMqSQ 2026-05-30 15:15:27 +00:00
Claude
43744e53ad feat: bound DM boot loading to a time window with scroll-to-load-more
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
2026-05-30 14:43:45 +00:00
Vitor Pamplona
ff4b4323d1 Merge pull request #3108 from vitorpamplona/claude/amazing-volta-UJrwo
Unify zap amount chips and add cashu reload flow
2026-05-30 10:33:33 -04:00
Claude
d6640be62c refactor(topup/zap): address audit cleanups — pin send mint, dedupe scrub, share clipboard, scope rebuilds
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
2026-05-30 14:13:26 +00:00
Claude
93648085f9 fix(zap settings): de-dupe preset amounts so drag-reorder + chip keys stay valid
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
2026-05-30 02:16:39 +00:00
Claude
380122bcb5 fix(topup): checkpoint the instant funds leave the wallet, not after the whole flow
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
2026-05-30 02:07:43 +00:00
Claude
2c02795e45 feat(zap popup): react to lnAddress + cashu/nutzap info loading so rails appear live
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
2026-05-30 01:02:31 +00:00
Claude
1e9b9fd295 fix(zap settings): fix chip drag (pointerInput outside the drag transform), regroup chip, header amount
- 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
2026-05-30 00:45:23 +00:00
Claude
67d6135ee7 fix(topup): never re-move funds on retry; wait for proofs before zapping
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
2026-05-30 00:45:21 +00:00
Claude
77c183186c fix(wallet): show the cashu logo (not a generic wallet icon) on the cashu wallet row
CashuWalletRow used MaterialSymbols.AccountBalanceWallet; swap it for the
tintable CustomHashTagIcons.Cashu so the cashu wallet reads as cashu.

https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
2026-05-30 00:31:28 +00:00
Claude
a1fd21d456 fix(zap chip): soften the toggle outline and use brand orange for selected cashu
- 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
2026-05-30 00:29:34 +00:00
Claude
fea00f5707 Merge remote-tracking branch 'origin/main' into claude/amazing-volta-UJrwo 2026-05-30 00:26:26 +00:00
Vitor Pamplona
b9bee7301f Merge pull request #3107 from vitorpamplona/claude/magical-faraday-VHJbK
Refactor RelayProxyClientConnector to use injected StateFlows
2026-05-29 20:25:32 -04:00
Claude
7bda12f047 fix(zap chip): outline the whole toggle, not the selected segment
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
2026-05-30 00:24:24 +00:00
Claude
96b689b1cb fix(zap chip): enlarge cashu glyph (+20%) and make the selected toggle state clear
- 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
2026-05-30 00:18:42 +00:00
Claude
3457bf4390 feat(zap chip): turn the rail picker into an animated segmented toggle (select, then send)
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
2026-05-30 00:06:55 +00:00
Claude
099c71a183 Merge remote-tracking branch 'origin/main' into claude/amazing-volta-UJrwo
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt
2026-05-29 23:48:34 +00:00
Claude
8c855a3fc6 fix(zap chip): symmetric padding for single-rail chips, quieter alternatives + amount
- 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
2026-05-29 23:42:27 +00:00
Claude
546007412d refactor: simplify Tor-bootstrap reconnect fix to a transport-change check
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
2026-05-29 23:41:35 +00:00
Vitor Pamplona
083e98f860 Merge pull request #3106 from vitorpamplona/claude/lucid-goldberg-YUBPw
Redesign Cashu icon as monochrome tintable glyph
2026-05-29 19:37:35 -04:00
Claude
93b77348ac refactor: enlarge the Cashu sunglasses further
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.
2026-05-29 23:30:48 +00:00
Claude
92b1af8a23 feat(zap settings): drag-and-drop reordering of quick-zap preset chips
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
2026-05-29 23:11:49 +00:00
Claude
c91ef86d4d refactor(zap/wallet): move NWC setup out of zap-amount settings to the Add-NWC screen
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
2026-05-29 23:06:29 +00:00
Claude
df33b99b07 refactor: round the Cashu nut body into a smooth outline
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.
2026-05-29 23:01:34 +00:00
Claude
5d6a0e542d feat(zap settings): preset chips mirror the live feed chip (rails + default), drop bolt emoji
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
2026-05-29 22:54:08 +00:00
Claude
71bed1cf0a refactor: slim Cashu body and enlarge the sunglasses
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.
2026-05-29 22:51:54 +00:00
Claude
d257749908 feat(zap): top-up screen — avatar header, multiple LN wallets, separate top-up amount
- 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
2026-05-29 22:41:27 +00:00
Claude
9010e85411 feat: monochrome outline Cashu icon for Material Symbols compatibility
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.
2026-05-29 22:38:43 +00:00
Claude
e1a651e72e fix: honor relay backoff during Tor bootstrap via per-relay config check
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
2026-05-29 22:30:53 +00:00
Claude
4ffb69c459 fix(zap): shrink cashu chip logo further (15→13dp)
Second pass on the cashu logo size — it still read large next to the Material
symbol rails; drop to 13dp and scale the reload "+" badge to 9dp to match.

https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
2026-05-29 22:18:31 +00:00
Claude
c801b8e580 feat(zap): merge preferred logo into the amount tap-target; circular option buttons
- 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
2026-05-29 22:10:54 +00:00
Claude
9386aa6eb2 feat(zap): amount-tiered default rail + preferred logo left of the number
- 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
2026-05-29 22:04:36 +00:00
Claude
c714eba482 feat(zap): rework top-up screen (rename, editable amount, clearer source, live balances) + chip ordering
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
2026-05-29 21:53:37 +00:00
Claude
bbec99af75 fix(zap): shrink reload cashu logo to one rail's footprint
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
2026-05-29 21:40:45 +00:00
Vitor Pamplona
1ec5f559e6 Merge pull request #3105 from vitorpamplona/claude/eager-davinci-WxjiM
Add dedicated podcast screen with episode list and inline player
2026-05-29 17:32:55 -04:00
Claude
ba35a4e527 feat(podcasts): translatable descriptions on the NoteCompose feed cards
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.
2026-05-29 21:30:44 +00:00
Claude
063ebbebce feat(podcasts): translatable show description + synthetic playback waveform
- 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.
2026-05-29 21:21:54 +00:00
Claude
116ff33cd8 test(zap): assert merge preserves preset order instead of sorting
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
2026-05-29 20:30:58 +00:00
Claude
594f86d1ce Merge remote-tracking branch 'origin/main' into claude/amazing-volta-UJrwo 2026-05-29 20:23:22 +00:00
Claude
b1961ea19c fix(zap): address reload-mint audit findings (double-submit, premature done, fee/poll robustness)
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
2026-05-29 20:23:00 +00:00
Claude
07855c2240 Merge remote-tracking branch 'origin/main' into claude/eager-davinci-WxjiM 2026-05-29 20:18:56 +00:00
Vitor Pamplona
936c96e342 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2026-05-29 16:08:38 -04:00
Claude
c504b465e1 refactor(podcasts): share the episode audio player; fix list player height
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").
2026-05-29 20:08:11 +00:00
Vitor Pamplona
dd92098343 updates all libs 2026-05-29 16:07:49 -04:00
Claude
ce32dc4fc3 feat(zap): Reload Mint screen to fund a recipient's mint, then nutzap
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
2026-05-29 19:32:30 +00:00
Claude
46e618aabe feat(podcasts): tap a podcast to open a show screen with all episodes
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.
2026-05-29 19:24:50 +00:00
Claude
c73e7924dd feat(cashu): add mint-balance queries and mint-to-mint rebalance orchestration
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
2026-05-29 19:19:33 +00:00
Claude
6f7cacf84b refactor(zap): merge on-chain amount presets into the single zap-amount list
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
2026-05-29 18:28:48 +00:00
Claude
c1c1a69148 feat(zap): unify zap picker into one chip per amount with per-rail logos
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
2026-05-29 18:09:22 +00:00
Vitor Pamplona
c3b40c4369 Merge pull request #3075 from vitorpamplona/claude/cashu-wallet-amethyst-sdOWe
Add NIP-60 Cashu wallet and NIP-61 nutzap support
2026-05-29 12:59:20 -04:00
Claude
4e18906f9a feat(nutzap): progress bar during send, matching lightning UX
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).
2026-05-29 15:32:25 +00:00
Claude
30a845a6c1 Merge remote-tracking branch 'origin/main' into claude/cashu-wallet-amethyst-sdOWe
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
2026-05-29 13:32:49 +00:00
Vitor Pamplona
c22cd0d242 Merge pull request #3086 from carpedkm/bounty-1497-live-chat-broadcast
Fix live stream chat relay fallback
2026-05-29 09:18:38 -04:00
Vitor Pamplona
a49f57505a Merge pull request #3102 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-29 09:15:37 -04:00
Crowdin Bot
b1f0f04bcc New Crowdin translations by GitHub Action 2026-05-29 13:02:55 +00:00
Vitor Pamplona
c1dd59a068 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
# Conflicts:
#	commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/LifecycleAwareKeyDataSourceSubscription.kt
2026-05-29 08:57:31 -04:00
Vitor Pamplona
c1c4f7f72a Uses the parent note's p-tags to load children notes that cannot be found 2026-05-29 08:47:28 -04:00
Vitor Pamplona
90109c9095 Removes the need of idHex on the BlankNote 2026-05-29 08:40:31 -04:00
Vitor Pamplona
ca8589b29c Quick check to make sure the gatherers are not duplicated 2026-05-29 08:37:08 -04:00
Vitor Pamplona
350aadc578 Pushes event hints for citations in Public Messages 2026-05-29 08:36:41 -04:00
Vitor Pamplona
fd88e2f8a5 Merge pull request #3095 from vitorpamplona/claude/amazing-ptolemy-26Nek
Use locale-aware date/time formatting throughout the app
2026-05-29 08:30:41 -04:00
Vitor Pamplona
9a6400a6e3 Merge pull request #3103 from greenart7c3/claude/elegant-knuth-grbfU
Fix lifecycle-aware subscription grace timer starvation
2026-05-29 06:34:53 -04:00
Claude
5667bd53c3 fix(relays): keep lifecycle-aware grace timer running while backgrounded
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
2026-05-29 09:29:48 +00:00
Vitor Pamplona
8fcfbf8477 Merge pull request #3098 from nrobi144/feat/desktop-visual-personality
feat(desktop): Visual Personality Overhaul — Unified Theme, Sidebar, Search, Cards
2026-05-29 05:13:00 -04:00
nrobi144
dd5b61a6f0 feat(desktop): add "All Screens" item before feeds in sidebar
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>
2026-05-29 07:09:50 +03:00
nrobi144
71a38674b0 fix(desktop): first feed item 16dp top padding via LazyColumn contentPadding
- 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>
2026-05-29 07:02:53 +03:00
nrobi144
cc698c5e91 fix(desktop): sidebar starts expanded + extra 16dp feed margin
- Sidebar always starts expanded (ignores persisted collapsed state)
- Feed spacer 60dp → 76dp (extra 16dp gap between header card and first item)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-29 07:02:52 +03:00
nrobi144
0e431d674c feat(desktop): branded loading screen instead of login flash on startup
- 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>
2026-05-29 07:02:52 +03:00
nrobi144
da0aaaa281 fix(desktop): wider collapsed sidebar + filled Tor icon when connected
- 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>
2026-05-29 07:02:52 +03:00
nrobi144
12918e7045 feat(desktop): Tor/Bunker status as proper sidebar nav items
- 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>
2026-05-29 07:02:52 +03:00
nrobi144
7ff4d8e3b7 fix(desktop): avatar ripple + P3 hardcoded colors + inline shapes
Avatar/Account switcher:
- Rewrite SidebarAccountHeader with same shape/hover as other nav items
- Entire row (avatar + display name) is clickable with rounded clip
- Inline DropdownMenu replaces overlaid AccountSwitcherDropdown
- Collapsed: compact avatar with same rounded hover treatment

P3 #004 — Hardcoded status colors:
- Add StatusGreen/StatusRed/StatusAmber to commons Colors.kt
- Replace 32 inline Color() values across 10 files with theme tokens
- Color.Red → MaterialTheme.colorScheme.error where appropriate
- Color.Green/Gray → StatusGreen/onSurfaceVariant

P3 #005 — Inline shapes:
- RoundedCornerShape(8.dp) → MaterialTheme.shapes.small (~20 files)
- RoundedCornerShape(12.dp) → MaterialTheme.shapes.medium
- RoundedCornerShape(16.dp) → MaterialTheme.shapes.large
- Pill shapes (100dp/999dp) kept as-is

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-29 07:02:52 +03:00
nrobi144
41a59a9499 fix(desktop): wire Open Full Search via LocalOpenFullSearch CompositionLocal
- 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>
2026-05-29 07:02:52 +03:00
nrobi144
b8f6751a68 fix(desktop): address P2 todos — feed margin + history click
Todo #001: Feed content margin under search
- Animate spacer height: 60dp collapsed → 300dp expanded (tween 200ms)
- Feed items no longer hidden by expanded search card

Todo #002: History item click populates input
- SearchHistorySection gains onHistoryItemClick callback
- Clicking recent search populates searchText with query text
- LaunchedEffect(searchText.text) triggers updateFromText() → relay search

Todo #003: Cmd+F context (deferred — P2 stays open)
- Currently Cmd+F toggles inline search on feeds only
- On non-feed screens it's a no-op (user can click Search in sidebar)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-29 07:02:52 +03:00
nrobi144
c7483a4f0d fix(desktop): SearchPill hover bounds + AccountSwitcher ripple size
- 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>
2026-05-29 07:02:51 +03:00
nrobi144
c53c8238fb fix(desktop): theming + modifier review fixes from multi-agent analysis
Shapes:
- DeckSidebar: RoundedCornerShape(8.dp) → MaterialTheme.shapes.small
- NoteCard images: RoundedCornerShape(8.dp) → MaterialTheme.shapes.small

Modifier patterns:
- NoteCard: remove hardcoded .fillMaxWidth() — callers now pass it
- FeedScreen NoteCard callers: add explicit Modifier.fillMaxWidth()

Ripple clipping (from prior commit):
- SearchPill: clip(pill shape) before hoverHighlight()
- Sidebar items: clip before clickable
- ColumnHeader: padding before pointerInput

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-29 07:02:51 +03:00
nrobi144
55704d520b fix(desktop): ripple/hover clipping — correct modifier ordering
- 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>
2026-05-29 07:02:51 +03:00
nrobi144
5e128cb535 fix(desktop): clear search scrim when navigating via sidebar
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>
2026-05-29 07:02:51 +03:00
nrobi144
971d20be77 fix(desktop): revert relay debounce to 300ms, separate 1s history save
- 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>
2026-05-29 07:02:51 +03:00
nrobi144
956eee67d7 feat(desktop): 1s debounce + auto-save search history on collapse
- 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>
2026-05-29 07:02:51 +03:00
nrobi144
28e8fc627e feat(desktop): polished inline search states — loading, empty, streaming
- 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>
2026-05-29 07:02:51 +03:00
nrobi144
e9756d41aa fix(desktop): add loading indicator + relay diagnostics for inline search
- 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>
2026-05-29 07:02:51 +03:00
nrobi144
e587bb946e feat(desktop): wire relay subscriptions for inline search results
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>
2026-05-29 07:02:50 +03:00
nrobi144
02679cc596 feat(desktop): animated search expand, tab-collapse, reuse SearchResultsList
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>
2026-05-29 07:02:50 +03:00
nrobi144
bec8fd81ea fix(desktop): search card floats above scrim, sidebar dims separately
- 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>
2026-05-29 07:02:50 +03:00
nrobi144
c8e9a4fa6a fix(desktop): search card extends downward, tabs always visible
- Feed tabs + compose button always visible (never hidden)
- Search pill becomes active input when focused, card extends downward
  with history section below the always-visible header row
- Scrim only covers feed content below, header stays sharp via zIndex(10f)
- Cmd+F focuses search, Escape or click-scrim dismisses

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-29 07:02:50 +03:00
nrobi144
ec8fbd3980 feat(desktop): full-width search expansion with Cmd+F via CompositionLocal
- 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>
2026-05-29 07:02:50 +03:00
nrobi144
c69c4462b9 fix(desktop): search pill visibility, sidebar + Add Feed button
- 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>
2026-05-29 07:02:50 +03:00
nrobi144
55e7fcb903 fix(desktop): feed header card design, inline search, compact tabs
- 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>
2026-05-29 07:02:49 +03:00
nrobi144
c21b85c83e feat(desktop): add search spotlight overlay with Cmd+F shortcut
- 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>
2026-05-29 07:02:49 +03:00
nrobi144
eb114f0a58 refactor(desktop): share MainSidebar across single-pane and deck modes
- 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>
2026-05-29 07:02:49 +03:00
nrobi144
b431c1efab feat(desktop): visual personality overhaul — unified theme, sidebar, cards
Phase 1: Replace per-OS color schemes with unified Amethyst brand
- Cyan/blue accent (#0096FF light, #4DB8FF dark) replacing OS-adaptive colors
- Amethyst purple as tertiary heritage color
- Unified shapes (8/12/16/24dp) replacing per-OS variants
- Standardized typography weights (Light for display, SemiBold for headlines)
- Letter spacing unified to -0.3sp

Phase 2: Spacing system
- AmethystSpacing CompositionLocal with design tokens
- LocalIsDarkTheme for M3-compatible dark mode detection

Phase 3: Sidebar redesign
- 240dp wide sidebar with icon + text labels (was 56dp icon-only)
- Animated collapse/expand with smooth width transition
- Avatar + username at top with account switcher
- Custom feeds section from FeedDefinitionRepository
- Active item cyan pill indicator with hover effects
- Collapse state persisted in Preferences
- Debounced fitColumnsToWidth to prevent animation thrash

Phase 4: Card refinement
- OutlinedCard with 1dp border replacing 1dp shadow elevation
- 16dp internal padding (was 12dp)
- Converted NoteCard, ReadsScreen, DraftsScreen, MyHighlightsScreen, UserProfileScreen

Phase 5: Column header restyling
- 48dp height (was 40dp) with surfaceContainer background
- 12dp horizontal padding (was 8dp)

Phase 6: Polish
- HoverModifiers.kt — shared hover highlight using onPointerEvent + drawBehind
- ShimmerPlaceholder.kt — skeleton loading animation in commons/commonMain

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-29 07:02:49 +03:00
Vitor Pamplona
16afed0f3f Merge pull request #3097 from vitorpamplona/claude/intelligent-heisenberg-MkXpX
Fix scroll position restoration in feed lists on return
2026-05-28 21:28:52 -04:00
Claude
f8ff9049d9 fix(onchain): highlight bolt for own onchain zaps + own pending in counter
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.
2026-05-28 23:06:10 +00:00
Claude
02fd0780d5 fix(feeds): don't snap to top when returning from a post screen
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.
2026-05-28 23:01:49 +00:00
Claude
aeadbbd276 Make TimeAgoFormatter + CalendarTimeFormat thread-safe
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.
2026-05-28 23:00:03 +00:00
Vitor Pamplona
ba049da389 Merge pull request #3096 from vitorpamplona/claude/busy-sagan-QqTna
Switch Java distribution from Zulu to Temurin
2026-05-28 18:58:06 -04:00
Claude
6312c24f2f fix(nutzap): orange highlight on the lightning bolt for cashu zaps
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.
2026-05-28 22:57:12 +00:00
Claude
d174e054e0 fix(nutzap): smaller gallery icon + drop redundant success toast
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.
2026-05-28 22:53:10 +00:00
Claude
c63c48a778 Cache CalendarTimeFormat formatters at module scope
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.
2026-05-28 22:47:55 +00:00
Claude
cbd8cd1b87 ci: switch setup-java distribution from zulu to temurin
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.
2026-05-28 22:46:29 +00:00
Claude
39f9dd334a feat(nutzap): dedicated cashu row in ReactionDetailGallery
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.
2026-05-28 22:37:20 +00:00
Claude
ef8f49733a feat(nutzap): notifications card surface
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.
2026-05-28 22:35:15 +00:00
Vitor Pamplona
18495449f8 Merge pull request #3094 from vitorpamplona/claude/kind-feynman-tZUzp
docs: consolidate CLAUDE.md guidance and inject skills dynamically
2026-05-28 18:26:12 -04:00
Claude
599fa2aa53 docs: restore canonical NIPs link in CLAUDE.md
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.
2026-05-28 22:24:33 +00:00
Vitor Pamplona
0eb2188ebd Merge pull request #3093 from vitorpamplona/claude/adoring-turing-THM7p
Add podcast support (NIP-F4) with favorites, metadata, and episodes
2026-05-28 18:24:11 -04:00
Claude
c679a870ab Respect Android system date/time format preferences
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.
2026-05-28 22:22:59 +00:00
Claude
12ed86627c feat(nutzap): fold nutzaps into reaction-row zap counter + icon highlight
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.
2026-05-28 22:20:25 +00:00
Claude
ab0719fa68 fix(podcasts): wire podcast kinds into every consumer; close audit findings
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.
2026-05-28 22:14:49 +00:00
Claude
aea885bab0 docs: add Verify-Don't-Guess rule and trim CLAUDE.md
- 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).
2026-05-28 22:12:53 +00:00
Vitor Pamplona
1fdde747de Merge pull request #3092 from vitorpamplona/claude/adoring-galileo-ws6FG
Add NIP-78 AppDataEvent (kind 78) and refactor AppSpecificDataEvent
2026-05-28 18:08:33 -04:00
Vitor Pamplona
cb8efd070e Merge pull request #3091 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-28 17:45:59 -04:00
Claude
6c3ceba234 docs: add Verify, Don't Guess standing instruction to CLAUDE.md 2026-05-28 21:41:32 +00:00
Crowdin Bot
47af414e47 New Crowdin translations by GitHub Action 2026-05-28 21:38:48 +00:00
Vitor Pamplona
a0af93bbbe Merge pull request #3090 from vitorpamplona/claude/exciting-meitner-KeUBf
Add NIP-71 audio-track support with language and bitrate tags
2026-05-28 17:36:56 -04:00
Claude
6a0f78a75c refactor(richtext): drop cashuA/cashuB shortcut in isMarkdown
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.
2026-05-28 21:27:42 +00:00
Claude
40ed26ea85 refactor(quartz): NIP-78 events use the build-template pattern
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`).
2026-05-28 21:16:03 +00:00
Claude
7efe6fdf2a feat(nip71): support audio-track imeta variants per nostr-protocol/nips#2255
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
2026-05-28 21:15:08 +00:00
Claude
53ee02ea69 refactor(richtext): CommonMark intraword rule for _ / *
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
2026-05-28 21:14:55 +00:00
Claude
2e7583adfd feat(quartz): NIP-78 — add kind 78 normal app data event
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.
2026-05-28 21:05:58 +00:00
Claude
a5cd3e0dc1 refactor(richtext): zero-allocation markdown detector + test suite
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.
2026-05-28 21:04:16 +00:00
Claude
df0bb2641a chore(commons): use Headphones + Podcasts glyphs for the podcast tabs
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.
2026-05-28 20:56:00 +00:00
Claude
a19a025274 Revert "fix(richtext): don't truncate single-atom content mid-token"
This reverts commit 3dbf247108.
2026-05-28 20:44:14 +00:00
Claude
f6b3eb0552 fix(richtext): don't markdown-detect cashu tokens
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.
2026-05-28 20:41:44 +00:00
Claude
09a46b7fac feat(amethyst): add Episodes & Podcasts screens for NIP-F4
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.
2026-05-28 20:36:42 +00:00
Claude
3dbf247108 fix(richtext): don't truncate single-atom content mid-token
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.
2026-05-28 20:27:53 +00:00
Claude
b47cdf5b96 feat(quartz): add NIP-F4 podcast event support
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.
2026-05-28 20:10:39 +00:00
Claude
6940d32799 fix(cashu): dedup NUT-09 restore + heal prior Resync duplicates
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.
2026-05-28 19:51:53 +00:00
Vitor Pamplona
ebf8f195e4 refactor(cashu): delete wrong-theory dodge scaffolding
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>
2026-05-28 15:15:18 -04:00
Vitor Pamplona
6b9573906b revert(cashu): restore generated decoder for /v1/restore
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>
2026-05-28 15:14:49 -04:00
Vitor Pamplona
7d5573532f revert(cashu): re-enable NUT-12 DLEQ on own-mint outputs
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>
2026-05-28 15:14:33 -04:00
Vitor Pamplona
1f3630c4ae fix(quartz): defeat ART JIT InstructionSimplifier crash by uninlining uLtInline
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>
2026-05-28 15:14:01 -04:00
Vitor Pamplona
0dc1610975 test(androidTest): unblock dex + Account() ctor compile
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>
2026-05-28 15:13:33 -04:00
David Kaspar
d2e5364074 Merge pull request #3089 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-28 18:23:40 +02:00
Crowdin Bot
0ff512c09e New Crowdin translations by GitHub Action 2026-05-28 15:57:45 +00:00
Claude
82e1096e69 Merge remote-tracking branch 'origin/main' into claude/cashu-wallet-amethyst-sdOWe 2026-05-28 15:57:39 +00:00
Vitor Pamplona
e9a1215063 Merge pull request #3088 from vitorpamplona/claude/intelligent-fermat-8y2or
Auto-stick feeds to top on prepend with StickToTopOnPrepend
2026-05-28 11:55:51 -04:00
Vitor Pamplona
938e9401b2 Merge pull request #3087 from vitorpamplona/claude/sweet-gates-pLvsz
Fix StrictMode violation in ML Kit translation initialization
2026-05-28 11:54:15 -04:00
Claude
b1da3c2161 fix: move ML Kit translation off the UI dispatcher
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.
2026-05-28 15:52:21 +00:00
Claude
b634a32ac6 fix(cashu): crank Bdhke warmup to 2048 iterations for ART tier-1
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).
2026-05-28 15:51:17 +00:00
Claude
c8b7c4dddc fix(cashu): bypass kotlinx.serialization for /v1/restore response
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.
2026-05-28 15:40:29 +00:00
Claude
9bef8d48c7 diag(cashu): targeted CashuTrace logs around restore HTTP boundary
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.
2026-05-28 13:35:09 +00:00
Daneul Kim
3acff81baa Fix live stream chat relay fallback 2026-05-28 22:12:28 +09:00
David Kaspar
b9bc21d78e Merge pull request #3084 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-28 14:29:20 +02:00
Crowdin Bot
6e9a905e64 New Crowdin translations by GitHub Action 2026-05-28 12:28:31 +00:00
davotoula
d27d215d97 i18n: add cs/de/sv plurals for music track counts; teach skill to diff <plurals>
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>
2026-05-28 14:26:55 +02:00
David Kaspar
a70e0e6cf9 Merge pull request #3083 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-28 14:18:12 +02:00
Crowdin Bot
a8d8d704c4 New Crowdin translations by GitHub Action 2026-05-28 12:16:37 +00:00
davotoula
b98c18351a i18n: add cs/de/sv translations for music tracks/playlists, video error fallback, wallet reorder
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 14:14:46 +02:00
David Kaspar
03fef56d08 Merge pull request #3082 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-28 14:09:54 +02:00
Crowdin Bot
e1b5c7e24b New Crowdin translations by GitHub Action 2026-05-28 12:08:43 +00:00
David Kaspar
e376f71d0b Merge pull request #3081 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-28 14:06:28 +02:00
Crowdin Bot
a5aefc3233 New Crowdin translations by GitHub Action 2026-05-28 12:05:11 +00:00
davotoula
4596102a66 i18n: add Czech, German, Swedish translations for NIP-82 sections, music upload banner, send
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 14:01:26 +02:00
David Kaspar
196175691e Merge pull request #3080 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-28 13:57:42 +02:00
Crowdin Bot
648f517a3f New Crowdin translations by GitHub Action 2026-05-28 11:38:10 +00:00
David Kaspar
38fe2861b0 Merge pull request #3079 from davotoula/fix/namecoin-password-toggle-icon
Use distinct icons for password visibility toggle
2026-05-28 13:36:13 +02:00
davotoula
9d44c22401 fix(quartz): two more iOS compile errors in NIP-60 Cashu
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`.
2026-05-28 13:06:47 +02:00
davotoula
daa9bbff3a fix(quartz): KMP-safe @Volatile import in Cashu warmup flags
`@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>
2026-05-28 12:50:15 +02:00
davotoula
ace6e979d9 fix(namecoin): use distinct icons for password visibility toggle
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.
2026-05-28 12:01:45 +02:00
Claude
63e9970340 refactor(cashu): Bdhke scratchpad via ThreadLocal — drop param API
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.
2026-05-28 02:43:48 +00:00
Claude
6b3bcdfa53 fix(cashu): warmup must be at-most-once per process
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
2026-05-28 02:13:16 +00:00
Claude
ae05bb9abd refactor(feeds): hoist auto-stick into Saveable* wrappers
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.
2026-05-28 02:09:40 +00:00
Claude
84dbf90076 fix(cashu): dedup restore unblind loop, pre-warm Bdhke + serializers
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.
2026-05-28 02:00:59 +00:00
Claude
56e5aea79f fix(cashu): scratchpad toCompressed + deep Bdhke tracing
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.
2026-05-28 01:23:48 +00:00
Claude
509cbcb297 fix(cashu): scratchpad verifyDleq / Carol / addRTimesA / hashToCurveCompressed
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.
2026-05-28 01:02:30 +00:00
Claude
b20bb4e1d0 fix(cashu): scratchpad Bdhke.blind + hashToCurve — covers restore path
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).
2026-05-28 00:41:57 +00:00
Claude
35c260f572 fix(cashu): allocation-free Bdhke.unblind via BdhkeScratchpad
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.
2026-05-28 00:24:27 +00:00
Claude
8d997a9973 fix(cashu): split Bdhke.unblind into smaller helpers — dodge ART JIT bug
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).
2026-05-27 23:45:22 +00:00
Claude
ac3f351458 feat(feeds): stick to top when items prepend and user is at top
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.
2026-05-27 23:35:56 +00:00
Claude
5d64d3829e fix(rich-text): render cashu preview in the no-preview path too
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.
2026-05-27 23:25:20 +00:00
Claude
ec44b001fc fix(cashu): NUT-13 counters move to a synchronous per-account store
"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.
2026-05-27 23:03:32 +00:00
Claude
23ea046d06 fix(cashu): skip NUT-12 DLEQ on our own mint outputs — ART JIT crash
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.
2026-05-27 22:44:54 +00:00
Vitor Pamplona
522a83c4de Merge pull request #3078 from vitorpamplona/claude/pensive-turing-6UC1W
Add dedicated NIP-82 software app detail screen
2026-05-27 18:38:03 -04:00
Claude
a2b4e5fa70 feat(nip82): compact apps feed card + dedicated detail screen
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).
2026-05-27 22:23:59 +00:00
Claude
a1f991d14f ui(cashu): mint discovery moves inline, rows surface follower avatars
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.
2026-05-27 22:11:51 +00:00
Claude
2a97511bd3 fix(cashu): NUT-09 restore — match echoed outputs by bTick only
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.
2026-05-27 21:56:44 +00:00
Claude
a1cbaea644 fix(cashu): cap NUT-09 restore request size to mint validation limit
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.
2026-05-27 21:37:43 +00:00
Vitor Pamplona
233a5b25b6 fix(nwc): trim subscription filter on response to avoid relay replays
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>
2026-05-27 17:18:25 -04:00
Claude
0b1fa9e775 fix(cashu): receive flow — gate concurrent mint, NUT-09 recover stuck quotes
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}.
2026-05-27 21:13:45 +00:00
Claude
02e611c986 docs(cli): plan — Cashu (NIP-60/61/87) in amy as an Amethyst test harness
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.
2026-05-27 21:04:32 +00:00
Vitor Pamplona
097f9a9a5b Merge pull request #3077 from mstrofnone/feat/desktop-namecoin-core-rpc-backend
feat(desktop): Namecoin Core RPC backend + composite fallback
2026-05-27 16:07:01 -04:00
Claude
d28458553a Merge remote-tracking branch 'origin/main' into claude/cashu-wallet-amethyst-sdOWe 2026-05-27 20:02:35 +00:00
m
d9d7b44e91 feat(desktop): Namecoin Core RPC backend + composite fallback
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
2026-05-28 06:01:54 +10:00
Vitor Pamplona
f898e4be57 Merge pull request #3067 from vitorpamplona/claude/confident-allen-AOGU6
Add music tracks and playlists support with NIP-51 events
2026-05-27 15:59:41 -04:00
Claude
bf02a235e0 Merge remote-tracking branch 'origin/main' into claude/confident-allen-AOGU6
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
2026-05-27 19:55:07 +00:00
Claude
6dc873a5d2 fix(cashu): resume mint quote — auto-complete on PAID, drop dead quotes
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.
2026-05-27 19:50:45 +00:00
Vitor Pamplona
08520f553f Merge pull request #3076 from mstrofnone/feat/desktop-namecoin-pinned-certs
feat(desktop): persist TOFU-pinned Namecoin ElectrumX certs
2026-05-27 15:45:19 -04:00
Claude
fa484c00eb ui(wallet): lead the wallet row with the drag handle
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.
2026-05-27 19:44:37 +00:00
m
b5b70fe693 feat(desktop): persist TOFU-pinned Namecoin ElectrumX certs
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.
2026-05-28 05:41:22 +10:00
David Kaspar
7c4220ba3d Merge pull request #3073 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-27 21:41:18 +02:00
Claude
8a55bba1f2 feat(music): parallel uploads + survive-screen-leave + visible progress
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.
2026-05-27 19:39:50 +00:00
Vitor Pamplona
80991c6c8b Merge pull request #3074 from vitorpamplona/claude/gallant-darwin-VfniN
Fix route hint re-firing on activity recreation
2026-05-27 15:33:28 -04:00
Vitor Pamplona
e937b460e1 Merge pull request #3072 from mstrofnone/feat/commons-namecoin-settings-extension
refactor(namecoin): consolidate NamecoinSettings into commons
2026-05-27 15:31:19 -04:00
Claude
187cb407c9 fix(new-user): stop Import Follow List from reappearing after dismiss
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.
2026-05-27 19:26:28 +00:00
Claude
8a3bdb0d78 ui(wallet): imePadding on screens whose body holds the text fields
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.
2026-05-27 19:21:40 +00:00
Claude
3082095a4b feat(music): auto-fill metadata from picked audio + drop audio URL field
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.
2026-05-27 19:18:41 +00:00
Claude
9c9cf8844d fix(music): save audio to MediaStore.Audio (not Video) and trim composer
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.
2026-05-27 19:08:54 +00:00
Crowdin Bot
2c9d9c3129 New Crowdin translations by GitHub Action 2026-05-27 19:08:46 +00:00
Vitor Pamplona
58f1ee9606 Merge pull request #3069 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-27 15:07:14 -04:00
Vitor Pamplona
63b91012fb Merge pull request #3071 from vitorpamplona/claude/trusting-mccarthy-SA1aI
Add Software Apps feed with follow list filtering
2026-05-27 15:07:05 -04:00
m
67edb32fa7 refactor(namecoin): consolidate NamecoinSettings into commons
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.
2026-05-28 05:06:44 +10:00
Vitor Pamplona
7692bee0a5 Merge pull request #3070 from mstrofnone/fix/namecoin-search-id-d-prefixes
fix(search): route d/ and id/ Namecoin namespaces through the resolution row
2026-05-27 15:06:10 -04:00
m
216176eeb7 fix(search): route d/ and id/ Namecoin namespaces through the resolution row
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/').
2026-05-28 04:46:55 +10:00
Claude
81b87634a3 fix(cashu): don't hammer mint on un-redeemable inbound nutzaps
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).
2026-05-27 18:44:49 +00:00
Claude
ea227f1968 fix(music): playlist cover chip is wrap-content again, not full-cover
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.
2026-05-27 18:33:22 +00:00
Claude
506ed94bfd fix(software-apps): add top-nav filter and route NIP-82 events through LocalCache
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.
2026-05-27 18:25:39 +00:00
Crowdin Bot
2b6798e78a New Crowdin translations by GitHub Action 2026-05-27 18:25:22 +00:00
Claude
510514644d fix(cashu): heal stale proofs inline on every user-initiated spend
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.
2026-05-27 18:23:39 +00:00
Vitor Pamplona
d5cca1e3e4 Merge pull request #3068 from mstrofnone/feat/namecoin-core-rpc-tofu
feat(namecoin): TOFU pin for Namecoin Core RPC TLS path
2026-05-27 14:23:25 -04:00
mstrofnone
a1071b5189 fix(namecoin): move setConfig into the bootstrap launch
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.
2026-05-28 04:18:15 +10:00
Claude
4177390591 feat(music): swap bookmark rows for playlist row in track dropdown menu
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.
2026-05-27 18:18:11 +00:00
Claude
82d2290e43 feat(music): cover image and audio file pickers on new-track composer
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.
2026-05-27 18:11:44 +00:00
Claude
71d50504fd feat(music): split tracks and playlists into separate filter pipelines
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.
2026-05-27 17:52:28 +00:00
Claude
4db226d5f2 fix(cashu): heal stale proofs + harden auto-redeem against double-spend
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.
2026-05-27 17:45:29 +00:00
m
cfb3f1b9fa feat(namecoin): TOFU pin for Namecoin Core RPC TLS path
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.
2026-05-28 03:32:28 +10:00
Claude
e876a162a4 fix(music): review feedback round
- 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.
2026-05-27 17:27:44 +00:00
Claude
5a4cb5cc16 fix(music): apply avatar+chip pattern to loading and error covers too
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
2026-05-27 17:21:00 +00:00
Claude
93a27abf23 fix(music): playlist cover chip no longer collides with author avatar
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.
2026-05-27 16:28:10 +00:00
Claude
1190abb30d refactor(user): eager-init pinned addressable notes
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.
2026-05-27 16:19:37 +00:00
Claude
052592b91b fix(cashu): NUT-12 hash input — uncompressed bytes, hex-encoded, UTF-8
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
2026-05-27 15:43:38 +00:00
Claude
a403f44d8b fix(music): observe playlist and track notes so cards update on relay arrival
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.
2026-05-27 15:36:30 +00:00
Vitor Pamplona
1cd57bd497 Merge pull request #3066 from vitorpamplona/claude/sweet-maxwell-UMahG
fix(playback): hide video controls while the error overlay is shown
2026-05-27 11:27:18 -04:00
Claude
5e25968059 feat(cashu): NUT-12 Carol verification + mint-info caching + keyset migration
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
2026-05-27 15:17:45 +00:00
Claude
805e010c57 fix(cashu): audit polish — DLEQ scalar check, batch counters, seed mutex, log throwables
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
2026-05-27 15:17:44 +00:00
Claude
29de889a1a feat(cashu): NUT-09 restore — recover proofs from seed end-to-end
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
2026-05-27 15:17:44 +00:00
Claude
f6b3c40b3f feat(cashu): wire NUT-20 signed mint quote into start/complete flow
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
2026-05-27 15:17:44 +00:00
Claude
d7e447428c feat(cashu): wire NUT-13 deterministic secrets into the live mint flow
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
2026-05-27 15:17:44 +00:00
Claude
d971ff4da7 feat(cashu): NUT-17 WebSocket subscription protocol (JSON-RPC layer)
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
2026-05-27 15:17:44 +00:00
Claude
4747c0f039 feat(cashu): NUT-20 signed mint quote — quote-theft prevention
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
2026-05-27 15:17:44 +00:00
Claude
c2f229b1ee feat(cashu): NUT-13 deterministic secrets + NUT-09 restore endpoint
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
2026-05-27 15:17:44 +00:00
Claude
70e04523ea feat(cashu): NUT-12 DLEQ verification on every blind signature
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
2026-05-27 15:17:44 +00:00
Claude
aa307bfb63 feat(cashu): NUT-02 input-fee math on swap / swap-to-locked / melt
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
2026-05-27 15:17:43 +00:00
Claude
6caaae460b ui(cashu): swap generic Wallet glyph for the bundled CustomHashTagIcons.Cashu logo
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
2026-05-27 15:17:43 +00:00
Claude
a105eb91d7 ui(zap): reorder amount-choice chips — Cashu, Lightning, on-chain
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
2026-05-27 15:17:43 +00:00
Claude
b0bb2885ea ui(cashu): @Preview composables for the new wallet bits
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
2026-05-27 15:17:43 +00:00
Claude
8fa636bbe8 refactor(model): lazy-pinned addressable notes on User via UserContext
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
2026-05-27 15:17:43 +00:00
Claude
0cb07761ce feat(cashu): add-recommendation input + suggest only on typed text
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
2026-05-27 15:17:43 +00:00
Claude
94121c71c9 feat(cashu): mint URL directory + autocomplete
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
2026-05-27 15:17:42 +00:00
Claude
fa9c7503ed fix(cashu): pending-invoice card lingering + thumbs-up mint recommendation never landing
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
2026-05-27 15:17:42 +00:00
Claude
12f8b025fb feat(zap): gate ZapAmountChoicePopup chips on recipient capability
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
2026-05-27 15:17:42 +00:00
Claude
9d0224ba5b feat(cashu): Wallet Settings screen with retractable mint recommendations
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
2026-05-27 15:17:42 +00:00
Claude
e4b0d80e5c ui(cashu): wallet polish — discard invoice, tile alignment, history rows
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
2026-05-27 15:17:42 +00:00
Claude
9cfca5272e fix(wallet): replace the chooser in the back stack when user picks a type
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.
2026-05-27 15:17:42 +00:00
Claude
817227806b fix(cashu): replace auto-popup with a tappable pending-quote banner
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.
2026-05-27 15:17:42 +00:00
Claude
8ac2a6cac3 ui(wallet): drop the "Your Wallets" header from the wallet list
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.
2026-05-27 15:17:41 +00:00
Claude
b9e85d15e0 feat(cashu): pre-cache kind:10019 alongside kind:0 for every viewed user
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
2026-05-27 15:17:41 +00:00
Claude
311c9d13d9 feat(cashu): account-load filter + LocalCache dispatch for NIP-60/61 kinds
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
2026-05-27 15:17:41 +00:00
Claude
3a35eaac73 feat(cashu): back up kind:17375 + kind:10019 to account preferences
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
2026-05-27 15:17:41 +00:00
Claude
bb86df246a fix(cashu): show "discovering" state instead of empty-create CTA on first launch
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
2026-05-27 15:17:41 +00:00
Claude
811aa26d57 fix(cashu): init the wallet VM synchronously in composition body
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
2026-05-27 15:17:41 +00:00
Claude
ba46f31359 feat(cashu): NIP-87 mint discovery + recommendations
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
2026-05-27 15:17:41 +00:00
Claude
c6757a2fae fix(cashu): make AddCashuWallet screen also work as Edit, preserve P2PK key
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
2026-05-27 15:17:40 +00:00
Claude
bfd00ccc34 feat(cashu): send NIP-61 nutzaps from the zap picker
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
2026-05-27 15:17:40 +00:00
Claude
bbd43e34e9 fix(cashu): close publish-bridge race + lock in exception types via tests
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
2026-05-27 15:17:40 +00:00
Claude
bae6e8dcb5 fix(cashu): correct skip-on-decrypt-fail + auto-resume orphan mint quotes
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
2026-05-27 15:17:40 +00:00
Claude
5cd756cea2 refactor(cashu): lift wallet state to Account, react to live cache updates
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
2026-05-27 15:17:40 +00:00
Claude
16401e536a feat(cashu): full NIP-60 wallet + NIP-61 nutzap receive
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
2026-05-27 15:17:40 +00:00
Claude
a64cc274cc feat(amethyst): scaffold NIP-60 Cashu wallet UI + state
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
2026-05-27 15:17:39 +00:00
Claude
023e2df542 feat(quartz): add BDHKE primitives for NIP-60 Cashu wallets
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
2026-05-27 15:17:39 +00:00
Claude
b2c76b7428 fix(playback): hide video controls while the error overlay is shown
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.
2026-05-27 15:13:25 +00:00
Claude
3f3ccd88c8 Merge remote-tracking branch 'origin/main' into claude/confident-allen-AOGU6 2026-05-27 15:12:43 +00:00
Claude
0b083b75aa fix(music): playlists feed uses its own follow-list subscription
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.
2026-05-27 15:10:24 +00:00
Vitor Pamplona
bbc7f9740e Merge pull request #3065 from vitorpamplona/claude/gifted-ritchie-5XjZf
Exclude author from zap split display logic
2026-05-27 11:09:29 -04:00
Vitor Pamplona
7d6bbac200 Merge pull request #3064 from vitorpamplona/claude/sweet-maxwell-UMahG
Add playback error overlay with browser fallback for video codec failures
2026-05-27 11:04:09 -04:00
Claude
e876e2b09b feat(zap-splits): hide single-author zap split row in NoteCompose
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.
2026-05-27 14:54:48 +00:00
Claude
89607376cc feat(playback): surface unsupported codec errors with browser fallback
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.
2026-05-27 14:51:09 +00:00
Claude
0139eb207c fix(music): subscribe each playlist track to relays so they actually load
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.
2026-05-27 14:49:02 +00:00
Claude
30b06773e8 fix(music): dedupe playlist description vs content when identical
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.
2026-05-27 14:42:38 +00:00
Claude
3e4f204d1f feat(music): standalone Playlists feed + simpler add-to-playlist picker
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.
2026-05-27 14:35:40 +00:00
Vitor Pamplona
d381cf9109 Merge pull request #3063 from davotoula/feat/avif-support
Comprehensive AVIF support (#837)
2026-05-27 10:32:03 -04:00
Claude
d837132247 fix(music): drop boilerplate content, fuse cover+player, clickable hashtags
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.
2026-05-27 14:19:08 +00:00
Claude
e510fcceca fix(music): synthetic waveform now actually varies per track
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.
2026-05-27 14:12:01 +00:00
davotoula
ef25f8c0e6 test(amethyst): instrumented coverage for AVIF upload + decode
Adds 4 instrumented test files + 3 tiny pre-committed AVIF fixtures to
catch regressions in the upload pipeline.
2026-05-27 16:11:20 +02:00
davotoula
f8b24c645a fix(chat): hide DM quality slider for AVIF and correct error framing 2026-05-27 16:11:20 +02:00
davotoula
7d580452e4 fix(uploads): surface specific AVIF metadata error instead of 'Upload cancelled' 2026-05-27 16:11:20 +02:00
davotoula
50a81c35cf Code review:
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.
2026-05-27 16:11:20 +02:00
davotoula
03c42f585e AVIF display + thumbnail-cache fixes from manual testing
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
2026-05-27 16:11:20 +02:00
davotoula
57724cee8c Comprehensive AVIF support (#837)
feat(ui): hide compression slider for non-compressible files (AVIF, GIF, SVG)
feat(images): custom Coil decoder for animated AVIF
feat(ui): include AVIF in animation-aware MIME predicates
fix(uploads): AVIF extension fallback in BlossomUploader
fix(uploads): AVIF extension fallback for NIP-96 multipart filename
feat(uploads): decode AVIF previews with ImageDecoder for blurhash/thumbhash
feat(uploads): fail-closed AVIF metadata inspection in MetadataStripper
fix(uploads): preserve AVIF bytes through MediaCompressor
feat(uploads): add MediaMimeTypes helper for AVIF detection
2026-05-27 16:11:20 +02:00
davotoula
adc0d36407 docs(amethyst): TDD-style implementation plan for AVIF support (issue #837)
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>
2026-05-27 16:11:20 +02:00
davotoula
a99927bf92 Docs: plan for comprehensive AVIF support (issue #837)
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
2026-05-27 16:11:20 +02:00
Claude
5f2f70b0bf fix(music): subscribe to relays, fake waveform, dedupe hashtag row
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.
2026-05-27 13:56:33 +00:00
Vitor Pamplona
1ef8b7405a Merge pull request #3060 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-27 09:56:19 -04:00
Crowdin Bot
72afadbb38 New Crowdin translations by GitHub Action 2026-05-27 13:20:50 +00:00
Vitor Pamplona
b12e160457 Merge pull request #3056 from mstrofnone/feat/namecoin-core-rpc-backend
feat(namecoin): add Namecoin Core RPC backend with optional ElectrumX fallback
2026-05-27 09:17:30 -04:00
Vitor Pamplona
7af34762bb Merge pull request #3062 from vitorpamplona/claude/payment-targets-ui-7pgrY
feat(profile): toast when no app handles a payment target scheme
2026-05-27 09:15:29 -04:00
Claude
023a3c6624 feat(profile): toast when no app handles a payment target scheme
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
2026-05-27 13:10:36 +00:00
Claude
9724931355 feat(music): voice-style audio player for audio-only tracks + follow-list spinner
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.
2026-05-27 13:10:13 +00:00
Vitor Pamplona
9856458c54 Merge pull request #3061 from vitorpamplona/claude/payment-targets-ui-7pgrY
feat(profile): modern chip layout for payment targets
2026-05-27 09:09:09 -04:00
Claude
62943d8f0a fix(music): preserve tags on edit/toggle, lock concurrent toggles, search, mute
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".
2026-05-27 11:10:09 +00:00
Vitor Pamplona
7e44df0d1f Merge pull request #3059 from davotoula/feat/emoji-pack-add-to-list-menu
Add "Add/remove to/from emoji list" row to pack-card menu
2026-05-27 06:34:10 -04:00
Vitor Pamplona
da5f01011c Merge pull request #3058 from nrobi144/feat/desktop-profile-editing
feat(desktop): full profile editing — 13 fields, image upload, NIP-05 verification, drag-and-drop
2026-05-27 06:33:26 -04:00
davotoula
205b629c9d Code review:
- tighten EmojiListToggleRow null-handling and label branching
2026-05-27 09:58:26 +02:00
davotoula
142bf67678 Add "Add to emoji list" row to pack-card menu
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.
2026-05-27 09:58:03 +02:00
nrobi144
bcf61d53ff feat(desktop): add drag-and-drop for avatar/banner, fix avatar overlay
- 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>
2026-05-27 10:22:18 +03:00
m
daa7c7913e feat(namecoin): mention umbrel alongside StartOS in docs and UI hints
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.
2026-05-27 14:50:37 +10:00
m
a58e7164b5 feat(namecoin): add Namecoin Core RPC backend with optional ElectrumX fallback
Adds a second resolution backend alongside the existing ElectrumX path:
users can now point Amethyst directly at a Namecoin Core full node
(e.g. a StartOS / Start9 installation) instead of (or in addition to)
trusting public ElectrumX operators.

Settings -> Namecoin grows three new pieces:

  1. Backend selector (radio) - ElectrumX | Namecoin Core RPC
  2. Core RPC section - URL, username, password, masked password,
     'Test RPC' button that calls getblockchaininfo and reports
     chain / height / sync %, error path with diagnostic message
  3. Fallback policy - independent toggles for falling back to the
     user's custom ElectrumX servers (Core RPC primary only) and/or
     the hardcoded public ElectrumX defaults

Quartz additions:
  - NamecoinBackend enum, NamecoinCoreRpcConfig (kotlinx.serialization),
    NamecoinFallbackPolicy
  - NamecoinNameBackend interface + ElectrumxNameBackend adapter +
    CompositeNamecoinBackend orchestrator (implements IElectrumXClient
    so NamecoinNameResolver is unchanged)
  - NamecoinCoreRpcClient (jvmAndroid) - JSON-RPC name_show /
    getblockchaininfo over OkHttp, reuses
    roleBasedHttpClientBuilder.okHttpClientForNip05() so Tor onion
    endpoints work without extra plumbing

Semantics:
  - Authoritative negatives (NameNotFound, NameExpired) short-circuit
    the chain - no silent privacy leak to other backends
  - Only transport / unreachable failures cascade through the chain
  - All fallback toggles default off (custom servers stay exclusive,
    matching existing behaviour)
  - Settings persisted via NamecoinSharedPreferences DataStore
  - HTTP transport delegated to roleBasedHttpClientBuilder so existing
    Tor/proxy/cert pinning all works for Core RPC too

Tests:
  - CompositeNamecoinBackendTest (8 cases) - short-circuit, cascade,
    authoritative-negative, electrumx-primary path, expired-name,
    random-exception-cascade
  - NamecoinCoreRpcClientTest (7 cases) - success parsing, auth header,
    name-not-found, expired, generic RPC errors, unusable config,
    probe success + auth failure
  - NamecoinSettingsTest (8 cases) - parser plus new backend / fallback
    fields

Builds clean: :amethyst:compileFdroidDebugKotlin, :quartz:jvmTest.
2026-05-27 14:03:47 +10:00
nrobi144
ce173debba fix(desktop): center avatar vertically and increase to 120dp
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-27 06:51:35 +03:00
nrobi144
2e405f5644 fix(desktop): redesign avatar picker as tappable circle with upload overlay
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>
2026-05-27 06:46:29 +03:00
nrobi144
b608ca02fb Merge remote-tracking branch 'upstream/main' into feat/desktop-profile-editing
# Conflicts:
#	desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt
2026-05-27 06:38:47 +03:00
Claude
1e3d2bbd3d feat(profile): modern chip layout for payment targets
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.
2026-05-27 02:55:15 +00:00
Claude
8424140d60 chore(music): @Preview composables for every new UI surface
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.
2026-05-27 02:51:36 +00:00
Claude
2210341291 refactor(music): align Add-to-Playlist with bookmark-management UI
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.
2026-05-27 02:37:52 +00:00
Claude
058f42b6e8 feat(music): full-screen feed, composer with FAB, add-to-playlist sheet
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.
2026-05-27 02:27:20 +00:00
Vitor Pamplona
4306f26460 Merge pull request #3055 from vitorpamplona/claude/affectionate-wright-GRP8r
fix(user-metadata): fall back to indexer relays when outbox is exhausted
2026-05-26 21:55:32 -04:00
Claude
db9f4da394 fix(music): cover IS the player — tap actually starts playback
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).
2026-05-27 01:28:15 +00:00
Claude
db2b9551f9 fix(user-metadata): fall back to indexer relays when outbox is exhausted
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.
2026-05-27 01:20:37 +00:00
Claude
bbb7a1282e feat(music): wire MusicTrack/MusicPlaylist kinds into feed filters, search, and labels
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)
2026-05-27 01:17:53 +00:00
Claude
336105f98b feat(music): wire music events into LocalCache consume dispatch
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.
2026-05-27 01:04:55 +00:00
Vitor Pamplona
f6066de92a Merge pull request #3054 from vitorpamplona/claude/pensive-brown-UVDZf
Replace wallet reorder buttons with drag-and-drop UI
2026-05-26 20:33:59 -04:00
Claude
973c2eeff7 feat(music): add Music Track (kind 36787) and Music Playlist (kind 34139)
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.
2026-05-27 00:30:34 +00:00
Claude
906ac06c57 feat(wallet): drag-and-drop reorder for NWC wallet cards
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.
2026-05-27 00:10:05 +00:00
Claude
290a6b1f85 fix(nwc): scope Send/Receive/Transactions to the wallet shown in the detail screen
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.
2026-05-27 00:05:55 +00:00
Vitor Pamplona
5fce6764b5 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2026-05-26 18:48:06 -04:00
Vitor Pamplona
cdb5e01821 fix(nwc): re-add #p to response filter for Alby relay routing
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>
2026-05-26 18:44:14 -04:00
Vitor Pamplona
2dd0166fee Better checks the id and sig before verifying the event. 2026-05-26 18:42:12 -04:00
Vitor Pamplona
f3ac87689a Merge pull request #3053 from vitorpamplona/claude/tor-stops-working-1PIcU
Add Tor self-heal watchdog + integration tests + Arti v2.3.0
2026-05-26 17:52:54 -04:00
Claude
2c89a62789 test(tor): expand tier-3 to verify each root cause of the wall-and-stop bug
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.
2026-05-26 21:43:14 +00:00
Claude
e39ea55fd6 test(tor): tier-3 integration — JVM host build of Arti, smoke + bootstrap tests
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.
2026-05-26 21:34:26 +00:00
Vitor Pamplona
f2bfd7a315 Merge pull request #3052 from vitorpamplona/claude/brave-clarke-hJ0PK
onchain zaps + nip-05 filter when returning users to Gemini
2026-05-26 17:22:25 -04:00
Claude
93163141b9 feat(amethyst): anti-impersonation safeguards on AppFunctions write verbs
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
2026-05-26 21:07:38 +00:00
Claude
3517606b81 test(tor): tier-1 TorManager unit tests + tier-3 instrumented scaffold
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.
2026-05-26 20:31:32 +00:00
Claude
612e05fa62 feat(amethyst): zapUser supports onchain (NIP-BC) rail
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.
2026-05-26 20:31:29 +00:00
Claude
9a2adf091d chore(tor): bump Arti to v2.3.0
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.
2026-05-26 20:06:15 +00:00
Claude
c3ddd4e7be fix(tor): audit fixes — first-bootstrap grace + tighten destroy() race
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.
2026-05-26 19:37:36 +00:00
Claude
db378a105c feat(tor): self-heal — drop & rebuild Arti on network change and stuck Connecting
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).
2026-05-26 19:09:54 +00:00
Claude
321adebfe6 fix(tor): clear remembered-approval window on user TorType toggle
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.
2026-05-26 17:54:04 +00:00
Vitor Pamplona
b258028b54 Merge pull request #3051 from vitorpamplona/claude/brave-clarke-hJ0PK
Phase 2: Gemini AppFunctions bridge + CLI action verbs
2026-05-26 12:23:57 -04:00
Claude
6671cb3cbc refactor(amethyst): getFeedDigest now uses HomeNewThreadFeedFilter — mirrors the home page exactly
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.
2026-05-26 16:04:47 +00:00
Claude
dc2c7f9be1 feat(amethyst): getFeedDigest verb — feed-summary surface for the LLM
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.
2026-05-26 15:58:30 +00:00
Claude
4617be2068 chore(amethyst): collapse unreachable NWC when branch
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.
2026-05-26 15:40:34 +00:00
Claude
ee0942d555 Merge remote-tracking branch 'origin/main' into claude/brave-clarke-hJ0PK 2026-05-26 15:32:47 +00:00
David Kaspar
d1610bf976 Merge pull request #3048 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-26 16:24:44 +02:00
Crowdin Bot
153b9495da New Crowdin translations by GitHub Action 2026-05-26 14:21:22 +00:00
Vitor Pamplona
9cd438570f Merge pull request #3050 from davotoula/fix/ios-build-failure
Complete Phase 2 KMP migration to unblock iOS CI
2026-05-26 10:19:16 -04:00
davotoula
28865f38c3 tests:
- cover CodePoints helpers and Channel.relays() equal-count behaviour
- Two new test files in commons/src/commonTest/, both run under :commons:jvmTest.
2026-05-26 15:53:37 +02:00
Claude
d0f6739a30 feat(amethyst): NWC auto-pay for zapUser and zapEvent
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.
2026-05-26 13:37:05 +00:00
davotoula
771ba67f31 Code review:
- guard shared NSDateFormatter in formattedDateTime iOS actual
2026-05-26 14:53:38 +02:00
Vitor Pamplona
bb921ad643 Merge pull request #3049 from nrobi144/feat/desktop-rich-text-and-profile
feat(desktop): rich text migration, profile metadata, copy JSON, @mention autocomplete
2026-05-26 07:55:55 -04:00
davotoula
5ec60e285b fix(commons): unblock :commons iOS compile after Phase 2 target flip
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>
2026-05-26 13:38:01 +02:00
davotoula
027808ae54 skills(find-missing-translations): filter out keys Crowdin already owns
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>
2026-05-26 09:20:51 +02:00
nrobi144
e4d7cdd327 feat(desktop): full profile editing with 13 fields, image upload, NIP-05 verification
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>
2026-05-26 09:53:34 +03:00
Claude
5efb5d90e5 feat(amethyst): zap verbs + LLM-friendly kdocs + Gemini discovery plan
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.
2026-05-26 00:01:13 +00:00
Claude
8e1b31a550 feat(amethyst): four write verbs for Gemini — postNote, follow, unfollow, sendDm
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.
2026-05-25 23:41:58 +00:00
Vitor Pamplona
e93492f491 Merge pull request #3047 from vitorpamplona/claude/zealous-mendel-TK91O
Phase 1: iOS support for Quartz and Commons (KMP purity)
2026-05-25 19:38:51 -04:00
Claude
ac105ca2f3 chore(amethyst): enrich AppFunction outputs so Gemini can name names
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.
2026-05-25 23:11:10 +00:00
Claude
c10ed49631 feat(amethyst): Tier 2+3 read-only Gemini verbs — 15 total
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.
2026-05-25 23:06:47 +00:00
Claude
5c46d0a7b3 feat(amethyst): five more read-only Gemini verbs — full Tier 1 read surface
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).
2026-05-25 22:58:30 +00:00
Claude
0619788644 fix(amethyst): supply app_metadata so AppFunctions discovery actually works
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.
2026-05-25 22:03:46 +00:00
Claude
82188b719a fix(amethyst): generate app_functions.xml so the system can resolve metadata
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.
2026-05-25 21:50:56 +00:00
Claude
44aa262363 fix(commons): tighter Base64Image contract + pin serializer wire format
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.
2026-05-25 20:05:00 +00:00
David Kaspar
97bb6a5720 Merge pull request #3046 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-25 21:04:55 +02:00
Crowdin Bot
61372313fd New Crowdin translations by GitHub Action 2026-05-25 18:55:58 +00:00
davotoula
46c46aa69e i18n: translate missing cs/de/sv strings (apps, on-chain zap, legal, NIP-82)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 20:50:56 +02:00
nrobi144
64293f2fcc fix(desktop): fix text spacing in rich text viewer
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>
2026-05-25 09:50:43 +03:00
nrobi144
d6875a144b feat(desktop): add @mention autocomplete to compose dialog
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>
2026-05-25 09:35:46 +03:00
nrobi144
76fdd5d25f Merge upstream/main into worktree-desktop-low-hanging-fruit 2026-05-25 06:45:44 +03:00
nrobi144
4d532e0698 fix(desktop): wire onHashtagClick, add RTL support, add identity fields
- Add onHashtagClick param to FeedNoteCard for hashtag navigation
- RTL paragraph alignment in DesktopRichTextViewer
- External identities (Twitter, GitHub, Mastodon) on profile card

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-25 06:43:21 +03:00
Claude
d1749c314f fix: audit findings on iOS-readiness migration
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.
2026-05-25 02:17:32 +00:00
Claude
f1845d6a06 feat(commons): add iOS actuals for KmpLock, WeakReference, isValidUrl
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.
2026-05-25 00:44:38 +00:00
Claude
95ca12231c feat(amethyst): two more read-only Gemini verbs + signer-prompt plan
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.
2026-05-25 00:18:31 +00:00
Claude
e02972e1be build(commons): enable iosArm64 + iosSimulatorArm64 targets
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.
2026-05-25 00:13:13 +00:00
Claude
880c1bfd4a refactor: clear final java.* imports from commons/commonMain
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.
2026-05-24 23:48:29 +00:00
Claude
31cfb53b25 feat(commons): extract NIP-17 DM verbs into shared actions package
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).
2026-05-24 23:45:33 +00:00
Claude
90fbe06f19 refactor: KMP URL validation + move UrlInfoItem to jvmAndroid
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.
2026-05-24 23:38:58 +00:00
Claude
39008f90d3 refactor: move feature-not-yet-iOS-ready files to jvmAndroid
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.
2026-05-24 23:36:10 +00:00
Claude
86434ea550 refactor(amethyst): use INostrClient.fetchAll instead of inlining the drain loop
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.
2026-05-24 23:16:12 +00:00
Claude
b150556f1e refactor(amethyst): drop PlayAmethyst — appfunctions doesn't need it
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.
2026-05-24 23:10:37 +00:00
Claude
6f1292bfcf refactor: replace @Synchronized / @Volatile with KMP primitives
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).
2026-05-24 23:06:30 +00:00
Claude
29236d7801 chore(commons,cli,amethyst): three correctness wins + caller-responsibility kdoc
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.
2026-05-24 21:35:49 +00:00
Claude
5c2f93f82f refactor: replace stately with LargeCache + KmpLock
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).
2026-05-24 21:10:35 +00:00
Claude
54b09ea6e2 fix(commons): split-aware zap requests stop misrouting funds on multi-party notes
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.
2026-05-24 21:10:30 +00:00
Claude
95beed16e1 refactor: KMP WeakReference + drop synchronized(this) from commonMain
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.
2026-05-24 18:30:27 +00:00
Claude
bf6467cdcf refactor: drop ConcurrentHashMap from commonMain
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.
2026-05-24 18:24:22 +00:00
Claude
17cee60aac feat(amethyst): expose searchProfiles to Gemini via androidx.appfunctions
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.
2026-05-24 18:23:59 +00:00
Claude
1b6b699d76 refactor: drop java.util.concurrent atomics from commonMain
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.
2026-05-24 18:11:20 +00:00
Claude
78ef4fa672 refactor: migrate Base64Image off java.util.Base64
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.
2026-05-24 18:07:25 +00:00
Vitor Pamplona
2450955478 adjusting format 2026-05-24 13:58:57 -04:00
Vitor Pamplona
07d77e1363 Merge pull request #3045 from vitorpamplona/claude/stoic-turing-TTiSd
Restructure privacy policy and add build-variant legal UI
2026-05-24 13:52:09 -04:00
Claude
6f8f5f7d57 docs: tighten PRIVACY.md — concise, truthful, lower-liability
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.
2026-05-24 16:50:45 +00:00
Claude
2628f45788 refactor: move ToS / legal links into flavor source sets
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.
2026-05-24 16:45:48 +00:00
Claude
b27fc34786 refactor: migrate FeedDefinitionSerializer to kotlinx.serialization
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.
2026-05-24 16:41:00 +00:00
Claude
2e47cb7110 feat(commons): add NIP-57 zap verbs in shared actions package
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.
2026-05-24 16:33:15 +00:00
Claude
f058662349 fix(fdroid): hide Play-only ToS gate and Privacy/Child-Safety links
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.
2026-05-24 16:24:57 +00:00
Claude
cde609203c feat(commons): add NIP-50 search verbs in shared actions package
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.
2026-05-24 16:23:34 +00:00
Claude
c3e03308f8 docs: clarify Child Safety Standards are a policy, not a license restriction
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.
2026-05-24 16:15:34 +00:00
Claude
e0c3b18731 ci: add iOS test job for quartz + commonMain purity gate
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.
2026-05-24 16:12:34 +00:00
Claude
5a276ce8d7 docs: expand Child Safety Standards for Play Store compliance
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).
2026-05-24 16:06:52 +00:00
Claude
257756438d feat(commons): extract follow/unfollow verbs into shared actions package
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.
2026-05-24 16:04:20 +00:00
Claude
f10973c06e docs: add iOS support plan 2026-05-24 15:59:59 +00:00
Vitor Pamplona
a9306dcea5 update dependencies 2026-05-23 17:42:26 -04:00
Vitor Pamplona
74a646c7eb Merge pull request #3040 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-23 17:30:01 -04:00
Crowdin Bot
05eb35a4ca New Crowdin translations by GitHub Action 2026-05-23 21:14:31 +00:00
Vitor Pamplona
4758562f88 Merge pull request #3043 from vitorpamplona/claude/multi-npub-external-signer-login-U5GIb
Fix account cache race condition in setDefaultAccount
2026-05-23 17:12:55 -04:00
Vitor Pamplona
d82143e392 Merge pull request #3042 from vitorpamplona/claude/reaction-row-padding-bug-DaQO0
Fix reaction row layout for icon-only rightmost items
2026-05-23 17:09:55 -04:00
Claude
99b7ca76be fix: only skip the weighted slice for an icon-only last reaction
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.
2026-05-23 21:06:42 +00:00
Vitor Pamplona
2ef738de14 Merge pull request #3039 from vitorpamplona/claude/fix-zaps-display-tHV2a
NIP-BC onchain zaps: add verification state machine & reverify driver
2026-05-23 17:04:22 -04:00
Claude
e5b0755d9b fix: secondary external-signer login lands on onboarding when switching
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.
2026-05-23 20:16:13 +00:00
Claude
18fb75285e fix: keep reaction icons evenly distributed when Share is disabled
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.
2026-05-23 19:27:19 +00:00
Claude
daa83959b6 refactor(onchain-zaps): extract verification coordinator from LocalCache
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.
2026-05-23 16:13:19 +00:00
Vitor Pamplona
fc5587f46f Merge pull request #3038 from nrobi144/feat/desktop-wallet-zapping
feat(desktop): wallet zapping, LNURL-pay send, QR receive, and session persistence
2026-05-23 12:05:56 -04:00
Vitor Pamplona
0461072254 Merge pull request #3041 from vitorpamplona/claude/jolly-cray-6vKga
Makes the NWC process less strict, while checking for inconsistencies after the request reply is processed.
2026-05-23 12:05:09 -04:00
Vitor Pamplona
fc7813afbb Merge pull request #3036 from vitorpamplona/claude/affectionate-gauss-UWDJ4
Add NIP-82 Software Applications support with dedicated feed
2026-05-23 12:00:08 -04:00
nrobi144
e33fef2ebd feat(desktop): rich text migration, copy raw JSON, and profile metadata
Replace desktop's 3-segment custom parser with commons' 23-segment
RichTextParser. Add DesktopRichTextViewer rendering all segment types:
hashtags, invoices (with NWC pay), cashu tokens, custom emoji, nowhere
links, emails, relay URLs, markdown, image galleries, and more.

Add 'Copy Raw JSON' to note overflow menu. Add nip05, website, and
lightning address to profile card with right-click copy support.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-23 15:46:46 +03:00
nrobi144
e4691f6d93 fix(desktop): remove obsolete ZapDialogLogicTest
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>
2026-05-23 15:45:54 +03:00
nrobi144
2b14b77acf feat(desktop): support LNURL-pay and lightning addresses in send dialog
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>
2026-05-23 15:40:19 +03:00
nrobi144
a5405fef34 Merge remote-tracking branch 'upstream/main' into feat/desktop-wallet-zapping
# Conflicts:
#	desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt
#	desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt
2026-05-23 15:19:47 +03:00
nrobi144
4936d187fe fix(desktop): improve send/receive dialogs and LNURL error surfacing
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>
2026-05-23 15:17:08 +03:00
nrobi144
00708d92d3 feat(desktop): redesign receive dialog with QR code and cleaner UX
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>
2026-05-23 14:35:18 +03:00
nrobi144
562cd3355b fix(desktop): fix feed cold-boot race and remove NWC diagnostic println
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>
2026-05-23 14:02:48 +03:00
nrobi144
e690292bbd fix(desktop): fix nsec session not persisting across restarts
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>
2026-05-23 14:02:36 +03:00
Vitor Pamplona
4391fae915 Merge pull request #3037 from vitorpamplona/claude/pin-followed-chats-iSRJ9
feat: pin followed public chats to the top of the Public Chats feed
2026-05-22 20:00:53 -04:00
Claude
0313dcf3fa fix(onchain-zaps): clear second-audit findings
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.
2026-05-22 22:42:25 +00:00
Vitor Pamplona
585b28163a Better rendering of Public Chats 2026-05-22 18:25:07 -04:00
Vitor Pamplona
2c8ed6c64f Merge pull request #3031 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-22 18:22:46 -04:00
Crowdin Bot
a699920e96 New Crowdin translations by GitHub Action 2026-05-22 22:06:54 +00:00
Vitor Pamplona
653ca7ce88 Merge pull request #3034 from nrobi144/feat/desktop-note-action-ux
feat(desktop): note action bar — long-press details popups + right-click customize
2026-05-22 18:05:22 -04:00
Vitor Pamplona
ae56a295d4 Merge pull request #3035 from greenart7c3/claude/epic-newton-OZLCC
Use URL SHA for Blossom bridge, not imeta hash
2026-05-22 14:53:00 -04:00
Claude
862dce27fe fix(blossom-bridge): always use URL sha, ignore imeta x
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.
2026-05-22 16:23:34 +00:00
nrobi144
3185df21b6 fix(desktop): reactive counters, quote boost, and boost detail popup
- 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>
2026-05-22 14:03:48 +03:00
nrobi144
27543ac304 feat(desktop): long-press details popups + right-click customize for note actions
- Long-press zap icon → floating popup with zap receipts (sender, amount, message)
- Long-press like icon → floating popup with reactions grouped by emoji
- Right-click like icon → emoji picker (DropdownMenu with 6 common emojis)
- Right-click repost icon → Repost/Quote options (DropdownMenu)
- Right-click zap icon → custom zap dialog (preserved existing behavior)
- Long-press reply → opens thread (same as click)
- ActivePopup sealed class ensures only one popup open at a time
- Popup + ElevatedCard for rich content, DropdownMenu for option lists
- combinedClickable with explicit ripple preserves IconButton UX
- PopupProperties(focusable = true) for desktop click-outside dismiss
- @Immutable on ZapReceipt for Compose stability
- Note param added to NoteActionsRow, passed from FeedScreen

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-22 07:00:31 +03:00
nrobi144
746dab51b4 fix(desktop): fix NWC relay connection, disconnect crash, and balance error handling
- 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>
2026-05-22 06:23:05 +03:00
Claude
a338574f44 fix(nwc): surface rejected spoof replies in timeout message
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.
2026-05-21 22:29:55 +00:00
Claude
b17bb8339e feat(nip82): render bundled assets inside the release card
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.
2026-05-21 21:41:52 +00:00
Claude
3f91cb1689 fix: actually fetch ChannelCreateEvent (kind 40) and widen the relay set
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.
2026-05-21 21:35:27 +00:00
Claude
73f1e6ae9c fix(onchain-zaps): harden against spoofing, fix re-verify lifecycle, audit cleanup
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.
2026-05-21 21:34:36 +00:00
Claude
8ce0edeb2c fix(nwc): verify response author against expected wallet-service pubkey
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.
2026-05-21 21:30:16 +00:00
Claude
9ac32a3a8e feat(nip82): software application visualization + apps feed
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.
2026-05-21 21:10:18 +00:00
Claude
ee0b658401 feat: keep Public Chats scrolled to top when new top items arrive
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.
2026-05-21 21:09:49 +00:00
Claude
5cdf69bb83 fix: move pin badge to cover-image corner and add section gap
- 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.
2026-05-21 20:52:55 +00:00
Claude
aca4529d72 fix(nwc): drop authors and #p from response subscription filter
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.
2026-05-21 20:49:31 +00:00
Claude
ba43edef57 feat: show pin icon on followed Public Chat cards
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.
2026-05-21 20:45:31 +00:00
Vitor Pamplona
7a6155db05 Merge pull request #3032 from vitorpamplona/claude/jolly-cray-6vKga
Surface NIP-47 wallet response errors to user
2026-05-21 16:45:11 -04:00
Claude
dd203a5537 fix(onchain-zaps): attach optimistically and re-verify on tip change
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.
2026-05-21 20:07:46 +00:00
Claude
a1cca4a748 fix(nwc): flush REQ before publishing event and surface silent errors
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.
2026-05-21 20:05:02 +00:00
Vitor Pamplona
a42dee5e12 Merge pull request #3029 from davotoula/i18n-cs-de-pt-sv-and-correct-0-entries-for-english
Calendar Translations  (cs, de, pt-BR, sv) and skill update
2026-05-21 15:56:58 -04:00
Vitor Pamplona
766b161363 Merge pull request #3030 from vitorpamplona/claude/intelligent-bohr-Lakt0
Enforce minimum on-chain zap amount and update default zap presets
2026-05-21 15:56:45 -04:00
Claude
9dcce46f67 feat(zaps): lower default zap amounts and add 1k-sat onchain floor
- Lightning quick-zap defaults: 100/500/1000 → 21/50/100 sats
- Onchain quick-zap default: 10k → 5k sats
- Block onchain zaps below 1000 sats in OnchainZapSendDialog so users
  aren't quietly creating transactions where miner fees dwarf the gift.
  The protocol dust threshold (330 sats) still applies underneath.
2026-05-21 19:43:08 +00:00
Claude
13c0e26de3 refactor: filter followed chats out of the main list before rendering
Avoids creating empty LazyColumn item slots for followed channels that
were skipped at render time.
2026-05-21 19:28:30 +00:00
nrobi144
704c8a3e87 fix(desktop): center wallet empty state vertically in column
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 13:50:46 +03:00
davotoula
ec2f621772 i18n(sv): apply Swedish translation review
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.
2026-05-21 09:52:51 +02:00
davotoula
44f8d1bfa8 i18n(de): apply German translation review
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).
2026-05-21 09:50:09 +02:00
davotoula
db8791f5fc i18n(pt-BR): apply Brazilian Portuguese translation review
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.
2026-05-21 09:45:09 +02:00
davotoula
a877ef7ec7 i18n(cs): apply Czech translation review
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.
2026-05-21 09:19:12 +02:00
davotoula
2d3a2582ee docs(skill): catch dead quantity="zero" entries in find-missing-translations 2026-05-21 08:45:38 +02:00
davotoula
898dec73b9 i18n: special-case count=0 at call site for calendar day a11y label 2026-05-21 08:45:37 +02:00
nrobi144
4d95b7ed8a fix(desktop): adapt wallet screen to upstream AccountManager API changes
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>
2026-05-21 09:36:55 +03:00
davotoula
4d4d81edbd translate calendar + on-chain wallet strings to cs, de, pt-BR, svclose remaining translation gaps in cs, de, pt-BR, sv
Translate the plurals and strings missed by the first pass
2026-05-21 08:33:56 +02:00
nrobi144
5bf4db4582 fix(desktop): wire zap types, relay hints, and add testing sheet
- 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>
2026-05-21 09:31:39 +03:00
nrobi144
a15ce33807 refactor(desktop): use dialogs for wallet actions, center home content
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>
2026-05-21 09:31:39 +03:00
nrobi144
420a10df50 fix(desktop): subscribe before publish in NWC RPC to avoid race condition
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>
2026-05-21 09:31:38 +03:00
nrobi144
ed06bb2267 test(desktop): add wallet and zapping test coverage
- ZapDialogLogicTest: 12 tests for formatSats, DEFAULT_ZAP_AMOUNTS,
  ZapType enum (labels, descriptions, entries)
- NwcPaymentHandlerTest: 18 tests for PaymentResult, BalanceResult,
  InvoiceResult sealed types, NWC connection validation, response
  event structure
- NwcRpcIntegrationTest: 5 full round-trip tests using real crypto
  (pay_invoice, get_balance, make_invoice request/response cycles,
  NWC URI -> client pipeline)
- Make formatSats and DEFAULT_ZAP_AMOUNTS internal for test access

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 09:31:38 +03:00
nrobi144
7bd5fb3122 feat(desktop): wire NWC wallet operations and AccountManager integration
- 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>
2026-05-21 09:31:38 +03:00
nrobi144
8313f0cf12 feat(desktop): add wallet column and enhanced zapping UX
Phase 1: Wallet deck column with NWC integration
- Add DeckColumnType.Wallet with full deck system integration
  (AppDrawer, ColumnHeader, DeckState persistence, MenuBar)
- WalletColumnScreen with 4 sub-screens: Home (balance + actions),
  Connect (NWC URI paste), Send (pay BOLT11), Receive (create invoice)
- Uses existing NwcPaymentHandler for payment execution

Phase 2: Zapping UX improvements
- Upgrade ZapAmountDialog with zap type selection
  (PUBLIC/PRIVATE/ANONYMOUS via FilterChips)
- Add custom amount input alongside preset chips
- One-click zap: left-click sends default amount via NWC,
  right-click opens custom zap dialog
- Configurable zap amounts parameter (no longer hardcoded)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 09:31:38 +03:00
Claude
3915e2b36f refactor: render pinned followed chats as a separate LazyColumn section
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.
2026-05-21 02:20:40 +00:00
Claude
68f0ded7d5 feat: pin followed public chats to the top of the Public Chats feed 2026-05-21 00:45:01 +00:00
Vitor Pamplona
6cd4de51aa Merge pull request #3028 from vitorpamplona/claude/fix-send-split-mode-AE6gg
fix: keep Send button visible in onchain zap split mode
2026-05-20 20:37:47 -04:00
Claude
465f3687c1 fix: keep Send button visible in onchain zap split mode
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.
2026-05-21 00:02:38 +00:00
Vitor Pamplona
6efee9f1f2 Merge pull request #3027 from vitorpamplona/claude/add-mention-notifications-PIhPf
feat(notifications): include public chat mentions in feed
2026-05-20 19:51:18 -04:00
Claude
39eedeeb17 feat(notifications): include public chat mentions in feed
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).
2026-05-20 23:21:47 +00:00
Vitor Pamplona
e0abc4a224 v1.11.0 2026-05-20 18:59:05 -04:00
Vitor Pamplona
ee67ba6408 Merge pull request #3025 from vitorpamplona/claude/plan-onchain-zap-events-t1cPq
feat: tighten the zap UI
2026-05-20 18:28:50 -04:00
Vitor Pamplona
7415b1db39 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2026-05-20 18:27:15 -04:00
Vitor Pamplona
f19a47007e Merge pull request #3024 from vitorpamplona/claude/fix-illegal-argument-exception-H4omh
fix(ui): restore outlined visual on EditPostView + ForwardZapTo TextF…
2026-05-20 18:26:45 -04:00
Claude
55e536dfd0 feat: tighten the zap UI
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.
2026-05-20 22:26:34 +00:00
Claude
05d958d182 fix(ui): restore outlined visual on EditPostView + ForwardZapTo TextFields
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.
2026-05-20 22:21:20 +00:00
David Kaspar
a4310317a0 Merge pull request #3017 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-21 00:04:24 +02:00
Crowdin Bot
40eb0f0b61 New Crowdin translations by GitHub Action 2026-05-20 22:02:32 +00:00
davotoula
b04cf3aedc i18n: convert calendar_reminder_settings_lead_choice to <plurals>
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).
2026-05-20 23:59:28 +02:00
Vitor Pamplona
924101c3f4 Fixes follow list rendering in the top note 2026-05-20 17:56:54 -04:00
Vitor Pamplona
0cc435aa02 Merge pull request #3023 from vitorpamplona/claude/add-notecompose-contactlist-sFMff
Add Contact List display and navigation screen
2026-05-20 17:49:40 -04:00
Vitor Pamplona
4fba293d60 Merge pull request #3020 from davotoula/feat/calendar-plurals-i18n
i18n: convert calendar count + reminder strings to <plurals>
2026-05-20 17:40:21 -04:00
Claude
a1cb5cb50d feat: add preview for DisplayContactList
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.
2026-05-20 21:40:02 +00:00
Vitor Pamplona
15474cfff4 Merge pull request #3022 from vitorpamplona/claude/fix-illegal-argument-exception-H4omh
Migrate text input to new Compose TextFieldState API
2026-05-20 17:39:06 -04:00
Vitor Pamplona
8bf1040f18 Merge pull request #3021 from vitorpamplona/claude/add-public-chip-confirmation-2Q7sh
Add warning dialog for copying public onchain addresses
2026-05-20 17:38:45 -04:00
davotoula
63e6283354 docs(claude): add res/CLAUDE.md note on <plurals> for Slavic locales
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>
2026-05-20 23:32:24 +02:00
Claude
d7a1b631b2 feat: confirm onchain copy with public-address warning
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.
2026-05-20 21:26:02 +00:00
davotoula
c7226d3293 i18n: convert calendar count + reminder strings to <plurals>
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>
2026-05-20 23:25:14 +02:00
Claude
6ecedb72f5 feat: render ContactListEvent in NoteCompose with tap-through user list
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.
2026-05-20 21:18:14 +00:00
Vitor Pamplona
995ec3dab2 Merge pull request #3018 from vitorpamplona/claude/plan-onchain-zap-events-t1cPq
Add on-chain split zap support with per-recipient distribution
2026-05-20 16:54:26 -04:00
Vitor Pamplona
b7e3ba27ac Merge pull request #3019 from vitorpamplona/claude/debug-metadata-relay-loading-opoto
Add fallback relay logic for abandoned users in metadata queries
2026-05-20 16:52:26 -04:00
Claude
f6db678249 fix: audit follow-ups (fee retry, perf, self-pay gate)
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.
2026-05-20 20:49:33 +00:00
Claude
b2959dcb75 refactor: migrate UrlUserTagTransformation to UrlUserTagOutputTransformation
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.
2026-05-20 20:46:09 +00:00
Claude
e6b6269314 feat: include indexer and proxy relays in trusted relay union 2026-05-20 20:41:33 +00:00
Vitor Pamplona
9d0397080f Adds Payment target to the usual user download 2026-05-20 16:39:05 -04:00
Claude
85dfe93ea7 feat: on-chain handoff from the custom-zap dialog
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
2026-05-20 20:35:01 +00:00
Claude
a90dd47ed4 feat: on-chain option on the Zap the Devs button
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.
2026-05-20 20:20:21 +00:00
Claude
dfd06e0dfd fix: skip offline relays when fetching user metadata 2026-05-20 20:18:56 +00:00
Claude
45aa6044b7 fix: on-chain zap splits — drop sender from splits, merge duplicates, gate Send on dust
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
2026-05-20 20:14:44 +00:00
Claude
13a599e282 fix: keep listening on default index+search relays for users with no kind 10002
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.
2026-05-20 19:57:38 +00:00
Claude
3ed2245d8c feat: on-chain zap splits
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
2026-05-20 19:55:15 +00:00
Vitor Pamplona
c19f0be57e Merge pull request #3013 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-20 15:53:53 -04:00
Vitor Pamplona
96883dbec1 Merge pull request #3015 from quentintaranpino/add-nostrcheck-media-server
Re-add Nostrcheck.me Blossom server to defaults
2026-05-20 15:53:42 -04:00
Claude
b0b0a13650 fix: swallow LegacyCursorAnchorInfo IllegalArgumentException
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.
2026-05-20 19:36:33 +00:00
quentintaranpino
0f92abd633 Re-add Nostrcheck.me Blossom server to defaults
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.
2026-05-20 21:35:14 +02:00
Claude
360dea9de9 feat: surface on-chain zaps from the reactions zap button
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
2026-05-20 19:24:10 +00:00
Crowdin Bot
030c982a01 New Crowdin translations by GitHub Action 2026-05-20 19:11:26 +00:00
Vitor Pamplona
5962f7d659 Merge pull request #3014 from vitorpamplona/claude/add-icon-tool-instruction-oZv6P
docs: add Material Symbols font subset regeneration guide
2026-05-20 15:09:22 -04:00
Claude
9b6d65c9f1 docs: require running material-symbols-subset.sh when adding new icons
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.
2026-05-20 19:00:38 +00:00
Vitor Pamplona
872bb4f245 Add new information icon 2026-05-20 14:44:59 -04:00
Vitor Pamplona
99e98e0213 Merge pull request #3012 from vitorpamplona/claude/social-wallet-info-popup-4O6s6
Add public wallet warning chip and dialog to onchain section
2026-05-20 13:32:50 -04:00
Vitor Pamplona
0c2a5720c1 Merge pull request #3007 from vitorpamplona/claude/gradle-to-kotlin-dsl-sOUlg
Migrate build scripts from Groovy to Kotlin DSL
2026-05-20 13:30:58 -04:00
Vitor Pamplona
e0f29aefb0 Merge pull request #3011 from greenart7c3/claude/update-payment-modal-ui-cFqYN
Refactor payment targets UI
2026-05-20 13:30:34 -04:00
Claude
89b47da42a feat: extract onchain Public chip strings to resources
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.
2026-05-20 17:20:16 +00:00
Claude
339ad6dae7 feat: clarify privacy guidance on onchain wallet popup
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.
2026-05-20 17:10:02 +00:00
Claude
7232456e9e fix: share payment targets dialog with reactions row, restore QR glyph
- 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
2026-05-20 16:53:11 +00:00
Claude
fc8f057e3e feat: add Public chip to onchain wallet card
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.
2026-05-20 16:39:34 +00:00
Claude
ec28d4eb4c feat: redesign payment targets modal with QR, copy and pay buttons
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
2026-05-20 16:35:47 +00:00
Vitor Pamplona
baf043343d Merge pull request #3009 from greenart7c3/claude/fix-reaction-payment-targets-UhsYP
fix: surface Pay row in Reaction Settings for existing accounts
2026-05-20 11:59:29 -04:00
Claude
59fd1f2244 build: use directories.add for benchmark androidTest resources
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).
2026-05-20 15:56:53 +00:00
Claude
ebfd066d5c build: convert remaining .gradle files to Kotlin DSL
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).
2026-05-20 14:17:47 +00:00
Claude
5841ca652c fix: surface Pay row in Reaction Settings for existing accounts
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.
2026-05-20 14:13:03 +00:00
Vitor Pamplona
fb4dfcefec Merge pull request #3006 from vitorpamplona/claude/add-thread-name-crashes-HsFy0
Include thread name in crash reports
2026-05-20 10:10:44 -04:00
Claude
c0a22e5c0b feat: include crashing thread name in crash report
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.
2026-05-20 13:53:11 +00:00
Vitor Pamplona
a4be1d21fe Merge pull request #3003 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-20 09:49:27 -04:00
Crowdin Bot
ce9a7da265 New Crowdin translations by GitHub Action 2026-05-20 13:46:35 +00:00
Vitor Pamplona
33e1790348 Merge pull request #3005 from vitorpamplona/claude/fix-motorola-runtime-exception-Grtj3
Handle ForegroundServiceStartNotAllowedException in PlaybackService
2026-05-20 09:44:56 -04:00
Vitor Pamplona
071b88c7eb Merge pull request #3004 from vitorpamplona/claude/modernize-zap-popup-QZWMV
Refactor ZapAmountChoicePopup UI with extracted components
2026-05-20 09:44:47 -04:00
Claude
754e03767a Revert "feat: replace zap button long-press-to-edit with long-press-to-custom"
This reverts commit 86a8fb0743.
2026-05-20 13:37:18 +00:00
Claude
86a8fb0743 feat: replace zap button long-press-to-edit with long-press-to-custom
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.
2026-05-20 13:33:01 +00:00
Claude
7403ccc825 fix: shrink Tune settings icon in zap popup to match chip size
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.
2026-05-20 13:18:58 +00:00
Claude
6be8f2db9e fix: vertically center items in zap amount popup row
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.
2026-05-20 13:17:37 +00:00
Claude
dde539f0fd fix: catch ForegroundServiceStartNotAllowedException in PlaybackService
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
2026-05-20 13:06:35 +00:00
davotoula
e131b0fec2 Show on chain zaps even if only reaction 2026-05-20 08:23:10 +02:00
Vitor Pamplona
4f094929d6 Merge pull request #3002 from vitorpamplona/claude/fix-locale-observable-by5fc
fix: observe locale in CalendarDateTimePickerButton
2026-05-19 20:10:17 -04:00
Claude
446345b35e feat: modernize zap amount choice popup to match reactions popup style
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.
2026-05-20 00:01:17 +00:00
Vitor Pamplona
d4f7f9f178 Merge pull request #3001 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-19 19:46:46 -04:00
Crowdin Bot
d4f7f745af New Crowdin translations by GitHub Action 2026-05-19 23:34:03 +00:00
Vitor Pamplona
3a07b1879e Merge pull request #2996 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-19 19:32:34 -04:00
Crowdin Bot
9099fd4b84 New Crowdin translations by GitHub Action 2026-05-19 23:27:44 +00:00
Vitor Pamplona
6f4978f149 Merge pull request #3000 from vitorpamplona/claude/revert-commit-32f7bda-ObZBH
Remove I2P support and privacy routing abstraction
2026-05-19 19:25:50 -04:00
Claude
ec3be14cf8 fix: observe locale in CalendarDateTimePickerButton
Use LocalConfiguration.current.locales[0] so the date formatter recomposes
when the device locale changes, satisfying the NonObservableLocale lint
check.
2026-05-19 23:24:57 +00:00
Vitor Pamplona
2ff67479a3 Merge pull request #2999 from vitorpamplona/claude/fix-notification-feed-bug-YNke0
Fix notification feed composition issue by removing CrossfadeIfEnabled
2026-05-19 19:16:13 -04:00
Claude
6a0801427b Revert "Merge pull request #2990 from vitorpamplona/claude/add-i2p-privacy-option-nK2X7"
This reverts commit d42482ff56, reversing
changes made to a8b6766f49.
2026-05-19 23:10:56 +00:00
Claude
9b94874d01 fix(notifications): drop crossfade around the card feed
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.
2026-05-19 23:00:50 +00:00
Vitor Pamplona
679c5a89e0 Merge pull request #2994 from vitorpamplona/claude/nip52-calendar-screens-yQc3j
Add NIP-52 calendar events support with UI and reminder system
2026-05-19 18:30:59 -04:00
Vitor Pamplona
297c2adffa Merge pull request #2998 from vitorpamplona/claude/fix-remember-return-type-l5SvB
Fix PrivacyOptionsScreen initialization with LaunchedEffect
2026-05-19 18:28:42 -04:00
Claude
952aa24001 fix(privacy): use LaunchedEffect instead of remember for VM reset
The remember(...) call returned Unit, which tripped the
RememberReturnType lint. Use LaunchedEffect, which is the
idiomatic Compose API for keyed side effects.
2026-05-19 22:27:13 +00:00
Vitor Pamplona
bef3bd61c5 Merge pull request #2997 from vitorpamplona/claude/fix-completion-handler-exception-6WASO
Simplify Nip11Retriever by removing nested withContext
2026-05-19 18:23:42 -04:00
Claude
97a6d4bcbd fix(calendars): wire feeds into updateFeedsWith so new events stream in live + drop redundant Calendar Lists label
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.
2026-05-19 22:21:23 +00:00
Claude
62da33f02b fix(relay-info): switch to Dispatchers.IO around the whole executeAsync call
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.
2026-05-19 22:13:58 +00:00
Crowdin Bot
5045ef9dcf New Crowdin translations by GitHub Action 2026-05-19 22:08:49 +00:00
David Kaspar
3a84aa0168 Merge pull request #2991 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-20 00:07:19 +02:00
Vitor Pamplona
1909fe7e4c Merge pull request #2995 from vitorpamplona/claude/test-mls-reply-notifications-SnaIM
Add reply support for Marmot/MLS group messages
2026-05-19 18:07:17 -04:00
Claude
c7a4796b25 refactor(marmot): audit follow-ups on MLS reply paths
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.
2026-05-19 22:04:14 +00:00
Claude
cc78744a10 fix(calendars): tapping a Calendar List opens the dedicated detail screen, not the generic thread view
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.
2026-05-19 21:58:35 +00:00
Claude
382729520c chore(commons): regenerate Material Symbols font subset to include EventAvailable
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.
2026-05-19 21:58:35 +00:00
Claude
1b563fb3f0 fix(calendars): clear EOSE cursor on list switch so evicted notes get re-fetched
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.
2026-05-19 21:58:35 +00:00
Claude
d96209b20d fix(calendars): top-bar filter switch resets scroll; week/month/day headers scroll with the disappearing bar
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.
2026-05-19 21:58:34 +00:00
Claude
638e22c262 fix(calendars): tighten ReactionsRow spacing on the detail screen
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.
2026-05-19 21:58:34 +00:00
Claude
fc1da467e7 feat(calendars): user-search participant picker + hide createdAt on cards
- 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.
2026-05-19 21:58:34 +00:00
Claude
5dfabf667f feat(calendars): route URL locations to the browser, not the maps app
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.
2026-05-19 21:58:34 +00:00
Claude
0dea41602a fix(calendars): apply scaffold padding so month/week/day headers and collections list don't render behind the top app bar
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).
2026-05-19 21:58:34 +00:00
Claude
5116bc21ae refactor(calendars): extract DAL to commons + a11y + inline-FQN cleanup
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).
2026-05-19 21:58:34 +00:00
Claude
ab5d884b34 feat(calendars): "Add to phone calendar" intent + multi-day bars in month grid
- 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.
2026-05-19 21:58:34 +00:00
Claude
971e9e4846 feat(calendars): reactions/zaps row, multi-day events, swipe nav, prefetch
- 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
2026-05-19 21:58:34 +00:00
Claude
eeda5810ae feat(calendars): share-as-nostr-link, reactive picker, calendar filter
#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
2026-05-19 21:58:34 +00:00
Claude
3315ccccec feat(calendars): reminder settings (enable + lead time) and store tests
#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
2026-05-19 21:58:33 +00:00
Claude
a5f03b21cc feat(calendars): gallery image picker + participant picker on create
#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
2026-05-19 21:58:33 +00:00
Claude
993810aa2f feat(calendars): add-to-calendar picker, delete collections, fix maps row
#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
2026-05-19 21:58:33 +00:00
Claude
cc5ae46f5b feat(calendars): modernize UI to match the rest of the app
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
2026-05-19 21:58:33 +00:00
Claude
55147b7781 feat(calendars): "starting soon" notifications for attended events
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
2026-05-19 21:58:33 +00:00
Claude
8d35a73416 feat(calendars): relative-time labels and iCalendar (.ics) export
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
2026-05-19 21:58:32 +00:00
Claude
18512e11ff feat(calendars): edit existing appointments
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
2026-05-19 21:58:32 +00:00
Claude
390d750638 i18n(calendars): replace hardcoded user-facing strings with resources
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
2026-05-19 21:58:32 +00:00
Claude
bf380ec015 feat(calendars): event detail screen, string cleanup, unit tests
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
2026-05-19 21:58:32 +00:00
Claude
e901364e87 feat(calendars): RSVP dedupe + collection editing with event picker
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
2026-05-19 21:58:32 +00:00
Claude
447301599c refactor(calendars): rename appointments feed, split collections to own route
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
2026-05-19 21:58:32 +00:00
Claude
87f9a346cc refactor(calendars): DST-safe nav, shared header, appointment-view adapter
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
2026-05-19 21:58:32 +00:00
Claude
802f89723e fix(calendars): audit pass on day-keying, threading and recomposition
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
2026-05-19 21:58:32 +00:00
Claude
809f21252c feat(calendars): add create flows for events and collections
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
2026-05-19 21:58:31 +00:00
Claude
5343c7c636 feat(calendars): inline RSVP buttons and 31924/31925 renders
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
2026-05-19 21:58:31 +00:00
Claude
b5d5cbed03 feat(calendars): add screens, navigation and view modes
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
2026-05-19 21:58:31 +00:00
Claude
fcab611bc4 feat(calendars): add NIP-52 feed foundation, DAL and relay subscription
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
2026-05-19 21:58:31 +00:00
Claude
2326738b84 fix(marmot): route reply button on MLS messages to the encrypted group
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.
2026-05-19 21:45:53 +00:00
Crowdin Bot
333a3f92ed New Crowdin translations by GitHub Action 2026-05-19 21:44:44 +00:00
Vitor Pamplona
6e37133b27 Merge pull request #2993 from vitorpamplona/claude/fix-geohash-loading-EJTQY
Refactor geolocation retry logic with suspend coroutines
2026-05-19 17:43:12 -04:00
Claude
6c4ff97cf1 fix(location): stop spinning Around Me when Geocoder is unavailable
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.
2026-05-19 21:32:11 +00:00
Vitor Pamplona
53d4a7ecae Merge pull request #2992 from vitorpamplona/claude/reorder-nav-filter-popup-b3WLj
Reorder FeedGroup enum for improved UX
2026-05-19 17:29:19 -04:00
Claude
77a9bc8754 feat: move DVMs before Communities in filter popup 2026-05-19 21:28:51 +00:00
Claude
b82ad10ca5 feat: place interest sets next to hashtags in filter popup 2026-05-19 21:25:12 +00:00
Claude
a568cbbc74 feat: reorder feed filter popup sections
Reorder sections in the top nav feed filter popup to: Feeds, Relays,
Hashtags, Locations, Communities, Lists, Interest Sets, DVMs.
2026-05-19 21:21:38 +00:00
Vitor Pamplona
d42482ff56 Merge pull request #2990 from vitorpamplona/claude/add-i2p-privacy-option-nK2X7
Add I2P support with unified privacy routing
2026-05-19 16:56:54 -04:00
Claude
40995b9670 refactor(privacy): drop I2pType.INTERNAL — EXTERNAL is the permanent answer
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
2026-05-19 20:37:53 +00:00
Vitor Pamplona
a8b6766f49 Merge pull request #2989 from vitorpamplona/claude/fix-zoomable-dialog-animation-3U3DK
Fix zoomed image dismiss animation to start from actual bounds
2026-05-19 15:59:18 -04:00
Claude
76c4771b58 fix(ui): start zoomable dialog close animation from zoomed bounds
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.
2026-05-19 19:42:07 +00:00
Vitor Pamplona
1f0eab5f1d Merge pull request #2985 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-19 15:36:57 -04:00
Vitor Pamplona
bcb6f131dd Merge pull request #2988 from vitorpamplona/claude/review-bitcoin-blockchain-plan-6NGZ5
Plan: local Bitcoin headers explorer for NIP-03 OTS (revised 2026-05-19)
2026-05-19 15:36:42 -04:00
Claude
24cf3fdc5f docs: update onchain-zap + headers-explorer plans for NIP-BC inline SPV tags
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.
2026-05-19 19:17:36 +00:00
Vitor Pamplona
3f458f8316 Merge pull request #2987 from vitorpamplona/claude/sync-notification-filters-RbvPY
Align push notifications with in-app feed filtering
2026-05-19 14:55:27 -04:00
Claude
d19bcf07b0 docs(quartz): add interop-test-vectors section to local-headers-explorer plan
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.
2026-05-19 18:35:30 +00:00
Claude
c75c2013fb fix(notifications): resolve addressable events to their replaceable note
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).
2026-05-19 18:32:49 +00:00
Claude
f3637dd7c1 docs(quartz): revise local-headers-explorer plan — SQLite, no bundle, OTS-only
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.
2026-05-19 18:14:30 +00:00
Claude
36e285f684 fix(notifications): WakeUp bypass + lookup hoist + comment cleanup
- 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.
2026-05-19 18:12:44 +00:00
Vitor Pamplona
8d4089e7e9 Merge pull request #2986 from vitorpamplona/claude/fix-illegal-argument-exception-V4AAM
Fix feed sorting race condition with snapshot-based comparator
2026-05-19 14:05:33 -04:00
Claude
df87557ef3 fix: stable sort in ShortsFeedFilter to avoid TimSort contract crash
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.
2026-05-19 17:57:15 +00:00
Claude
49b244f026 fix: align push notifications with Notifications feed filter
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.
2026-05-19 17:26:33 +00:00
Crowdin Bot
8b3ed48fb6 New Crowdin translations by GitHub Action 2026-05-19 17:21:37 +00:00
Vitor Pamplona
ed686b71ed Merge pull request #2981 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-19 13:20:01 -04:00
Vitor Pamplona
37ea5dea01 Merge pull request #2984 from vitorpamplona/claude/fix-zap-validation-6CbFV
NIP-57 Appendix F zap receipt validation with LNURL provider verification
2026-05-19 13:19:52 -04:00
Crowdin Bot
100533dfd7 New Crowdin translations by GitHub Action 2026-05-19 17:14:23 +00:00
Vitor Pamplona
88fd15eefc Merge pull request #2982 from vitorpamplona/claude/fix-video-notification-clickable-O811N
Fix playback notification tap target in warm-pool fast path
2026-05-19 13:12:37 -04:00
Vitor Pamplona
872533f102 Merge pull request #2983 from vitorpamplona/claude/fix-empty-transaction-chips-edyYE
Improve onchain transaction filter UX with separate empty states
2026-05-19 13:12:13 -04:00
Claude
0c2eecf62c fix: make playback notification tap open the note on warm-pool resume
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.
2026-05-19 16:33:55 +00:00
Claude
4346427e9f Validate zap receipts against LNURL provider's nostrPubkey (NIP-57 Appendix F)
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.
2026-05-19 16:19:29 +00:00
Claude
cb6e1fe696 fix: keep filter chips visible on empty onchain transactions list
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.
2026-05-19 16:18:33 +00:00
Vitor Pamplona
1afa86cb26 Merge pull request #2974 from vitorpamplona/claude/clickable-wallet-card-hHTbM
Add on-chain transaction history screen with pagination
2026-05-19 10:35:31 -04:00
Vitor Pamplona
facdd3dc8f Merge pull request #2978 from davotoula/feat/hashtag-limit-plurals-i18n
Convert hashtag-limit message to <plurals>
2026-05-19 10:35:05 -04:00
Vitor Pamplona
439331722a Merge pull request #2973 from vitorpamplona/claude/stop-video-background-timeout-jSLCO
Release MediaController after 30s background timeout
2026-05-19 10:34:44 -04:00
Vitor Pamplona
7b720a9a8d Merge pull request #2977 from davotoula/feat/onchain-zaps-reactions-gallery
Show on-chain Bitcoin zappers as a dedicated ₿ row in the expanded reactions gallery
2026-05-19 10:34:19 -04:00
Vitor Pamplona
e94e1782c1 Merge pull request #2975 from nrobi144/fix/desktop-deb-launch-crash
test(desktop): add Compose UI smoke test + release .deb launch CI
2026-05-19 10:33:49 -04:00
Vitor Pamplona
a747e20c86 Merge pull request #2980 from vitorpamplona/claude/debug-longpress-root-note-4UR5I
Fix popup menu positioning and parameter naming
2026-05-19 10:01:12 -04:00
Claude
f2f02ab5cb fix(thread): restore long-press on root note + anchor popup to the card
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.
2026-05-19 13:45:56 +00:00
David Kaspar
61a282ba28 Merge branch 'main' into feat/hashtag-limit-plurals-i18n 2026-05-19 15:39:24 +02:00
David Kaspar
5973429860 Merge pull request #2979 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-19 15:38:46 +02:00
Crowdin Bot
e1e185a6f2 New Crowdin translations by GitHub Action 2026-05-19 13:26:50 +00:00
Vitor Pamplona
de222d152b Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2026-05-19 09:24:34 -04:00
davotoula
8c3868bc32 i18n: convert hashtag-limit message to <plurals> and add cs/pt-BR/sv/de translations 2026-05-19 14:36:17 +02:00
davotoula
cf44c092dd Code review:
- invalidate zaps flow when removeAllChildNotes clears onchainZaps
- simplify on-chain zap gallery after review
2026-05-19 13:10:02 +02:00
davotoula
91ded74636 Show on-chain zappers in expanded reactions gallery
- Add OnchainZappedIcon and PendingClockBadge
- move PendingClockBadge to TopStart to avoid follow-dot clash
2026-05-19 13:10:02 +02:00
David Kaspar
d41cf6d75b Merge pull request #2976 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-19 12:34:53 +02:00
nrobi144
5ad21b6acd fix(desktop): disable ProGuard optimization entirely — fixes kmp-tor crash
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>
2026-05-19 10:11:11 +03:00
Crowdin Bot
7c3399438d New Crowdin translations by GitHub Action 2026-05-19 06:50:42 +00:00
David Kaspar
733e2945bd Merge pull request #2972 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-19 08:49:16 +02:00
nrobi144
487dd3f2ac fix(desktop): disable method/marking/static ProGuard optimization
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>
2026-05-19 09:48:01 +03:00
nrobi144
c0c055e771 fix(desktop): restore java.management module — confirms #2819 fix
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>
2026-05-19 09:26:26 +03:00
nrobi144
d0a6bbc96f test(ci): intentionally remove java.management to verify smoke test catches #2819
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>
2026-05-19 09:05:10 +03:00
nrobi144
2010e41b25 fix(ci): allow dpkg post-install error, verify binary extracted
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>
2026-05-19 07:59:33 +03:00
nrobi144
cca9c2ff3a fix(ci): use dpkg --force-all to skip xdg-desktop-menu error on runner
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>
2026-05-19 07:39:34 +03:00
nrobi144
cf6541a1e1 test(desktop): add Compose UI smoke test + release .deb launch CI
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>
2026-05-19 07:09:16 +03:00
Claude
7dce3a420f feat(wallet): time-windowed relay sub + coalesce zap bursts
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.
2026-05-19 00:52:12 +00:00
Claude
020d5195b5 feat(wallet): observe onchain zaps reactively, no more per-row scan
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.
2026-05-19 00:35:31 +00:00
Claude
4e796c8e8b feat(privacy): plug remaining HTTP paths into the route-aware stack
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.
2026-05-19 00:16:13 +00:00
Claude
19c750dede feat(privacy): BlockedRouteException — typed fail-closed signal
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.
2026-05-19 00:12:55 +00:00
Claude
2a9514d657 refactor(privacy): RoleBasedHttpClientBuilder routes via PrivacyRouter
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.
2026-05-19 00:11:23 +00:00
Claude
e977b96ee6 revert(wallet): drop onchain txid index, scan notes instead
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.
2026-05-19 00:10:53 +00:00
Claude
5da20a9b44 feat(privacy): I2pManager + tri-state DualHttpClientManager
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).
2026-05-19 00:09:29 +00:00
Vitor Pamplona
ab3d1dd7e5 fix(quartz/sqlite): set busy_timeout to deflake reader+writer races
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>
2026-05-18 20:02:35 -04:00
Claude
a42a156f13 feat(privacy): extend PrivacyOptionsScreen with I2P section and clearnet picker
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.
2026-05-18 23:35:37 +00:00
Crowdin Bot
cfa19ce7da New Crowdin translations by GitHub Action 2026-05-18 23:22:26 +00:00
Vitor Pamplona
0d91245e02 Merge pull request #2971 from vitorpamplona/claude/add-nowhere-links-LPMnM
Add Nowhere link detection and branded card rendering
2026-05-18 19:20:34 -04:00
Claude
4d428025d3 feat(voice): pause voice notes on background like videos
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
2026-05-18 23:18:51 +00:00
Claude
b16d44721c perf(richtext): collapse nowhere-link classifier into the existing URL branches 2026-05-18 22:54:35 +00:00
Claude
acf3daaf75 feat(wallet): tappable onchain rows + txid index in LocalCache
- 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.
2026-05-18 22:54:04 +00:00
Claude
b71b7cb420 fix(video): skip background release timer for PiP
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
2026-05-18 22:50:33 +00:00
Vitor Pamplona
8b31e60f52 Merge pull request #2965 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-18 18:36:46 -04:00
Claude
a2da9ae2c6 i18n: extract NowhereLinkCard tool labels to string resources 2026-05-18 22:33:23 +00:00
Claude
fdd954c2ed feat(privacy): PrivacyRoutingFlow facade reading from live pref flows
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.
2026-05-18 22:31:26 +00:00
Crowdin Bot
37f6f92c04 New Crowdin translations by GitHub Action 2026-05-18 22:14:57 +00:00
Vitor Pamplona
68c081602f Merge pull request #2966 from vitorpamplona/claude/fix-video-mime-type-sqkiz
Fix MediaStore MIME type compatibility for video files
2026-05-18 18:13:19 -04:00
Claude
09857b8924 Merge remote-tracking branch 'origin/main' into claude/fix-video-mime-type-sqkiz 2026-05-18 22:04:15 +00:00
Vitor Pamplona
af4835aff3 Merge pull request #2967 from vitorpamplona/claude/notification-settings-refactor-FZ1bx
Extract notification settings to dedicated screen
2026-05-18 17:58:04 -04:00
Claude
b2b4895570 refactor(notifications): cleaner Compose patterns in Categories section
- 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.
2026-05-18 21:53:32 +00:00
Claude
b4ef70176e test: cover MIME type normalization for MediaStore
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.
2026-05-18 21:51:58 +00:00
Claude
4d88444a59 feat(notifications): split delivery vs display, add Categories section
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.
2026-05-18 21:49:09 +00:00
Vitor Pamplona
d3c9f5a4f0 Merge pull request #2970 from vitorpamplona/claude/fix-timedout-exception-TJ2te
Refactor media upload to use AccountViewModel.launchSigner
2026-05-18 17:47:47 -04:00
Vitor Pamplona
54a6109d83 Merge pull request #2969 from vitorpamplona/claude/hashtag-limit-warning-FQwtJ
Add excessive hashtag detection to hidden note UI
2026-05-18 17:44:54 -04:00
Vitor Pamplona
2fff2c137c Merge pull request #2968 from vitorpamplona/claude/fix-image-loading-layout-DlLYr
Support floating-point dimensions in NIP-92 imeta tags
2026-05-18 17:43:29 -04:00
Claude
6be26e1412 fix(media): route media-upload signing through launchSigner
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.
2026-05-18 21:35:38 +00:00
Claude
48d9e80e20 refactor: address self-audit on notification settings
- 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.
2026-05-18 21:34:32 +00:00
Vitor Pamplona
90272fea6f Merge pull request #2964 from vitorpamplona/claude/fix-classcastexception-android-CYKoy
Replace AtomicReference with LargeCache in RelayAuthenticator
2026-05-18 17:23:51 -04:00
Claude
669199ab7e refactor(quartz): use LargeCache for RelayAuthenticator authStatus
#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.
2026-05-18 21:21:02 +00:00
Vitor Pamplona
a3b2d270c7 Merge pull request #2957 from mstrofnone/feat/search-bar-namecoin-resolution-indicator
feat(search): inline Namecoin resolution indicator in global search bar
2026-05-18 16:40:52 -04:00
m
1c5230cfc5 feat(search): inline Namecoin resolution indicator in global search bar
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.
2026-05-19 06:33:47 +10:00
Vitor Pamplona
34cb4eb2ad Merge pull request #2956 from mstrofnone/feat/onchain-zap-namecoin-resolution-indicator
feat(onchain-zaps): inline Namecoin resolution indicator + result row
2026-05-18 16:26:02 -04:00
mstrofnone
012dae31d0 feat(onchain-zaps): inline Namecoin resolution indicator + result row
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.
2026-05-19 06:11:39 +10:00
Vitor Pamplona
67e2ee6bd7 Merge pull request #2955 from mstrofnone/feat/onchain-zap-bit-recipient
feat(onchain-zaps): enable Send when typed name resolves via NIP-05
2026-05-18 15:33:04 -04:00
Vitor Pamplona
994923fb84 Merge pull request #2958 from mstrofnone/feat/profile-fields-long-press-copy
feat(profile): long-press to copy Nostr Address, Website, LN Address, identities, payment targets
2026-05-18 15:31:13 -04:00
Vitor Pamplona
869447cfb1 Merge pull request #2962 from davotoula/fix/relay-authenticator-concurrent-map
Thread-safe authStatus in RelayAuthenticator (#2946)
2026-05-18 15:30:36 -04:00
Vitor Pamplona
f57facab06 Merge pull request #2963 from vitorpamplona/claude/fix-git-repo-ui-overlap-LWtb9
Apply scaffold padding to GitRepositoryOverview
2026-05-18 15:24:44 -04:00
Claude
ee18490761 fix: pad GitRepositoryOverview content to clear top/bottom bars
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.
2026-05-18 19:16:32 +00:00
davotoula
fcf704a3c6 fix(quartz): make RelayAuthenticator authStatus thread-safe (#2946)
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.
2026-05-18 18:34:17 +02:00
David Kaspar
5cd9d3d873 Merge pull request #2950 from mstrofnone/feat/electrumx-add-ethicnology
feat(electrumx): add electrum.nmc.ethicnology.com to default server set
2026-05-18 18:26:21 +02:00
davotoula
67fc5608cc sonar fixes 2026-05-18 16:54:19 +02:00
David Kaspar
6c71eec2fa Merge pull request #2961 from nrobi144/fix/desktop-proguard-jackson-crash
fix(desktop): fix release build crashes (ProGuard, VLC, jlink modules)
2026-05-18 16:39:47 +02:00
David Kaspar
b51de1ae19 Merge pull request #2960 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-18 16:38:59 +02:00
nrobi144
de5dc34cb7 fix(desktop): fix macOS VLC bundled discovery and video rendering
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>
2026-05-18 16:52:43 +03:00
nrobi144
935ad84ac3 fix(desktop): fix release build ProGuard rules that crash app on launch
ProGuard in release builds (packageReleaseDmg/Deb/Rpm) strips classes
accessed via reflection, JNI, or service loading — causing multiple
runtime failures:

- Jackson ExceptionInInitializerError: enum constants stripped, NPE in
  MapperConfig.collectFeatureDefaults() (#2929, 5000 sats bounty)
- JNA/VLCJ: static methods stripped, SIGABRT in native callbacks
- secp256k1/SQLite/kmp-tor: JNI loader classes stripped
- Coil/okhttp/okio: image loading and networking broken
- Kotlin metadata stripped: Jackson can't call default constructors

Changes:
- Upgrade ProGuard 7.7.0 → 7.9.1 (Kotlin 2.3 metadata support)
- Disable optimization (-dontoptimize) to prevent bytecode rewriting
  that produces VerifyError (Guardsquare/proguard#460)
- Add -keep rules for all JNI/reflection-dependent libraries
- Keep Kotlin @Metadata annotations for Jackson deserialization
- Suppress Kotlin 2.3 compile-time stub warnings

Tested: release DMG builds and launches without crash on macOS.

Closes #2929

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-18 16:52:27 +03:00
Crowdin Bot
543c4dfeb8 New Crowdin translations by GitHub Action 2026-05-18 12:50:46 +00:00
Vitor Pamplona
34f5dcac69 Merge pull request #2959 from greenart7c3/claude/fix-blossom-cache-url-8zolt
fix(blossom): only bridge to local cache when URL is BUD-01 layout
2026-05-18 08:49:22 -04:00
mstrofnone
69190e7810 feat(profile): long-press to copy Nostr Address, Website, LN Address, identities, payment targets
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.
2026-05-18 15:53:16 +10:00
mstrofnone
1f47a63470 feat(onchain-zaps): enable Send when typed name resolves via NIP-05
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.
2026-05-18 13:50:47 +10:00
Vitor Pamplona
447c662dff Merge pull request #2954 from vitorpamplona/claude/fix-illegal-argument-exception-b572Q
Fix public chat deduplication by channel ID extraction
2026-05-17 22:12:58 -04:00
Claude
cc83c24427 fix: dedupe public-channel rows in chatroom list updates
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.
2026-05-18 01:43:08 +00:00
Claude
da2145459e fix: normalize video/x-m4v to video/mp4 when saving to MediaStore
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.
2026-05-18 01:40:00 +00:00
Vitor Pamplona
51e136abdd Merge pull request #2953 from greenart7c3/claude/fix-payment-targets-loading-fx9jo
fix: subscribe and observe PaymentTargetsEvent for other users
2026-05-17 21:03:59 -04:00
Claude
961c649901 feat: explain hashtag-limit hide reason in HiddenNote
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.
2026-05-18 00:58:36 +00:00
Claude
e719d9ef00 fix(imeta): accept floating-point dimensions so image space is reserved pre-load
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
2026-05-17 23:22:10 +00:00
Claude
27488f8f34 fix: subscribe and observe PaymentTargetsEvent for other users
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.
2026-05-17 22:20:48 +00:00
David Kaspar
358bd6a325 Merge pull request #2952 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-17 22:36:21 +02:00
Claude
9390c25b2c feat: extract notification settings into dedicated screen
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.
2026-05-17 17:49:45 +00:00
Crowdin Bot
7b6293b945 New Crowdin translations by GitHub Action 2026-05-17 15:49:38 +00:00
David Kaspar
4044c9341c Merge pull request #2951 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-17 17:48:02 +02:00
Crowdin Bot
92a25e3a38 New Crowdin translations by GitHub Action 2026-05-17 12:34:52 +00:00
davotoula
53c6d5d14d i18n: translate call-permission and git-repo strings (cs, pt-BR, sv, de)
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>
2026-05-17 14:26:26 +02:00
m
968a396779 feat(electrumx): add electrum.nmc.ethicnology.com to default server set
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)
2026-05-17 17:09:42 +10:00
Vitor Pamplona
0cf8ecef13 Merge pull request #2949 from vitorpamplona/claude/add-zaps-to-stats-Z2qHO
feat(notifications): include onchain zaps in summary stats
2026-05-16 19:07:39 -04:00
Vitor Pamplona
57acfe200a Merge pull request #2948 from vitorpamplona/claude/change-hashtag-limit-Jujia
Raises the default maximum hashtag limit from 5 to 8
2026-05-16 19:06:22 -04:00
Vitor Pamplona
55802fa21b Merge pull request #2947 from vitorpamplona/claude/restore-lightning-url-field-LdXOc
feat(profile): restore Lightning Address + LNURL fields in Edit Profile
2026-05-16 19:05:53 -04:00
Claude
09c4d70048 fix(blossom): only bridge to local cache when URL is BUD-01 layout
`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.
2026-05-16 22:45:06 +00:00
Claude
92b1930228 feat(notifications): include onchain zaps in summary stats
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.
2026-05-16 22:28:30 +00:00
Claude
e5cf575602 feat(video): release MediaController after 30s in background
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
2026-05-16 22:25:19 +00:00
Claude
8ffe1aee4f feat(wallet): clickable onchain card opens transaction history
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.
2026-05-16 22:19:47 +00:00
Claude
44234aa648 feat(profile): inline Lightning Address + LNURL fields in Edit Profile
Promote the two lightning fields out of an expandable section so they
sit alongside name/about/nip05/website as regular always-visible inputs.

https://claude.ai/code/session_01R2E6rMfhxKA8csVarjxKJd
2026-05-16 22:16:52 +00:00
Claude
7e750d2517 feat(profile): restore Lightning Address + LNURL fields in Edit Profile
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
2026-05-16 22:00:30 +00:00
Claude
4a23ecf7ef Raises the default maximum hashtag limit from 5 to 8 2026-05-16 21:38:58 +00:00
Claude
ca9f7233f2 test: verify real-world 5.5KB nowhr.xyz/s store link round-trips through the parser 2026-05-16 21:16:45 +00:00
Claude
a1e37109c3 test: cover ~5KB nowhere-link fragments end-to-end through the parser 2026-05-16 21:01:53 +00:00
Vitor Pamplona
280f21159f v1.10.0 2026-05-16 16:53:00 -04:00
Claude
637bb4aba5 feat(privacy): persist I2pSettings and preferredClearnetTransport
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.
2026-05-16 20:44:28 +00:00
Claude
318e4250de feat: render nowhere links inline as branded cards
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.
2026-05-16 20:35:37 +00:00
Vitor Pamplona
463c61c61e Removes marmot debug lines 2026-05-16 16:31:27 -04:00
Vitor Pamplona
b96f1b5847 Merge pull request #2944 from vitorpamplona/claude/nip88-polls-quartz-p5fBa
Add NIP-BC onchain Bitcoin zaps support (send, receive, display)
2026-05-16 16:27:31 -04:00
Claude
627b75681f feat(wallet): redesign onchain card to match NWC WalletCard
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.
2026-05-16 20:21:25 +00:00
Claude
e4b8e7ccc8 feat(onchain-zaps): redesign send sheet + render in thread NoteMaster
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.
2026-05-16 20:16:21 +00:00
Claude
a011421ff5 refactor(privacy): single preferred clearnet transport, fail-closed hidden services
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.
2026-05-16 20:06:35 +00:00
Claude
5aedcd0a19 feat(onchain-zaps): user search + chip wrapping in send dialog
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.
2026-05-16 20:05:09 +00:00
Vitor Pamplona
3f12bdc2d7 Merge pull request #2943 from vitorpamplona/claude/fix-show-more-gradient-WruXm
Fix gradient rendering with transparent background colors
2026-05-16 15:59:04 -04:00
Claude
1d5ce994a6 fix(onchain-zaps): UserProfileZapsViewModel crashed casting kind 8333 as LnZapEvent
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).
2026-05-16 19:56:22 +00:00
Claude
e6a5ba9e15 fix: use theme background when ShowMore gradient color is transparent
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.
2026-05-16 19:46:39 +00:00
Claude
6361fb1c9d feat(onchain-zaps): rich NoteCompose render for kind 8333
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.
2026-05-16 19:42:01 +00:00
Claude
fb2f05d9cb feat: scaffold I2P as a parallel privacy transport to Tor
Foundational types for offering I2P alongside the existing internal Tor.
No wiring yet — HTTP managers, RoleBasedHttpClientBuilder, the Android
I2P service and the Privacy settings UI follow in later commits.

Quartz (relay URL classifier):
- Add isI2p() and classifyHidden() to RelayUrlNormalizer
- Add HiddenServiceKind { CLEARNET, LOCALHOST, ONION, I2P }
- Add NormalizedRelayUrl.isI2p() / classifyHidden() extensions
- Extend the scheme-default branch so .i2p hosts default to ws:// like .onion

Commons (transport-agnostic types):
- PrivacyTransport enum { DIRECT, TOR, I2P }
- TransportChoice (UI-facing per-feature picker, screen-coded for persistence)
- FeatureRole + FeatureTransportChoices: per-feature picks for clearnet traffic
- PrivacySettings aggregate { tor, i2p, features }
- PrivacyRouter.route(url, role, settings): hostname pin for hidden services,
  per-feature choice for clearnet, downgrades to DIRECT if backing transport is OFF

Commons (I2P settings model, mirrors tor/):
- I2pSettings, I2pType (OFF/INTERNAL/EXTERNAL), I2pRelaySettings
- I2pRelayEvaluation, I2pServiceStatus
- II2pManager, II2pSettingsPersistence (platform-agnostic interfaces)
- PrivacyRelayEvaluation composing TorRelayEvaluation + I2pRelayEvaluation

Tests:
- PrivacyRouterTest covers localhost bypass, onion pin, i2p pin, hostname-wins-over-picker,
  per-feature picks routing independently, downgrade-when-transport-OFF, .b32.i2p
2026-05-16 19:37:06 +00:00
Vitor Pamplona
f1d02f3c60 Merge pull request #2942 from vitorpamplona/claude/fix-call-button-permission-63OLL
Handle denied call permissions with settings dialog
2026-05-16 15:18:18 -04:00
Claude
8a5d0019c1 Merge remote-tracking branch 'origin/main' into claude/nip88-polls-quartz-p5fBa 2026-05-16 19:15:32 +00:00
Vitor Pamplona
05467582b4 Merge pull request #2941 from vitorpamplona/claude/fix-notecompose-read-status-rslby
Sync inner note highlight fade with parent background color
2026-05-16 15:14:31 -04:00
Vitor Pamplona
f901db699d Merge pull request #2940 from vitorpamplona/claude/fix-ci-build-desktop-2qHSV
Replace linuxdeploy with appimagetool for AppImage packaging
2026-05-16 15:08:44 -04:00
Claude
19309c4587 fix: also fade reply previews together with the parent NoteCompose
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.
2026-05-16 19:07:26 +00:00
Claude
7ed93bcbc2 fix(desktop): package AppImage with appimagetool, not linuxdeploy
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.
2026-05-16 19:04:07 +00:00
Claude
e885192f51 fix: surface call-permission denial instead of silent failure
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().
2026-05-16 18:49:26 +00:00
Claude
523b0616cf fix: quoted NoteCompose stuck on the "new item" purple background
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.
2026-05-16 18:45:26 +00:00
Vitor Pamplona
63ddb5159f Merge pull request #2939 from vitorpamplona/claude/remove-mac-13-cio-1mGRB
ci: remove macos-13 x64 build legs
2026-05-16 14:41:01 -04:00
Claude
25a49d1050 ci: remove macos-13 x64 build legs
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.
2026-05-16 17:18:51 +00:00
Claude
d77f2f7333 docs(onchain-zaps): record the hand-rolled Bitcoin consensus code decision
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.
2026-05-14 18:16:30 +00:00
Claude
07724e410b fix(onchain-zaps): share the OTS Tor-aware explorer endpoint + server setting
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.
2026-05-14 17:26:44 +00:00
Claude
db6254872e fix(onchain-zaps): recover the OnchainZapBuilder package + re-audit fixes
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.
2026-05-14 13:55:18 +00:00
Claude
1d7be1d444 fix(onchain-zaps): address audit findings — fund-safety, interop, robustness
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.
2026-05-14 13:29:48 +00:00
Claude
5f16ebc060 feat(quartz): NIP-BC sign_psbt over NIP-55 external signer (Phase B)
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.
2026-05-14 12:50:47 +00:00
Claude
bb0f41d320 docs: mark onchain zaps Phase D (send flow) shipped 2026-05-14 12:31:11 +00:00
Claude
40ccffee35 feat: NIP-BC onchain zap send flow (Phase D)
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.
2026-05-14 12:29:31 +00:00
Claude
f5e7240425 docs: mark onchain zaps Phase A.2 (send-side foundation) shipped 2026-05-14 11:32:15 +00:00
Claude
a42b1e0f32 feat(quartz): NIP-BC onchain zap send-side foundation (Phase A.2)
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).
2026-05-14 11:29:47 +00:00
Claude
bb5613ca8b docs: update onchain zaps plan — A.1 + C shipped, A.2/B/D pending
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.
2026-05-14 04:59:23 +00:00
Claude
6ce193d46a feat(amethyst): show onchain balance on wallet screen Bitcoin section
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.
2026-05-14 04:58:18 +00:00
Claude
031159b3f6 feat(amethyst): NIP-BC onchain zap receive + display
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.
2026-05-14 04:54:40 +00:00
Claude
79f1d43581 feat(quartz): NIP-BC onchain zap receive-side foundation
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.
2026-05-14 04:34:30 +00:00
Claude
d095dc790d feat(quartz): add NIP-BC onchain zaps (kind 8333)
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

View File

@@ -12,14 +12,27 @@ architecture while sharing the back end components with the android counterpart.
a non-interactive JVM command-line client that drives the same `quartz` + `commons` code — used by
humans, agents, and interop tests. `quic` is a from-scratch pure-Kotlin QUIC v1 + HTTP/3 +
WebTransport client (no JNI, no BouncyCastle), built because no Android-compatible Java QUIC library
exists. `nestsClient` runs the audio-room protocol on top of `:quic` for the NIP-53
audio-rooms feature. It implements both IETF `draft-ietf-moq-transport-17` (under
exists. `geode` is a standalone JVM Nostr relay (Ktor) built on quartz's
relay-server code; smaller modules are `benchmark` (Android macrobenchmarks) and
`quic-interop` (QUIC interop runner, lives at `quic/interop`). `nestsClient` runs
the audio-room protocol on top of `:quic` for the NIP-53 audio-rooms feature. It implements both IETF `draft-ietf-moq-transport-17` (under
`moq/`) and **moq-lite Lite-03** (kixelated's variant, under `moq/lite/`); the
production listener AND speaker paths both run on moq-lite to interop with the
nostrnests reference relay. The IETF code is kept as a reference + unit-test
implementation for any future IETF target; see
`nestsClient/plans/2026-04-26-moq-lite-gap.md`.
Canonical NIP specs live at <https://github.com/nostr-protocol/nips> — use
`/nip <number>` to pull a specific one (it fetches the spec file directly).
## Verify, Don't Guess
Don't assert a diagnosis you haven't reproduced. This repo gives you cheap
verification tools: `./gradlew test`, per-module test suites, and the `amy`
CLI (built partly to drive `quartz`/`commons` for interop checks). If a
claim is checkable in under a minute, check it before stating it — write
the failing case first, watch it fail, then explain.
## Architecture
```
@@ -40,10 +53,11 @@ amethyst/
│ ├── commonMain/ # Protocol, frame/packet codecs, TLS state machine
│ ├── jvmAndroid/ # JCA-backed AEAD + UDP socket actuals
│ └── commonTest/ # RFC vector + adversarial tests
├── nestsClient/ # Audio-room client (IETF MoQ-transport today; moq-lite phase pending)
├── nestsClient/ # Audio-room client (production runs on moq-lite; IETF MoQ kept as reference)
│ └── src/
│ ├── commonMain/ # MoQ session, NestsListener, audio glue
│ └── jvmAndroid/ # Opus encode/decode, AudioRecord/AudioTrack
├── geode/ # Standalone JVM Nostr relay (Ktor) on quartz's relay-server code
├── desktopApp/ # Desktop JVM application (layouts, navigation)
├── amethyst/ # Android app (layouts, navigation)
└── cli/ # Amy — non-interactive CLI (JVM only, no Compose)
@@ -51,7 +65,12 @@ amethyst/
**Sharing Philosophy:**
- `quartz/` = Nostr business logic, protocol, data (no UI)
- `commons/` = Shared UI components, icons, composables, flows and ViewModels
- `commons/` = Shared code for every front end (Android, Desktop, iOS, and the
headless `cli`): domain models, state holders, ViewModels, the relay client,
shared services, **and** the Compose UI that ≥1 GUI front end renders. The
package taxonomy, the CLI-safe / UI boundary, and a "where does my code go?"
guide are documented in **`commons/ARCHITECTURE.md`** — read it before adding
a new package or dropping code into `commons`.
- `quic/` = Transport library (QUIC + HTTP/3 + WebTransport); reusable for any
KMP project that needs MoQ. Has no Android-framework dependencies.
- `nestsClient/` = MoQ + audio-rooms client; takes `:quic` as transport,
@@ -65,191 +84,62 @@ The global `docs/plans/` folder is frozen — don't add new plans there.
## Tech Stack
Exact versions live in `gradle/libs.versions.toml` (the source of truth — check
there rather than trusting a number copied here).
| Layer | Technology |
|-------|------------|
| **Core** | Quartz (Nostr KMP) |
| **UI** | Compose Multiplatform 1.10.3 |
| **UI** | Compose Multiplatform |
| **Async** | kotlinx.coroutines + Flow |
| **Network** | OkHttp (JVM) |
| **Serialization** | Jackson |
| **DI** | Manual / Koin |
| **Build** | Gradle 8.x, Kotlin 2.3.20 |
| **Build** | Gradle + Kotlin Multiplatform |
## Skills
Specialized skills provide domain expertise with bundled resources and patterns:
The full list of available skills (with descriptions and triggers) is injected
into every session, so it isn't duplicated here. Two kinds exist and are meant
to be used together:
| Skill | Expertise | When to Use |
|-------|-----------|-------------|
| `nostr-expert` | Nostr protocol (Quartz library) | Event types, NIPs, tags, signing, Bech32, NIP-44, LargeCache |
| `kotlin-expert` | Advanced Kotlin patterns | StateFlow, sealed classes, @Immutable, DSLs, common utilities |
| `kotlin-coroutines` | Advanced async patterns | supervisorScope, callbackFlow, relay pools, testing |
| `kotlin-multiplatform` | Platform abstraction | expect/actual, source sets, sharing decisions |
| `compose-expert` | Shared UI components | Material3, state hoisting, recomposition, rich-text parsing |
| `android-expert` | Android platform | Navigation, permissions, lifecycle, Material3, Coil image loading |
| `desktop-expert` | Desktop platform | Window, MenuBar, keyboard shortcuts, DeckLayout |
| `gradle-expert` | Build system | Dependencies, versioning, packaging, optimization |
| `account-state` | `Account` + `LocalCache` | Per-user StateFlows, event store, adding account-scoped settings |
| `relay-client` | Subscriptions & filter assembly | `ComposeSubscriptionManager`, assemblers, preloaders, EOSE |
| `feed-patterns` | Feeds & DAL | `FeedFilter`, `AdditiveComplexFeedFilter`, `FeedViewModel` family |
| `auth-signers` | `NostrSigner` implementations | Local, NIP-46 bunker, NIP-55 Android external signer |
| `quartz-integration` | Quartz as an external library | Gradle setup, `NostrClient`, `KeyPair`, for external projects |
| `amy-expert` | Amy CLI (`cli/` module) | Adding `amy <verb>` commands, JSON output contract, extracting logic from `amethyst/` into `commons/` so CLI can call it |
| `find-missing-translations` | Utility | Extract untranslated Android strings |
| `find-non-lambda-logs` | Utility | Audit Log calls for lambda overloads |
### Technique-layer skills (Compose / Kotlin best practices)
These are general Compose/Kotlin decision-framework skills (vendored from
`chrisbanes/skills`). The skills above are **codebase-oriented** ("where is X in
Amethyst, what pattern do we use"); these are **technique-oriented** ("what is
the correct Compose/Kotlin design here"). They complement — not replace — the
codebase skills: e.g. `compose-expert` tells you where shared composables live,
`compose-slot-api-pattern` tells you how to shape their public API.
| Skill | Expertise | Complements |
|-------|-----------|-------------|
| `compose-recomposition-performance` | Router: which recomposition axis is the problem | `compose-expert` |
| `compose-stability-diagnostics` | Compiler reports, strong skipping, `ImmutableList` at UI boundaries | `compose-expert`, `kotlin-expert` |
| `compose-state-deferred-reads` | Phase-aware state reads, block-form modifiers, provider lambdas | `compose-expert` |
| `compose-slot-api-pattern` | `@Composable` slot design for reusable components | `compose-expert` |
| `compose-modifier-and-layout-style` | `modifier` parameter conventions, chain construction, conditional hoisting | `compose-expert` |
| `compose-side-effects` | `LaunchedEffect`/`DisposableEffect`/`SideEffect`, keys, `rememberUpdatedState` | `compose-expert` |
| `compose-state-holder-ui-split` | State-holder vs plain-UI composable split | `compose-expert`, `feed-patterns` |
| `kotlin-flow-state-event-modeling` | StateFlow/SharedFlow/Channel choice, sentinels, `stateIn`, `update {}` | `kotlin-expert` |
| `kotlin-coroutines-structured-concurrency` | Stored-scope anti-pattern, `suspend` boundaries, `runBlocking`, cancellation | `kotlin-coroutines` |
| `kotlin-types-value-class` | `@JvmInline value class` vs `data class`, Compose stability | `kotlin-expert` |
## Workflow
**When you ask for a feature:**
1. **Quick skill assessment** - I identify which skills are relevant
2. **Propose which skills** - I present which skills I'll use for the task
3. **Get approval** - You review and approve (or adjust) the skill selection
4. **Review plan using approved skills** - I invoke the approved skills to create detailed implementation plan
5. **Execute with skills** - Skills collaborate to implement the feature
**Example:**
```
You: "Add video support to notes"
Me: "I'll use:
- /nostr-expert (NIP-71 video events)
- /compose-expert (video player UI)
- /android-expert (platform video APIs)
Proceed?"
You: "yes"
Me: [invokes skills to create plan]
"Plan from skills:
1. nostr-expert: Use NIP-71 kind 34235 for video events...
2. compose-expert: Create VideoPlayer composable in commons...
3. android-expert: Use ExoPlayer for Android...
Proceed with implementation?"
You: "yes"
Me: [implements using skill guidance]
```
## Commands
- `/desktop-run` - Build and run desktop app
- `/nip <number>` - Get NIP implementation guidance
- **Codebase-oriented** skills (`nostr-expert`, `compose-expert`, `feed-patterns`,
`account-state`, `amy-expert`, …) answer "where is X in Amethyst, what pattern
do we use here."
- **Technique-oriented** skills (vendored from `chrisbanes/skills`, e.g.
`compose-slot-api-pattern`, `kotlin-flow-state-event-modeling`) answer "what is
the correct Compose/Kotlin design." They complement, not replace, the codebase
skills: `compose-expert` tells you where shared composables live;
`compose-slot-api-pattern` tells you how to shape their public API.
## Feature Workflow
**CRITICAL: Always check existing implementations first before creating new code!**
**CRITICAL: Check existing implementations first — most logic already exists.**
Before writing code, survey all modules (use Grep/Explore) for managers, caches,
state systems, filters, ViewModels, and composables that already do the job. Your
job is usually to **reuse** (`quartz` protocol/business logic), **extract**
(Android UI/ViewModels → `commons`), and add **platform-specific** layouts/nav —
not to duplicate existing managers, caches, or state.
When picking up a new task or feature, follow this process:
Summarize the survey in your plan: for each component, note whether it's
reused as-is, extracted from `amethyst/` to `commons/`, genuinely new
(platform-specific only), or a duplicate of an existing pattern to avoid.
### Step 0: Survey Existing Implementation (MANDATORY)
**Share vs keep platform-native:**
**Before writing ANY code, thoroughly audit ALL modules:**
- **Share** → `quartz/commonMain/` (business logic, data models, protocol) and
`commons/commonMain/` (major UI components, **ViewModels** under
`viewmodels/`, icons). ViewModels are platform-agnostic state + logic
(StateFlow/SharedFlow), so they belong in `commons`.
- **Keep native** → screen composables/scaffolding (Desktop `Window` vs Android
`Activity`), navigation (sidebar vs bottom nav), platform interactions
(gestures, keyboard shortcuts), system integrations (notifications, file
pickers).
1. **Search for existing implementations across all modules:**
```bash
# Search in quartz for protocol/business logic
grep -r "class.*Manager\|object.*Cache\|class.*Filter" quartz/src/commonMain/
# Search in commons for UI components
grep -r "@Composable.*Card\|@Composable.*View\|@Composable.*Dialog" commons/src/
# Search in amethyst for Android patterns
grep -r "class.*ViewModel\|class.*Account\|class.*State" amethyst/src/main/java/
# Search for specific functionality
grep -r "fun isFollowing\|fun subscribe\|fun getMetadata" {quartz,commons,amethyst}/src/
```
2. **Understand existing architecture patterns:**
- Event stores and caching systems
- State management patterns (StateFlow, mutable states)
- ViewModel patterns and lifecycle handling
- Filter builders and relay subscription patterns
- UI component hierarchies
3. **Key principle:** Most logic already exists! Your job is to:
- **Reuse** existing protocol/business logic from quartz
- **Extract** shareable UI components AND ViewModels from amethyst to commons
- Create **platform-specific** layouts/navigation for Desktop
- **NOT** duplicate existing managers, caches, or state systems
4. **Document findings in implementation plan as a matrix:**
| File/Component | Status | Location | Action |
|----------------|--------|----------|--------|
| FilterBuilders | ✅ Exists | quartz/relay/filters/ | Reuse as-is |
| NoteCard | 📦 Extract | amethyst/ui/note/ → commons/ | Extract to commons |
| HomeFeedViewModel | 📦 Extract | amethyst/ → commons/commonMain/viewmodels/ | Extract to commons |
| ProfileCache | ⚠️ Avoid | N/A | Already in User/Account pattern |
**Legend:**
- ✅ **Reuse** - Exists and can be used directly
- 📦 **Extract** - Exists in Android, needs extraction to commons
- 🆕 **New** - Doesn't exist, needs creation (platform-specific only)
- ⚠️ **Avoid** - Duplicate functionality, use existing pattern instead
### Step 1: Analyze Android Implementation
After surveying (Step 0), deeply examine the Android implementation:
1. Find the relevant feature/component in `amethyst/` module
2. Understand the current implementation patterns
3. Identify dependencies and integrations
4. Map out what code can be shared vs platform-specific
### Step 2: Create Implementation Plan
Before coding, create a plan that categorizes work into three buckets:
| Category | Description | Location |
|----------|-------------|----------|
| **Android-Specific** | Platform-native layouts, navigation patterns | `amethyst/`, `androidMain/` |
| **Reusable (Shared)** | Business logic, UI components, **ViewModels**, state management | `quartz/commonMain/`, `commons/commonMain/` |
| **Desktop-Specific** | Desktop-native layouts, navigation patterns, platform APIs | `desktopApp/`, `jvmMain/` |
### Step 3: Code Sharing Strategy
**Share:**
- Business logic and data models → `quartz/commonMain/`
- Major UI components (cards, lists, dialogs) → `commons/commonMain/`
- **ViewModels** (state, business logic) → `commons/commonMain/viewmodels/`
- Icons and visual assets → `commons/commonMain/`
**Keep Platform-Native:**
- **Screen composables** (layout, scaffolding) - Desktop uses `Window`, Android uses `Activity`
- Navigation patterns (sidebar vs bottom nav)
- Platform-specific interactions (gestures, keyboard shortcuts)
- 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*
module (`quartz`, `commons`, `amethyst`, `desktopApp`, `cli`, `quic`,
`nestsClient`, …), whether you add it to `gradle/libs.versions.toml` or to a
module's `build.gradle.kts`: determine its license **before** wiring it in.
Amethyst ships under the **MIT** license, so a copyleft dependency linked into a
distributed artifact (APK, desktop binary) can force that artifact's
combined-work terms onto the whole project.
Verify against the dependency's actual `LICENSE`/`COPYING` file or its published
POM — **not from memory**. Then classify and act:
- **Permissive** (MIT, Apache-2.0, BSD, ISC, MPL-2.0, zlib, …) → **OK**,
proceed.
- **LGPL, or GPL/EPL with a linking / Classpath exception** → **WARN.**
Acceptable to link (the exception keeps our own code MIT), but call it out in
your summary so the human knows. Confirm the exception actually exists in the
LICENSE text — don't assume it does.
- **Stricter than LGPL** — GPL/AGPL **without** a linking exception, SSPL,
proprietary/commercial-only, or anything where the linking-exception check is
"no" → **STRONGLY WARN and STOP.** Do not add it silently. Surface it
prominently and **require an explicit call-out in the PR description** so a
maintainer makes the decision. Prefer a permissive alternative, a clean-room
implementation, or dropping the feature.
For any GPL-family hit the decisive question is always **"is there a linking
(LGPL/Classpath) exception?"** — that is what separates a WARN from a STOP.
(Example: TarsosDSP, GPLv3 with no exception, was removed from `amethyst` and
replaced with an in-house pitch shifter for exactly this reason.)
## Quartz KMP Structure
The Quartz library uses expect/actual for platform-specific implementations:
Quartz uses expect/actual for platform-specific implementations (e.g. crypto
backed by `secp256k1-kmp-jni-android` on Android and `secp256k1-kmp-jni-jvm` on
JVM). See `/kotlin-multiplatform` for the expect/actual and source-set patterns.
```kotlin
// commonMain - shared protocol logic
expect class CryptoProvider {
fun sign(message: ByteArray, privateKey: ByteArray): ByteArray
fun verify(message: ByteArray, signature: ByteArray, publicKey: ByteArray): Boolean
}
## Icons
// androidMain - uses secp256k1-kmp-jni-android
actual class CryptoProvider { /* Android implementation */ }
The Material Symbols font bundled at
`commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf`
is a **subset** that only contains the glyphs referenced from
`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt`.
// jvmMain - uses secp256k1-kmp-jni-jvm
actual class CryptoProvider { /* JVM implementation */ }
**MANDATORY:** Whenever you add a new icon — i.e. introduce a
`MaterialSymbol("\uXXXX")` codepoint that wasn't already referenced anywhere in
`MaterialSymbols.kt` — you MUST regenerate the subset font by running:
```bash
./tools/material-symbols-subset/subset.sh
```
## Key Patterns
Commit the regenerated `material_symbols_outlined.ttf` alongside your
`MaterialSymbols.kt` change. Without this step the new icon renders as tofu (□)
at runtime because the glyph is not in the bundled font.
### Platform Abstraction
```kotlin
// commonMain
expect fun openExternalUrl(url: String)
// androidMain
actual fun openExternalUrl(url: String) {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
}
// jvmMain (Desktop)
actual fun openExternalUrl(url: String) {
Desktop.getDesktop().browse(URI(url))
}
```
Reusing a codepoint already present in `MaterialSymbols.kt` does NOT require
regenerating. See `tools/material-symbols-subset/README.md` for details and
prerequisites (`pip install fonttools brotli`).
## Code Formatting
After completing any task that modifies Kotlin files, always run:
@@ -329,12 +243,5 @@ Do this before considering the task complete.
## Git Workflow
- Branch: `feat/desktop-<feature>` or `fix/desktop-<issue>`
- Commits: Conventional commits (`feat:`, `fix:`, etc.)
- Never use `--no-verify`
## Resources
- [Nostr NIPs](https://github.com/nostr-protocol/nips)
- [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/)
- [KMP Documentation](https://kotlinlang.org/docs/multiplatform.html)

View File

@@ -12,7 +12,7 @@ Build and run the Amethyst Desktop application:
If the build fails, check:
1. **JDK Version**: Requires JDK 17+
1. **JDK Version**: Requires JDK 21+ (`jvmToolchain(21)` in `desktopApp/build.gradle.kts`)
```bash
java -version
```
@@ -39,7 +39,7 @@ If the build fails, check:
./gradlew :desktopApp:packageMsi
# Linux
./gradlew :desktopApp:packageDeb
./gradlew :desktopApp:packageDeb # or :desktopApp:packageRpm
```
Outputs will be in `desktopApp/build/compose/binaries/`
Outputs will be in `desktopApp/build/compose/binaries/main/`

View File

@@ -8,7 +8,7 @@ Extract the component `$ARGUMENTS` from the Android app to shared KMP code:
1. **Locate the component** in the amethyst module:
```bash
find amethyst/src -name "*$ARGUMENTS*" -o -name "*$ARGUMENTS*"
find amethyst/src -name "*$ARGUMENTS*"
grep -r "fun $ARGUMENTS\|class $ARGUMENTS" amethyst/src/
```
@@ -18,7 +18,7 @@ Extract the component `$ARGUMENTS` from the Android app to shared KMP code:
- Android Compose specifics vs standard Compose
3. **Identify what can be shared**:
- Pure Composable functions → `shared-ui/commonMain/`
- Pure Composable functions → `commons/commonMain/`
- Business logic → `quartz/commonMain/`
- Platform-specific → create expect/actual

View File

@@ -1,339 +1,37 @@
# AmethystMultiplatform Skills Creation Plan
## Overview
Create 8 hybrid domain skills combining general expertise with AmethystMultiplatform-specific patterns.
**Approach:** Each skill provides domain knowledge + project-specific implementation patterns from codebase.
## Skills to Implement
### 1. kotlin-multiplatform ✅ COMPLETED
**Focus:** KMP architecture, jvmAndroid source set pattern, expect/actual
**SKILL.md sections:**
- Mental model: KMP hierarchy as dependency graph
- Source set architecture: commonMain → jvmAndroid → {androidMain, jvmMain}
- The jvmAndroid pattern (unique to this project, verified in quartz/build.gradle.kts:132-149)
- expect/actual mechanics with 24+ examples from codebase
- iOS framework setup for Quartz distribution
**Bundled resources:**
- `references/source-set-hierarchy.md` - Visual diagram + examples
- `references/expect-actual-catalog.md` - All 24 expect/actual pairs with patterns
- `scripts/validate-kmp-structure.sh` - Verify source set dependencies
- `assets/kmp-hierarchy-diagram.png` - Visual graph
**Differentiation:** Existing kotlin-multiplatform agent = general KMP. This skill = Amethyst's unique jvmAndroid pattern, concrete examples.
**Status:** ✅ Skill created and packaged at `.claude/skills/kotlin-multiplatform/`
---
### 2. gradle-expert ✅ COMPLETED
**Focus:** Build optimization, dependency resolution, multi-module KMP troubleshooting
**SKILL.md sections:**
- Build architecture: 4 modules, dependency flow
- Version catalog mastery (libs.versions.toml)
- Module dependency patterns (api vs implementation)
- Android-specific: compileSdk, proguard
- Desktop packaging: TargetFormat, distributions
- Build performance: daemon, parallel, caching
- Common errors: compose version conflicts, secp256k1 JNI variants
**Bundled resources:**
- `references/build-commands.md` - Common gradle tasks
- `references/dependency-graph.md` - Module visualization
- `references/version-catalog-guide.md` - Version catalog patterns
- `references/common-errors.md` - Troubleshooting guide
- `scripts/analyze-build-time.sh` - Performance report
- `scripts/fix-dependency-conflicts.sh` - Conflict patterns
**Differentiation:** Focus on 4-module structure, KMP + Android + Desktop combo, specific issues (compose conflicts).
**Status:** ✅ SKILL.md (549 lines) + 4 references + 2 scripts created at `.claude/skills/gradle-expert/`
---
### 3. kotlin-expert ✅ DRAFT COMPLETE
**Focus:** Flow state management, sealed hierarchies, immutability, DSL builders, inline/reified
**SKILL.md sections:**
- Flow state management: StateFlow/SharedFlow patterns (AccountManager, RelayConnectionManager)
- Sealed hierarchies: sealed class vs sealed interface decision trees (AccountState, SignerResult)
- Immutability: @Immutable for Compose performance (173+ event classes)
- DSL builders: Type-safe fluent APIs (TagArrayBuilder, TlvBuilder)
- Inline functions: reified generics, performance optimization (OptimizedJsonMapper)
- Value classes: Zero-cost wrappers (optimization opportunity)
**Bundled resources:**
- `references/flow-patterns.md` - StateFlow/SharedFlow with AccountManager, RelayManager patterns
- `references/sealed-class-catalog.md` - All 8 sealed types in quartz with usage patterns
- `references/dsl-builder-examples.md` - TagArrayBuilder, PrivateTagArrayBuilder, TlvBuilder, custom DSL patterns
- `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)
5. ✅ DRAFT - Created SKILL.md + 4 reference files (flow, sealed, dsl, immutability)
6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS)
7. ✅ ITERATE - Draft complete (skipping deep iteration for now)
8. ⏸️ TEST - Deferred to later (requires real usage scenarios)
9. ⏸️ FINALIZE - Deferred to later
10. ✅ DOCUMENT - Updated plan
---
### 4. compose-expert ✅ COMPLETED
**Focus:** Shared composables, state management, animations, Material3
**SKILL.md sections:**
- Shared composables philosophy (100+ already shared in commons/commonMain)
- State management: remember, derivedStateOf, produceState (visual patterns)
- Recomposition optimization: @Stable/@Immutable (visual usage)
- Material3 conventions: theming
- Custom icons: ImageVector builders (robohash pattern)
- Platform differences: Desktop vs Android UI
- Performance: lazy lists, image loading
- Decision framework: share by default in commonMain
**Bundled resources:**
- `references/shared-composables-catalog.md` - Complete catalog with patterns
- `references/state-patterns.md` - State hoisting, derivedStateOf examples
- `references/icon-assets.md` - ImageVector patterns, roboBuilder DSL
- `scripts/find-composables.sh` - Grep @Composable utility
**Differentiation:** Multiplatform Compose patterns, shared vs platform UI philosophy, Amethyst conventions (robohash, custom icons). Delegates navigation to platform experts, defers Kotlin language details to kotlin-expert.
**Status:** ✅ SKILL.md (578 lines) + 3 references + 1 script created at `.claude/skills/compose-expert/`
---
### 5. ios-expert
**Focus:** iosMain patterns, Swift/KMP interop, XCFramework generation
**SKILL.md sections:**
- iOS source sets: iosMain, iosArm64Main
- Swift interop: type mapping, nullability
- expect/actual iOS: 10+ examples from quartz/iosMain
- XCFramework setup: baseName = "quartz-kmpKit"
- Platform APIs: platform.posix, CFNetwork, Security
- CocoaPods integration
- XCode project setup
**Bundled resources:**
- `references/ios-actual-implementations.md` - 10 iosMain actuals
- `references/swift-interop-guide.md` - Type mapping
- `references/xcode-integration.md` - XCode setup
- `scripts/generate-xcframework.sh` - Build all iOS targets
**Differentiation:** iOS platform specialization with Amethyst iosMain patterns, Quartz framework setup.
---
### 6. desktop-expert ✅ DRAFT COMPLETE
**Focus:** Desktop UX, window management, Compose Desktop APIs, OS-specific conventions
**SKILL.md sections:**
- Desktop entry point: application {} DSL
- Window management: WindowState, positioning, multi-window
- Menu system: MenuBar, keyboard shortcuts (OS-aware)
- System tray: minimize to tray
- Desktop navigation: NavigationRail pattern (vs Android bottom nav)
- File system: Desktop.getDesktop(), file pickers, drag-drop
- Desktop UX principles: keyboard-first, native feel, tooltips
- OS-specific behavior: macOS vs Windows vs Linux
- Platform detection: PlatformDetector utility
- Packaging: DMG, MSI, DEB distribution
**Bundled resources:**
- `references/desktop-compose-apis.md` - Complete Desktop API catalog (Window, Tray, MenuBar, Dialog, etc.)
- `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/`
**10-Step Progress:**
1. ✅ UNDERSTAND - Defined desktop usage scenarios
2. ✅ EXPLORE - Analyzed desktopApp/ module patterns (Main.kt, FeedScreen.kt, LoginScreen.kt)
3. ✅ RESEARCH - Compose Desktop APIs, OS-specific UX conventions (JetBrains docs, HIG)
4. ✅ SYNTHESIZE - Extracted desktop principles from codebase
5. ✅ DRAFT - Created SKILL.md + 4 reference files
6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS)
7. ✅ ITERATE - Draft complete (skipping deep iteration for now)
8. ⏸️ TEST - Deferred to later (requires real desktop scenarios)
9. ⏸️ FINALIZE - Deferred to later
10. ✅ DOCUMENT - Updated plan
---
### 7. android-expert ✅ DRAFT COMPLETE
**Focus:** Android platform APIs, navigation, permissions, Material Design
**SKILL.md sections:**
- Android module structure: amethyst/ layout
- Navigation: Navigation Compose, bottom nav
- Permissions: runtime (camera, biometric)
- Platform APIs: Intent, Context, ContentResolver
- Lifecycle: Lifecycle-aware, ViewModel
- Material Design: Android Material 3
- Build config: Proguard, R8
- Android UX: mobile-first patterns
**Bundled resources:**
- `references/android-navigation.md` - Navigation Compose
- `references/android-permissions.md` - Permission handling
- `references/proguard-rules.md` - Proguard explanation
- `scripts/analyze-apk-size.sh` - APK optimization
**Differentiation:** amethyst module structure, Android vs desktop patterns, Amethyst conventions.
**Status:** ✅ SKILL.md + 3 references + 1 script created at `.claude/skills/android-expert/`
**10-Step Progress:**
1. ✅ UNDERSTAND - Defined Android usage scenarios
2. ✅ EXPLORE - Analyzed amethyst/ module patterns
3. ✅ RESEARCH - Android best practices + KMP Android patterns
4. ✅ SYNTHESIZE - Extracted Android principles from codebase
5. ✅ DRAFT - Initialized skill, created resources
6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS)
7. ✅ ITERATE - Draft complete (skipping deep iteration for now)
8. ⏸️ TEST - Deferred to later
9. ⏸️ FINALIZE - Deferred to later
10. ✅ DOCUMENT - Updated plan
---
### 8. nostr-expert ✅ COMPLETED
**Focus:** Nostr protocol, NIPs, Quartz architecture, event patterns
**SKILL.md sections:**
- Quartz architecture: package structure by NIP (57 NIPs implemented)
- Event anatomy: IEvent, Event, kinds, tags
- EventTemplate & TagArrayBuilder DSL patterns
- Common event types: TextNoteEvent, MetadataEvent, ReactionEvent, Addressable events
- Tag patterns: e-tag, p-tag, a-tag, d-tag with builders
- Threading (NIP-10): reply/root markers
- Cryptography: secp256k1 signing, NIP-44 encryption
- Bech32 encoding: npub, nsec, note, nevent
- Event validation & verification
- Common workflows: publishing, querying, zaps, gift-wrapped DMs
**Bundled resources:**
- `references/nip-catalog.md` - All 57 NIPs with package locations (179 lines)
- `references/event-hierarchy.md` - Event class hierarchy, kind classifications (293 lines)
- `references/tag-patterns.md` - Tag structure, TagArrayBuilder DSL, parsing (251 lines)
- `scripts/nip-lookup.sh` - Find NIP implementations by number or search term
**Differentiation:** nostr-protocol agent = NIP specs. This skill = Quartz implementation patterns (57 NIPs), concrete code examples from codebase.
**Status:** ✅ SKILL.md (552 lines) + 3 references + 1 script created at `.claude/skills/nostr-expert/`
---
## Implementation Workflow
Using skill-creator 10-step methodology per skill:
**Overall Plan:**
1. **UNDERSTAND** ✅ - 8 skills defined, user clarifications obtained
2. **EXPLORE** ✅ - Codebase analyzed via Explore agent
3. **RESEARCH** ✅ - Domain patterns identified via Plan agent
4. **SYNTHESIZE** ✅ - Skills designed above
**Per-Skill Implementation:**
- kotlin-multiplatform: ✅ COMPLETED
- gradle-expert: ✅ COMPLETED
- kotlin-expert: ✅ COMPLETED
- compose-expert: ✅ COMPLETED
- desktop-expert: ✅ COMPLETED
- android-expert: ✅ COMPLETED
- nostr-expert: ✅ COMPLETED
- ios-expert: ⏸️ DEFERRED (iOS not yet implemented in AmethystMultiplatform)
## Critical Files Referenced
**Build patterns:**
- `/quartz/build.gradle.kts:132-149` - jvmAndroid source set
- `/commons/build.gradle.kts` - Shared UI setup
**Code patterns:**
- `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt` - Event structure
- `/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt` - StateFlow pattern
- `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Platform.kt` - expect/actual
**Documentation:**
- `/docs/shared-ui-analysis.md` - UI migration strategy
## Output Location
`.claude/skills/<skill-name>/` for each skill
## Next Steps
1. ✅ Save this plan as `.claude/core-skills-plan.md` for reference
2. ✅ Completed kotlin-multiplatform skill
3. ✅ Completed gradle-expert skill
4. ✅ Completed kotlin-expert skill
5. ✅ Completed compose-expert skill
6. ✅ Completed desktop-expert skill
7. ✅ Completed android-expert skill
8. ✅ Completed nostr-expert skill
9. ⏸️ Deferred ios-expert (iOS not yet implemented in codebase)
## Current Status: 7/8 Skills Completed
**Completed Skills (Auto-loaded from `.claude/skills/`):**
1. ✅ kotlin-multiplatform (KMP architecture, jvmAndroid pattern, expect/actual)
2. ✅ gradle-expert (Build system, dependencies, version catalog, troubleshooting)
3. ✅ kotlin-expert (Flow state, sealed classes, @Immutable, DSL builders)
4. ✅ compose-expert (Shared composables, state management, Material3, ImageVector)
5. ✅ desktop-expert (Desktop UX, window management, Compose Desktop APIs)
6. ✅ android-expert (Android platform APIs, navigation, permissions)
7. ✅ nostr-expert (Nostr protocol, Quartz implementation, NIPs, events, tags)
**Deferred:**
- ⏸️ ios-expert (iOS not implemented yet in AmethystMultiplatform)
## Skill Loading
**All completed skills are automatically loaded** when this project opens. Skills are auto-discovered from `.claude/skills/` directory.
To manually verify skills are loaded:
```bash
ls -1 .claude/skills/
```
Should show:
- android-expert/
- compose-expert/
- desktop-expert/
- gradle-expert/
- kotlin-expert/
- kotlin-multiplatform/
- nostr-expert/
---
# Amethyst Skill Library — History & Changelog
> Historical record of how the `.claude/skills/` library was built and audited.
> The 8 original skills were created in 2025 using the skill-creator 10-step
> methodology (detailed per-skill progress logs pruned in 2026-06 — see git
> history of this file if you need them). For the current skill list and how
> the two skill layers (codebase-oriented vs technique-oriented) fit together,
> see the Skills section of `.claude/CLAUDE.md`.
## Phase 1 (2025): Core skills created
Eight hybrid domain skills (general expertise + Amethyst-specific patterns),
each with a SKILL.md plus bundled `references/` and `scripts/`:
1. **kotlin-multiplatform** — KMP architecture, the jvmAndroid source-set pattern, expect/actual catalog
2. **gradle-expert** — build system, version catalog, dependency troubleshooting
3. **kotlin-expert** — Flow state, sealed hierarchies, @Immutable, DSL builders
4. **compose-expert** — shared composables, state management, Material3, ImageVector
5. **desktop-expert** — Desktop UX, window management, Compose Desktop APIs
6. **android-expert** — Android navigation, permissions, platform APIs
7. **nostr-expert** — Nostr protocol, Quartz implementation, NIPs, events, tags
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/`).
### New references added to existing skills
- `nostr-expert/references/nip19-bech32.md``Nip19Parser`, `Bech32Util`, `TlvBuilder`, entities.
@@ -346,33 +44,44 @@ After a full audit of the skill library, the following changes were made:
### New skills created
- **`account-state/`** — `Account.kt` (50+ StateFlow properties) and `LocalCache.kt` event store.
- `references/account-state-flow.md`, `references/local-cache.md`
- **`relay-client/`** — `ComposeSubscriptionManager`, filter assemblers, preloaders (`MetadataPreloader`, `MetadataRateLimiter`).
- `references/filter-assemblers.md`, `references/preloaders.md`
- **`relay-client/`** — `ComposeSubscriptionManager`, filter assemblers, preloaders.
- **`feed-patterns/`** — `FeedFilter`, `AdditiveComplexFeedFilter`, `FeedViewModel` hierarchy in `commons/`.
- `references/feed-filter-composition.md`, `references/viewmodel-base-classes.md`
- **`auth-signers/`** — `NostrSigner` abstraction across `NostrSignerInternal`, `NostrSignerRemote` (NIP-46), `NostrSignerExternal` (NIP-55).
- `references/nip46-remote-signer.md`, `references/nip55-android-signer.md`
- **`auth-signers/`** — `NostrSigner` abstraction across internal, NIP-46 remote, and NIP-55 external signers.
### Updated skills directory (Phase 2)
```
- android-expert/
- auth-signers/ (new)
- account-state/ (new)
- compose-expert/
- desktop-expert/
- feed-patterns/ (new)
- find-missing-translations/
- find-non-lambda-logs/
- gradle-expert/
- kotlin-coroutines/
- kotlin-expert/
- kotlin-multiplatform/
- nostr-expert/
- quartz-integration/
- relay-client/ (new)
- quartz-kmp.md (breadcrumb pointer)
```
## Phase 3 (2026-06): Fable 5 config review
### Still deferred
- ⏸️ `ios-expert` — iOS targets are mature but iOS-specific UI work hasn't surfaced yet in this repo.
Instructions written to coach older models were removed now that the model
handles them natively; stale references fixed:
- `CLAUDE.md`: deleted the 5-step skill-approval "Workflow" section
(skills auto-trigger; the approval loop blocked autonomous sessions);
condensed "Verify, Don't Guess" to the repo-specific tooling pointers and
dropped references to `/bugfix` / `/investigate` (never committed to this
repo); replaced the mandated emoji survey matrix with one-line guidance.
- `android-expert` and `desktop-expert` SKILL.md gained YAML frontmatter —
without it they were listed without trigger descriptions and never
auto-invoked.
- `commands/extract.md`: fixed stale `shared-ui/` module name → `commons/`.
- `skills/quartz-kmp.md` breadcrumb deleted (KMP migration long complete;
`quartz-integration` and `nostr-expert` cover its pointers).
- Stop hook moved to `.claude/hooks/stop-spotless.sh` and gated on modified
Kotlin files, so Q&A-only turns no longer pay a Gradle invocation.
Second audit pass (every concrete claim checked against the code; `amy-expert`,
`find-missing-translations`, `find-non-lambda-logs`, and the vendored technique
skills verified clean):
- `auth-signers`: bunker login entry point corrected — `NostrSignerRemote.fromBunkerUri(...)`
+ `connect()`, not the nonexistent `RemoteSignerManager.connect(url)`.
- `nostr-expert`: NIP count 57 → 80+ packages; `Nip44v2.encrypt/decrypt`
static-object snippet replaced with the real `Nip44` facade
(returns `EncryptedInfo`, `encodePayload()` for event content); invented
`Nip19.npubEncode`/`Nip19Result` API replaced with the real `ByteArray`
extensions (`toNpub()`, …), entity `create()` helpers, and
`Nip19Parser.uriToRoute()?.entity`.
- `nostr-expert/references/nip-catalog.md`: heading count (60+8) replaced with
actual package counts (87 + 23 experimental) and a ground-truth pointer.
- `quartz-integration`: NIP-19 decode example rewritten for
`ParseReturn.entity` (the `Nip19Parser.Return.*` sealed class never existed);
Event Store section corrected from "Android only" to commonMain/all platforms
with the real `store.sqlite.EventStore` import and suspend generic `query<T>`.

View File

@@ -160,7 +160,7 @@ echo -e "\n504667f4c0de7af1a06de9f4b1727b84351f2910" >> "$ANDROID_SDK_DIR/licens
echo -e "\nd975f751698a77b662f1254ddbeed3901e976f5a" > "$ANDROID_SDK_DIR/licenses/intel-android-extra-license"
# Create local.properties if missing
REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDE_PROJECT_DIR:-/home/user/Amber}")"
REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDE_PROJECT_DIR:-$PWD}")"
LOCAL_PROPS="$REPO_ROOT/local.properties"
if [ ! -f "$LOCAL_PROPS" ]; then
echo "sdk.dir=$ANDROID_SDK_DIR" > "$LOCAL_PROPS"

12
.claude/hooks/stop-spotless.sh Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/bash
# Stop hook: format Kotlin sources, but only when the working tree actually
# has modified Kotlin files — skips the Gradle invocation on Q&A-only turns.
set -uo pipefail
cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0
if git status --porcelain 2>/dev/null | grep -qE '[.]kts?$'; then
./gradlew spotlessApply 2>/dev/null
fi
exit 0

View File

@@ -16,7 +16,7 @@
"hooks": [
{
"type": "command",
"command": "./gradlew spotlessApply 2>/dev/null",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-spotless.sh",
"timeout": 120
}
]

View File

@@ -1,6 +1,6 @@
---
name: account-state
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
Relay frame ──► LocalCache.insertOrUpdateNote() ──► LocalCacheFlow emits change
Account observes relevant kinds (3, 10002, 10000, …)
Account state objects pin the relevant addressable notes
Account StateFlow updates (followList, relays, mutes, …)
State-object `.flow` updates (kind3FollowList, nip65RelayList, muteList, …)
ViewModels collect
@@ -33,22 +33,22 @@ Relay frame ──► LocalCache.insertOrUpdateNote() ──► LocalCacheFlow e
Composables render
```
`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.
- Settings: `defaultZapAmountsFlow`, `theme`, `language`, `proxyFlow`, `showSensitiveContentFlow`, …
- 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:
- `kind3FollowList = Kind3FollowListState(...)` ← NIP-02 ContactList (kind 3)
- `nip65RelayList = Nip65RelayListState(...)` ← NIP-65 RelayList (kind 10002), plus siblings `dmRelayList`, `searchRelayList`, `blockedRelayList`, `trustedRelayList`, `proxyRelayList`, `broadcastRelayList`, `indexerRelayList`, …
- `muteList = MuteListState(...)` ← NIP-51 MuteList (kind 10000)
- `bookmarkState = BookmarkListState(...)` ← NIP-51 Bookmarks (kind 10003), plus `labeledBookmarkLists`, `pinState`, `interestSets`, `peopleLists`, `followLists`, `hashtagList`, `geohashList`, `communityList`, `emoji`, `blossomServers`, …
- 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.
### `LocalCache.kt`
@@ -72,31 +72,29 @@ Relay frame ──► LocalCache.insertOrUpdateNote() ──► LocalCacheFlow e
Typical recipe:
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.
- `references/local-cache.md``LocalCache` internals, insertion path, indexes.
- Complements: `nostr-expert` (event parsing), `relay-client` (subscription wiring), `feed-patterns` (how feeds consume this state), `auth-signers` (how mutation signs events).

View File

@@ -1,93 +1,94 @@
# Account StateFlow Catalog
# Account State-Object Catalog
`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.)
## Identity & Contacts
| Flow | Kind(s) | Source | Model package |
|------|---------|--------|---------------|
| `userProfile().liveMetadata` | 0 MetadataEvent | relay | `model/nip01UserMetadata/` |
| `followListFlow` | 3 ContactListEvent | relay | `model/nip02FollowLists/` |
| `followersFlow` | derived | LocalCache scan | — |
| `muteListFlow` | 10000 NIP-51 | relay | `model/nip51Lists/` |
| `blockListFlow` | 10000 list variant | relay | `model/nip51Lists/` |
| Account property | State class | Kind | Package |
|------------------|-------------|------|---------|
| `userMetadata` | `UserMetadataState` | 0 | `amethyst/.../model/nip01UserMetadata/` |
| `kind3FollowList` | `Kind3FollowListState` | 3 | `model/nip02FollowLists/` |
| `muteList` (+ `muteListDecryptionCache`) | `MuteListState` | 10000 | `model/nip51Lists/muteList/` |
| `blockPeopleList`, `peopleLists` | `BlockPeopleListState`, `PeopleListsState` | NIP-51 people sets | `model/nip51Lists/peopleList/` |
| `followLists` | `FollowListsState` | NIP-51 follow sets | `model/nip51Lists/peopleList/` |
| `hiddenUsers` | `HiddenUsersState` — derived from `muteList.flow` + `blockPeopleList.flow` | — | `model/nip51Lists/` |
| `allFollows` | `MergedFollowListsState` — merges kind3 + people/follow/hashtag/geohash/community lists | — | `model/serverList/` |
## Relays & Connectivity
## Relay Lists
| Flow | Kind | Package |
|------|------|---------|
| `relayListFlow` | 10002 RelayList (NIP-65) | `model/nip65RelayList/` |
| `dmRelayListFlow` | 10050 | `model/nip65RelayList/` |
| `searchRelayListFlow` | 10007 | `model/nip65RelayList/` |
| `nip86RelayListFlow` | NIP-86 relay management | `model/nip86RelayManagement/` |
| `proxyFlow`, `torStateFlow` | local preferences | `model/torState/`, `AccountSyncedSettings` |
| Account property | State class | Kind | Package |
|------------------|-------------|------|---------|
| `nip65RelayList` | `Nip65RelayListState` | 10002 | `model/nip65RelayList/` |
| `dmRelayList` | `DmRelayListState` | 10050 | `model/nip17Dms/` |
| `searchRelayList` | `SearchRelayListState` | 10007 | `model/nip51Lists/searchRelays/` |
| `blockedRelayList` | `BlockedRelayListState` | 10006 | `model/nip51Lists/blockedRelays/` |
| `localRelayList` | `LocalRelayListState` | local | `model/localRelays/` |
| `privateStorageRelayList` | `PrivateStorageRelayListState` | private storage | `model/edits/` |
| `keyPackageRelayList`, `trustedRelayList`, `proxyRelayList`, `broadcastRelayList`, `indexerRelayList`, `relayFeedsList` | per-feature `…RelayListState` classes, each with a `DecryptionCache` sibling | custom relay sets | `model/nip51Lists/…` |
Derived relay views (merge several of the above): `homeRelays`
(`AccountHomeRelayState`), `outboxRelays`, `dmRelays`, `notificationRelays`,
`trustedRelays`, `followPlusAllMineWithIndex`, `followPlusAllMineWithSearch`,
`defaultGlobalRelays`.
## Content Lists
| Flow | Kind | Package |
|------|------|---------|
| `bookmarkListFlow` | 10003 | `model/nip51Lists/` |
| `privateBookmarksFlow` | encrypted list | `model/nip51Lists/` |
| `topNavFeedsFlow` | custom | `model/topNavFeeds/` |
| `customEmojisFlow` | 10030 NIP-30 | `model/nip30CustomEmojis/` |
| `marmotGroupsFlow` | NIP-29 (marmot variant) | `model/marmot/` |
| `nip72CommunitiesFlow` | 34550 (NIP-72) | `model/nip72Communities/` |
| `nip64ChessFlow` | NIP-64 chess games | `model/nip64Chess/` |
| Account property | State class | Kind | Package |
|------------------|-------------|------|---------|
| `bookmarkState` (and legacy `oldBookmarkState`) | `BookmarkListState` | 10003 | `model/nip51Lists/` |
| `labeledBookmarkLists` | `LabeledBookmarkListsState` | NIP-51 bookmark sets | `model/nip51Lists/labeledBookmarkLists/` |
| `pinState` | `PinListState` | NIP-51 | `model/nip51Lists/` |
| `interestSets` | `InterestSetsState` | NIP-51 interest sets | `model/nip51Lists/interestSets/` |
| `hashtagList` / `geohashList` | `HashtagListState` / `GeohashListState` | NIP-51 | `model/nip51Lists/hashtagLists/`, `…/geohashLists/` |
| `communityList` | `CommunityListState` | NIP-72 communities | `model/nip72Communities/` |
| `favoriteAlgoFeedsList` | `FavoriteAlgoFeedsListState` | NIP-51 | `model/nip51Lists/` |
| `emoji`, `ownedEmojiPacks` | `EmojiPackState`, `OwnedEmojiPacksState` | 10030 | `commons/.../commons/model/nip30CustomEmojis/` |
| `publicChatList` | `PublicChatListState` | NIP-28 | `commons/.../commons/model/nip28PublicChats/` |
| `ephemeralChatList` | `EphemeralChatListState` | ephemeral chats | `commons/.../commons/model/emphChat/` |
| `blossomServers` | `BlossomServerListState` | Blossom (BUD) | `model/nipB7Blossom/` |
## Messaging
## Other Feature State
| Flow | Kind | Package |
|------|------|---------|
| `dmInboxFlow` | 14 / 1059 (NIP-17 / gift-wrap) | `model/nip17Dms/` |
| `nwcSettingsFlow` | NIP-47 wallet connect | `model/nip47WalletConnect/` |
| `paymentTargetsFlow` | NIP-A3 | `model/nipA3PaymentTargets/` |
| `blossomServersFlow` | NIP-B7 blossom | `model/nipB7Blossom/` |
| Account property | State class | Purpose | Package |
|------------------|-------------|---------|---------|
| `vanish` | `VanishRequestsState` | NIP-62 vanish requests | `model/nip62Vanish/` |
| `appSpecific` | `AppSpecificState` | NIP-78 app data | `model/nip78AppSpecific/` |
| `otsState` | `OtsState` | NIP-03 OpenTimestamps | `model/nip03Timestamp/` |
| `live*FollowListsPerRelay` | `OutboxLoaderState(...).flow` — already a flow | per-feed outbox routing | `model/topNavFeeds/` |
| `privateDMDecryptionCache`, `draftsDecryptionCache` | `PrivateDMCache`, `DraftEventCache` | NIP-44 decryption caches | — |
## Settings & UI
| Flow | Source | Package |
|------|--------|---------|
| `uiSettingsFlow` | local | `model/UiSettings.kt`, `UiSettingsFlow.kt` |
| `antiSpamFilter` | local | `model/AntiSpamFilter.kt` |
| `privacyOptionsFlow` | local | `model/privacyOptions/` |
| `trustedAssertionsFlow` | derived | `model/trustedAssertions/` |
| `defaultZapAmountsFlow`, `theme`, `language` | local preferences | `AccountSettings.kt`, `AccountSyncedSettings.kt` |
## Advanced / Derived
| Flow | Purpose | Package |
|------|---------|---------|
| `accountsCacheFlow` | multi-account switcher | `model/accountsCache/` |
| `algoFeedsFlow` | custom algorithmic feeds | `model/algoFeeds/` |
| `vanishFlow` | NIP-62 account vanish requests | `model/nip62Vanish/` |
| `nip78AppSpecificFlow` | NIP-78 app-specific data | `model/nip78AppSpecific/` |
| `serverListFlow` | media/upload servers | `model/serverList/` |
Note the migration direction: newer/extracted state classes live in
`commons/src/commonMain/.../commons/model/`, the rest still in
`amethyst/src/main/java/.../model/`. Check both when looking for one.
## Publishing Mutations
Every flow has a corresponding mutation method on `Account` that:
State objects' mutation helpers (e.g. `MuteListState.hideUser(pubkey)`,
`BookmarkListState` add/remove) **build and sign** the updated replaceable
event via the quartz event class (`XEvent.add / remove / create`) and return
it. The caller (usually a method on `Account`) is responsible for sending it
through the client. Decryption results are cached in the paired
`…DecryptionCache` so re-renders don't re-decrypt.
1. Constructs the updated event using a `TagArrayBuilder`.
2. Signs through the injected `NostrSigner` (see `auth-signers` skill).
3. Publishes to the appropriate relay set.
4. Updates the local StateFlow *before* relay round-trip (optimistic).
5. Rolls back / reconciles on failure.
## When a State Object Doesn't Exist Yet
Examples of mutation methods (names may vary slightly in current code):
- `follow(pubKey)` / `unfollow(pubKey)`
- `addBookmark(noteId)` / `removeBookmark(noteId)`
- `mute(pubKey)` / `unmute(pubKey)`
- `updateRelayList(...)`, `updateDmRelayList(...)`
- `sendPost(...)`, `sendReaction(...)`, `sendZap(...)`
If you're adding a new NIP that's user-scoped, follow the pattern (full recipe
in `SKILL.md`):
## When a Flow Doesn't Exist Yet
If you're adding a new NIP that's user-scoped, follow the pattern:
1. Create `model/nipXX…/` with an optional `ExtState`/builder class.
2. Add `private val _xFlow = MutableStateFlow(initial)` + `val xFlow: StateFlow<T> = _xFlow.asStateFlow()` to `Account`.
3. Wire the relay subscription (see `relay-client` skill).
4. Add the mutation method that builds, signs, and publishes.
5. Update persistence if the setting is local-only (`AccountSettings.kt`).
1. Create `model/nipXX…/XState.kt` modeled on `MuteListState` (encrypted) or
`BookmarkListState` (plain).
2. Pin the note with `cache.getOrCreateAddressableNote(...)`, expose
`val flow: StateFlow<…>` via `stateIn(scope, Eagerly, default)`.
3. Instantiate it in `Account.kt` (plus a `DecryptionCache` sibling if
private), and wire the relay subscription (see `relay-client` skill).
4. Add mutation helpers that build, sign, and return the event; publish from
the calling site.
5. Use `AccountSettings` for the local backup copy if the list must survive
relay loss.

View File

@@ -1,3 +1,8 @@
---
name: android-expert
description: Android platform patterns for the `amethyst/` module. Use when working with (1) Android navigation (Navigation Compose, type-safe routes, bottom nav), (2) runtime permissions (camera, notifications, biometrics), (3) platform APIs (Intent, Context, Activity, ContentResolver), (4) Material3 theming and edge-to-edge UI, (5) AndroidManifest.xml and intent filters, (6) Proguard/R8 and APK optimization, (7) Android lifecycle (ViewModel, collectAsStateWithLifecycle), (8) Coil image loading. Delegates shared composables to compose-expert, build files to gradle-expert, and KMP structure to kotlin-multiplatform.
---
# android-expert
Android platform expertise for Amethyst Multiplatform project. Covers Compose Navigation, Material3, permissions, lifecycle, and Android-specific patterns in KMP architecture.
@@ -733,125 +738,29 @@ fun SignerIntegration(accountViewModel: AccountViewModel) {
## 6. Build Configuration
### Android Block
Build files — the `android {}` block, the version catalog, dependencies,
Proguard/R8, and Desktop packaging — are **gradle-expert's** domain. Use
`/gradle-expert` instead of duplicating that guidance here. In particular, the
app version and the Android `versionCode` both live in
`gradle/libs.versions.toml` (`app` / `appCode`); `amethyst/build.gradle.kts`
reads both from the catalog, so a release bump is a single-file edit.
The one build detail that is genuinely Android-specific — not generic Gradle —
is the **product-flavor split** that ships two channels from one codebase:
**build.gradle (Amethyst pattern):**
```gradle
android {
namespace = 'com.vitorpamplona.amethyst'
compileSdk = 36
defaultConfig {
applicationId = "com.vitorpamplona.amethyst"
minSdk = 26 // Android 8.0 (Oreo)
targetSdk = 36 // Android 15
versionCode = 444
versionName = "1.09.2"
vectorDrawables {
useSupportLibrary = true
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
buildFeatures {
compose = true
buildConfig = true // Enable BuildConfig access
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
}
packaging {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}
// Product flavors for Play Store vs F-Droid
flavorDimensions = ["channel"]
productFlavors {
create("play") {
dimension = "channel"
// Firebase, Google services
}
create("fdroid") {
dimension = "channel"
// UnifiedPush, open-source alternatives
}
}
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_21
}
flavorDimensions = ["channel"]
productFlavors {
create("play") { dimension = "channel" } // Firebase, Google services
create("fdroid") { dimension = "channel" } // UnifiedPush, open-source only
}
```
### Dependencies
`play` carries Firebase/Google services; `fdroid` swaps them for UnifiedPush and
open-source alternatives so the F-Droid build stays proprietary-free.
**Key Android Dependencies:**
```gradle
dependencies {
// Compose BOM
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui.tooling.preview)
// Navigation
implementation(libs.androidx.navigation.compose)
// Lifecycle
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
// Activity
implementation(libs.androidx.activity.compose)
// Accompanist
implementation(libs.accompanist.permissions)
// Shared module
implementation(project(":commons"))
implementation(project(":quartz"))
}
```
### Proguard Rules
**Common Rules for Amethyst:**
```proguard
# Keep Kotlin metadata
-keep class kotlin.Metadata { *; }
# Keep Nostr event classes
-keep class com.vitorpamplona.quartz.events.** { *; }
# Keep serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
# OkHttp
-dontwarn okhttp3.**
-keep class okhttp3.** { *; }
# Compose
-keep class androidx.compose.** { *; }
-dontwarn androidx.compose.**
```
**Reference:** See `references/proguard-rules.md` for complete Proguard configuration.
### APK Optimization
**Reference:** See `scripts/analyze-apk-size.sh` for APK size analysis.
Proguard/R8 rules: see `references/proguard-rules.md`. APK size analysis:
`scripts/analyze-apk-size.sh`.
## 7. KMP Android Source Sets

View File

@@ -75,7 +75,7 @@ Most feature code should go through `Account`'s mutation methods (`account.sendR
Entry points:
- **Existing private key** (`nsec`, 32-byte hex, file) → `NostrSignerInternal`.
- **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.

View File

@@ -1,3 +1,8 @@
---
name: desktop-expert
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.
**Why NavigationRail?**
**Why a left sidebar?**
- Desktop has horizontal space (1200+ dp width)
- Vertical sidebar is standard desktop pattern
- Always visible (no tabs hidden)
@@ -285,7 +290,7 @@ Row(Modifier.fillMaxSize()) {
**Android comparison:**
- Android: `BottomNavigationBar` (horizontal, bottom)
- Desktop: `NavigationRail` (vertical, left)
- Desktop: left vertical sidebar (`MainSidebar`)
### Multi-Pane Layouts

View File

@@ -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.
```kotlin
@Composable

View File

@@ -1,6 +1,6 @@
---
name: feed-patterns
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 │
└─────────────────────────────────────────────────────────────┘
│ uses
┌─────────────────────────────────────────────────────────────┐
│ amethyst/.../ui/dal/ (Android; feeds defined per screen)
│ FeedFilter<T> (abstract) │
│ amethyst/.../ui/dal/ (Android-only additions)
│ AdditiveComplexFeedFilter<T, U> │
│ ChangesFlowFilter │
│ FilterByListParams │
│ DefaultFeedOrder
│ DefaultFeedOrder (Note/Event/Card comparators)
│ (FeedFilters.kt & ChangesFlowFilter.kt here are just │
│ back-compat typealiases re-exporting commons) │
│ │
Plus concrete feeds: HomeFeedFilter, HashtagFeedFilter,
BookmarkListFeedFilter, NotificationFeedFilter, …
Concrete feeds: HomeNewThreadFeedFilter,
HashtagFeedFilter, NotificationFeedFilter, … live in
│ feature folders under ui/screen/loggedIn/*/dal/ │
└─────────────────────────────────────────────────────────────┘
│ reads
┌─────────────────────────────────────────────────────────────┐
│ model/LocalCache.kt + Account.<featureFlow>
│ model/LocalCache.kt + account.<feature>.flow │
└─────────────────────────────────────────────────────────────┘
```
@@ -60,25 +66,33 @@ Amethyst's "feed" abstraction is: a `FeedFilter` that decides which notes belong
- **`MarmotGroupFeedViewModel.kt`** — NIP-29 / marmot group feed.
- **`LiveStreamTopZappersViewModel.kt`, `SearchBarState.kt`, `ChatNewMessageState.kt`** — narrower, non-feed states that share the plumbing.
### Android DAL (the filters)
### Shared filter bases (commons)
`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/`:
- **`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.
### Android DAL (additions on top)
`amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/`:
- **`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 different type 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`.
- **`FeedFilters.kt` / `ChangesFlowFilter.kt`** — back-compat typealiases re-exporting the commons classes; don't add logic here.
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:
- `feedKey()` — stable identity (e.g. hashtag name, account pubkey).
- `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) } }`.
@@ -86,9 +100,9 @@ Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities,
## Filter Sharing (Android vs Desktop)
- `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.
## Gotchas
@@ -96,7 +110,7 @@ Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities,
- **`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.
## References

View File

@@ -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/`.
class HashtagFeedFilter(
private val accountViewModel: AccountViewModel,
private val hashtag: String,
) : AdditiveComplexFeedFilter<Note, Set<Note>>() {
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = "Hashtag-$hashtag"
@@ -28,7 +29,7 @@ class HashtagFeedFilter(
override fun feed(): List<Note> {
val params = FilterByListParams.create(
excludeMuted = true,
hiddenUsers = accountViewModel.hiddenUsersFlow.value,
hiddenUsers = account.hiddenUsers.flow.value,
)
return LocalCache.hashtagIndex[hashtag]
.orEmpty()
@@ -69,7 +70,7 @@ class HashtagFeedViewModel(
)
```
If membership changes aggressively (e.g. the user toggles a mute), use `ListChangeFeedViewModel` instead and hook into `Account.muteListFlow`.
If membership changes aggressively (e.g. the user toggles a mute), use `ListChangeFeedViewModel` instead and hook into `account.muteList.flow`.
## 4. Wire Invalidation
@@ -78,7 +79,7 @@ If membership changes aggressively (e.g. the user toggles a mute), use `ListChan
```kotlin
init {
viewModelScope.launch {
accountViewModel.muteListFlow.collect { invalidateAll() }
account.muteList.flow.collect { invalidateAll() }
}
}
```

View File

@@ -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):
@@ -39,24 +56,55 @@ Target: amethyst/src/main/res/values-<locale>/strings.xml
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")
echo "=== missing <string> ==="
comm -23 \
<(grep '<string name=' amethyst/src/main/res/values/strings.xml \
| grep -v 'translatable="false"' \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<string name=' amethyst/src/main/res/values-cs-rCZ/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort)
# Plurals: a separate resource type — MUST be diffed independently
echo "=== missing <plurals> ==="
comm -23 \
<(grep '<plurals name=' amethyst/src/main/res/values/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<plurals name=' amethyst/src/main/res/values-cs-rCZ/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort)
```
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":
```bash
for locale in cs-rCZ de-rDE sv-rSE pt-rBR; do
ns=$(comm -23 \
<(grep '<string name=' amethyst/src/main/res/values/strings.xml \
| grep -v 'translatable="false"' | sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<string name=' amethyst/src/main/res/values-$locale/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) | wc -l)
np=$(comm -23 \
<(grep '<plurals name=' amethyst/src/main/res/values/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<plurals name=' amethyst/src/main/res/values-$locale/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) | wc -l)
echo "$locale: strings=$ns plurals=$np total=$((ns+np))"
done
```
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
while IFS= read -r key; do
grep "name=\"$key\"" amethyst/src/main/res/values/strings.xml
done < <(comm -23 \
@@ -65,6 +113,19 @@ done < <(comm -23 \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<string name=' amethyst/src/main/res/values-cs-rCZ/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort))
# Missing <plurals>: extract the multi-line block (opening tag through </plurals>)
while IFS= read -r key; do
awk -v key="$key" '
$0 ~ "<plurals name=\"" key "\"" { in_p = 1 }
in_p { print }
in_p && /<\/plurals>/ { in_p = 0 }
' amethyst/src/main/res/values/strings.xml
done < <(comm -23 \
<(grep '<plurals name=' amethyst/src/main/res/values/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<plurals name=' amethyst/src/main/res/values-cs-rCZ/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort))
```
### 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.
case "$f" in
*values-ar*|*values-cy*) continue ;;
esac
awk -v file="$f" '
/<plurals/ { in_plurals = 1; name = $0; sub(/.*name="/, "", name); sub(/".*/, "", name) }
in_plurals && /quantity="zero"/ {
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:
- Arabic (`ar`): `zero`, `one`, `two`, `few`, `many`, `other`
- 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:
```kotlin
val label = if (count == 0) {
stringRes(R.string.foo_no_items, dateLabel)
} else {
pluralStringResource(R.plurals.foo_items, count, dateLabel, count)
}
```
- 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 `<string name=`** — `<plurals>` is a separate resource type; a source `<plurals>` missing from a locale will never show up in a `<string>` diff. Always run the diff twice (once per resource type) as shown in Step 2. The same goes for `<string-array>` if the project uses it.
- **Trusting a git "sync-timestamp" heuristic to pre-filter the list** — this skill used to skip keys added before the last `New Crowdin translations` commit, on the theory that Crowdin had already "decided" them. It was dropped: a key added shortly before an export that translators hadn't reached yet is genuinely missing, so the heuristic silently dropped real work. Use the raw on-disk diff and reconcile against the Crowdin web UI's untranslated count instead.
- **Adding source-identical fallbacks locally** — they get overwritten on the next Crowdin sync. Android falls back to `values/strings.xml` at runtime anyway, so a key intentionally kept as English already renders correctly. Skip these by inspection (brand terms, loanwords, `v%1$s`-style strings); don't translate them to an identical value.
- **Skipping per-locale diffs when only diffing cs-rCZ** — Crowdin can strip different keys in different locales (each translator's choice), so cs-rCZ is not a reliable upper bound. Diff each target locale and union the results.
- **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 `<item quantity="zero">` to special-case count=0** — outside Arabic and Welsh, this entry is unreachable: ICU/CLDR maps 0 → `other`, so the runtime never picks the zero item and the user sees `"…0 items"`. Special-case at the call site with a separate `<string>` instead.

View File

@@ -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

View File

@@ -3,46 +3,50 @@
## Visual Hierarchy
```
┌─────────────────────────────────────────────────────────┐
│ Root Project │
(Amethyst)
└─────────────────────────────────────────────────────────┘
┌────────────────┼────────────────┐
│ │
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ :amethyst :desktopApp │ │ :benchmark
(Android) │ (JVM) │ │ (Android)
└─────────────┘ └─────────────┘ └─────────────┘
│ │
│ │
────────────────┼────────────────┘
─────────────┐
:commons
(KMP UI)
│ │
jvmAndroid
/ \
│ jvm android
└─────────────┘
┌─────────────┐
│ :quartz
│(KMP Library)│
│ │
│ commonMain │
│ │ │
│ jvmAndroid │
│ / | \ │
│jvm and ios │
└─────────────┘
Apps / harnesses Libraries
┌─────────────┐ ┌─────────────┐ ┌────────────┐
:amethyst │ │ :desktopApp │ │ :benchmark
│ (Android) │ │ (JVM) │ │ (Android) │
└──┬───┬───┬──┘ └──┬───────┬──┘ └─┬───────┬──┘
───────┼────┐ │ │ │
│ │
│ ┌────────────────┐ │ │ (androidTest │
│ │ :commons ◄┼──┼──only)
│ │ (KMP UI) │
└───────┬────────┘ │ │ │
──────────────┐ │ ┌─┴──┴─┐ ┌───────┐ │
│ :nestsClient │ │ :cli │ │:geode │
│ (KMP, MoQ) │(JVM) │ │(JVM │ │
────────────┘ │ └──┬───┘ │relay) │ │
│ │ └───┬───┘
│ │ │
┌────────┐
│ :quic
│ (KMP)
└───┬────┘ │ │ │ │
│ ▲ │ │ │ │
│ └── :quic-interop │
▼ ▼
┌──────────────────────────────┐
│ :quartz │
(KMP Library)
└──────────────────────────────┘
```
Verified edges (from each module's `build.gradle.kts`):
- `:amethyst``:quartz`, `:commons`, `:nestsClient`
- `:desktopApp``:quartz`, `:commons`
- `:benchmark``:quartz`, `:commons` (androidTest only)
- `:cli``:quartz`, `:commons`
- `:geode``:quartz` (api + testFixtures)
- `:nestsClient``:quartz` (api), `:quic`
- `:quic``:quartz` (api)
- `:quic-interop``:quic` (project dir: `quic/interop`)
## Module Details
### :quartz (KMP Nostr Library)
@@ -86,11 +90,41 @@
**Type:** Android Library
**Targets:** Android
**Dependencies:**
- Modules: `:commons`, `:quartz`
- Modules: `:commons`, `:quartz` (androidTest only)
- External: AndroidX Benchmark
**Role:** Performance benchmarking for Android builds
### :cli (Amy CLI)
**Type:** JVM Application (no Compose)
**Dependencies:** `:quartz`, `:commons`
**Role:** `amy`, the non-interactive command-line client; thin assembly layer, no new logic (see `amy-expert` skill)
### :geode (Relay Server)
**Type:** JVM Application (Ktor)
**Dependencies:** `:quartz` (api + testFixtures)
**Role:** Standalone Nostr relay built on quartz's relay-server code
### :quic (QUIC Transport)
**Type:** Kotlin Multiplatform Library
**Dependencies:** `:quartz` (api)
**Role:** Pure-Kotlin QUIC v1 + HTTP/3 + WebTransport client (no JNI); transport for MoQ
### :nestsClient (Audio Rooms)
**Type:** Kotlin Multiplatform Library
**Dependencies:** `:quartz` (api), `:quic`
**Role:** MoQ / moq-lite audio-room client for the NIP-53 nests feature
### :quic-interop (Interop Harness)
**Type:** JVM Application (project dir `quic/interop`)
**Dependencies:** `:quic`
**Role:** QUIC interop-runner test client
## Dependency Flow Patterns
### Desktop Build Chain
@@ -177,9 +211,9 @@ implementation(libs.jna) // JAR variant
implementation(compose.ui) // Compose Multiplatform BOM
implementation(compose.material3)
// Version catalog alignment
composeMultiplatform = "1.9.3"
composeBom = "2025.12.01" // AndroidX Compose
// Version catalog alignment (re-check libs.versions.toml — these drift)
composeMultiplatform = "1.11.1"
composeBom = "2026.05.01" // AndroidX Compose
```
**Why:** Two Compose ecosystems (Multiplatform + AndroidX) must align

View File

@@ -807,6 +807,5 @@ Passing lambda to function?
---
**Version:** 1.0.0
**Last Updated:** 2025-12-30
**Codebase Reference:** AmethystMultiplatform commit 258c4e011
**Version:** 1.0.1
**Last Updated:** 2026-06-10

View File

@@ -1,6 +1,6 @@
---
name: nostr-expert
description: Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (57 NIPs in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent). Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details.
description: Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (80+ NIP packages in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent). Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details.
---
# Nostr Protocol Expert (Quartz Implementation)
@@ -313,26 +313,24 @@ class LocalSigner(private val privateKey: ByteArray) : ISigner {
### Encryption (NIP-44)
```kotlin
// Modern encryption (ChaCha20-Poly1305)
object Nip44v2 {
fun encrypt(plaintext: String, privateKey: ByteArray, pubKey: HexKey): String
fun decrypt(ciphertext: String, privateKey: ByteArray, pubKey: HexKey): String
// Modern encryption (ChaCha20-Poly1305) via the Nip44 facade
// (nip44Encryption/Nip44.kt — picks the current version, decrypts any)
object Nip44 {
fun encrypt(msg: String, privateKey: ByteArray, pubKey: ByteArray): Nip44v2.EncryptedInfo
fun decrypt(payload: String, privateKey: ByteArray, pubKey: ByteArray): String
}
// Usage
val encrypted = Nip44v2.encrypt(
plaintext = "Secret message",
privateKey = myPrivateKey,
pubKey = recipientPubKey
)
val encrypted = Nip44.encrypt("Secret message", myPrivateKey, recipientPubKey)
val payload = encrypted.encodePayload() // base64 string for event content
val decrypted = Nip44v2.decrypt(
ciphertext = encrypted,
privateKey = myPrivateKey,
pubKey = senderPubKey
)
val decrypted = Nip44.decrypt(payload, myPrivateKey, senderPubKey)
```
Most code should not call `Nip44` directly — go through
`signer.nip44Encrypt(plaintext, toPublicKey)` / `signer.nip44Decrypt(ciphertext, fromPublicKey)`
so remote/external signers keep working.
**Pattern**: Elliptic curve Diffie-Hellman + ChaCha20-Poly1305 AEAD.
### NIP-04 (Deprecated)
@@ -345,44 +343,34 @@ object Nip04 {
}
```
**Note**: Use NIP-44 (Nip44v2) for new implementations. NIP-04 has security issues.
**Note**: Use NIP-44 (`Nip44`) for new implementations. NIP-04 has security issues.
## Bech32 Encoding (NIP-19)
Encoding uses extension functions on `ByteArray` (`nip19Bech32/ByteArrayExt.kt`);
TLV entities carry relay hints via `create()` helpers on the entity classes in
`nip19Bech32/entities/`. Decoding goes through `Nip19Parser`, whose
`uriToRoute()` returns a `ParseReturn?` wrapping the parsed `Entity`.
```kotlin
object Nip19 {
// Encode
fun npubEncode(pubkey: HexKey): String // npub1...
fun nsecEncode(privateKey: ByteArray): String // nsec1...
fun noteEncode(eventId: HexKey): String // note1...
fun neventEncode(eventId: HexKey, relays: List<String> = emptyList()): String
fun nprofileEncode(pubkey: HexKey, relays: List<String> = emptyList()): String
fun naddrEncode(kind: Int, pubkey: HexKey, dTag: String, relays: List<String> = emptyList()): String
// Encode simple entities: ByteArray extensions
val npub = pubkeyBytes.toNpub() // "npub1..."
val nsec = privKeyBytes.toNsec() // "nsec1..."
val note = eventIdBytes.toNote() // "note1..."
// Decode
fun decode(bech32: String): Nip19Result
}
sealed class Nip19Result {
data class NPub(val hex: HexKey) : Nip19Result()
data class NSec(val hex: HexKey) : Nip19Result()
data class Note(val hex: HexKey) : Nip19Result()
data class NEvent(val hex: HexKey, val relays: List<String>) : Nip19Result()
data class NProfile(val hex: HexKey, val relays: List<String>) : Nip19Result()
data class NAddr(val kind: Int, val pubkey: HexKey, val dTag: String, val relays: List<String>) : Nip19Result()
}
// Encode TLV entities with relay hints (relays: List<NormalizedRelayUrl>)
val nevent = NEvent.create(eventIdHex, authorHex, kind, relays)
val nprofile = NProfile.create(pubkeyHex, relays)
```
**Usage**:
```kotlin
// Encode
val npub = Nip19.npubEncode(pubkeyHex)
// Output: "npub1..."
// Decode
when (val result = Nip19.decode(npub)) {
is Nip19Result.NPub -> println("Pubkey: ${result.hex}")
is Nip19Result.NEvent -> println("Event: ${result.hex}, relays: ${result.relays}")
// Decode (also accepts nostr: URIs); entity types live in nip19Bech32.entities
when (val entity = Nip19Parser.uriToRoute(input)?.entity) {
is NPub -> println("Pubkey: ${entity.hex}")
is NEvent -> println("Event: ${entity.hex}, relays: ${entity.relay}")
is NAddress -> println("Address: ${entity.aTag()}")
null -> println("not a valid bech32 entity")
else -> println("Other type")
}
```

View File

@@ -1,4 +1,8 @@
# NIP Catalog: 60 Standard + 8 Experimental NIPs in Quartz
# NIP Catalog: Quartz NIP Packages
As of 2026-06 Quartz has **87 standard `nip*` packages** plus **23 packages
under `experimental/`**. The categorized list below may lag behind —
`ls quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/` is ground truth.
## Standard NIPs by Category

View File

@@ -7,7 +7,7 @@ description: Integration guide for using the Quartz Nostr KMP library in externa
Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects.
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.09.2` (Maven Central)
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.12.5` (Maven Central)
**Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`)
**License**: MIT
@@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr
```toml
[versions]
quartz = "1.09.2"
quartz = "1.12.5"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -41,7 +41,7 @@ kotlin {
```kotlin
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.09.2")
implementation("com.vitorpamplona.quartz:quartz:1.12.5")
}
```
@@ -447,23 +447,28 @@ val textNote = Event.fromJson(json) as? TextNoteEvent
```kotlin
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
// Decode any bech32 entity
val result = Nip19Parser.uriToRoute("npub1abc...")
// Returns: NPub | NSec | Note | NEvent | NProfile | NAddr | null
when (val r = Nip19Parser.uriToRoute(input)) {
is Nip19Parser.Return.NPub -> println("pubkey: ${r.hex}")
is Nip19Parser.Return.Note -> println("event id: ${r.hex}")
is Nip19Parser.Return.NEvent -> println("event: ${r.hex}, relays: ${r.relays}")
is Nip19Parser.Return.NProfile -> println("profile: ${r.hex}")
is Nip19Parser.Return.NAddr -> println("address: ${r.kind}:${r.pubKey}:${r.dTag}")
null -> println("not a valid bech32 entity")
else -> {}
// Decode any bech32 entity (plain or nostr:-prefixed).
// uriToRoute() returns Nip19Parser.ParseReturn? — the parsed Entity is in .entity
when (val entity = Nip19Parser.uriToRoute(input)?.entity) {
is NPub -> println("pubkey: ${entity.hex}")
is NNote -> println("event id: ${entity.hex}")
is NEvent -> println("event: ${entity.hex}, relays: ${entity.relay}")
is NProfile -> println("profile: ${entity.hex}")
is NAddress -> println("address: ${entity.aTag()}")
null -> println("not a valid bech32 entity")
else -> {}
}
// The parser also handles nostr: URI scheme
val result = Nip19Parser.uriToRoute("nostr:npub1abc...")
// Encode: ByteArray extensions from nip19Bech32/ByteArrayExt.kt
val npub = pubkeyBytes.toNpub() // also toNsec(), toNote(), ...
// TLV entities with relay hints (relays: List<NormalizedRelayUrl>)
val nevent = NEvent.create(eventIdHex, authorHex, kind, relays)
```
---
@@ -601,21 +606,22 @@ In Xcode: drag & drop the `.xcframework` into your project, then use from Swift
---
## 14. Event Store (Android only)
## 14. Event Store (SQLite, all platforms)
SQLite-based storage with full NIP support (NIP-09, NIP-40, NIP-45, NIP-50, NIP-62):
SQLite-backed storage in `commonMain` (JVM, Android, iOS — uses the bundled
androidx.sqlite driver) with full NIP support (NIP-09, NIP-40, NIP-45, NIP-50,
NIP-62). All operations are `suspend`:
```kotlin
import com.vitorpamplona.quartz.nip01Core.store.EventStore
import android.content.Context
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
val store = EventStore()
val store = EventStore() // default DB file "events.db"
// Insert
store.insert(event)
// Query
val events = store.query(
val events = store.query<Event>(
Filter(authors = listOf(pubKey), kinds = listOf(1), limit = 50)
)
@@ -623,7 +629,7 @@ val events = store.query(
val count = store.count(Filter(kinds = listOf(1)))
// Full-text search (NIP-50)
val results = store.query(Filter(search = "bitcoin"))
val results = store.query<Event>(Filter(search = "bitcoin"))
```
---

View File

@@ -3,7 +3,7 @@
## Current version
```
com.vitorpamplona.quartz:quartz:1.09.2
com.vitorpamplona.quartz:quartz:1.12.5
```
Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz
@@ -16,7 +16,7 @@ Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/qua
```toml
[versions]
quartz = "1.09.2"
quartz = "1.12.5"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -55,7 +55,7 @@ kotlin {
```kotlin
// build.gradle.kts (app module)
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.09.2")
implementation("com.vitorpamplona.quartz:quartz:1.12.5")
}
```
@@ -70,7 +70,7 @@ plugins {
}
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.09.2")
implementation("com.vitorpamplona.quartz:quartz:1.12.5")
// JNA needed for libsodium (NIP-44) on JVM
implementation("net.java.dev.jna:jna:5.18.1")
}

View File

@@ -1,23 +0,0 @@
# Quartz KMP (Legacy Skill — Migration Complete)
> The KMP migration of Quartz is **complete**. This file is kept for historical reference.
>
> For integrating Quartz into external projects, use the **`quartz-integration`** skill instead.
> For working with Quartz internals within Amethyst, use the **`nostr-expert`** skill.
## What was migrated
The Quartz library was successfully converted from Android-only to full KMP supporting:
- **commonMain** — All Nostr protocol logic, events, filters, tags
- **jvmAndroid** — OkHttp WebSocket, Jackson JSON, relay serializers
- **androidMain** — SQLite event store, NIP-55 Android signer
- **jvmMain** — Desktop JVM crypto (lazysodium-java, secp256k1-jni-jvm)
- **iosMain** — iOS targets (XCFramework `quartz-kmpKit`)
## Current artifact
```
com.vitorpamplona.quartz:quartz:1.09.2
```
See `.claude/skills/quartz-integration/SKILL.md` for full integration guide.

View File

@@ -24,17 +24,23 @@ relayClient/
├── assemblers/ # "Given these inputs, build this relay Filter"
│ ├── MetadataFilterAssembler.kt # kind 0 for N pubkeys
│ ├── ReactionsFilterAssembler.kt # kind 7 for N note ids
── FeedMetadataCoordinator.kt # coordinates metadata loads for a feed
── FeedMetadataCoordinator.kt # coordinates metadata loads for a feed
│ └── CashuMintDirectoryFilterAssembler.kt / CashuWalletFilterAssembler.kt
├── composeSubscriptionManagers/
│ ├── ComposeSubscriptionManager.kt # interface Subscribable<T>
│ ├── MutableComposeSubscriptionManager.kt # reference impl
│ └── ComposeSubscriptionManagerControls.kt # DisposableEffect-style controls
├── eoseManagers/ # EOSE tracking per subscription
│ └── IEoseManager / BaseEoseManager / PerKeyEoseManager / SingleSubEoseManager
├── nip17Dm/ # gift-wrap DM plumbing
│ └── FilterGiftWrapsToPubkey.kt / GiftWrapDecryptor.kt
├── preload/
│ ├── MetadataPreloader.kt # bulk-fetch metadata with rate limiting
│ └── MetadataRateLimiter.kt # token-bucket-ish limiter
└── subscriptions/
── KeyDataSourceSubscription.kt # "this set of keys drives this filter"
── KeyDataSourceSubscription.kt # "this set of keys drives this filter"
├── LifecycleAwareKeyDataSourceSubscription.kt
└── PrioritizedSubscriptionQueue.kt / SubscriptionPriority.kt
```
## Core Concept: `Subscribable<T>`

View File

@@ -6,7 +6,7 @@ description: >-
inputs:
tag:
description: "Tag to validate (e.g. v1.08.0)"
description: "Tag to validate (e.g. vX.YY.Z)"
required: true
is_prerelease:
description: "Whether the release is marked as prerelease (empty string treated as false)"

View File

@@ -0,0 +1,90 @@
name: Import macOS Developer ID certificate
description: >
Import a Developer ID Application certificate (base64-encoded .p12) into a
throwaway keychain so codesign/jpackage can find it during the job. Soft:
when no certificate is supplied it is a no-op and reports signing=false, so
callers build UNSIGNED artifacts exactly as before.
inputs:
certificate-p12-base64:
description: Base64 of the Developer ID Application .p12 (cert + private key)
required: true
certificate-password:
description: Password used when the .p12 was exported
required: true
outputs:
signing:
description: "'true' if a certificate was imported, else 'false'"
value: ${{ steps.import.outputs.signing }}
keychain:
description: >
Absolute path of the throwaway keychain holding the imported cert (empty
when signing=false). Pass to tools that need an explicit keychain — e.g.
Compose's macOS signing, whose certificate lookup doesn't resolve the
search list reliably on CI runners the way bare `codesign` does.
value: ${{ steps.import.outputs.keychain }}
identity:
description: >
The imported certificate's full "Developer ID Application: NAME (TEAMID)"
common name, resolved from the keychain (empty when signing=false or if it
could not be parsed). `codesign` accepts a SHA-1 hash or a CN substring,
but Compose's MacSigner runs `security find-certificate -c <identity>`
after prepending "Developer ID Application: " — so it only works with the
exact common name. Feed this to Compose's `signing.identity`.
value: ${{ steps.import.outputs.identity }}
runs:
using: composite
steps:
- id: import
shell: bash
env:
CERT_P12: ${{ inputs.certificate-p12-base64 }}
CERT_PASSWORD: ${{ inputs.certificate-password }}
run: |
set -euo pipefail
if [[ -z "${CERT_P12:-}" ]]; then
echo "::notice::No macOS signing certificate configured — artifacts will be UNSIGNED."
echo "signing=false" >> "$GITHUB_OUTPUT"
exit 0
fi
KEYCHAIN="$RUNNER_TEMP/amethyst-signing.keychain-db"
KEYCHAIN_PWD="$(openssl rand -base64 24)"
CERT_PATH="$RUNNER_TEMP/developer_id.p12"
security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN"
echo "$CERT_P12" | base64 --decode > "$CERT_PATH"
security import "$CERT_PATH" -P "$CERT_PASSWORD" \
-k "$KEYCHAIN" -T /usr/bin/codesign -T /usr/bin/productsign
# Let codesign use the private key without an interactive UI prompt.
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PWD" "$KEYCHAIN" >/dev/null
# Prepend our keychain to the user search list so codesign sees it.
security list-keychains -d user -s "$KEYCHAIN" \
$(security list-keychains -d user | sed -e 's/[\"[:space:]]//g')
# Also make it the default keychain. Bare `codesign` resolves identities
# via the search list, but some tools (Compose's MacSigner runs
# `security find-certificate` to map the identity to a cert) don't find
# it on the search list alone on these runners. Callers that need an
# explicit keychain can read the `keychain` output below.
security default-keychain -d user -s "$KEYCHAIN"
rm -f "$CERT_PATH"
# Resolve the certificate's exact common name. Compose's MacSigner maps
# its identity to a cert via `security find-certificate -c <name>` (after
# prepending "Developer ID Application: " when absent), so it needs the
# full CN — a SHA-1 hash or partial name in the secret signs fine with
# bare `codesign` but yields "Could not find certificate ...". Pull the
# canonical name straight from the keychain so the caller is independent
# of whatever form the MAC_SIGN_IDENTITY secret takes.
IDENTITY_NAME="$(security find-identity -v -p codesigning "$KEYCHAIN" \
| grep -o '"Developer ID Application:[^"]*"' | head -1 | tr -d '"' || true)"
echo "signing=true" >> "$GITHUB_OUTPUT"
echo "keychain=$KEYCHAIN" >> "$GITHUB_OUTPUT"
echo "identity=$IDENTITY_NAME" >> "$GITHUB_OUTPUT"
if [[ -n "$IDENTITY_NAME" ]]; then
echo "::notice::Resolved signing identity: $IDENTITY_NAME"
else
echo "::warning::Could not resolve a 'Developer ID Application' identity from the keychain; callers will fall back to the MAC_SIGN_IDENTITY secret."
fi

View File

@@ -24,7 +24,7 @@ jobs:
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'zulu'
distribution: 'temurin'
java-version: 21
# Remote Gradle build cache: writes on push to main, reads on PRs and
@@ -34,12 +34,12 @@ jobs:
# :amethyst alone). Replaces the narrower `cache: gradle` previously on
# actions/setup-java, which only cached `modules-2`.
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
- name: Linter (gradle)
run: ./gradlew spotlessCheck
run: ./gradlew spotlessCheck :quartz:verifyKmpPurity :commons:verifyKmpPurity
build-desktop:
needs: lint
@@ -71,92 +71,28 @@ jobs:
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'zulu'
distribution: 'temurin'
java-version: 21
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
# Cache vlc-setup plugin downloads (VLC + UPX archives) keyed on the
# versions pinned in desktopApp/build.gradle.kts. Each OS gets its own
# cache namespace because the plugin downloads platform-specific archives.
# On a hit the vlcDownload / upxDownload tasks are up-to-date and we
# never touch get.videolan.org; on a miss (version bump or new runner)
# we fall back to fetching, which is what the in-build retry budget
# exists for.
- name: Cache vlc-setup downloads
uses: actions/cache@v4
with:
path: ~/.gradle/vlcSetup
key: vlcsetup-${{ runner.os }}-${{ hashFiles('desktopApp/build.gradle.kts') }}
restore-keys: |
vlcsetup-${{ runner.os }}-
# Pre-fetch VLC + UPX archives into ~/.gradle/vlcSetup before invoking
# Gradle. The vlc-setup plugin (ir.mahozad.vlc-setup 0.1.0) writes its
# downloads to ${gradleUserHomeDir}/vlcSetup/ and sets overwrite(false),
# so an existing file there makes vlcDownload / upxDownload up-to-date
# and Gradle never opens a socket to videolan.org.
#
# Why curl instead of relying on de.undercouch.gradle.tasks.download:
# curl --retry-all-errors with a long --retry-max-time tolerates a
# sustained get.videolan.org outage far better than the plugin's inner
# retry budget (retries(4) + 5min readTimeout in build.gradle.kts),
# which has been hitting SocketTimeoutException on Windows runners.
#
# Cache hit: the file is already on disk, fetch() short-circuits, this
# step takes <1s. Cache miss: curl downloads with aggressive retries,
# populating the cache for the next run.
#
# Versions are pinned to match desktopApp/build.gradle.kts (vlcVersion
# = 3.0.20) and the vlc-setup extension default (upxVersion = 4.2.4).
# NOTE: vlcVersion lags behind upstream VLC because the Linux plugins on
# Maven Central (ir.mahozad:vlc-plugins-linux) are only published for
# 3.0.20 / 3.0.20-2. Bump only after the Maven artifact is republished.
# macOS does not download UPX — UPX cannot compress .dylib files.
- name: Pre-fetch VLC + UPX archives
env:
VLC_VERSION: "3.0.20"
UPX_VERSION: "4.2.4"
run: |
set -euo pipefail
DEST="$HOME/.gradle/vlcSetup"
mkdir -p "$DEST"
fetch() {
local url="$1" out="$2"
if [[ -s "$out" ]]; then
echo "cached: $out"
return 0
fi
echo "fetching: $url"
curl -fL --retry 10 --retry-delay 5 --retry-all-errors \
--retry-max-time 900 --connect-timeout 30 \
-o "$out.part" "$url"
mv "$out.part" "$out"
}
case "${{ runner.os }}" in
Windows)
fetch "https://get.videolan.org/vlc/${VLC_VERSION}/win64/vlc-${VLC_VERSION}-win64.zip" \
"$DEST/vlc-${VLC_VERSION}.zip"
fetch "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-win64.zip" \
"$DEST/upx-${UPX_VERSION}.zip"
;;
Linux)
fetch "https://repo1.maven.org/maven2/ir/mahozad/vlc-plugins-linux/${VLC_VERSION}/vlc-plugins-linux-${VLC_VERSION}.jar" \
"$DEST/vlc-${VLC_VERSION}.jar"
fetch "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-amd64_linux.tar.xz" \
"$DEST/upx-${UPX_VERSION}.tar.xz"
;;
macOS)
fetch "https://get.videolan.org/vlc/${VLC_VERSION}/macosx/vlc-${VLC_VERSION}-universal.dmg" \
"$DEST/vlc-${VLC_VERSION}.dmg"
;;
esac
# Compose UI smoke test (DesktopLaunchSmokeTest) uses Skiko which needs
# a display server on Linux. xvfb provides a virtual framebuffer.
- name: Install xvfb (Linux)
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y xvfb
- name: Test + Build Desktop (gradle)
run: ./gradlew :quartz:jvmTest :commons:jvmTest :nestsClient:jvmTest :cli:test :desktopApp:test :desktopApp:${{ matrix.desktop-task }}
run: |
CMD="./gradlew :quartz:jvmTest :commons:jvmTest :nestsClient:jvmTest :cli:test :desktopApp:test :desktopApp:${{ matrix.desktop-task }}"
if [ "${{ runner.os }}" = "Linux" ]; then
xvfb-run --auto-servernum $CMD
else
$CMD
fi
# jpackage pins libicu to the build host's version (libicu74 on
# ubuntu-24.04). Rewrite the .deb so testers on other Debian/Ubuntu
@@ -174,6 +110,71 @@ jobs:
name: ${{ matrix.desktop-artifact-name }}
path: ${{ matrix.desktop-artifact-path }}
test-quartz-ios:
# Phase 1 of the iOS support plan
# (amethyst/plans/2026-05-24-ios-support.md): keep :quartz green on iOS
# so JVM-only imports can't sneak into commonMain unnoticed. The
# `verifyKmpPurity` task in the lint job is the fast pre-check (Linux,
# ~1s); this job is the real one — compiles for the device variant
# and actually runs the simulator test suite.
needs: lint
runs-on: macos-latest
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: 21
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
# Two tasks, two purposes:
# - iosSimulatorArm64Test runs the existing iosTest suite on the
# simulator (NIP-04 / NIP-17 / NIP-19 / NIP-49 / AES-GCM /
# Chatroom keys), exercising secp256k1 and CryptoKit-backed
# primitives on a real Apple toolchain.
# - compileTestKotlinIosArm64 catches any device-only compile drift
# (iosArm64 = aarch64-apple-ios) without needing a physical
# device to run on. Compile-only is enough — running on-device
# would require xcodebuild + a provisioning profile.
- name: Test Quartz on iOS
run: |
./gradlew \
:quartz:iosSimulatorArm64Test \
:quartz:compileTestKotlinIosArm64
# :commons gained iosArm64 + iosSimulatorArm64 targets in Phase 2 of the
# iOS plan. Actual UI / lifecycle wiring will land with the iosApp module
# in Phase 3, but the shared commonMain + commonTest sources are already
# built here against an Apple Native frontend. Same two-task shape as
# quartz above:
# - iosSimulatorArm64Test compiles AND runs the shared commonTest suite
# on the simulator. commonTest is built for every target, so a test
# reaching for a JVM-only API (JUnit, javaClass, @JvmStatic, or a
# jvmAndroid-only symbol) breaks the Apple build even though
# :commons:jvmTest stays green — this is the job that catches it.
# - compileTestKotlinIosArm64 catches device-only compile drift
# (iosArm64 = aarch64-apple-ios) without needing a physical device.
- name: Test Commons on iOS
run: |
./gradlew \
:commons:iosSimulatorArm64Test \
:commons:compileTestKotlinIosArm64
- name: Upload iOS Test Reports
uses: actions/upload-artifact@v7
if: failure()
with:
name: Quartz iOS Test Reports
path: quartz/build/reports
test-and-build-android:
needs: lint
runs-on: ubuntu-latest
@@ -185,11 +186,11 @@ jobs:
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'zulu'
distribution: 'temurin'
java-version: 21
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}

View File

@@ -11,7 +11,7 @@ on:
type: boolean
default: false
test_tag:
description: 'Synthetic tag name for dry-run (e.g. v1.08.0-dryrun); ignored on tag push'
description: 'Synthetic tag name for dry-run (e.g. vX.YY.Z-dryrun); ignored on tag push'
type: string
default: 'v0.0.0-dryrun'
@@ -21,9 +21,13 @@ permissions:
env:
# Asset naming contract: amethyst-desktop-<version>-<family>-<arch>.<ext>
# Single source of truth in scripts/asset-name.sh.
# linuxdeploy pinned release — bump via Dependabot, verify SHA256 via env var below.
LINUXDEPLOY_URL: https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20240109-1/linuxdeploy-x86_64.AppImage
LINUXDEPLOY_SHA256: c86d6540f1df31061f02f539a2d3445f8d7f85cc3994eee1e74cd1ac97b76df0
# appimagetool pinned release — bump via Dependabot, verify SHA256 via env var below.
# We used to use linuxdeploy here, but it auto-walks the AppDir with ldd to
# bundle deps — that fights jpackage's self-contained JRE (libjvm.so has
# $ORIGIN RPATH so ldd can't resolve it standalone). appimagetool only
# embeds the AppDir as-is, which is what we actually want.
APPIMAGETOOL_URL: https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage
APPIMAGETOOL_SHA256: 46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1
jobs:
# ---------------------------------------------------------------------------
@@ -35,7 +39,6 @@ jobs:
fail-fast: false
matrix:
include:
- { os: macos-13, arch: x64, family: macos, tasks: "packageReleaseDmg" }
- { os: macos-14, arch: arm64, family: macos, tasks: "packageReleaseDmg" }
- { os: windows-latest, arch: x64, family: windows, tasks: "packageReleaseMsi createReleaseDistributable" }
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" }
@@ -52,7 +55,7 @@ jobs:
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'zulu'
distribution: 'temurin'
java-version: 21
- name: Resolve tag + version
@@ -84,20 +87,53 @@ jobs:
if: matrix.family == 'linux'
run: sudo apt-get update && sudo apt-get install -y rpm fakeroot
- name: Fetch linuxdeploy (linux-portable only, SHA-verified)
- name: Fetch appimagetool (linux-portable only, SHA-verified)
if: matrix.family == 'linux-portable'
run: |
set -euo pipefail
curl -fsSL --retry 3 "$LINUXDEPLOY_URL" -o desktopApp/packaging/appimage/linuxdeploy-x86_64.AppImage
actual=$(sha256sum desktopApp/packaging/appimage/linuxdeploy-x86_64.AppImage | awk '{print $1}')
if [[ "$actual" != "$LINUXDEPLOY_SHA256" ]]; then
echo "::error::linuxdeploy SHA256 mismatch. Expected $LINUXDEPLOY_SHA256, got $actual"
# appimagetool 1.9.0 validates the .desktop file via desktop-file-validate.
sudo apt-get update && sudo apt-get install -y desktop-file-utils
curl -fsSL --retry 3 "$APPIMAGETOOL_URL" -o desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
actual=$(sha256sum desktopApp/packaging/appimage/appimagetool-x86_64.AppImage | awk '{print $1}')
if [[ "$actual" != "$APPIMAGETOOL_SHA256" ]]; then
echo "::error::appimagetool SHA256 mismatch. Expected $APPIMAGETOOL_SHA256, got $actual"
exit 1
fi
chmod +x desktopApp/packaging/appimage/linuxdeploy-x86_64.AppImage
chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
# macOS only: import the Developer ID Application cert into a throwaway
# keychain so jpackage's codesign pass can find it. Soft — if the
# MAC_CERTIFICATE_P12 secret isn't set (forks, or before Apple creds are
# provisioned) the DMG is built UNSIGNED, exactly as before. notarytool
# runs as part of the gradle task when the identity env is exported below.
- name: Import Apple Developer ID certificate (macOS leg, if configured)
if: matrix.family == 'macos'
id: mac_keychain
uses: ./.github/actions/import-macos-cert
with:
certificate-p12-base64: ${{ secrets.MAC_CERTIFICATE_P12 }}
certificate-password: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
- name: Build desktop artifacts
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
env:
# Empty on non-macOS legs and on the macOS leg when no cert is
# configured — the gradle macOS{} block skips signing when the
# identity is blank. Prefer the full common name resolved from the
# keychain over the raw secret: Compose's signer maps the identity via
# `security find-certificate` and only matches the exact "Developer ID
# Application: …" CN, whereas the secret may be a hash or partial name
# (which bare codesign accepts but Compose does not). Fall back to the
# secret if resolution failed.
AMETHYST_MAC_SIGN_IDENTITY: ${{ steps.mac_keychain.outputs.signing == 'true' && (steps.mac_keychain.outputs.identity || secrets.MAC_SIGN_IDENTITY) || '' }}
# Explicit keychain for Compose's MacSigner. Its `security
# find-certificate` lookup doesn't resolve the imported cert via the
# search list on these runners ("Could not find certificate ... in
# keychain []"), so point it at the throwaway keychain directly.
AMETHYST_MAC_SIGN_KEYCHAIN: ${{ steps.mac_keychain.outputs.signing == 'true' && steps.mac_keychain.outputs.keychain || '' }}
AMETHYST_NOTARY_APPLE_ID: ${{ secrets.MAC_NOTARY_APPLE_ID }}
AMETHYST_NOTARY_PASSWORD: ${{ secrets.MAC_NOTARY_PASSWORD }}
AMETHYST_NOTARY_TEAM_ID: ${{ secrets.MAC_NOTARY_TEAM_ID }}
with:
max_attempts: 2
timeout_minutes: 15
@@ -204,11 +240,10 @@ jobs:
fail-fast: false
matrix:
include:
- { os: macos-13, arch: x64, family: macos, tasks: "amyImage" }
- { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" }
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" }
runs-on: ${{ matrix.os }}
timeout-minutes: 30
timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image
defaults:
run:
shell: bash
@@ -219,7 +254,7 @@ jobs:
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'zulu'
distribution: 'temurin'
java-version: 21
- name: Resolve tag + version
@@ -257,6 +292,96 @@ jobs:
timeout_minutes: 15
command: ./gradlew --no-daemon :cli:${{ matrix.tasks }}
# amy is headless: the Compose UI render stack (skiko + its native dylibs,
# foundation/material/material3/ui/animation) must never reach the CLI
# image. cli/build.gradle.kts excludes it from runtimeClasspath; this
# guards against a transitive dep silently dragging it back (size + macOS
# notarization-surface regression). compose.runtime is CLI-safe and stays.
- name: Assert no Compose UI in the amy image
run: |
set -euo pipefail
LIB="cli/build/install/amy/lib"
leak="$(ls "$LIB" | grep -iE 'skiko|foundation(-layout)?-desktop|material3?-desktop|material-ripple|ui-desktop|animation(-core)?-desktop' || true)"
if [ -n "$leak" ]; then
echo "::error::Compose UI render stack leaked into the amy CLI image:"
echo "$leak" | sed 's/^/ /'
echo "Exclude it in cli/build.gradle.kts (configurations.runtimeClasspath)."
exit 1
fi
echo "OK: no skiko / Compose UI render jars in the amy image ($(du -sh "$LIB" | cut -f1))."
# macOS only: import the Developer ID cert (no-op without the secret) so
# the next step can codesign the jlink image. The jvm bundle for
# Homebrew-core is NOT signed here — Homebrew strips quarantine itself.
- name: Import Apple Developer ID certificate (macOS leg, if configured)
if: matrix.family == 'macos'
id: mac_keychain
uses: ./.github/actions/import-macos-cert
with:
certificate-p12-base64: ${{ secrets.MAC_CERTIFICATE_P12 }}
certificate-password: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
# Codesign + notarize the macOS jlink image (amy-<ver>-macos-arm64.tar.gz)
# for users who download it directly. A loose tarball can't be stapled
# (stapler only does .app/.dmg/.pkg), so Gatekeeper verifies notarization
# online on first run. Runs before "Collect" so the tarred image is signed.
- name: Sign + notarize amy image (macOS leg, if configured)
if: matrix.family == 'macos' && steps.mac_keychain.outputs.signing == 'true'
env:
SIGN_IDENTITY: ${{ secrets.MAC_SIGN_IDENTITY }}
NOTARY_APPLE_ID: ${{ secrets.MAC_NOTARY_APPLE_ID }}
NOTARY_PASSWORD: ${{ secrets.MAC_NOTARY_PASSWORD }}
NOTARY_TEAM_ID: ${{ secrets.MAC_NOTARY_TEAM_ID }}
run: |
set -euo pipefail
IMG="cli/build/amy-image/amy"
ENTITLEMENTS="cli/packaging/macos/amy.entitlements"
# First sign the macOS Mach-O natives buried INSIDE the bundled jars
# (secp256k1/jna/sqlite/skiko/jkeychain/mediaplayer). The loose-file
# loop below can't see them, but Apple's notary recurses into jars and
# rejects any unsigned Mach-O — so this must run before notarize.
SIGN_IDENTITY="$SIGN_IDENTITY" scripts/sign-macos-jar-natives.sh "$IMG"
# Sign every loose Mach-O binary in the bundled JRE. Each is signed
# independently (no enclosing .app seals them), so order is irrelevant.
# Executables get the hardened-runtime entitlements; dylibs don't.
while IFS= read -r f; do
case "$(file -b "$f")" in
*Mach-O*executable*)
codesign --force --options runtime --timestamp \
--entitlements "$ENTITLEMENTS" --sign "$SIGN_IDENTITY" "$f" ;;
*Mach-O*)
codesign --force --options runtime --timestamp \
--sign "$SIGN_IDENTITY" "$f" ;;
esac
done < <(find "$IMG" -type f)
codesign --verify --strict --verbose=2 "$IMG/runtime/bin/java"
# Notarize: zip the signed image, submit, wait for Apple's verdict.
# The notary service recursively inspects the lib/*.jar files; their
# embedded Mach-O natives are signed by sign-macos-jar-natives.sh
# above. Surface the per-file log on any non-Accepted verdict so a
# regression is diagnostic rather than a bare failure.
ZIP="$RUNNER_TEMP/amy-notarize.zip"
OUT="$RUNNER_TEMP/notary-submit.json"
ditto -c -k --keepParent "$IMG" "$ZIP"
if ! xcrun notarytool submit "$ZIP" \
--apple-id "$NOTARY_APPLE_ID" --password "$NOTARY_PASSWORD" \
--team-id "$NOTARY_TEAM_ID" --wait --output-format json > "$OUT"; then
echo "::warning::notarytool submit exited non-zero"
fi
cat "$OUT"
STATUS="$(jq -r '.status // "Unknown"' "$OUT" 2>/dev/null || echo Unknown)"
SUBMISSION_ID="$(jq -r '.id // empty' "$OUT" 2>/dev/null || true)"
if [ "$STATUS" != "Accepted" ]; then
echo "::error::Notarization status: $STATUS"
if [ -n "$SUBMISSION_ID" ]; then
echo "----- notary log -----"
xcrun notarytool log "$SUBMISSION_ID" \
--apple-id "$NOTARY_APPLE_ID" --password "$NOTARY_PASSWORD" \
--team-id "$NOTARY_TEAM_ID" || true
fi
exit 1
fi
# jpackage pins libicu to the build host's version (libicu74 on
# ubuntu-24.04). Rewrite the .deb so it installs across Debian/Ubuntu.
- name: Relax libicu dependency in .deb
@@ -273,6 +398,23 @@ jobs:
source scripts/asset-name.sh
collect_cli_assets "${{ matrix.family }}" "${{ matrix.arch }}" "${{ steps.ver.outputs.version }}" dist
# Homebrew-core ships a no-JRE jar bundle and depends_on "openjdk" — it
# cannot use the jlink tarball above (bundled runtime) nor build from
# source (its sandbox blocks Gradle's Maven downloads). installDist
# (bin/amy + lib/*.jar, no runtime/) is exactly that bundle. It is pure
# JVM bytecode, so one platform-independent asset serves every OS; we cut
# it on the linux leg only. amyImage depends on installDist, so the
# cli/build/install/amy tree already exists here.
- name: Package no-JRE jvm bundle for Homebrew (linux leg only)
if: matrix.family == 'linux'
run: |
set -euo pipefail
VER="${{ steps.ver.outputs.version }}"
SRC="cli/build/install/amy"
test -x "$SRC/bin/amy"
( cd "$SRC" && tar czf "$OLDPWD/dist/amy-${VER}-jvm.tar.gz" bin lib )
echo "Collected: dist/amy-${VER}-jvm.tar.gz"
- name: Enforce CLI size budget (200 MB per asset)
run: |
set -euo pipefail
@@ -342,7 +484,7 @@ jobs:
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'zulu'
distribution: 'temurin'
java-version: 21
- name: Cache gradle

143
.github/workflows/smoke-test-desktop.yml vendored Normal file
View File

@@ -0,0 +1,143 @@
name: Desktop Smoke Test
on:
workflow_dispatch:
pull_request:
paths:
- 'desktopApp/**'
- '.github/workflows/smoke-test-desktop.yml'
permissions:
contents: read
concurrency:
group: smoke-desktop-${{ github.ref }}
cancel-in-progress: true
jobs:
# -------------------------------------------------------------------------
# 1) Compose UI test — verifies the composable tree renders (login screen)
# under the dev classpath. Catches missing string resources, broken
# composables, and basic dependency-graph issues.
# -------------------------------------------------------------------------
compose-ui-test:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: 21
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: true
- name: Install xvfb
run: sudo apt-get update && sudo apt-get install -y xvfb
- name: Run desktop tests (including UI smoke test)
run: xvfb-run --auto-servernum ./gradlew :desktopApp:test
# -------------------------------------------------------------------------
# 2) Release .deb build + launch — builds the ProGuard'd, jlink'd .deb
# package, installs it, and verifies the process stays alive for 10s.
# Catches ProGuard stripping (JNI, reflection), missing jlink modules
# (java.management, java.prefs), and native lib bundling issues.
# -------------------------------------------------------------------------
release-deb-launch:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: 21
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: true
- name: Install xvfb + packaging deps
run: sudo apt-get update && sudo apt-get install -y xvfb fakeroot
- name: Build release .deb
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
with:
max_attempts: 2
timeout_minutes: 20
command: ./gradlew --no-daemon :desktopApp:packageReleaseDeb
- name: Relax libicu dependency
run: |
set -euo pipefail
chmod +x scripts/relax-deb-libicu.sh
scripts/relax-deb-libicu.sh desktopApp/build/compose/binaries/main-release/deb/*.deb
- name: Install .deb
run: |
# jpackage's post-install script runs xdg-desktop-menu which fails
# on CI runners ("No writable system menu directory"). The files are
# extracted successfully; only the menu registration fails. Allow the
# dpkg error, then verify the binary was actually installed.
sudo dpkg -i desktopApp/build/compose/binaries/main-release/deb/*.deb || true
echo "Installed files:"
dpkg -L amethyst | head -30
# Fail if the binary wasn't actually extracted
test -d /opt/amethyst || test -d /opt/Amethyst || {
echo "FAIL: /opt/amethyst not found after dpkg -i"
exit 1
}
- name: Smoke test — app launches and stays alive
run: |
set -euo pipefail
# Find the launcher binary
LAUNCHER=$(find /opt -name "Amethyst" -type f -executable 2>/dev/null | head -1)
if [[ -z "$LAUNCHER" ]]; then
# Fallback: search dpkg file list
LAUNCHER=$(dpkg -L amethyst | grep -E '/bin/[Aa]methyst$' | head -1)
fi
if [[ -z "$LAUNCHER" ]]; then
echo "FAIL: could not find Amethyst launcher binary"
dpkg -L amethyst
exit 1
fi
echo "Launcher: $LAUNCHER"
# Launch under xvfb with a timeout safety net
xvfb-run --auto-servernum timeout 30 "$LAUNCHER" &
APP_PID=$!
echo "PID: $APP_PID"
# Wait 10s — if the process is still alive, the app launched successfully
sleep 10
if kill -0 "$APP_PID" 2>/dev/null; then
echo "PASS: Application launched and stayed alive for 10s"
kill "$APP_PID" || true
wait "$APP_PID" 2>/dev/null || true
else
wait "$APP_PID" 2>/dev/null
EXIT_CODE=$?
echo "FAIL: Application exited with code $EXIT_CODE within 10s"
exit 1
fi
- name: Upload .deb artifact (for manual testing)
if: always()
uses: actions/upload-artifact@v7
with:
name: Release DEB (smoke-tested)
path: desktopApp/build/compose/binaries/main-release/deb/*.deb

21
.gitignore vendored
View File

@@ -161,14 +161,23 @@ TASKS.md
.claude/settings.local.json
.claude/scheduled_tasks.lock
# Downloaded VLC binaries (vlc-setup plugin)
desktopApp/src/jvmMain/appResources/linux/
desktopApp/src/jvmMain/appResources/macos/
desktopApp/src/jvmMain/appResources/windows/
# Per-OS appResources slots — historically the ir.mahozad.vlc-setup plugin
# populated these with VLC binaries (no longer used; superseded by
# kdroidFilter ComposeMediaPlayer). We still ignore the directory contents
# by default to keep stale workspaces from accidentally bundling old VLC
# trees into local packages, but explicitly track the ffmpeg/README.md
# drop-in slot for the LGPL FFmpeg binaries used by VideoThumbnailCache.
desktopApp/src/jvmMain/appResources/linux/*
desktopApp/src/jvmMain/appResources/macos/*
desktopApp/src/jvmMain/appResources/windows/*
!desktopApp/src/jvmMain/appResources/linux/ffmpeg/
!desktopApp/src/jvmMain/appResources/macos/ffmpeg/
!desktopApp/src/jvmMain/appResources/windows/ffmpeg/
desktopApp/src/jvmMain/appResources/*/ffmpeg/*
!desktopApp/src/jvmMain/appResources/*/ffmpeg/README.md
# CI-fetched AppImage tooling (downloaded by create-release workflow; not committed)
desktopApp/packaging/appimage/linuxdeploy-x86_64.AppImage
desktopApp/packaging/appimage/linuxdeploy-extracted/
desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
desktopApp/packaging/appimage/squashfs-root/
# Git worktrees

2
.idea/kotlinc.xml generated
View File

@@ -8,6 +8,6 @@
</component>
<component name="KotlinJpsPluginSettings">
<option name="externalSystemId" value="Gradle" />
<option name="version" value="2.3.21" />
<option name="version" value="2.4.0" />
</component>
</project>

View File

@@ -1,13 +1,22 @@
# Building Amethyst Desktop
This guide covers building Amethyst Desktop from source, the release pipeline,
and one-time bootstrap steps for distribution channels.
This guide has everything **any fork** needs to build Amethyst from source and
cut its own release: prerequisites, build commands, the CI release pipeline, the
secrets it needs, the distribution channels, and one-time bootstrap steps.
> **Amethyst maintainers:** the account-specific checklist for shipping the
> official build (Play Console upload, Zapstore `zsp publish` with our nsec,
> secret ownership) lives in [`RELEASE_OPS.md`](RELEASE_OPS.md). This
> file stays fork-generic.
- [Prerequisites](#prerequisites)
- [Clone + first build](#clone--first-build)
- [Generated & vendored artifacts](#generated--vendored-artifacts)
- [Per-format build commands](#per-format-build-commands)
- [Asset naming contract](#asset-naming-contract)
- [Release runbook](#release-runbook)
- [Secrets the CI needs](#secrets-the-ci-needs)
- [Distribution channels](#distribution-channels)
- [Bootstrap runbook (one-time)](#bootstrap-runbook-one-time)
- [Troubleshooting installs](#troubleshooting-installs)
- [Uninstall + state paths](#uninstall--state-paths)
@@ -27,7 +36,8 @@ Platform-specific:
- **macOS**: Xcode Command Line Tools (`xcode-select --install`)
- **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset`
- **Linux (all)**: nothing extra for `.deb`; `rpm` + `fakeroot` for `.rpm`; `linuxdeploy` for AppImage
- **Linux (all)**: nothing extra for `.deb`; `rpm` + `fakeroot` for `.rpm`;
`appimagetool` + `desktop-file-utils` for AppImage
Install Linux RPM tooling:
@@ -39,12 +49,15 @@ sudo apt-get install -y rpm fakeroot
sudo dnf install -y rpm-build
```
Install linuxdeploy locally (CI fetches its own — SHA-verified):
Install appimagetool locally (CI fetches its own — SHA-verified):
```bash
curl -fsSL -o desktopApp/packaging/appimage/linuxdeploy-x86_64.AppImage \
https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20240109-1/linuxdeploy-x86_64.AppImage
chmod +x desktopApp/packaging/appimage/linuxdeploy-x86_64.AppImage
# Debian/Ubuntu — appimagetool calls desktop-file-validate on the .desktop entry
sudo apt-get install -y desktop-file-utils
curl -fsSL -o desktopApp/packaging/appimage/appimagetool-x86_64.AppImage \
https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage
chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
```
---
@@ -64,6 +77,29 @@ cd amethyst
---
## Generated & vendored artifacts
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.
---
## Per-format build commands
| Artifact | Command | Output |
@@ -107,11 +143,11 @@ amethyst-desktop-<version>-<family>-<arch>.<ext>
Where:
| Field | Values |
|---|---------------------------------------------------------|
| `<version>` | Tag stripped of leading `v` (e.g. `1.09.2`) |
| `<family>` | `macos`, `windows`, `linux` |
| `<arch>` | `x64`, `arm64` |
| Field | Values |
|---|-------------------------------------------------------|
| `<version>` | Tag stripped of leading `vX.YY.ZZ` |
| `<family>` | `macos`, `windows`, `linux` |
| `<arch>` | `x64`, `arm64` |
| `<ext>` | `dmg`, `msi`, `zip`, `deb`, `rpm`, `AppImage`, `tar.gz` |
Single source of truth: [`scripts/asset-name.sh`](scripts/asset-name.sh).
@@ -120,10 +156,10 @@ any change is a breaking contract.
Examples:
- `amethyst-desktop-1.09.2-macos-x64.dmg`
- `amethyst-desktop-1.09.2-macos-arm64.dmg`
- `amethyst-desktop-1.09.2-windows-x64.msi`
- `amethyst-desktop-1.09.2-linux-x64.AppImage`
- `amethyst-desktop-1.12.1-macos-x64.dmg`
- `amethyst-desktop-1.12.1-macos-arm64.dmg`
- `amethyst-desktop-1.12.1-windows-x64.msi`
- `amethyst-desktop-1.12.1-linux-x64.AppImage`
---
@@ -132,38 +168,37 @@ Examples:
The release flow is driven by a tag push. Every cut ships Android + Desktop +
Quartz library in one pipeline.
1. **Bump the app version** in `gradle/libs.versions.toml`:
1. **Bump the app version and Android `versionCode`** in
`gradle/libs.versions.toml` (`appCode` is a monotonic integer — it must
increment even when `app` is unchanged):
```toml
[versions]
app = "1.08.1" # new semver
appCode = "449" # Android versionCode
```
2. **Bump Android `versionCode`** in `amethyst/build.gradle` (monotonic integer,
must increment even for same `versionName`):
`amethyst/build.gradle.kts` reads both from the catalog
(`versionCode = libs.versions.appCode.get().toInt()`), so there is nothing
else to edit.
```groovy
versionCode = 443
versionName = generateVersionName(libs.versions.app.get())
```
3. **Commit + tag + push**:
2. **Commit + tag + push**:
```bash
git commit -am "chore(release): 1.08.1"
git tag -s v1.08.1 -m "Release 1.08.1"
git commit -am "chore(release): 1.12.1"
git tag -s v1.12.1 -m "Release 1.12.1"
git push && git push --tags
```
4. **Wait** for the `Create Release Assets` workflow to finish (~2530 min).
3. **Wait** for the `Create Release Assets` workflow to finish (~2530 min).
5. **Verify**:
4. **Verify**:
- GH Release contains 8 desktop assets + 12 Android assets
- Asset sizes look sane (see §Enforce asset size budget — CI auto-fails at 1 GB/asset)
- Intel + ARM DMGs both present
- Android flow unchanged
6. **Stable vs prerelease** — a tag containing `-rc`, `-beta`, `-alpha`, `-dev`,
5. **Stable vs prerelease** — a tag containing `-rc`, `-beta`, `-alpha`, `-dev`,
or `-snapshot` is auto-classified as prerelease. Stable tags trigger the
Homebrew + Winget bump workflows.
@@ -199,19 +234,149 @@ uninstall before a new release. Leave it alone forever.
---
## Secrets the CI needs
The `Create Release Assets` workflow reads these from GitHub repo secrets. A
fork must provide its **own** values — none are inherited. (`GITHUB_TOKEN` is
provided automatically; everything else you set yourself.)
| Secret | What it is | Used for |
|---|---|---|
| `SIGNING_KEY` | Base64 of your **Android keystore** (`.jks`/`.keystore`) | Signs the Play + F-Droid **AAB and APK** |
| `KEY_ALIAS` | Keystore key alias | Same Android signing step |
| `KEY_STORE_PASSWORD` | Keystore password | Same |
| `KEY_PASSWORD` | Key password | Same |
| `SONATYPE_USERNAME` | Maven Central (Sonatype) user token name | Publishing the `quartz` library |
| `SONATYPE_PASSWORD` | Maven Central user token password | Same |
| `SIGNING_PRIVATE_KEY` | **GPG/PGP** private key, ASCII-armored | Signs the Maven artifacts (Central requires it) |
| `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)
keytool -genkey -v -keystore upload.jks -keyalg RSA -keysize 2048 \
-validity 10000 -alias upload # creates the keystore (once)
base64 -i upload.jks | tr -d '\n' # paste output into SIGNING_KEY
# GPG key → armored private key for SIGNING_PRIVATE_KEY
gpg --full-generate-key # create the key (once)
gpg --armor --export-secret-keys <KEY_ID> # paste output into SIGNING_PRIVATE_KEY
# Apple Developer ID Application cert → base64 for MAC_CERTIFICATE_P12.
# In Keychain Access, export the "Developer ID Application: ..." cert (with its
# private key) as a .p12, setting an export password (-> MAC_CERTIFICATE_PASSWORD).
base64 -i developer_id.p12 | tr -d '\n' # paste output into MAC_CERTIFICATE_P12
security find-identity -v -p codesigning # shows the exact MAC_SIGN_IDENTITY string
# 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
reads an optional per-release changelog from
`fastlane/metadata/android/en-US/changelogs/<versionCode>.txt`.
---
## Bootstrap runbook (one-time)
### Secrets to provision in GitHub repo settings
The full secret inventory is in [§ Secrets the CI needs](#secrets-the-ci-needs).
The two that need the most setup care are the package-manager PATs, because of
their token type and scope:
| Secret | Purpose | Scope |
|---|---|---|
| `HOMEBREW_TOKEN` | Bump Homebrew cask | Fine-grained PAT — `Homebrew/homebrew-cask` only — `Contents: write` + `Pull requests: write` — 90d expiry |
| `WINGET_TOKEN` | Submit Winget manifests | Classic PAT — `public_repo` — 90d expiry (dedicated bot account preferred; `vedantmgoyal9/winget-releaser` does not support fine-grained) |
All existing secrets (`SIGNING_KEY`, `SONATYPE_USERNAME`, etc.) remain
unchanged.
Rotate both on a 90-day cadence. Owner: assigned via `docs/RELEASE_OPS.md`
Rotate both on a 90-day cadence. Owner: assigned via `RELEASE_OPS.md`
or equivalent issue tracker. On rotation, paste new token and run
`gh workflow run bump-homebrew.yml` on the most recent stable tag to verify.
@@ -219,19 +384,64 @@ or equivalent issue tracker. On rotation, paste new token and run
```bash
brew bump-cask-pr amethyst-nostr \
--version 1.09.2 \
--url "https://github.com/vitorpamplona/amethyst/releases/download/v1.09.2/amethyst-desktop-1.09.2-macos-arm64.dmg"
--version 1.12.1 \
--url "https://github.com/vitorpamplona/amethyst/releases/download/v1.12.1/amethyst-desktop-1.12.1-macos-arm64.dmg"
```
The cask filename is `amethyst-nostr` (not `amethyst` — that's taken by a
tiling window manager). After the first PR is merged, `bump-homebrew.yml`
auto-submits new version bumps on each stable release.
> **The desktop app is already on mainline Homebrew.** `homebrew/cask` *is* the
> mainline cask repo — GUI apps live in homebrew-**cask**, CLIs in
> homebrew-**core**; both are "mainline." A private tap is only the *fallback*
> if Homebrew ever rejects the (now signed + notarized) cask.
### Homebrew-core formula for the `amy` CLI (one-time initial PR)
The CLI goes to **homebrew-core** (mainline formulae), not homebrew-cask —
casks are for GUI apps. homebrew-core builds in a **network-sandboxed**
environment, so a from-source Gradle build can't resolve its Maven
dependencies there. Instead the formula downloads the pre-built **no-JRE jar
bundle** `amy-<version>-jvm.tar.gz` (published by `create-release.yml`) and
`depends_on "openjdk"`. The reference formula lives at
[`cli/packaging/homebrew/amy.rb`](cli/packaging/homebrew/amy.rb).
To submit:
```bash
# 1. Grab the published asset's sha256
curl -fsSL -o amy-jvm.tar.gz \
https://github.com/vitorpamplona/amethyst/releases/download/v1.12.1/amy-1.12.1-jvm.tar.gz
shasum -a 256 amy-jvm.tar.gz
# 2. Fill the url + sha256 into cli/packaging/homebrew/amy.rb, then open the PR
brew create --set-name amy --tap homebrew/core \
https://github.com/vitorpamplona/amethyst/releases/download/v1.12.1/amy-1.12.1-jvm.tar.gz
# (paste the reference formula body, run `brew audit --new amy`,
# `brew install --build-from-source amy`, `brew test amy`, then PR it.)
```
Caveats that the maintainer must weigh before submitting:
- **Name collision.** `amy` may already exist in homebrew-core — check with
`brew search amy` first. If taken, fall back to `amethyst-cli`.
- **Pre-built-jar scrutiny.** homebrew-core prefers source builds; downloading
a jar bundle is an accepted-but-reviewed pattern for JVM tools. Be ready to
justify it (sandboxed Gradle can't fetch Maven deps).
- **Bundle size.** The bundle is ~70 MB today because `:commons` leaks
Compose/Skiko jars onto the CLI classpath. Trimming that (a `:commons`
core/ui split) would shrink it and smooth review — tracked as a follow-up.
After the formula merges, the `livecheck` block lets homebrew-core's BrewTestBot
auto-open version-bump PRs on each stable release — no token or workflow on our
side (unlike the cask/winget bumps).
### Winget (one-time initial submission)
```bash
wingetcreate new \
https://github.com/vitorpamplona/amethyst/releases/download/v1.09.2/amethyst-desktop-1.09.2-windows-x64.msi
https://github.com/vitorpamplona/amethyst/releases/download/v1.12.1/amethyst-desktop-1.12.1-windows-x64.msi
```
Set `PackageIdentifier = VitorPamplona.Amethyst`. After the first manifest is
@@ -368,9 +578,17 @@ for the deprecation date. When it hits:
Homebrew has committed to disabling unsigned casks in `Homebrew/homebrew-cask`
on 2026-09-01. Before that date:
**Option A**: Commit budget to Apple Developer Program ($99/yr), add
`signing { sign.set(true) }` + `notarization {}` blocks to
`desktopApp/build.gradle.kts`, wire Developer ID + notary creds into CI.
**Option A (wiring done — needs Apple creds)**: The `signing { sign.set(true) }`
+ `notarization {}` blocks are already in `desktopApp/build.gradle.kts` (gated on
the `AMETHYST_MAC_SIGN_IDENTITY` env var), and the macOS leg of
`create-release.yml` imports a Developer ID cert into a throwaway keychain and
exports the signing/notary env. It all stays a **no-op until the six
`MAC_*`/notary secrets are provisioned** (see [§ Secrets the CI
needs](#secrets-the-ci-needs)) — until then the DMG builds unsigned. To turn it
on: join the Apple Developer Program ($99/yr), create a *Developer ID
Application* certificate, generate an app-specific password, and set the six
secrets. The first signed+notarized DMG is best validated with a
`workflow_dispatch` dry-run before a real tag.
**Option B**: Pivot to a private Homebrew tap:

File diff suppressed because it is too large Load Diff

View File

@@ -1,54 +1,114 @@
# Amethyst Privacy Policy and Terms of Use
## Privacy Policy
**App:** Amethyst (Android Nostr client)<br>
**Publisher:** Vitor Pamplona<br>
**Contact:** amethyst@vitorpamplona.com<br>
**Last updated:** 2026-05-24
Effective as of Jun 12, 2023
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).
- Content that is deceptive or fraudulent.
- Content promoting illegal drugs, tobacco, firearms, ammunition, or illegal gambling.
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.

View File

@@ -14,7 +14,8 @@ Join the social network you control.
[![PlayStore downloads](https://img.shields.io/endpoint?color=green&logo=google-play&logoColor=green&url=https%3A%2F%2Fplay.cuzi.workers.dev%2Fplay%3Fi%3Dcom.vitorpamplona.amethyst%26gl%3DUS%26hl%3Den%26l%3DPlayStore%26m%3D%24shortinstalls)](https://play.google.com/store/apps/details?id=com.vitorpamplona.amethyst)
[![Last Version](https://img.shields.io/github/release/vitorpamplona/amethyst.svg?maxAge=3600&label=Stable&labelColor=06599d&color=043b69)](https://github.com/vitorpamplona/amethyst)
[![JitPack version](https://jitpack.io/v/vitorpamplona/amethyst.svg)](https://jitpack.io/#vitorpamplona/amethyst)
[![Maven Central](https://img.shields.io/maven-central/v/com.vitorpamplona.quartz/quartz?label=Quartz%20%28Maven%20Central%29&labelColor=27303D&color=0877d2)](https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz)
[![JitPack snapshots](https://img.shields.io/badge/Quartz%20snapshots-JitPack-27303D?labelColor=27303D&color=0877d2)](https://jitpack.io/#vitorpamplona/amethyst)
[![CI](https://img.shields.io/github/actions/workflow/status/vitorpamplona/amethyst/build.yml?labelColor=27303D)](https://github.com/vitorpamplona/amethyst/actions/workflows/build.yml)
[![License: Apache-2.0](https://img.shields.io/github/license/vitorpamplona/amethyst?labelColor=27303D&color=0877d2)](/LICENSE)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/vitorpamplona/amethyst)
@@ -269,18 +270,16 @@ For the Play build:
## Deploying
Full release + bootstrap runbooks (Android AAB upload, desktop packaging,
Homebrew cask, Winget manifest, Apple Developer signing budget time-box) live
in [BUILDING.md § Release runbook](BUILDING.md#release-runbook) and
[BUILDING.md § Bootstrap runbook (one-time)](BUILDING.md#bootstrap-runbook-one-time).
A release is one tag push. Bump `app` and `appCode` in
`gradle/libs.versions.toml`, then `git tag -s vX.Y.Z && git push --tags` — the
`Create Release Assets` workflow builds and signs every Android, desktop, CLI,
and Maven artifact, and Homebrew + Winget auto-bump on stable tags.
TL;DR for cutting a release:
1. Bump `app` in `gradle/libs.versions.toml` (e.g. `"1.08.1"`)
2. Bump `versionCode` in `amethyst/build.gradle`
3. `git commit -am "chore(release): 1.08.1" && git tag -s v1.08.1 && git push --tags`
4. Wait for `Create Release Assets` workflow — 20 Android assets + 8 desktop assets go live on GH Release; Homebrew + Winget auto-bump on stable tags
5. Upload AAB to Play Store manually (existing step)
- **[BUILDING.md](BUILDING.md)** — everything any fork needs: build commands,
the CI pipeline, the secrets it requires, and the distribution channels.
- **[RELEASE_OPS.md](RELEASE_OPS.md)** — the Amethyst maintainers'
ship checklist: the manual Play Store upload, Zapstore `zsp publish`, F-Droid
pull, and release-notes publishing.
## Using the Quartz library
@@ -298,20 +297,43 @@ repositories {
Add the following line to your `commonMain` dependencies:
```gradle
implementation('com.vitorpamplona.quartz:quartz:1:05.0')
implementation('com.vitorpamplona.quartz:quartz:1.12.5')
```
Variations to each platform are also available:
```gradle
implementation('com.vitorpamplona.quartz:quartz-android:1:05.0')
implementation('com.vitorpamplona.quartz:quartz-jvm:1:05.0')
implementation('com.vitorpamplona.quartz:quartz-iosarm64:1:05.0')
implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1:05.0')
implementation('com.vitorpamplona.quartz:quartz-android:1.12.5')
implementation('com.vitorpamplona.quartz:quartz-jvm:1.12.5')
implementation('com.vitorpamplona.quartz:quartz-iosarm64:1.12.5')
implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1.12.5')
```
Check versions on [MavenCentral](https://central.sonatype.com/search?q=com.vitorpamplona.quartz)
#### Snapshots (JitPack)
Tagged releases go to Maven Central. For **pre-release / snapshot** builds —
e.g. to test an unreleased fix straight from `main` or a feature branch — use
[JitPack](https://jitpack.io/#vitorpamplona/amethyst), which builds the module
on demand from any git ref:
```gradle
repositories {
maven { url = uri("https://jitpack.io") }
}
dependencies {
// version can be a tag, a commit hash, or "<branch>-SNAPSHOT"
implementation("com.github.vitorpamplona.amethyst:quartz:main-SNAPSHOT")
}
```
The resolvable refs and the exact module coordinates are listed on the
[JitPack page](https://jitpack.io/#vitorpamplona/amethyst). Prefer a Maven
Central release for anything shipping to production — JitPack snapshots are not
guaranteed stable.
### How to use
Manage logged in users with the `KeyPair` class

218
RELEASE_OPS.md Normal file
View File

@@ -0,0 +1,218 @@
# Release Ops (Amethyst maintainers)
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
its event id into `amethyst/build.gradle.kts`:
```kotlin
buildConfigField("String", "RELEASE_NOTES_ID", "\"<new-event-id-hex>\"")
```
This id is what the in-app drawer's "Release Notes" link and the donation
card open (`DrawerContent.kt`, `ShowDonationCard.kt`). It must point at the
note for *this* version, so publish the note **before** tagging and commit
the new id together with the version bump.
<!-- TODO(maintainer): document the exact command/account used to publish the
release-notes note (which signer, which relays). -->
4. **Sanity-build locally** (optional but cheap): `./gradlew assembleRelease`
and a desktop `packageDistributionForCurrentOS`, or run the workflow's
dry-run (see BUILDING.md § Dry-run).
---
## 2. Cut the release
Commit, tag, push — see [`BUILDING.md` § Release runbook](BUILDING.md#release-runbook)
for the exact commands. The tag must equal `app` from the catalog (the workflow
asserts this and fails fast otherwise). A clean `vMAJOR.MINOR.PATCH` tag is
classified **stable** and triggers the Homebrew/Winget bumps; anything with a
`-rc`/`-beta`/`-alpha`/`-dev` suffix is a prerelease and skips them.
When the `Create Release Assets` workflow finishes (~2530 min) the GH Release
holds, per the asset-name contract:
- **Android:** 5 Google Play APKs + 5 F-Droid APKs + 2 AABs
(`amethyst-googleplay-*-v…apk` / `.aab`, `amethyst-fdroid-*-v…apk` / `.aab`)
- **Desktop:** 8 assets (DMG/MSI/DEB/RPM/AppImage/zip/tar.gz)
- **CLI:** the `amy` artifacts
- **Maven Central:** `com.vitorpamplona.quartz:quartz:<version>` published
---
## 3. Per-channel shipping
### GitHub Releases — automatic
Nothing to do beyond pushing the tag. Verify the asset count and that Intel +
ARM DMGs are both present (BUILDING.md § Verify).
### Google Play — manual upload
1. Download `amethyst-googleplay-<version>.aab` from the GH Release.
2. Play Console → app `com.vitorpamplona.amethyst` → **Production** (or the
staged-rollout track we're using) → create release → upload the AAB.
3. The release notes field can reuse the `docs/changelog` text.
4. Roll out.
### F-Droid — pull / build-from-source
F-Droid does **not** accept an upload from us. Its build server polls the repo,
and when it sees the new `v*` tag it builds the **`fdroid` product flavor** from
source (reproducibly) per the recipe in the separate
[`fdroiddata`](https://gitlab.com/fdroid/fdroiddata) repo
(`metadata/com.vitorpamplona.amethyst.yml`), then signs and publishes to the
F-Droid repo on its own cadence.
What we own to keep that working:
- The **`fdroid` flavor** (`amethyst/src/fdroid/…`) must stay free of
proprietary deps — it swaps Firebase/Google services for UnifiedPush and
no-op/open implementations (ML Kit, writing assistant, push). Google-only
libraries live behind the `play` flavor.
- The fastlane metadata under `fastlane/metadata/android/` (descriptions,
images). F-Droid reads per-version changelogs from
`fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` if present —
add one (e.g. `449.txt`) when we want a changelog shown on F-Droid; otherwise
none is displayed.
- The `AutoUpdateMode`/`UpdateCheckMode` in the fdroiddata recipe tracks tags,
so a correct `vX.Y.Z` tag + bumped `versionCode` is usually all F-Droid needs.
After a release, just confirm F-Droid picked up the new version (it can lag a
few days): <https://f-droid.org/packages/com.vitorpamplona.amethyst/>.
### Zapstore — `zsp publish` with Amethyst's nsec
[Zapstore](https://zapstore.dev/) is a Nostr-native app store. The `zsp` CLI
reads [`zapstore.yaml`](zapstore.yaml) at the repo root (name, summary,
description, tags, license, `icon`, screenshots, `supported_nips`, and the
`variants` regexes that match our `*-fdroid-*.apk` / `*-googleplay-*.apk`
GH-release assets), then publishes a signed software-release event to Nostr
relays.
```bash
# from the repo root, after the GH Release assets exist
zsp publish
```
It signs with **Amethyst's nsec** — provide the key the way `zsp` expects
(`SIGN_WITH` env var / prompt / its own config), never commit it.
**Relays.** `zsp` does *not* take relays from `zapstore.yaml`; it reads the
`RELAY_URLS` env var (comma-separated) and defaults to `wss://relay.zapstore.dev`
when unset. To fan the release event out to more relays for discoverability,
set `RELAY_URLS` for the run:
```bash
RELAY_URLS="wss://relay.zapstore.dev,wss://relay.damus.io,wss://nos.lol,wss://vitor.nostr1.com" \
SIGN_WITH=<amethyst-nsec> zsp publish
```
Keep `wss://relay.zapstore.dev` in the list — that is the relay the Zapstore app
itself reads from.
### Homebrew + Winget — automatic
`bump-homebrew.yml` and `bump-winget.yml` fire on stable tags and open PRs
against `Homebrew/homebrew-cask` (cask `amethyst-nostr`) and
`microsoft/winget-pkgs` (`VitorPamplona.Amethyst`). No action unless one fails —
then see BUILDING.md § Bootstrap and § Incident response.
---
## 4. Operated infrastructure
### Push notification server
The Google Play (FCM) flavor delivers push through a server we operate at
`push.amethyst.social`, built from
[`vitorpamplona/amethyst-push-notif-server`](https://github.com/vitorpamplona/amethyst-push-notif-server).
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 |
| `HOMEBREW_TOKEN`, `WINGET_TOKEN` | Cask + winget bump PRs | **90-day cadence** (see BUILDING.md § Bootstrap) |
| `CROWDIN_PERSONAL_TOKEN`, `CROWDIN_PROJECT_ID` | Translation sync | On compromise |
Owner assignments and rotation reminders live with the team (issue tracker).
<!-- TODO(maintainer): name the owner per secret and where backups live. -->
---
## 6. Post-release verification
- [ ] GH Release: expected asset count, Intel + ARM DMGs, sizes sane.
- [ ] Maven Central: `quartz:<version>` resolves (allow propagation time).
- [ ] Play Console: rollout started, no policy rejection.
- [ ] Zapstore: release event visible.
- [ ] F-Droid: new version detected (may lag days).
- [ ] Homebrew + Winget bump PRs opened (stable only).
- [ ] In-app "Release Notes" link opens the note matching `RELEASE_NOTES_ID`.
- [ ] Push still works on a `play` build (only if the push contract changed —
see § 4); UnifiedPush still works on an `fdroid` build.
If anything ships broken, see [`BUILDING.md` § Incident response](BUILDING.md#incident-response).

View File

@@ -1,435 +0,0 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.googleServices)
alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.serialization)
}
def getCurrentBranch() {
try {
def branch = 'git rev-parse --abbrev-ref HEAD'.execute().text.trim()
return branch
} catch (Exception e) {
println "Could not determine git branch: ${e.message}"
return "unknown"
}
}
def generateVersionName(String baseVersion) {
def currentBranch = getCurrentBranch()
if (currentBranch == "main" || currentBranch == "master" || currentBranch == "unknown" || currentBranch == "HEAD") {
return baseVersion
} else {
// Clean branch name for version (replace special characters)
def cleanBranch = currentBranch.replaceAll(/[^a-zA-Z0-9\-_]/, "-")
// Limit branch name to maximum 20 characters
if (cleanBranch.length() > 20) {
cleanBranch = cleanBranch.substring(0, 20)
}
return "${baseVersion}-${cleanBranch}"
}
}
// Workaround: stability.analyzer plugin doesn't declare task dependencies properly for Gradle 9.x
afterEvaluate {
def stabilityNames = tasks.names.findAll { it.contains("StabilityCheck") }
def compileNames = tasks.names.findAll { it.matches("compile.*UnitTestKotlin") }
stabilityNames.each { scName ->
compileNames.each { ctName ->
tasks.named(scName).configure { mustRunAfter(tasks.named(ctName)) }
}
}
}
android {
namespace = 'com.vitorpamplona.amethyst'
compileSdk = libs.versions.android.compileSdk.get().toInteger()
defaultConfig {
applicationId = "com.vitorpamplona.amethyst"
minSdk = libs.versions.android.minSdk.get().toInteger()
targetSdk = libs.versions.android.targetSdk.get().toInteger()
versionCode = 445
versionName = generateVersionName(libs.versions.app.get())
buildConfigField "String", "RELEASE_NOTES_ID", "\"b1b91d7ee0c5da9d081d1a53470248ee4585b058b11aa34fe28c0e3e07ac1e0a\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
resourceConfigurations += [
'ar',
'ar-rSA',
'bn-rBD',
'cs',
'cs-rCZ',
'cy-rGB',
'da-rDK',
'de',
'de-rDE',
'el-rGR',
'en-rGB',
'eo',
'eo-rUY',
'es',
'es-rES',
'es-rMX',
'es-rUS',
'et-rEE',
'fa',
'fa-rIR',
'fi-rFI',
'fo-rFO',
'fr',
'fr-rCA',
'fr-rFR',
'gu-rIN',
'hi-rIN',
'hr-rHR',
'hu',
'hu-rHU',
'in',
'in-rID',
'it-rIT',
'iw-rIL',
'ja',
'ja-rJP',
'kk-rKZ',
'ko-rKR',
'ks-rIN',
'ku-rTR',
'lt-rLT',
'ne-rNP',
'nl',
'nl-rBE',
'nl-rNL',
'pcm-rNG',
'pl-rPL',
'pt-rBR',
'pt-rPT',
'ru',
'ru-rRU',
'ru-rUA',
'sa-rIN',
'sl-rSI',
'so-rSO',
'sr-rSP',
'ss-rZA',
'sv-rSE',
'sw-rKE',
'sw-rTZ',
'ta',
'ta-rIN',
'th',
'th-rTH',
'tr',
'tr-rTR',
'uk',
'uk-rUA',
'ur-rIN',
'uz-rUZ',
'vi-rVN',
'zh',
'zh-rCN',
'zh-rHK',
'zh-rSG',
'zh-rTW'
]
}
// Opt-in fast-build flags. Default behavior is unchanged.
//
// -PdisableAbiSplits=true skip per-ABI APK splits; produces a single
// APK per (flavor, buildType) instead of 5.
// Cuts ~600 MB of intermediates and several
// minutes off CI.
// -PdisableUniversalApk=true when ABI splits are enabled, skip the
// extra universal APK output. (No effect
// when disableAbiSplits is also set, since
// there are no splits to add to.)
// -Pamethyst.skipMapping=true disable R8 minification on release and
// benchmark. APK is larger, but builds are
// much faster and outputs/mapping/ (~260MB)
// is not produced. Local-dev and PR-CI use
// only — release pipelines must not set it.
def disableAbiSplits = providers.gradleProperty("disableAbiSplits")
.map { it.toBoolean() }.getOrElse(false)
def disableUniversalApk = providers.gradleProperty("disableUniversalApk")
.map { it.toBoolean() }.getOrElse(false)
def skipMapping = providers.gradleProperty("amethyst.skipMapping")
.map { it.toBoolean() }.getOrElse(false)
buildTypes {
release {
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), 'proguard-rules.pro'
minifyEnabled = !skipMapping
}
debug {
applicationIdSuffix '.debug'
versionNameSuffix '-DEBUG'
resValue "string", "app_name", "@string/app_name_debug"
}
create("benchmark") {
initWith(getByName("release"))
applicationIdSuffix '.benchmark'
versionNameSuffix '-BENCHMARK'
resValue "string", "app_name", "@string/app_name_benchmark"
profileable = true
signingConfig = signingConfigs.debug
}
}
// TODO: remove this when lightcompressor uses one MP4 parser only
packaging {
resources {
resources.pickFirsts.add('builddef.lst')
resources.pickFirsts.add('META-INF/LICENSE.md')
resources.pickFirsts.add('META-INF/LICENSE-notice.md')
}
}
flavorDimensions = ["channel"]
productFlavors {
play {
getIsDefault().set(true)
dimension "channel"
buildConfigField "boolean", "IS_CASTING_AVAILABLE", "true"
}
fdroid {
dimension "channel"
buildConfigField "boolean", "IS_CASTING_AVAILABLE", "false"
}
}
splits {
abi {
enable = !disableAbiSplits
reset()
include "x86", "x86_64", "arm64-v8a", "armeabi-v7a"
universalApk = !disableUniversalApk
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
buildFeatures {
compose = true
buildConfig = true
resValues = true
}
packagingOptions {
resources {
excludes += ['/META-INF/{AL2.0,LGPL2.1}', '**/libscrypt.dylib']
}
}
lint {
disable 'MissingTranslation'
}
testOptions {
unitTests.returnDefaultValues = true
}
}
// TODO: until google merges and unifiedpush updates https://github.com/tink-crypto/tink-java-apps/pull/5
configurations.all {
def tink = "com.google.crypto.tink:tink-android:1.17.0"
resolutionStrategy {
force(tink)
dependencySubstitution {
substitute module('com.google.crypto.tink:tink') using module(tink)
}
}
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_21
}
}
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
metricsDestination = layout.buildDirectory.dir("compose_compiler")
}
dependencies {
implementation platform(libs.androidx.compose.bom)
implementation project(path: ':quartz')
implementation project(path: ':commons')
implementation project(path: ':nestsClient')
implementation libs.androidx.core.ktx
implementation libs.androidx.activity.compose
implementation libs.androidx.ui
implementation libs.androidx.ui.graphics
implementation libs.androidx.ui.tooling.preview
// Needs this to open gallery / image upload
implementation libs.androidx.fragment.ktx
// Navigation
implementation libs.androidx.navigation.compose
// Material 3 Design
implementation libs.androidx.material3
// Adaptive Layout / Two Pane
implementation libs.androidx.material3.windowSize
implementation libs.accompanist.adaptive
// Lifecycle
implementation libs.androidx.lifecycle.runtime.ktx
implementation libs.androidx.lifecycle.runtime.compose
implementation libs.androidx.lifecycle.viewmodel.compose
// Zoomable images
implementation libs.zoomable
// Biometrics
implementation libs.androidx.biometric.ktx
// Background Work
implementation libs.androidx.work.runtime.ktx
// Websockets API
implementation libs.okhttp
implementation libs.okhttpCoroutines
// Encrypted Key Storage
implementation libs.androidx.security.crypto.ktx
implementation libs.androidx.datastore.preferences
// view videos
implementation libs.androidx.media3.exoplayer
implementation libs.androidx.media3.exoplayer.hls
implementation libs.androidx.media3.ui.compose.material3
implementation libs.androidx.media3.session
// important for proxy / tor
implementation libs.androidx.media3.datasource.okhttp
// Load images from the web.
implementation libs.coil.compose
// view gifs
implementation libs.coil.gif
// view svgs
implementation libs.coil.svg
// enables network for coil
implementation libs.coil.okhttp
// loads thumbnails for media3
// TODO: Replace this to the FrameExtractor in media 3
// when FrameExtractor accepts custom data sources.
implementation(libs.coil.video)
// Permission to upload pictures:
implementation libs.accompanist.permissions
// For QR generation
implementation libs.zxing
implementation libs.zxing.embedded
// Markdown
//implementation "com.halilibo.compose-richtext:richtext-ui:0.16.0"
//implementation "com.halilibo.compose-richtext:richtext-ui-material:0.16.0"
//implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0"
// Markdown (With fix for full-image bleeds)
implementation libs.markdown.ui
implementation libs.markdown.ui.material3
implementation libs.markdown.commonmark
// Language picker and Theme chooser
implementation libs.androidx.appcompat
// Dynamically adjust between phone and tablet UI
implementation libs.androidx.window.core.android
// Local model for language identification
playImplementation libs.google.mlkit.language.id
// Google services model the translate text
playImplementation libs.google.mlkit.translate
// On-device AI writing assistance (Gemini Nano via AICore)
playImplementation libs.google.mlkit.genai.proofreading
playImplementation libs.google.mlkit.genai.prompt
playImplementation libs.google.mlkit.genai.rewriting
// On-device alt-text suggestions: genai image description (preferred, descriptive sentences)
// with image-labeling as a keyword-join fallback for devices without AICore.
playImplementation libs.google.mlkit.genai.image.description
// PushNotifications
playImplementation platform(libs.firebase.bom)
playImplementation libs.firebase.messaging
//PushNotifications(FDroid)
fdroidImplementation libs.unifiedpush
// Google Cast SDK — Chromecast support. Play flavor only because the
// framework hard-depends on Google Play services, which is unavailable
// on de-Googled / GrapheneOS devices that ship the F-Droid build.
playImplementation libs.play.services.cast.framework
// Charts
implementation libs.vico.charts.compose
implementation libs.vico.charts.m3
// Waveform visualizer
implementation libs.audiowaveform
// Video compression lib
implementation libs.abedElazizShe.video.compressor.fork
// Image compression lib
implementation libs.zelory.image.compressor
// EXIF metadata stripping
implementation libs.androidx.exifinterface
// Voice anonymization DSP
implementation libs.tarsosdsp
// WebRTC for voice/video calls
implementation libs.stream.webrtc.android
// Cbor for cashuB format
implementation libs.kotlinx.serialization.cbor
// Kotlin serialization for the times where we need the Json tree and performance is not that important.
implementation(libs.kotlinx.serialization.json)
testImplementation libs.junit
testImplementation libs.mockk
testImplementation libs.kotlinx.coroutines.test
androidTestImplementation platform(libs.androidx.compose.bom)
androidTestImplementation libs.androidx.junit
androidTestImplementation libs.androidx.junit.ktx
androidTestImplementation libs.androidx.espresso.core
androidTestImplementation libs.androidx.ui.test.junit4
androidTestImplementation libs.mockk.android
debugImplementation platform(libs.androidx.compose.bom)
debugImplementation libs.androidx.ui.tooling
debugImplementation libs.androidx.ui.test.manifest
implementation libs.androidx.camera.core
implementation libs.androidx.camera.camera2
implementation libs.androidx.camera.lifecycle
implementation libs.androidx.camera.view
implementation libs.androidx.camera.extensions
}

515
amethyst/build.gradle.kts Normal file
View File

@@ -0,0 +1,515 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.googleServices)
alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.serialization)
alias(libs.plugins.googleKsp)
}
fun getCurrentBranch(workingDir: java.io.File): String =
try {
val process =
ProcessBuilder("git", "rev-parse", "--abbrev-ref", "HEAD")
.directory(workingDir)
.redirectErrorStream(true)
.start()
val branch =
process.inputStream
.bufferedReader()
.use { it.readText() }
.trim()
val exitCode = process.waitFor()
if (exitCode != 0) "unknown" else branch
} catch (e: Exception) {
println("Could not determine git branch: ${e.message}")
"unknown"
}
fun generateVersionName(
baseVersion: String,
workingDir: java.io.File,
): String {
val currentBranch = getCurrentBranch(workingDir)
if (currentBranch == "main" || currentBranch == "master" || currentBranch == "unknown" || currentBranch == "HEAD") {
return baseVersion
}
// Clean branch name for version (replace special characters)
var cleanBranch = currentBranch.replace(Regex("[^a-zA-Z0-9\\-_]"), "-")
// Limit branch name to maximum 20 characters
if (cleanBranch.length > 20) {
cleanBranch = cleanBranch.substring(0, 20)
}
return "$baseVersion-$cleanBranch"
}
// Workaround: stability.analyzer plugin doesn't declare task dependencies properly for Gradle 9.x
afterEvaluate {
val stabilityNames = tasks.names.filter { it.contains("StabilityCheck") }
val compileNames = tasks.names.filter { it.matches(Regex("compile.*UnitTestKotlin")) }
stabilityNames.forEach { scName ->
compileNames.forEach { ctName ->
tasks.named(scName).configure { mustRunAfter(tasks.named(ctName)) }
}
}
}
android {
namespace = "com.vitorpamplona.amethyst"
compileSdk =
libs.versions.android.compileSdk
.get()
.toInt()
defaultConfig {
applicationId = "com.vitorpamplona.amethyst"
minSdk =
libs.versions.android.minSdk
.get()
.toInt()
targetSdk =
libs.versions.android.targetSdk
.get()
.toInt()
versionCode =
libs.versions.appCode
.get()
.toInt()
versionName = generateVersionName(libs.versions.app.get(), rootDir)
buildConfigField("String", "RELEASE_NOTES_ID", "\"40e817712e397c07ba31784a92fa474aa095896a828c0e2dea0d09c60d49ee1e\"")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
@Suppress("UnstableApiUsage")
resourceConfigurations +=
listOf(
"ar",
"ar-rSA",
"bn-rBD",
"cs",
"cs-rCZ",
"cy-rGB",
"da-rDK",
"de",
"de-rDE",
"el-rGR",
"en-rGB",
"eo",
"eo-rUY",
"es",
"es-rES",
"es-rMX",
"es-rUS",
"et-rEE",
"fa",
"fa-rIR",
"fi-rFI",
"fo-rFO",
"fr",
"fr-rCA",
"fr-rFR",
"gu-rIN",
"hi-rIN",
"hr-rHR",
"hu",
"hu-rHU",
"in",
"in-rID",
"it-rIT",
"iw-rIL",
"ja",
"ja-rJP",
"kk-rKZ",
"ko-rKR",
"ks-rIN",
"ku-rTR",
"lt-rLT",
"ne-rNP",
"nl",
"nl-rBE",
"nl-rNL",
"pcm-rNG",
"pl-rPL",
"pt-rBR",
"pt-rPT",
"ru",
"ru-rRU",
"ru-rUA",
"sa-rIN",
"sl-rSI",
"so-rSO",
"sr-rSP",
"ss-rZA",
"sv-rSE",
"sw-rKE",
"sw-rTZ",
"ta",
"ta-rIN",
"th",
"th-rTH",
"tr",
"tr-rTR",
"uk",
"uk-rUA",
"ur-rIN",
"uz-rUZ",
"vi-rVN",
"zh",
"zh-rCN",
"zh-rHK",
"zh-rSG",
"zh-rTW",
)
}
// Opt-in fast-build flags. Default behavior is unchanged.
//
// -PdisableAbiSplits=true skip per-ABI APK splits; produces a single
// APK per (flavor, buildType) instead of 5.
// Cuts ~600 MB of intermediates and several
// minutes off CI.
// -PdisableUniversalApk=true when ABI splits are enabled, skip the
// extra universal APK output. (No effect
// when disableAbiSplits is also set, since
// there are no splits to add to.)
// -Pamethyst.skipMapping=true disable R8 minification on release and
// benchmark. APK is larger, but builds are
// much faster and outputs/mapping/ (~260MB)
// is not produced. Local-dev and PR-CI use
// only — release pipelines must not set it.
val disableAbiSplits =
providers
.gradleProperty("disableAbiSplits")
.map { it.toBoolean() }
.getOrElse(false)
val disableUniversalApk =
providers
.gradleProperty("disableUniversalApk")
.map { it.toBoolean() }
.getOrElse(false)
val skipMapping =
providers
.gradleProperty("amethyst.skipMapping")
.map { it.toBoolean() }
.getOrElse(false)
buildTypes {
getByName("release") {
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
isMinifyEnabled = !skipMapping
}
getByName("debug") {
applicationIdSuffix = ".debug"
versionNameSuffix = "-DEBUG"
resValue("string", "app_name", "@string/app_name_debug")
}
create("benchmark") {
initWith(getByName("release"))
applicationIdSuffix = ".benchmark"
versionNameSuffix = "-BENCHMARK"
resValue("string", "app_name", "@string/app_name_benchmark")
isProfileable = true
signingConfig = signingConfigs.getByName("debug")
}
}
// TODO: remove this when lightcompressor uses one MP4 parser only
packaging {
resources {
pickFirsts.add("builddef.lst")
pickFirsts.add("META-INF/LICENSE.md")
pickFirsts.add("META-INF/LICENSE-notice.md")
}
}
flavorDimensions += "channel"
productFlavors {
create("play") {
isDefault = true
dimension = "channel"
buildConfigField("boolean", "IS_CASTING_AVAILABLE", "true")
}
create("fdroid") {
dimension = "channel"
buildConfigField("boolean", "IS_CASTING_AVAILABLE", "false")
}
}
splits {
abi {
isEnable = !disableAbiSplits
reset()
include("x86", "x86_64", "arm64-v8a", "armeabi-v7a")
isUniversalApk = !disableUniversalApk
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
buildFeatures {
compose = true
buildConfig = true
resValues = true
}
packaging {
resources {
excludes += listOf("/META-INF/{AL2.0,LGPL2.1}", "**/libscrypt.dylib")
}
}
lint {
disable += "MissingTranslation"
}
testOptions {
unitTests.isReturnDefaultValues = true
// Lets TorArtiNativeIntegrationTest's System.loadLibrary("arti_android")
// find the desktop-host build of our Arti JNI shim. The Android .so
// variants live in src/main/jniLibs/{arm64-v8a,x86_64}/ and are loaded
// on-device — this Linux x86_64 .so is just for JVM unit-test runs.
// -Pamethyst.arti.integration=true opts the (slow, network-dependent)
// tests in; see TorArtiNativeIntegrationTest.kdoc.
unitTests.all { test ->
test.systemProperty(
"java.library.path",
"$projectDir/src/test/native-libs/x86_64-linux",
)
project
.findProperty("amethyst.arti.integration")
?.let { test.systemProperty("amethyst.arti.integration", it.toString()) }
}
}
}
// androidx.appfunctions-compiler runs in a per-module mode by default,
// emitting only the dispatcher Kotlin code. The aggregator that builds
// the `app_functions.xml` asset (which the system reads to discover our
// @AppFunction methods) is gated behind this KSP argument — without it,
// the manifest's `android.app.appfunctions` property points at a file
// that doesn't exist and the System UI logs "Unable to resolve
// AppFunctionMetadata." Set on the app module only; library modules
// (commons/quartz) would set it to "false".
ksp {
arg("appfunctions:aggregateAppFunctions", "true")
}
// TODO: until google merges and unifiedpush updates https://github.com/tink-crypto/tink-java-apps/pull/5
configurations.all {
val tink = "com.google.crypto.tink:tink-android:1.17.0"
resolutionStrategy {
force(tink)
dependencySubstitution {
substitute(module("com.google.crypto.tink:tink")).using(module(tink))
}
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
metricsDestination = layout.buildDirectory.dir("compose_compiler")
}
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(project(":quartz"))
implementation(project(":commons"))
implementation(project(":nestsClient"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
// Needs this to open gallery / image upload
implementation(libs.androidx.fragment.ktx)
// Navigation
implementation(libs.androidx.navigation.compose)
// Material 3 Design
implementation(libs.androidx.material3)
// Adaptive Layout / Two Pane
implementation(libs.androidx.material3.windowSize)
implementation(libs.accompanist.adaptive)
// Lifecycle
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
// Zoomable images
implementation(libs.zoomable)
// Biometrics
implementation(libs.androidx.biometric.ktx)
// Background Work
implementation(libs.androidx.work.runtime.ktx)
// Reads workouts from Android Health Connect (Samsung Health, Google Fit, Fitbit, Garmin, …)
implementation(libs.androidx.health.connect.client)
// Websockets API
implementation(libs.okhttp)
implementation(libs.okhttpCoroutines)
// Encrypted Key Storage
implementation(libs.androidx.security.crypto.ktx)
implementation(libs.androidx.datastore.preferences)
// view videos
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.exoplayer.hls)
implementation(libs.androidx.media3.ui.compose.material3)
implementation(libs.androidx.media3.session)
// important for proxy / tor
implementation(libs.androidx.media3.datasource.okhttp)
// Load images from the web.
implementation(libs.coil.compose)
// view gifs
implementation(libs.coil.gif)
// view svgs
implementation(libs.coil.svg)
// enables network for coil
implementation(libs.coil.okhttp)
// loads thumbnails for media3
// TODO: Replace this to the FrameExtractor in media 3
// when FrameExtractor accepts custom data sources.
implementation(libs.coil.video)
// Permission to upload pictures:
implementation(libs.accompanist.permissions)
// For QR generation
implementation(libs.zxing)
implementation(libs.zxing.embedded)
// OpenStreetMap tiles for road event location maps (kind 1315/1316)
implementation(libs.osmdroid.android)
// Markdown
// implementation "com.halilibo.compose-richtext:richtext-ui:0.16.0"
// implementation "com.halilibo.compose-richtext:richtext-ui-material:0.16.0"
// implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0"
// Markdown (With fix for full-image bleeds)
implementation(libs.markdown.ui)
implementation(libs.markdown.ui.material3)
implementation(libs.markdown.commonmark)
// LaTeX math rendering ($...$ and $$...$$ inline equations)
implementation(libs.jlatexmath.android)
implementation(libs.jlatexmath.font.greek)
implementation(libs.jlatexmath.font.cyrillic)
// Language picker and Theme chooser
implementation(libs.androidx.appcompat)
// Dynamically adjust between phone and tablet UI
implementation(libs.androidx.window.core.android)
// Local model for language identification
"playImplementation"(libs.google.mlkit.language.id)
// Google services model the translate text
"playImplementation"(libs.google.mlkit.translate)
// On-device AI writing assistance (Gemini Nano via AICore)
"playImplementation"(libs.google.mlkit.genai.proofreading)
"playImplementation"(libs.google.mlkit.genai.prompt)
"playImplementation"(libs.google.mlkit.genai.rewriting)
// On-device alt-text suggestions: genai image description (preferred, descriptive sentences)
// with image-labeling as a keyword-join fallback for devices without AICore.
"playImplementation"(libs.google.mlkit.genai.image.description)
// PushNotifications
"playImplementation"(platform(libs.firebase.bom))
"playImplementation"(libs.firebase.messaging)
// PushNotifications(FDroid)
"fdroidImplementation"(libs.unifiedpush)
// Google Cast SDK — Chromecast support. Play flavor only because the
// framework hard-depends on Google Play services, which is unavailable
// on de-Googled / GrapheneOS devices that ship the F-Droid build.
"playImplementation"(libs.play.services.cast.framework)
// androidx.appfunctions — Gemini App Functions adapter. Pre-stable
// (alpha) as of May 2026 — scoped to the play channel so the F-Droid
// build stays free of Google AI dependencies. Surface is an
// AppFunctionService registered in amethyst/src/play/AndroidManifest.xml,
// generated at compile time by the KSP-driven appfunctions-compiler.
"playImplementation"(libs.androidx.appfunctions)
"playImplementation"(libs.androidx.appfunctions.service)
"kspPlay"(libs.androidx.appfunctions.compiler)
// Charts
implementation(libs.vico.charts.compose)
implementation(libs.vico.charts.m3)
// Waveform visualizer
implementation(libs.audiowaveform)
// Video compression lib
implementation(libs.abedElazizShe.video.compressor.fork)
// Image compression lib
implementation(libs.zelory.image.compressor)
// EXIF metadata stripping
implementation(libs.androidx.exifinterface)
// WebRTC for voice/video calls
implementation(libs.stream.webrtc.android)
// Cbor for cashuB format
implementation(libs.kotlinx.serialization.cbor)
// Kotlin serialization for the times where we need the Json tree and performance is not that important.
implementation(libs.kotlinx.serialization.json)
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.junit.ktx)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.ui.test.junit4)
androidTestImplementation(libs.mockk.android)
debugImplementation(platform(libs.androidx.compose.bom))
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
implementation(libs.androidx.camera.core)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view)
implementation(libs.androidx.camera.extensions)
}

View File

@@ -0,0 +1,367 @@
# Onchain Zaps in Amethyst
**Date:** 2026-05-14
**Status:** Active
Implementation plan for NIP-BC (kind 8333) onchain Bitcoin zaps in Amethyst Android.
Quartz now ships the `OnchainZapEvent` data model; this document describes the
rest of the system.
## Design decisions
- **Wallet model.** Non-custodial. The user's Nostr pubkey IS the BIP-341
internal key of a P2TR output, so every account has exactly one onchain
address derived from its identity. No seed phrase, no separate wallet
creation.
- **Signers.** v1 supports `NostrSignerInternal` (local keypair) and
`NostrSignerExternal` (NIP-55 Android external signer). The NIP-55 path is
broken until Amber implements `sign_psbt`; surface "update your signer"
there. `NostrSignerRemote` (NIP-46) is deferred.
- **Chain backend.** User-configured Esplora-compatible API
(mempool.space, blockstream.info, self-hosted). The configured server sees
the user's UTXO queries — accepted tradeoff for v1. **Spec PR
nostr-protocol/nips#2332 adds optional `["block", …]` and
`["proof", …]` tags on `kind:8333` that let a verifier with only the
Bitcoin header chain check a zap end-to-end without trusting the
explorer. Amethyst already parses both tags (see `BlockTag.kt` /
`ProofTag.kt`) but no production code path produces or consumes them
yet — see the "Inline SPV proofs" section below.** The explorer
endpoint is shared with OpenTimestamps via `BitcoinExplorerEndpoint`:
same user-configured server, same Tor-aware default selection.
- **Scope.** Full send + receive + display loop on Android. Desktop is out of
scope for v1.
### Architecture decision: hand-rolled Bitcoin consensus code (2026-05-14)
**Decision:** keep the hand-rolled Bitcoin consensus layer in
`quartz/.../nipBCOnchainZaps/{psbt,taproot}/` — the transaction codec,
serialization/txid, BIP-341 sighash, BIP-174 PSBT codec/signer/finalizer, and
BIP-341/350 address derivation. Do **not** pull in `fr.acinq.bitcoin-kmp`.
**Considered alternative:** replace the `psbt/` + transaction + sighash layer
with `fr.acinq.bitcoin-kmp` (mature, same vendor as the `secp256k1` binding
already in the build).
**Rationale for keeping it hand-rolled:**
- It is a deliberately small, constrained subset — single-key-path P2TR only,
no script trees, one transaction shape.
- It is pinned to authoritative external test vectors at *every* layer:
BIP-341 sighash (all 7 vectors + ANYONECANPAY), the BIP-341 tweak, the full
BIP-341 witness *signature bytes*, the 7 BIP-341/350 P2TR mainnet addresses,
and tx serialization against the genesis coinbase. It is "matches the
authoritative vectors," not "trust our code."
- Consistent with the project's stance on minimal dependencies (cf. the
from-scratch `quic` module).
- No new transitive dependencies or version-conflict surface.
**Consequence / what this commits us to:** we own the correctness of this code
forever. If the scope ever expands beyond single-key-path P2TR (script-path
spends, multisig, PSBT fields we don't model), revisit this decision — at that
point a vetted library is the better trade. The `nipBCOnchainZaps/{psbt,taproot}/`
packages carry a pointer back to this section.
## Architecture
| Layer | Concerns | Location |
|---|---|---|
| Quartz | Address derivation, PSBT codec, Esplora client, NIP-BC verifier, `signPsbt` on signer hierarchy | `quartz/.../nipBCOnchainZaps/{taproot,psbt,chain,build,verify}/` |
| Commons | Send/wallet ViewModels, shared composables | `commons/.../onchain/` |
| Amethyst | `OnchainSection` in existing `WalletScreen`, `LocalCache.consume(OnchainZapEvent)`, kind list edits in 8 filter files, NIP-55 intent plumbing | `amethyst/.../ui/onchain/`, `amethyst/.../model/LocalCache.kt`, existing filter files |
## Merging into the existing wallet UI
The existing `WalletScreen` is a NIP-47 NWC multi-wallet manager. Onchain wallet
fits as a separate top section, since it has different semantics (single
deterministic wallet per account, no NWC URI, chain-backed).
```
WalletScreen
├── TopAppBar
├── BitcoinSection ← NEW, single card
│ └── OnchainWalletCard (balance, bc1p address, tap → OnchainWalletDetailScreen)
└── LightningSection ← existing MultiWalletHomeContent
├── NoWalletSetup
└── NwcWalletCard × N
```
The "+" `Add wallet` icon still adds NWC entries only — onchain is implicit.
Section labels: "Bitcoin" and "Lightning".
## Subscription edits — extend existing kind lists
No new assemblers. Add `OnchainZapEvent.KIND` (8333) to the existing
`LnZapEvent.KIND` (9735) sites:
| File | Edit |
|---|---|
| `amethyst/.../FilterRepliesAndReactionsToNotes.kt:48-60` | Add to `RepliesAndReactionsKinds` (note `#e`) |
| `amethyst/.../FilterUserProfileZapReceived.kt:30` | Add to `UserProfileZapReceiverKinds` (profile `#p`) |
| `amethyst/.../zaps/dal/UserProfileZapsViewModel.kt:55` | Add to inline `kinds = listOf(...)` |
| `amethyst/.../FilterNotificationsToPubkey.kt:58-65` | Add to `SummaryKinds` (notifications `#p`) |
| `amethyst/.../NotificationFeedFilter.kt:123` | Add to `NOTIFICATION_KINDS` |
| `amethyst/.../NotificationDispatcher.kt:98` | Add to `NOTIFICATION_KINDS` |
| `amethyst/.../FilterMessagesToLiveStream.kt:46` | Add to live-activity zap kinds (`#a`) |
| `amethyst/.../FilterGoalForLiveActivity.kt:58` | Add to goal-zap kinds (`#e`) |
## Display path — fold into `Note.zapsAmount`
Today: `LocalCache.consume(LnZapEvent)``Note.addZap()``Note.updateZapTotal()`
sums lightning amounts into `Note.zapsAmount`, which `ReactionsRow` /
`ObserveZapAmountText` / `SlidingAnimationAmount` render. We add onchain zap
sats to the same `Note.zapsAmount` — no UI changes required.
- `commons/.../model/Note.kt:154-157, 621-632`
- Add `var onchainZaps = mapOf<...>()` (separate map from `zaps`).
- Extend `updateZapTotal()` to add **verified** onchain sats. Unverified or
pending tx amounts are NOT counted.
- `amethyst/.../model/LocalCache.kt` (after the `consume(LnZapEvent)` block ~line 1667)
- New `consume(event: OnchainZapEvent)` handler.
- Reject self-zap.
- Enqueue verification against the configured `OnchainBackend`.
- On success: `Note.addOnchainZap(event, verifiedSats)` on each `repliesTo`.
- On failure or zero verified amount: discard.
- Dedupe by `(txid, target)`.
## Quartz additions
- `nipBCOnchainZaps/taproot/`
- `SegwitAddress.kt` — bech32m segwit address encoder/decoder
- `TaprootAddress.kt` — Nostr pubkey → bc1p P2TR address via BIP-341
key-path-only tweak (uses `Secp256k1Instance.pubKeyTweakAdd`)
- `nipBCOnchainZaps/chain/`
- `OnchainBackend` interface — `getTx`, `getUtxos`, `broadcast`,
`tipHeight`, `feeEstimates`
- `BitcoinTx`, `BitcoinTxOutput`, `Utxo` data models
- `BitcoinTxParser` — minimal raw-tx parser (just enough for verification)
- `EsploraBackend` (jvmAndroid) — OkHttp impl
- `nipBCOnchainZaps/verify/`
- `OnchainZapVerifier` — implements all spec rules: reject self-zap, sum
only outputs paying the derived recipient address, dedupe `(txid, target)`,
cap claimed amount at verified amount
- `nipBCOnchainZaps/psbt/` (Phase A.2)
- Minimal BIP-174 codec
- `PsbtTaprootKeyPathSigner` — BIP-341 TapTweak + Schnorr sign
- `nipBCOnchainZaps/build/` (Phase A.2)
- `OnchainZapBuilder` — given (sender, recipient, sats, feeRate, utxos),
build a PSBT with change back to sender's Taproot
- `NostrSigner.signPsbt` (Phase A.2)
- Internal: signs directly
- External (NIP-55): launches `sign_psbt` intent — needs Amber update
- Remote (NIP-46): `NotSupportedException` stub
## Commons additions
- `OnchainWalletViewModel` — derived address, balance, UTXO list
- `OnchainZapSendViewModel` — build → sign → broadcast → publish kind 8333
- `OnchainZapSendDialog`, `OnchainAddressQrCard`, `FeeRatePicker`
## Account state
In `AccountSettings.kt` next to `nwcWallets`:
```kotlin
val onchainEsploraEndpoint: MutableStateFlow<String> // default mempool.space
val onchainDefaultFeeTier: MutableStateFlow<FeeTier> // SLOW / NORMAL / FAST
```
Derived `Account.onchainBalance: StateFlow<Long?>` populated by
`OnchainWalletViewModel`.
## Send-from-note merge
Existing `ZapAmountChoicePopup` (`ReactionsRow.kt:1877-1977`) stays as the
Lightning fast path. Two minimal hooks:
1. Append `[ ⛓ Onchain… ]` row to the popup. Tapping it opens
`OnchainZapSendDialog` (fee picker + confirmation), separate from the
instant-tap Lightning UX.
2. Optional settings toggle "Show onchain zap option" — defaults off until a
balance is observed.
## Phased delivery
| Phase | Deliverable | Status |
|---|---|---|
| **A.1** | Quartz foundation (receive side): taproot address, bech32m segwit, Esplora client, verifier + tests | **Shipped** |
| **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** |
### Phase A.2 — shipped
- `nipBCOnchainZaps/psbt/`: `BitcoinIO` (LE byte codec + varint), `BitcoinTransaction`
(legacy + segwit serialization, witness-stripped txid), `TaprootSigHash`
(BIP-341 SigMsg + TapSighash, all sighash types), `Psbt` (BIP-174 subset,
unknown-record-preserving), `PsbtSigner` (key-path signing), `PsbtFinalizer`.
- `nipBCOnchainZaps/build/OnchainZapBuilder`: largest-first coin selection +
unsigned-PSBT assembly with dust-aware change.
- `TaprootAddress.tweakSecretKey`: BIP-341 `taproot_tweak_seckey`.
- `Secp256k1Instance.privKeyNegate`: new primitive (commonMain + 3 actuals).
- `NostrSigner.signPsbt`: real impl on internal signer; delegated by the
client-tag wrapper; `UnsupportedMethodException` stubs on NIP-46 / NIP-55.
### Phase D — shipped
- `commons/onchain/OnchainZapSender`: stateless orchestrator —
load UTXOs → `OnchainZapBuilder.build``signer.signPsbt`
`PsbtFinalizer``OnchainBackend.broadcast` → publish kind:8333 via an
injected callback. Per-stage `OnchainZapSendResult.Failure`; broadcast txid
preserved when only the receipt-publish stage fails.
- `Account.sendOnchainZap`: binds the account signer, `LocalCache.onchainBackend`,
and `signAndComputeBroadcast` into the orchestrator.
- `OnchainZapSendDialog`: recipient npub (or fixed recipient + `zappedEvent`
for a future note-zap-menu entry), amount, fee tier from the backend's
estimates, comment; runs the send and shows progress + result.
- `OnchainSection`: "Send" button on the wallet-screen Bitcoin card.
### Phase B — shipped
- `CommandType.SIGN_PSBT` (`sign_psbt`) — also usable in NIP-55 `perms` lists
via `Permission`, which wraps `CommandType` directly.
- `SignPsbtResult` result type; `SignPsbtQuery` (background ContentResolver),
`SignPsbtRequest` / `SignPsbtResponse` (foreground Intent), mirroring the
`derive_key` string-in/string-out shape — the PSBT hex rides the
`nostrsigner:` URI, the signed PSBT comes back in `result`.
- `BackgroundRequestHandler.signPsbt` / `ForegroundRequestHandler.signPsbt`;
`NostrSignerExternal.signPsbt` now does the real background-then-foreground
query instead of throwing. External signers that predate `sign_psbt` reply
with no `result``CouldNotPerformException`, surfaced by the send dialog.
### What's still pending
1. **Note-zap-menu entry point.** `OnchainZapSendDialog` already accepts
`recipientPubKey` + `zappedEvent`; wiring an "Onchain" option into the
existing `ZapAmountChoicePopup` is the remaining UI hook for event zaps.
2. **NIP-46 `sign_psbt`.** `NostrSignerRemote.signPsbt` still throws
`UnsupportedMethodException` — the bunker-side command is not standardized
yet.
3. **Send-side `block` + `proof` emit.** See "Inline SPV proofs" below.
4. **Receive-side SPV verification.** See "Inline SPV proofs" below.
## Inline SPV proofs (`block` + `proof` tags) — pending
### Spec status (2026-05-19)
`nostr-protocol/nips#2332` adds two new optional tags on `kind:8333`:
```
["block", "<block-hash-hex>", "<height>"]
["proof", "<raw-tx-hex>", "<merkle-proof-hex>"]
```
Together they let a verifier with only the Bitcoin header chain check a
zap end-to-end without trusting the chain backend. The recipient hashes
`raw-tx-hex` to recover the txid, walks the merkle proof from that leaf
up to the header's `merkleRoot`, parses outputs from `raw-tx-hex`, and
sums those that pay the recipient's derived Taproot script.
**One spec ambiguity outstanding** (raised back to the PR author):
direction-at-each-step encoding for `<merkle-proof-hex>` — Bitcoin's
merkle tree is tx-order-sensitive, so concatenated siblings alone don't
specify left vs right. Suggested resolution: `1-byte direction || 32-byte
sibling` per step (33 B stride). This blocks Phase G.2 (walker
implementation), nothing else.
### Current state in Amethyst
| Layer | Status | Location |
|---|---|---|
| `BlockTag(blockHashHex, height: Long)` | ✅ shipped | `quartz/.../nipBCOnchainZaps/zap/tags/BlockTag.kt` |
| `ProofTag(rawTxHex, merkleProofHex)` | ✅ shipped | `…/zap/tags/ProofTag.kt` |
| `OnchainZapEvent.block()` / `.proof()` accessors | ✅ shipped | `…/zap/OnchainZapEvent.kt:89-93` |
| TagArray + TagArrayBuilder helpers | ✅ shipped | `…/zap/TagArrayExt.kt`, `…/zap/TagArrayBuilderExt.kt` |
| Tx parser + witness-stripped txid (reusable for the walker) | ✅ shipped | `…/psbt/BitcoinTransaction.kt` |
| Recipient scriptPubKey derivation | ✅ shipped | `taproot/TaprootAddress.scriptPubKeyHexForRecipient` |
| `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
sees ≥1 confirmation:
- Call `backend.getMerkleProof(txid)``(block_hash, height, siblings, pos)`.
- Encode `siblings + pos` into the spec's `<merkle-proof-hex>` wire format
(depends on the resolved spec encoding).
- Build a second `kind:8333` referencing the same `i` tag, with `block`
and `proof` filled in. Publish.
3. Verifiers MUST dedupe by `(txid, target)` and prefer the variant
carrying a valid SPV proof. (This dedupe rule is also in the suggested
spec amendment.)
Two relay broadcasts per zap, ~2 KB extra each. The post-confirmation
worker is per-account, persists across app restarts, and exits cleanly
once each pending tx is either confirmed (publish second receipt) or
abandoned (after some timeout — TBD).
### Receive-side
`OnchainZapVerifier.verify(event)` gets a new fast path:
1. If `event.block() != null && event.proof() != null`:
- Look up header by hash via `LocalHeadersBitcoinExplorer.byHash()` (S1).
If unavailable yet, fall through to the existing `backend.getTx()` path.
- `BitcoinTransaction.parse(rawTxHex)` → tx; recompute
`dsha256(stripWitness(tx))` → expected_txid; check it matches the
`i` tag's txid.
- `MerkleProofVerifier.verify(expected_txid, proofBytes, header.merkleRoot)` → boolean.
- Parse outputs from the same parsed tx; sum outputs paying the
recipient's `scriptPubKeyHexForRecipient`, skipping outputs paying
the sender's own derived script (change).
- Confirmations = `tip.height block.height + 1`.
- Return `Confirmed`.
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 |
| **G.6** | End-to-end tests: real mainnet block + proof + zap; negative cases (wrong root, truncated, wrong direction) | G.1 | 1 d |
| **G.7** | (Optional) Settings toggle "Verify zaps trustlessly when possible" — off by default until S1 ships | S1 Phase 7 | ½ d |
| **Total** | | | ~34 d (after S1 ships and spec encoding lands) |
S1 (the headers explorer at `quartz/plans/2026-05-08-local-headers-explorer.md`)
is the long pole. The SPV work itself is small — every primitive needed
already exists in the codebase, the only new thing is the merkle walker.
## Risks / open questions
- **secp256k1-kmp tweak coverage** — pubKeyTweakAdd exists on JVM/Android/JNI
but not on the pure-Kotlin native impl. iOS support deferred until upstream
ships it or we contribute.
- **PSBT correctness** — single-key-path-only is a small surface; still needs
thorough testing against BIP-174 test vectors before any user funds move.
- **Esplora privacy** — the configured server sees user UTXO queries. Default
to a reputable provider, make user-configurable, consider future Tor option.
- **NIP-55 ecosystem** — Amber must implement `sign_psbt` for external signer
accounts to use this feature.
- **`Note.zaps` shape** — separate `onchainZaps` map vs sealed-type fold-in.
Leaning separate map for v1.
- **Verification network calls from `LocalCache`** — `consume` runs on the
relay thread; verification needs a coroutine scope + `OnchainBackend`
instance injected from `Account` on app start.

View File

@@ -0,0 +1,390 @@
# iOS Support for Amethyst
**Date:** 2026-05-24
**Status:** Phase 1 complete; Phase 2 in flight
**Owner:** TBD
Plan to incrementally bring Amethyst to iOS by extending the existing
KMP layers from the bottom up. Each phase is independently shippable —
we can pause between any two phases without leaving the tree in a
broken state.
## Why this is tractable today
The structural work that usually dooms a KMP-to-iOS effort is already
done:
- `quartz/` has `iosArm64` + `iosSimulatorArm64` targets configured,
a working `Platform.ios.kt` actual, and 6 iOS test files that pass.
- Jackson and OkHttp — the two big JVM-only dependencies — are *already
isolated to `jvmAndroid`* in quartz (`quartz/src/jvmAndroid/.../jackson/`,
`quartz/src/jvmAndroid/.../okhttp/`). `commonMain` is JVM-free except
for the obvious `kotlinx.*` stack.
- `commons/commonMain` has exactly **one** Jackson reference
(`FeedDefinitionSerializer.kt`) and zero OkHttp references. The rest
of the JVM stickiness lives in `jvmAndroid` / `jvmMain` / `androidMain`,
which is where it belongs.
- Compose Multiplatform 1.10.3 is in use, which supports iOS officially.
- `secp256k1-kmp` ships iOS targets. `androidx.collection` (LruCache) and
`androidx.lifecycle.viewmodel.compose` are KMP since 2.8.
What this means: we are not embarking on a months-long "purify
commonMain" migration before any iOS code can compile. Phase 1 is
mostly **add iOS to CI** and **patch the last few leaks**.
## Module-by-module dep matrix
Status legend:
- ✅ iOS-ready (targets configured, no JVM-only deps in shared code)
- 🟡 Partial (intermediate source sets need adding, but no major dep blockers)
- 🔴 Blocked (significant native work required)
- ⛔ Out of scope (won't ship on iOS)
| Module | Today | Phase 1 | Phase 2 | Phase 3 | Phase 4 | Phase 5 |
|---|---|---|---|---|---|---|
| `quartz/` | ✅ | CI + audit | — | — | — | — |
| `commons/` (non-UI) | 🟡 | — | ✅ | — | — | — |
| `commons/` (UI) | 🟡 | — | — | ✅ | — | — |
| `iosApp/` (new) | n/a | — | — | scaffold | feature-complete | — |
| `quic/` | 🔴 | — | — | — | — | iOS actuals |
| `nestsClient/` | 🔴 | — | — | — | — | iOS actuals |
| `amethyst/` (app) | ⛔ | — | — | — | — | — |
| `desktopApp/` | ⛔ | — | — | — | — | — |
| `cli/` | ⛔ | — | — | — | — | — |
## Source-set diagram (target end state)
```
commons/src/
├── commonMain/ ── all targets
│ ├── coreMain/ ── ViewModels, state, DAL (no Compose)
│ │ ├── jvmAndroidCore/ ── Android + Desktop
│ │ │ ├── androidCore/
│ │ │ └── jvmCore/
│ │ └── nativeCore/ ── iOS
│ │ ├── iosArm64Core/
│ │ └── iosSimArm64Core/
│ └── uiMain/ ── Compose UI, icons, resources
│ ├── jvmAndroidUi/
│ │ ├── androidUi/
│ │ └── jvmUi/
│ └── nativeUi/ ── iOS Compose
```
(Names sketched for clarity; in practice we'll fold `coreMain` /
`uiMain` together once *every* file in `uiMain` compiles for iOS —
the split is a transitional scaffold for Phase 2 ↔ Phase 3.)
`quartz/`, `quic/`, `nestsClient/` already use a `jvmAndroid` shared
source set; we'll add a sibling `nativeMain` (or just `iosMain` where
that's simpler) when each module turns on iOS.
---
## Phase 1 — Lock down Quartz on iOS
**Duration estimate:** 12 weeks
**Deliverable:** `./gradlew :quartz:iosSimulatorArm64Test` runs in CI on every PR.
### Tasks
1. **Add iOS to CI for `:quartz`.**
- GitHub Actions macOS runner step: `iosSimulatorArm64Test` +
`iosArm64SourceSetTest` (compile only).
- This is the single most valuable change in the entire plan — it
prevents anyone from accidentally re-adding a JVM-only import to
`commonMain`.
2. **Audit the `jvmAndroid` boundary.**
- Confirm everything Jackson/OkHttp-related lives in `jvmAndroid`
(it does today — keep it that way).
- Add a checkstyle / detekt rule, or a simple grep gate in CI, that
fails the build if `com.fasterxml.jackson` or `okhttp3` shows up
in `commonMain`.
3. **Validate `secp256k1` iOS path.**
- Make sure `KeyPair`, `SchnorrSigner`, NIP-44 v2 vectors run green
on `iosSimulatorArm64Test`.
- The iOS tests already cover NIP-04 / NIP-17 / NIP-19 / NIP-49 — we
just need to surface them in CI.
4. **Plan the `expect`/`actual` for iOS HTTP.**
- Phase 1 only sketches the design; the actual `Ktor-darwin` wiring
lands in Phase 2 when `:commons` needs it.
- Decide: Ktor everywhere, vs OkHttp on JVM/Android + Ktor on iOS.
**Recommendation:** keep OkHttp on JVM/Android (we use OkHttp-specific
features in relay reconnect logic) and add an iOS-only Ktor actual.
### Risks
- None major. The work here is mostly defensive.
---
## Phase 2 — Bring `:commons` to iOS, non-UI first
**Duration estimate:** 23 weeks
**Deliverable:** `./gradlew :commons:iosSimulatorArm64Test` compiles every
shared ViewModel and state class.
### Tasks
1. **Add iOS targets to `commons/build.gradle.kts`.**
- `iosArm64()` + `iosSimulatorArm64()`.
- Introduce intermediate source sets `coreMain` (all targets) and
`uiMain` (JVM + Android only, for now).
2. **Migrate `FeedDefinitionSerializer.kt` off Jackson.**
- Move to `kotlinx.serialization`, OR
- Push it down into `jvmAndroidCore` and create a `nativeCore` actual.
**Recommendation:** migrate. It's one file; one-time cost is small;
reduces split-actual surface area forever.
3. **Add `expect`/`actual` wrappers for JVM-only deps used by ViewModels.**
| Concern | JVM/Android | iOS actual |
|---|---|---|
| HTTP client | OkHttp | Ktor + `Ktor-darwin` |
| Secure key storage | Android Keystore / java-keyring | Keychain Services |
| EXIF strip (image upload) | `commons-imaging` | `ImageIO` (`CGImageSourceCopyPropertiesAtIndex`) |
| File I/O paths | `java.io.File` | `NSFileManager` / `okio` |
| Logging | `android.util.Log` / SLF4J | `os_log` via cinterop, or plain `println` to start |
4. **Compile-only iOS for `:commons` ViewModels.**
- At the end of Phase 2 we have ViewModels, account state, LocalCache
wrappers, filter assemblers, and `ComposeSubscriptionManager` building
on iOS — but no UI yet.
- Smoke test: write a small `commonTest` that constructs an `Account`,
subscribes to a stub relay, and verifies a follow event lands in
`LocalCache`. Run it on iOS simulator.
### Risks
- **Ktor migration scope creep.** Hold the line: Phase 2 only wraps HTTP
behind `expect`. Don't refactor the relay pool. That's a separate PR.
- **Coroutines dispatcher differences.** `Dispatchers.IO` does not exist on
Kotlin/Native by default — code that explicitly references it needs a
`KmpDispatchers.IO` shim. Audit before Phase 2 starts.
### Phase 2 audit (2026-05-24): commons/commonMain iOS-blocker inventory
Audit of all 335 .kt files in `commons/src/commonMain/`. Better than feared
— most files are already KMP-clean. The actual blockers are 21 files
across ~6 distinct concerns. Each row below is a small mergeable PR.
**By blocker category:**
| Blocker | Files | Fix |
|---|---|---|
| `java.util.Base64` | 1 (`Base64Image.kt`) | `kotlin.io.encoding.Base64` (stdlib since 1.8) |
| `AtomicLong` / `AtomicInteger` | 2 (`ChessLobbyState.kt`, `SigningState.kt`) | `kotlinx.atomicfu.atomic` |
| `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 |
| `WeakReference` | 5 (`Channel.kt`, `Chatroom.kt`, `MarmotGroupChatroom.kt`, `UserRelaysCache.kt`, **+1**) | `expect class KmpWeakReference<T>` actuals: JVM `java.lang.ref.WeakReference`; iOS `kotlin.native.ref.WeakReference` |
| `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.
**Recommended PR order** (ascending cost, descending obviousness):
1. ✅ Phase 1 complete (gates + iOS CI for quartz, Jackson migration).
2. **Base64** (1 file, ~2 LOC change). Demonstrates the pattern.
3. **Atomics** (2 files, atomicfu plugin + ~10 LOC).
4. **ConcurrentHashMap** (4 files; needs concurrency audit per file).
5. **WeakReference** (5 files + 1 new expect/actual).
6. **`:nestsClient` files → jvmAndroid** (2 files; pure source-set move).
7. **`RenderMarkdown.kt` → jvmAndroid OR iOS verification** (1 file; depends on lib check).
8. **URL parsing** (2 files; either ktor-http dep or expect/actual).
9. **Charsets, BigDecimal, File** (3 files; small per-file decisions).
10. After ~9 lands: add iOS targets to `:commons`, expect failures to be down
to ~zero, run `compileKotlinIosSimulatorArm64` to confirm.
11. Then proceed with the original Phase 2 plan items (Ktor for HTTP,
SecureKeyStore expect/actual, etc.) for the cross-cutting deps.
---
## Phase 3 — Compose Multiplatform UI on iOS
**Duration estimate:** 34 weeks
**Deliverable:** A read-only iOS `.ipa` on TestFlight internal that connects
to relays and renders a feed.
### Tasks
1. **Flip `uiMain` to target = all (including iOS).**
- Compose Multiplatform 1.10.3 supports iOS. The Material Symbols font
and other Compose Resources already work cross-platform.
2. **Audit UI deps for iOS.**
| Dep | Status | Action |
|---|---|---|
| `jetbrains.compose.*` (1.10.3) | ✅ | None |
| `androidx.lifecycle.viewmodel.compose` 2.8+ | ✅ KMP | None |
| `coil3` | ✅ iOS | Swap network fetcher from `coil-okhttp` to `coil-ktor` on iOS via source-set split |
| `markdown-ui` / `markdown-ui-material3` | ⚠️ Verify | Likely OK on iOS; if not, fall back to commonmark + custom renderer |
| `kotlinx-collections-immutable` | ✅ | None |
| Material Symbols font | ✅ | None (already via Compose Resources) |
3. **Create the `iosApp/` module.**
- SwiftUI `App` + `UIViewControllerRepresentable` hosting
`ComposeUIViewController { App() }`.
- Tab bar (UIKit) for top-level navigation, Compose for each tab's
content area. **Same split philosophy as Desktop**: native shell,
shared content.
- Add Xcode project + Gradle Kotlin/Native framework wiring (no
CocoaPods; use the JetBrains-recommended `embedAndSignAppleFrameworkForXcode`).
4. **Ship a "read-only Nostr browser" first cut.**
- Profile view, single-feed home, NoteCard rendering, image loading,
basic navigation.
- No posting, no DMs, no audio rooms.
- This validates the *entire* stack — relay client, LocalCache, feed DAL,
NoteCard composable, Coil 3, Compose Resources, font rendering — without
touching signing.
### Risks
- **Compose iOS performance on large feeds.** Profile early with a realistic
`LocalCache` (10k+ notes) before locking screen architecture. If recomposition
storms appear, lean harder on `compose-stability-diagnostics` and
`compose-state-deferred-reads` skills.
- **Touch interactions vs Android conventions.** Pull-to-refresh, swipe
back, long-press menus all differ on iOS. Some screens may need
platform-specific gesture handling.
- **Markdown rendering library iOS support.** If `markdown-ui-material3`
doesn't ship iOS artifacts, this is a half-week detour to switch
renderers. Verify in week 1 of Phase 3.
---
## Phase 4 — Write paths: signing, posting, settings
**Duration estimate:** 23 weeks
**Deliverable:** Fully read/write iOS client, minus audio rooms.
### Tasks
1. **Wire `NostrSignerInternal` to Keychain.**
- The signer is already KMP — only the key storage actual needs
adding (done in Phase 2's `SecureKeyStore` abstraction).
2. **Make `NostrSignerRemote` (NIP-46 bunker) work on iOS.**
- Should be KMP-clean once Ktor migration is done. Audit for any
stray Jackson / OkHttp inside the NIP-46 path.
3. **NIP-55 alternative.**
- **There is no Amber on iOS.** Plan replacements:
- Push users toward NIP-46 bunkers (Nsec.app, Amber-as-bunker,
remote nostr-connect URIs).
- URL-scheme handoff to native iOS signers (`nos2x-fhe`, `Nostore`)
*if* they expose a sign API. Track separately.
- Onboarding screen needs an iOS-specific copy variant.
4. **Posting, reactions, zaps.**
- Mostly free — ViewModels already in `:commons`. Wire UI buttons and
test end-to-end on TestFlight.
5. **Settings UI.**
- Share via Compose. iOS-native preference screens are a polish item
for later.
### Risks
- **Apple App Review on cryptocurrency / zaps.** Lightning zaps via LNURL
are fine (no in-app crypto purchase). Anything that looks like an
in-app wallet or onchain send may need legal review and / or feature
gating per-region. Start review conversations early.
- **Push notifications.** APNs is the only path on iOS. Nostr DM push
relays don't speak APNs natively. Likely needs a small relay-proxy
(similar to `notify.damus.io`'s architecture). Design doc in
`amethyst/plans/` before Phase 4 ends.
---
## Phase 5 — `:quic` + `:nestsClient` for audio rooms (optional)
**Duration estimate:** 46 weeks
**Status:** Defer until 14 are solid. App is shippable on iOS without
audio rooms.
### Tasks
1. **`:quic` — add iOS actuals.**
- UDP socket via `Network.framework` (`NWConnection` with `.udp`).
- AEAD (AES-GCM, ChaCha20-Poly1305) via Apple CryptoKit
(`AES.GCM.SealedBox`, `ChaChaPoly`).
- TLS state machine is already pure Kotlin in `commonMain` — no change.
2. **`:nestsClient` — add iOS actuals.**
- Opus encode/decode: `libopus` via cinterop, or pull `opus.framework`
from a Swift Package / CocoaPods spec.
- Mic + speaker: `AVAudioEngine` (input/output nodes) instead of
`AudioRecord` / `AudioTrack`.
3. **moq-lite listener path first** (the production path per CLAUDE.md),
then speaker.
### Risks
- **Background audio on iOS.** Audio rooms in the background need a
proper `AVAudioSession` category + the `audio` background mode in
`Info.plist`. Apple sometimes rejects apps that abuse this. Worth a
separate audit before submission.
- **Opus framework distribution.** `libopus` via Swift Package is
cleanest; CocoaPods is fine but pulls in a build-time dep on Ruby.
Decide before Phase 5 starts.
---
## Phase 6 — Ship polish (ongoing, post-Phase 4)
- App Store metadata, screenshots, privacy manifest
(`NSPrivacyAccessedAPI*` declarations — file access, user defaults).
- Localizations carry over automatically via Compose Resources.
- Background fetch limits — iOS is far stricter than Android. Tune
feed prefetch + relay reconnect for background launch budgets.
- TestFlight beta → public release.
---
## Cross-cutting risks (track from day one)
| Risk | Mitigation | First chance to catch |
|---|---|---|
| `commonMain` regresses with a JVM-only import | Add iOS to CI on every PR | Phase 1, task 1 |
| Coroutines `Dispatchers.IO` ergonomics on iOS | Audit + introduce `KmpDispatchers` shim | Phase 2, task 3 |
| Compose iOS performance on big feeds | Early profiling with realistic `LocalCache` | Phase 3 risk section |
| App Store review (zaps, onchain) | Talk to legal / read App Store guidelines early | Phase 4 risk section |
| No NIP-55 equivalent on iOS | Lean on NIP-46; document in onboarding | Phase 4, task 3 |
| Push notifications via APNs | Relay-proxy design doc | Phase 4, end of phase |
| Background audio policy | `AVAudioSession` audit + `Info.plist` review | Phase 5 risk section |
## Suggested first PR
Smallest useful start: **Phase 1, tasks 1 + 2** — add `:quartz` iOS to
CI and add the import-gate that prevents Jackson / OkHttp regressions
in `commonMain`. That single PR de-risks the rest of the plan without
touching any product code.
## Open questions
- Do we want a `:cli` analogue on iOS (a "headless" Nostr daemon)? Out
of scope for this plan, but iosArm64 *could* host one if we ever need
a CLI-on-phone story.
- Mac Catalyst vs native macOS: Desktop is already JVM-Compose. We
could theoretically also ship Catalyst from the iOS build, but that's
three "desktop"-ish targets to maintain. Recommendation: punt.
- iPad layout: do we want a separate split-view UI like `desktopApp`,
or just scale up the iPhone layout? Phase 3 keeps the iPhone layout;
iPad polish is a Phase 6 item.

View File

@@ -0,0 +1,239 @@
# AppFunctions signer prompts — design
**Date:** 2026-05-25
**Status:** Draft — no code yet
How write verbs invoked from background Gemini context (via
`androidx.appfunctions` 1.0.0-alpha09 → `PlatformAppFunctionService`)
acquire a signature from each of Amethyst's three signer types. This
is the gating concern that has us only exposing read-only verbs so far
(`searchProfiles`, `searchNotes`, `getFollowing`).
## The three signer types and what each needs
| 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.
- **Typed exceptions.** `AppFunctionPermissionRequiredException`,
`AppFunctionDeniedException`, `AppFunctionCancelledException`,
`AppFunctionAppException`. The non-default constructors take a `Bundle`
the system shell can interpret known keys (e.g. a `PendingIntent` to launch
an in-app confirmation). Concrete bundle contract is undocumented in
alpha09; needs a sample-app check or an experiment.
- **`PendingIntent`** is listed as a supported parameter type, which strongly
implies a returned `PendingIntent` can prompt the system to launch the
app's UI for follow-up.
- **No streaming.** Functions return one value or throw. There's no native
"in progress" / "user is approving" signal back to Gemini.
## Per-signer approach
### NostrSignerInternal — just works
Verb runs end-to-end inside the dispatch coroutine. `signer.sign(...)` is
synchronous. Publish via `client.publish(...)`. Return success.
**Verbs this covers immediately:** post, follow/unfollow, search-relay
list updates, kind:10002 changes — anything where the signed event is
sent and forgotten.
**Edge case — background `app.client`.** When the app process is
foreground-bound but the user is in Gemini, the client should be
connected. When the user has killed Amethyst recently, the service
process might be cold-started and the client not yet connected to any
relay. The verb needs to either:
- Wait for `client.connect()` (~hundreds of ms once the WebSocket is
established) — acceptable inside the 5-10s window.
- Use `INostrClient.publish(...)` which queues the publish for when the
connection comes up. Quartz needs to confirm this is the actual
behavior; might require `withTimeout` around the publish.
### NostrSignerRemote — the cleanest async case
The bunker sends a NIP-46 request to a relay, the bunker app sees it on
the user's other device, the user approves, the signed event comes back.
`signer.sign(...)` suspends until the response arrives or its internal
timeout fires (default 30s).
**Approach:** call `signer.sign(...)` from within the verb, with a
`withTimeout` budget aligned to App Functions UX expectations (Gemini
typically waits ~30s before showing the user "no response"). On
timeout, throw `AppFunctionCancelledException`. On success, publish and
return.
**Open question — concurrent foreground signing.** If the user is also
trying to send a post from the foreground UI at the same moment, the
bunker app gets two simultaneous requests. NIP-46 handles this — each
request has a unique id — but Amber-like bunker apps may queue both
prompts confusingly. Worth a manual test.
### NostrSignerExternal — the hard case
NIP-55 bounces to a separate Android app's activity. From a background
`PlatformAppFunctionService`, we can't directly `startActivity(...)`
there's no foreground intent stack to attach to.
**Two viable approaches:**
#### Option A — throw a typed exception with a PendingIntent
```kotlin
@AppFunction
suspend fun postNote(ctx: AppFunctionContext, text: String): PostResult {
val account = activeAccount() ?: throw AppFunctionDeniedException("not signed in")
if (account.signer is NostrSignerExternal) {
// Build a PendingIntent that opens Amethyst at a "approve this
// post" screen, with the draft text passed through extras.
val approvalIntent = buildApprovePostPendingIntent(account, text)
throw AppFunctionPermissionRequiredException(
message = "Amethyst needs to launch the external signer to approve this post.",
extras = bundleOf("pending_intent" to approvalIntent),
)
}
// … happy path for the in-process signer
}
```
The system shell renders "Open Amethyst to continue", user taps,
Amethyst opens, user approves through Amber's activity, post lands. The
Gemini conversation doesn't see the final result — the user has to come
back to Gemini and re-confirm.
**UX gap.** No way to communicate the eventual outcome back to Gemini's
chat. Acceptable for v1.
#### Option B — refuse write verbs when the signer is NIP-55
Throw `AppFunctionNotSupportedException` immediately. User configures a
different signer (local or NIP-46) to enable Gemini-driven writes.
Simpler, cleaner, but limits the audience — many Amethyst users on
Amber would lose the feature.
**Recommendation:** start with Option B, ship Internal + Remote support,
then add Option A behind a feature flag in a follow-up. Option B
unblocks the feature for ~70% of users today; Option A is more work
and has the unresolved "result doesn't get back to Gemini" wrinkle.
## Per-write-verb concerns
### postNote(text)
- Internal: sign → publish to outbox. Done.
- Remote: sign (suspends) → publish. Done.
- External: throw NotSupported, or PendingIntent dance.
- Side concern: should this go into the user's drafts vs immediately
publish? Gemini-issued posts feel like they should publish (the
user asked for it), but a "review before post" screen via PendingIntent
is a nice safety net even for the local-signer path.
### follow(npub) / unfollow(npub)
- Same signer paths as postNote, simpler payload.
- Reads the current kind:3, modifies, signs, publishes — `FollowActions`
is ready.
- **No** "preview" step needed — follow/unfollow is reversible.
### sendDm(recipient, text)
- Same signer paths.
- `DmActions.buildTextDm` is ready, plus `resolveDmRelays`.
- **Concern**: strict mode (default) refuses to send when recipient has
no kind:10050. Should Gemini's `sendDm` default to strict or
permissive? Argument for strict: it's NIP-17 spec behavior. Argument
for permissive: Gemini users won't know what kind:10050 is and will
see confusing failures. **Lean: permissive by default**, surface the
source in the result.
### zapUser(npub, sats, comment?)
- Same signer paths for the kind:9734 zap request.
- But there's a *second* signing-like step: an LN payment via NWC
(if configured). NWC has its own permission model and can also fail.
- For v1: build the zap request, fetch the BOLT11 invoice, return the
invoice in the result. User pays via their wallet. Skip NWC
auto-payment.
### zapEvent(eventId, sats, comment?)
- Same as zapUser but uses `ZapActions.buildEventZapRequestsForSplits`
so multi-party notes route correctly.
- May return multiple invoices (one per split recipient).
## Account selection
All verbs read `Amethyst.instance.sessionManager.loggedInAccount()` once
at entry. **Multi-account question**: should Gemini be able to specify
*which* account to act as? Two answers:
- v1: no — always act as the currently-active account. Matches what the
user sees in the foreground UI. Simpler.
- Later: add an optional `accountNpub: String?` parameter to each write
verb. Defaults to the active account.
Start with v1.
## Permissions surfaced to Gemini
The App Functions schema XML (auto-generated by KSP) lists each verb
plus its parameters. Gemini's tool picker shows these to the user. We
should add a `description` (via `isDescribedByKDoc = true`, which we
already do) that makes write verbs sound consequential — "Publishes a
note to your Nostr followers", not "Calls postNote".
## Open questions for an experiment day
1. What concrete `Bundle` keys does the system shell respect on
`AppFunctionPermissionRequiredException`? Run a tiny test app, throw
the exception with various bundle contents, observe what Gemini
surfaces.
2. Can the user approve a Gemini-issued write from within the Gemini
chat (inline confirmation) or only by opening Amethyst? Affects
Option A's UX.
3. Does `INostrClient.publish(...)` actually queue when the relay
pool is disconnected, or does it return immediately with no
delivery? Determines whether the verb needs an explicit
"wait for at least one OK" gate.
4. NWC and Gemini: if the user has a NWC wallet configured, should
`zapUser` auto-pay? Adds another consent layer.
## Minimum viable first write verb
Pick **`postNote(text)`** as the pilot.
Why:
- Simplest: one signed event, one publish, one ack.
- Read-back is straightforward: return the event id + the relays it
landed on. Gemini can compose "Posted! Here's the link: nostr:nevent…".
- Failure modes are well-bounded (signer error, no outbox relays, all
relays rejected).
- No multi-party complexity (zap splits, DM strict mode).
Scope of the pilot:
- `NostrSignerInternal` only (Option B for NIP-55, Remote in a
follow-up). Document the cutoff in kdoc.
- Returns `PostNoteResult(eventId, publishedTo, rejectedBy)` — an
`@AppFunctionSerializable`.
- Builds on the existing `commons/.../quartz/.../TextNoteEvent.build`
and `client.publish` — no new actions needed.
- ~50 lines of new code in `AmethystAppFunctions.kt`, plus the result
class.
Once shipped, follow-ups in order:
1. `follow(npub)` / `unfollow(npub)` — same signer caveat.
2. NIP-46 (Remote) signer support — change the gate from "signer is
internal" to "signer can sign in-process".
3. `sendDm(recipient, text)` — first write verb that uses encryption.
4. `zapUser` / `zapEvent` — non-trivial because of LN flow.
5. NIP-55 support via PendingIntent (Option A) once the system-shell
contract is understood.
No write-verb code lands until question (1) above is answered —
otherwise the NIP-55 path is undefined and we ship something that
"sort of works" for half our users.

View File

@@ -0,0 +1,131 @@
# Verifying Gemini-side AppFunctions discovery
**Date:** 2026-05-26
**Status:** Active — answers the open question from
`2026-05-25-appfunctions-signer-prompts.md`
The Phase 2 work proves the app side: 21 `@AppFunction` verbs are
registered, indexed by `AppFunctionManagerService`, and dispatchable
via `adb shell cmd app_function execute-app-function`. The remaining
unknown is whether **Gemini's chat UI** actually surfaces our verbs to
the user — that's a separate layer (model-side tool picker) we can't
exercise from the test command.
## What we know
* **Library state.** Built against `androidx.appfunctions
1.0.0-alpha09`. Schemas (`@AppFunctionSchemaDefinition`) are
optional and the official Google sample (`android/appfunctions`
ChatApp) doesn't use them — meaning we're not at a structural
disadvantage by not defining our own. There's no canonical
`nostr.social` schema registry yet.
* **Discovery strategy.** Without schemas, Gemini's tool picker
matches on the function's natural-language description (KDoc, via
`@AppFunction(isDescribedByKDoc = true)`) and the parameter
descriptions. We've reworked every verb's first sentence to be a
use-when imperative — "Find a person on Nostr by name…" — instead
of an implementation description ("Searches kind:0 metadata…").
## What we don't know yet
* Whether Gemini's model picks up our verbs at all from a typical
user query.
* Whether Gemini's `AppFunctionSearchSpec` filters by
`schemaCategory` / `schemaName` (in which case we're invisible
until we annotate) or by description (in which case we should
surface).
* What feature flags / Gemini-app versions are required. App
Functions is generally available on Android 16+, but Gemini's
third-party tool picker has shipped in waves.
## Verification protocol
### 1. Confirm the device is set up
```bash
# Pixel 8 or newer on Android 16 QPR1+
adb shell getprop ro.build.version.release
adb shell pm list packages | grep -i gemini # com.google.android.apps.bard
```
### 2. Reinstall the Play debug APK with the new descriptions
```bash
./gradlew :amethyst:assemblePlayDebug
adb install -r amethyst/build/outputs/apk/play/debug/amethyst-play-universal-debug.apk
adb shell am start -n com.vitorpamplona.amethyst.debug/com.vitorpamplona.amethyst.ui.MainActivity
# sign in if needed, give Amethyst a few seconds to register
```
### 3. Confirm metadata is indexed end-to-end
```bash
adb shell cmd app_function list-app-functions | grep -c amethyst
# should print ≥ 21 — one entry per @AppFunction across our class
```
### 4. Test prompts in Gemini
These are deliberately mapped to one specific verb each. Run them in
order, take notes on which surface a tool call and which don't.
| Prompt to Gemini | Should pick |
|---|---|
| "Find vitorpamplona on Nostr" | searchProfiles |
| "What's happening on Nostr today?" | getRecentFromFollows |
| "Who am I logged in as on Nostr?" | getActiveAccountInfo |
| "Did anyone DM me on Nostr recently?" | getRecentDms |
| "How many sats did I earn on Nostr this week?" | getZapsReceived |
| "Show me Nostr posts about bitcoin" | searchByHashtag |
| "Tell me about npub1xq5eqwlhxy3ldakahsfglccvzy4j6ayyxje5a92zu90hc05dxn7qrsns90" | getProfile |
| "What are people I follow saying on Nostr?" | getRecentFromFollows |
| "Catch me up on what Snowden's been posting" | getNotesByUser |
For each: did Gemini offer to call the tool? Did it call the right
one? Did it render the result?
### 5. Diagnose any miss
If Gemini doesn't surface a verb:
1. **Check Gemini's tools view.** In the Gemini app:
Settings → Apps. Our package should appear in the list of apps
the assistant can interact with. If it's not there at all, the
system hasn't told Gemini about us yet — wait a few minutes after
install or force-reindex by clearing AppSearch.
2. **Force a re-index.**
```bash
adb shell pm clear --user 0 com.android.appsearch || true
adb shell am force-stop com.vitorpamplona.amethyst.debug
adb shell am start -n com.vitorpamplona.amethyst.debug/com.vitorpamplona.amethyst.ui.MainActivity
```
3. **Verify per-prompt.** If the package is listed but a specific
prompt doesn't trigger a tool call, the issue is description
matching — our use-when phrasing isn't catching that query.
Adjust the kdoc and rebuild.
## When schemas become worth doing
We'll move from "skipped" to "implement" if:
1. Step 4 above shows Gemini consistently fails to surface verbs that
should obviously match (suggesting it's filtering by schema, not
description), OR
2. Another Nostr Android client ships AppFunctions and wants to
co-implement a shared schema namespace (so a Nostr-aware agent
could route to whichever client is installed).
Until either of those happens, the simpler description-matching path
is in place and is what every public AppFunctions sample uses today.
## Open follow-ups (independent of this verification)
* NIP-55 (Amber) signer support — write verbs currently refuse with
`AppFunctionNotSupportedException` because we can't launch Amber's
approval activity from a background dispatch. The PendingIntent
escape hatch (Option A in the signer-prompt plan) is the next move
if NIP-55 usage matters.
* NWC auto-pay for `zapUser` / `zapEvent` — today we return the
BOLT11 invoice; the caller pastes it into a wallet. With NWC
configured we could pay automatically.
* Schema definitions if step 4 above shows we need them.

View File

@@ -0,0 +1,191 @@
# All Amethyst screens as AppFunctions / MCP endpoints
**Date:** 2026-05-26
**Status:** Active — informs the v1 read-verb surface and guides
future MCP work
## The principle
Every screen in Amethyst has a dedicated `FeedContentState` driven by
a `*FeedFilter` that reads from `LocalCache`. The list is in
`amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt`
— there are ~30 entries today.
> 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?" |
| Long-form (discover) | `discoverReads` | `discoverArticles` | "find interesting Nostr articles" |
| Marketplace | `discoverMarketplace` | `discoverMarketplaceListings` | "what's for sale on Nostr?" |
| Communities (discover) | `discoverCommunities` | `discoverCommunities` | "find Nostr communities" |
| Communities (list) | `communitiesList` | `getMyCommunities` | "communities I'm a member of" |
| Public chats (discover) | `discoverPublicChats` | `discoverPublicChats` | "find Nostr chat channels" |
| Public chats (list) | `publicChatsFeed` | `getMyPublicChats` | "chats I'm in" |
| DVMs | `discoverDVMs` | `discoverDvms` | "what compute services are available?" |
| Follow sets | `discoverFollowSets` | `discoverFollowSets` | "find curated follow lists" |
| Live streams | `liveStreamsFeed` | (replace `getLiveStreams`) | already exists |
| Nests | `nestsFeed` | `getNests` | "audio rooms" |
| Articles (mine + follows) | `articlesFeed` | `getMyArticles` | combined long-form |
| Polls (open) | `openPollsFeed` | `getOpenPolls` | "what should I vote on?" |
| Polls (closed) | `closedPollsFeed` | `getRecentPollResults` | "what did people vote on?" |
| All polls | `pollsFeed` | (combined; less useful as a verb) | — |
| Badges | `badgesFeed` | `getBadges` | "show me my Nostr badges" |
| Software apps | `softwareAppsFeed` | `discoverNostrApps` | "what apps exist on Nostr?" |
| Emoji packs | `browseEmojiSetsFeed` | `discoverEmojiPacks` | "find custom emoji" |
| Follow packs | `followPacksFeed` | `discoverFollowPacks` | "find people to follow by topic" |
| Products | `productsFeed` | `getProductListings` | "what products are listed?" |
| Calendar appointments | `calendarAppointmentsFeed` | `getUpcomingEvents` | "what Nostr events are coming up?" |
| Calendar collections | `calendarCollectionsFeed` | `getEventCollections` | "what conferences are happening?" |
| Notifications (all) | `notifications` | `getRecentNotifications` | "what's happened to me on Nostr?" |
| Notifications (follows) | `notificationsFollowing` | (variant param) | "notifications from follows" |
| Notifications (everyone) | `notificationsEveryone` | (variant param) | "all notifications" |
| Drafts | `drafts` | `getMyDrafts` | "what did I start writing?" |
| Web bookmarks | `webBookmarks` | `getMyBookmarks` | "what did I bookmark?" |
That's ~25 unmapped feeds. Each verb is a ~30-line wrapper following
the `getFeedDigest` shape — read filter, project to result type,
return.
## Implementation pattern
```kotlin
@AppFunction(isDescribedByKDoc = true)
suspend fun getMyBookmarks(
appFunctionContext: AppFunctionContext,
hoursBack: Int = 168,
maxNotes: Int = 50,
): SearchNotesResult {
val account = Amethyst.instance.sessionManager.loggedInAccount()
?: return SearchNotesResult.empty()
val sinceSecs = TimeUtils.now() - hoursBack.coerceIn(1, 24 * 365).toLong() * 3600L
val feed = WebBookmarkFeedFilter(account).feed()
.asSequence()
.mapNotNull { it.event }
.filter { it.createdAt >= sinceSecs }
.sortedByDescending { it.createdAt }
.take(maxNotes.coerceIn(1, 200))
.map { it.toFeedNoteHit() }
.toList()
return SearchNotesResult(matches = feed)
}
```
The pattern is genuinely uniform. Most verbs would even share a
helper like `feedAsResult(filter, sinceHours, max) -> SearchNotesResult`.
## Result-type strategy
Most feeds project to `SearchNotesResult` since the screen is "a list
of notes." A few need bespoke types:
* Notifications — could return a `NotificationHit` carrying the
notification kind (reply, mention, zap, repost, reaction) since
the LLM needs to know "you got 3 zaps and 1 reply".
* Calendar events — natural fit for an `EventHit` with start/end
times.
* Communities / public chats — list-of-rooms more than list-of-notes.
Default to reusing `NoteHit` (now carries `kind`); add bespoke types
only when the LLM needs structure the LLM can't derive from `kind` +
`content`.
## What doesn't fit cleanly
Some screens are too interactive for a single AppFunction call:
* **Chats / DMs** — sending and reading a stream of messages is
more conversational; the agent loop should handle it. `sendDm` +
`getRecentDms` cover the basics.
* **Profile pages** — already covered by `getProfile` +
`getNotesByUser` rather than a "profile feed."
* **Settings screens** — out of scope; the agent shouldn't mutate
user prefs.
## When the cache is cold
For verbs backed by `LocalCache` (the screens), the result is sparse
when the user hasn't opened the app recently. The mitigation strategy
is:
1. The relay-drain verbs (`searchProfiles`, `searchNotes`,
`searchByHashtag`, `searchArticles`, `getRecentFromFollows`,
`getNotesByUser`, `getProfile`, `getRecentDms`,
`getZapsReceived`, `getLiveStreams`) all do their own fetch.
Use these when freshness matters.
2. The screen-mirror verbs (`getFeedDigest` and the proposed
additions) reflect what the foreground saw. Use these when
"what was the user looking at?" is the semantic.
Both shapes have value. The agent's prompt-matching kdoc decides
which gets called.
## MCP angle
When we add an MCP server for Amethyst (separate effort), the same
`*FeedFilter.feed()` calls power the MCP tool implementations. The
AppFunctions adapter and the MCP server share the projection
helpers (`toFeedNoteHit`, `toProfileHit`, etc.) and the result
types. The transport layer is the only difference.
The screen-feed mapping above is the source of truth for both
surfaces.
## Concrete next steps
Highest user-value follow-ups (each ~30 min):
1. `getRecentNotifications` — answers "what's been happening to me
on Nostr?" without a per-kind walk.
2. `getMyBookmarks` — agent recall over saved Nostr content.
3. `getOpenPolls` — "what should I vote on?"
4. `getMyDrafts` — "what was I writing?"
5. `getUpcomingEvents` — calendar / agenda integration.
After these, the rest are mostly "discover X" variants that follow
the same template.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,314 @@
# AVIF support — comprehensive design
**Issue:** [vitorpamplona/amethyst#837](https://github.com/vitorpamplona/amethyst/issues/837)
**Date:** 2026-05-26
**Branch:** `feat/avif-support` (based on `d1610bf97`, origin/main = upstream/main)
**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 < 31 where the platform decoder cannot handle AVIF at all.
This is **not** a minimum-merge or bounty-targeted effort. The goal is a full solution.
---
## 2. Context
### 2.1 What works today on `main`
AVIF is **already partially supported**:
- `commons/.../richtext/RichTextParser.kt` recognizes `.avif` extension and `image/avif` MIME links in note text render as image embeds.
- `amethyst/.../video/datasource/FeedBasis.kt` includes `image/avif` in `SUPPORTED_VIDEO_FEED_MIME_TYPES` AVIF posts are eligible for the media feed.
- `commons/.../jvmMain/.../upload/MediaMetadata.kt` maps the `.avif` extension to `image/avif`.
- Desktop file pickers (`DesktopFilePicker.kt`, `ComposeNoteDialog.kt`, `ChatPane.kt`) accept `.avif`.
- `amethyst/.../service/images/ImageLoaderSetup.kt` registers `coil3.gif.AnimatedImageDecoder.Factory()`. On Android **API 31+** this routes through the platform `ImageDecoder`, which supports both still and animated AVIF natively.
Net: on Android 12+, display of still and animated AVIF should mostly already work via Coil. The original issue author may not realize this.
### 2.2 What's broken today
The upload pipeline does not know about AVIF:
- `amethyst/src/main/java/.../service/uploads/MediaCompressor.kt::compressImage()` re-encodes every non-GIF/SVG image as `Bitmap.CompressFormat.JPEG` at 640 px width. An uploaded animated AVIF is **destroyed** flattened to a single JPEG frame.
- `amethyst/src/main/java/.../service/uploads/MetadataStripper.kt` uses `ExifInterface` to rewrite metadata. `ExifInterface` does not reliably handle AVIF containers, so AVIFs either fail or are corrupted.
- `amethyst/src/main/java/.../service/uploads/PreviewMetadataCalculator.kt` uses `BitmapFactory.decodeStream` / `decodeByteArray`. **`BitmapFactory` does not support AVIF on any Android version.** Result: AVIFs upload with no blurhash, no thumbhash, no dimensions receivers see a blank placeholder until the full image downloads.
- `amethyst/src/main/java/.../service/uploads/nip96/Nip96Uploader.kt` falls back to a no-extension multipart filename when `MimeTypeMap` is silent (which it can be for AVIF on older Android). Some NIP-96 servers reject extension-less uploads.
### 2.3 Prior work
Four PRs were opened against #837 and all closed without merge:
| PR | Author | Verdict |
|---|---|---|
| #2825 | KingParmenides | Added a parallel `AnimatedUrlImage` composable that bypassed loading/thumbhash/blurhash slots. Vitor: "delivered very little." **Discard.** |
| #3008 | alan747271363-art | Narrow MediaCompressor + MetadataStripper skip. No on-device test. |
| #3010 | ProtonsAndElectrons | Skip compression + ImageDecoder previews + AVIF EXIF inspection + `.avif` filename fallback. Emulator boot only. |
| #3016 | cybercraftsolutionsllc | Same as #3010 plus `image/avif-sequence` MIME (not real) and an instrumented test. Closed because contributor never did a manual signed-in upload via the UI. |
All upload-pipeline PRs converged on roughly the same fix. They were rejected for **lack of manual on-device verification**, not for being wrong. We will draw inspiration from #3016's diff structure but write the production code from scratch on current main.
### 2.4 Inbound branches that could matter later
Four Claude-Code experimental branches exist but have **no PR opened**, so they are not on any merge path today:
- `upstream/claude/amethyst-kmp-conversion-zYNmg` would move `amethyst/src/main/` `amethyst/src/androidMain/`. If this lands later, all file paths in this spec need a search-and-replace.
- `upstream/claude/add-crop-trim-uploads-RuQBe` would add UCrop image crop + media3-transformer video trim before upload. If it lands, a new AVIF surface decision is needed: UCrop almost certainly does not handle AVIF input.
- `origin/claude/fix-video-deletion-timing-LGFn4` introduces `TempFileTracker` in `UploadOrchestrator`. If it lands, the AVIF passthrough (`MediaCompressorResult(uri, contentType, null)`) needs reverification against the new lifecycle.
None of these are scheduled. Re-check them at PR-ready time.
---
## 3. Architecture and guiding principle
**AVIF is not a new subsystem.** It is the act of making AVIF behave like animated WebP everywhere. The right mental model: find every place WebP/GIF gets special-cased and make sure AVIF lands in the same branch.
Production code change: small (estimated ~150250 LoC, ~5 files).
Verification surface: large see §5 for the ~12 display surfaces; each is exercised against {still, animated} × {API 31+, API < 31}, against two upload protocols (Blossom and NIP-96). The full row count in the manual test plan is 50.
**Explicit non-goals:**
- Shipping a JNI libavif decoder for API < 31. Too much APK weight (~3 MB) for too little gain. API < 31 gets graceful placeholder behavior (existing Coil error slot), documented as a known limitation.
- Adding new composables. PR #2825 made that mistake. Existing `MyAsyncImage` / `RobohashAsyncImage` / `ZoomableContentView` / `AsyncImage` paths must handle AVIF unchanged.
- Supporting `image/avif-sequence`. Not IANA-registered. RFC 9081 defines `image/avif` for both still and animated.
---
## 4. Upload-pipeline changes (Android, `amethyst/`)
Concrete file-level diff. All paths verified against current main on branch `feat/avif-support`.
| File | Change |
|---|---|
| `amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaMimeTypes.kt` *(new)* | Centralize AVIF MIME helpers: `isAvif(contentType: String?): Boolean`, `const val AVIF_MIME = "image/avif"`, `const val AVIF_EXTENSION = "avif"`. Keep the four consumer files below honest. |
| `amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt` | In `compressImage()`, branch early on `isAvif(contentType)`: return `MediaCompressorResult(uri, contentType, null)` same pattern SVG already follows. Verify SVG branch as the reference pattern. |
| `amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MetadataStripper.kt` | For AVIF: inspect EXIF via `ExifInterface(stream)`. If no sensitive tags pass through unchanged. If sensitive tags found OR inspection fails for any reason **fail closed** (reject upload, surface error to user). Do not attempt to rewrite the AVIF container. |
| `amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/PreviewMetadataCalculator.kt` | For AVIF on API 28, decode via `ImageDecoder.createSource(...).decodeBitmap()`. For animated AVIF this returns the first frame which is exactly what we want for static hashes. Use the same decoded bitmap to compute blurhash + thumbhash + dimensions. On API < 28, skip preview metadata (return nulls, log warning). The "decoded once, both hashes computed from same pixels" invariant in the existing code is preserved. |
| `amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt` | When `MimeTypeMap` returns null for the AVIF content type, fall back to `.avif` (or `AVIF_EXTENSION` from `MediaMimeTypes`) for the multipart filename. |
| `amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/...` | Same filename fallback if the Blossom uploader has equivalent logic. Inspect first; may be no-op. |
### 4.1 Cross-references for sanity
The same `MediaMimeTypes.isAvif(...)` helper should also be used by the existing `RichTextParser.kt` and `FeedBasis.kt` if practical but only as a follow-up cleanup, not as part of this change. Adding it now creates churn that's hard to review.
---
## 5. Display surface
### 5.1 Expected zero-code-change surfaces (verify only)
These should work today on API 31+ through Coil's `AnimatedImageDecoder.Factory()`. Verification only:
- Note body images via `MyAsyncImage` / `ZoomableContentView`.
- Profile pictures via `RobohashAsyncImage` (`AnimatedImageDrawable` should route through).
- Profile banner.
- DM images.
- Profile media tab / gallery.
- Long-form (NIP-23) post body.
- Compose preview thumbnail.
- Emoji pack image rendering (`Emoji.kt`, `ShowEmojiSuggestionList.kt`, `EmojiPackCard.kt`, `EmojiPackScreen.kt`).
- Emoji suggestion popup.
- Custom emoji inline in note text.
- Custom emoji as a reaction.
If any of these fail on a real device, the fix is in `ImageLoaderSetup.kt` configuration, not in adding new composables.
### 5.2 Coil decoder factory order
In `amethyst/src/main/java/.../service/images/ImageLoaderSetup.kt`, `AnimatedImageDecoder.Factory()` must run **before** `GifDecoder.Factory()` so AVIFs aren't accidentally routed to the GIF decoder. Verify the current order matches.
### 5.3 Desktop (`desktopApp/`)
Desktop uses Coil-JVM which does **not** include `AnimatedImageDecoder` (no platform `ImageDecoder` outside Android). Animated AVIF on Desktop will likely show first-frame only. Still AVIF: unknown may require additional Coil decoder. **Verify, then document as known limitation if confirmed.** Do not add JVM libavif binding in this PR.
---
## 6. Animation lifecycle (pause off-screen, resume on-screen)
PR #2825 attempted this for AVIF and broke loading/blurhash slots in the process. The correct approach: discover the **existing** pause/resume mechanism for animated WebP and ensure AVIF lands in the same branch.
### 6.1 Verification step (before any code change)
1. Locate the pause/resume mechanism. Likely candidates: `MyAsyncImage`, `ZoomableContentView`, `GifVideoView`, or a `DisposableEffect` on visibility that calls `AnimatedImageDrawable.start()` / `stop()`.
2. Check the predicate:
- **If it branches on `drawable is AnimatedImageDrawable`** MIME-agnostic, AVIF works for free, **zero code change**.
- **If it branches on a hardcoded MIME list** (e.g. `setOf("image/gif", "image/webp")`) add `"image/avif"`.
3. Same audit for the emoji composables modified by PR #2825 (`Emoji.kt`, `ShowEmojiSuggestionList.kt`, `EmojiPackCard.kt`, `EmojiPackScreen.kt`).
### 6.2 Hard constraint
**Do not introduce a parallel `AnimatedUrlImage` composable.** That was PR #2825's mistake and the reason it was closed. Any new abstraction must be additive to existing composables, not a replacement.
---
## 7. Caching
Coil 3's `ImageLoader` (configured in `ImageLoaderSetup.kt`) caches in two layers: memory (bitmaps) and disk (raw bytes). Both are URL-keyed, format-agnostic.
**Expected: zero code change.** Verification:
- First load of an AVIF URL downloads; second load hits memory cache (no network).
- Background return load hits disk cache; no network.
- Force-stop relaunch load hits disk cache; no network.
- Confirm Coil's memory budget accounts for animated AVIF frame buffers (an animated AVIF avatar can be 515 MB decoded). If thrashing observed, adjust `ImageLoader.Builder.memoryCache { ... }` in `ImageLoaderSetup.kt`.
- Confirm the disk cache survives app restart (`coil_image_cache` under app cache dir).
- For animated AVIF in scroll-heavy feeds: confirm Coil doesn't decode every frame from disk on every scroll-into-view. If it does, that's a Coil-level concern and may require either a feature request to Coil or a workaround in the composable (cache the drawable, not just the bytes).
---
## 8. Pre-API-31 fallback
Amethyst's `minSdk = 26`. Five API levels (2630) have **no platform AVIF support at all**.
**Decision: accept Coil's existing error slot.** AVIF fails to decode Coil falls through to its built-in error placeholder (broken-image icon, same UX as a 404). Avatar surfaces fall back to the existing robohash placeholder. No new strings, no new dialogs, no JNI libavif.
Document in PR description and §11 below.
---
## 9. Testing & hardening
### 9.1 Three layers
**Layer 1 — JVM unit tests** (in `amethyst/src/test/`):
- `MediaCompressorTest`: AVIF URI returns unchanged; MIME preserved; not converted to JPEG.
- `MetadataStripperTest`: clean AVIF passes; AVIF with synthetic GPS EXIF rejected; AVIF that ExifInterface can't parse fails closed.
- `PreviewMetadataCalculatorTest`: AVIF input via a fake `ImageDecoder.Source` blurhash + thumbhash + dimensions returned (or skipped on API < 28 with no crash).
- `Nip96UploaderTest` (or equivalent): filename fallback to `.avif` when `MimeTypeMap` is silent.
Runs via the standard pre-push hook scope: `:amethyst:testPlayDebugUnitTest`.
**Layer 2 — Android instrumented tests** (in `amethyst/src/androidTest/`):
- `AvifUploadPipelineInstrumentedTest`: generate an 8×8 still AVIF in app cache, drive it through `MediaCompressor` `MetadataStripper` `PreviewMetadataCalculator` `Nip96Uploader` filename helper. Assert: bytes preserved, blurhash + thumbhash non-null, no JPEG output.
- `AvifAnimatedDecodeInstrumentedTest`: tiny 8×8 / 3-frame animated AVIF, assert Coil's `ImageLoader.execute()` returns an `AnimatedImageDrawable` with `numberOfFrames > 1`.
- Run on at least two emulators: API 35 (modern path) and API 28 (pre-API-31 path), to confirm both code branches are exercised.
**Layer 3 — Manual on-device verification.** This is the gate that killed prior PRs.
See **`~/docs/amethyst/2026-05-26-avif-manual-test-plan.md`** for the full 50-row checklist covering:
- §2.1 Compose / upload flow (8 rows)
- §2.2 Display in feeds / notes (6 rows)
- §2.3 Profile pictures and banner (6 rows)
- §2.4 Direct messages (3 rows)
- §2.5 Emoji packs / NIP-30 the original use case (8 rows)
- §2.6 Caching (5 rows)
- §3.13.5 Hardening (corrupted, large, EXIF, API < 31, animation lifecycle)
- §4 Desktop spot-check
- §5 PR sign-off checklist
### 9.2 Upload protocol matrix
Each upload row of the manual checklist runs against **both**:
- A Blossom server (e.g. `blossom.primal.net`).
- A NIP-96 server (e.g. `nostr.build`).
Bytes downloaded post-upload are binary-compared to the local source. Server-side transcoding (some NIP-96 servers do this) is documented as a server-side caveat, not a regression.
### 9.3 Test asset corpus
Generated via `avifenc` (libavif), `ffmpeg`, and `exiftool`:
- `still-medium.avif` 800×600 still
- `animated-medium.avif` animated from GIF source
- `animated-tiny-3frames.avif` 8×8, 3 frames (committed as test fixture)
- `still-tiny-8x8.avif` 8×8 still (committed as test fixture)
- `emote-64.avif` 64×64 animated, for the emoji-pack proof point
- `still-with-gps.avif` synthetic GPS EXIF for the hardening test
- `corrupted.avif` truncated for the decoder-failure test
- `large-animated.avif` >20 MB for OOM/ANR hardening
Tiny fixtures committed under `amethyst/src/androidTest/assets/avif/`. Larger files referenced by local path in the manual checklist only.
### Running the AVIF tests locally
The AVIF test layer is **not wired into CI** — run it locally on an emulator before merging any
AVIF-touching change. See `amethyst/plans/2026-05-27-avif-instrumented-tests-plan.md` for the
full plan.
**JVM unit tests** (no emulator needed):
```bash
./gradlew :amethyst:testPlayDebugUnitTest --tests 'com.vitorpamplona.amethyst.service.uploads.MediaMimeTypesTest'
./gradlew :amethyst:testPlayDebugUnitTest --tests 'com.vitorpamplona.amethyst.service.images.ThumbnailDiskCacheAvifTest'
```
**Instrumented tests** (boot an API 31+ emulator first — API 35 recommended):
```bash
./gradlew :amethyst:connectedPlayDebugAndroidTest \
'-Pandroid.testInstrumentationRunnerArguments.package=com.vitorpamplona.amethyst.service.uploads'
./gradlew :amethyst:connectedPlayDebugAndroidTest \
'-Pandroid.testInstrumentationRunnerArguments.package=com.vitorpamplona.amethyst.service.images'
```
**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:** AVIF doesn't render neither `AnimatedImageDecoder` (Android 8.0/API 26 doesn't have it) nor `BitmapFactory` can decode AVIF. Verified on API 26 emulator: posts containing AVIF URLs show the URL as a clickable link inline in the note body, no crash, no UI hang. When scrolling an AVIF post into view, Coil briefly renders the blurhash placeholder (via `BlurHashFetcher`) before the failed decode transitions to the link fallback that's a small UX win, not a regression. Avatar slots fall back to robohash. No JNI libavif shipped.
- **Android API < 31 picker:** the Android gallery picker greys AVIF out because `MediaStore` only recognises AVIF as a picker-supported image type from API 31+. This is an OS-level filter, not an Amethyst limitation. Workaround for users on older Android: use the **Files** (Storage Access Framework) picker root, which doesn't apply the MIME-handler filter. The upload pipeline itself is API-agnostic bytes pushed via SAF would upload successfully even on API 26, but the resulting AVIF wouldn't render on the same device's feed for the reason above.
- **Desktop (JVM):** Animated AVIF likely still-frame only. Upload works. Confirm with §4 of manual test plan and document.
- **Desktop AVIF preview metadata:** `commons/src/jvmMain/.../MediaMetadata.kt` uses `javax.imageio.ImageIO.read(file)` which has no AVIF support in the standard JDK. Desktop-posted AVIF notes therefore ship without `blurhash`, `thumbhash`, or `dim` in the `imeta` tag. Bytes round-trip correctly; receivers on Android still decode and render the AVIF only the loading-placeholder UX is degraded (blank slot until the network fetch completes instead of a blurhash blur and pre-allocated aspect-ratio box). Fixable later either by hand-parsing the ISOBMFF `ispe` box for dimensions, or by adding a JVM AVIF ImageIO plugin to `desktopApp`. Out of scope for this PR.
- **Desktop AVIF display:** Coil-JVM has no AVIF decoder. Posts containing AVIF URLs show no image on Desktop (whether posted from Desktop or Android). Verified by spot-check: a 200x200 single-frame AVIF posted from Amethyst Desktop did not render in the Desktop client either. Android receivers display normally.
- **NIP-96 server transcoding:** If a chosen NIP-96 server transcodes AVIF JPEG/WebP server-side, that is outside Amethyst's control. PR description must name the servers tested and the observed behavior.
- **`image/avif-sequence` MIME:** Intentionally rejected. RFC 9081 says `image/avif` covers both still and animated. PR #3016 introduced this; we don't.
- **Coil-level frame re-decoding:** If observed under §7 verification, treat as a follow-up issue. Out of scope for this PR.
- **Animated AVIF playback smoothness:** Frame advancement is driven by `android.graphics.drawable.AnimatedImageDrawable` (the platform decoder), not Amethyst code. On most Android devices including Pixel, the AV1 path is software-only (`dav1d`) the hardware AV1 decoder is wired for video, not for `ImageDecoder`. Per-frame decode cost is orders of magnitude higher than GIF (LZW) or animated WebP (VP8). Expect lower effective frame rates for large source images at high frame counts. Mitigation is encoder-side: small dimensions, low fps, modest frame counts. Not a regression animated AVIF could not render at all before this PR.
- **Animated AVIF playback machinery:** Coil 3's bundled `AnimatedImageDecoder.Factory` only recognizes HEIF brands (msf1/hevc/hevx) at offset 8 and misses AVIF's `avis`/`avif`/`avo1` brands. We ship `AvifAnimatedDecoderFactory` to plug the gap and an upstream Coil fix is separate. Verified against Coil 3.4.0 and 3.5.0-beta01.
- **Profile picture thumbnail cache:** `ThumbnailDiskCache` skips animated AVIF (`ftyp avis`) so the avatar always re-decodes through the AVIF path. Side effect: animated AVIF avatars don't benefit from the 256x256 JPEG thumbnail cache that other formats enjoy, costing some decode CPU per re-render. Acceptable trade-off vs. permanently freezing the avatar on its first frame.
- **Strip-metadata toggle OFF on AVIF leaks EXIF:** the fail-closed AVIF inspector only runs when the user has strip-metadata enabled (the default). If the user toggles strip-metadata off, AVIF is uploaded as-is including any GPS / device-serial / datetime EXIF tags it carries. This matches the existing behavior for JPEG/PNG (the toggle universally means "trust me, upload as-is") but is worth knowing: AVIF cannot be safely stripped in-place, so toggle-off is the only way to upload an AVIF whose EXIF the user wants preserved. Users who want privacy guarantees should leave the toggle on.
---
## 12. Implementation sequence (rough)
To be expanded by `writing-plans` into a step-by-step plan. High-level:
1. Branch `feat/avif-support` (done based on `d1610bf97`).
2. Add `MediaMimeTypes.kt` (new file, helpers only).
3. Patch `MediaCompressor.kt` (early return for AVIF).
4. Patch `MetadataStripper.kt` (fail-closed EXIF inspection).
5. Patch `PreviewMetadataCalculator.kt` (`ImageDecoder` for AVIF on API 28).
6. Patch `Nip96Uploader.kt` (and Blossom uploader if applicable) for filename fallback.
7. JVM unit tests for steps 26.
8. Audit animation-lifecycle code paths 6.1) patch or skip per findings.
9. Generate test corpus, commit tiny fixtures.
10. Write `AvifUploadPipelineInstrumentedTest`, `AvifAnimatedDecodeInstrumentedTest`. Run on API 35 + API 28 emulators.
11. Manual on-device verification per `~/docs/amethyst/2026-05-26-avif-manual-test-plan.md`.
12. Record §10 proof artifact.
13. `./gradlew spotlessApply`, run pre-push hook scope, push, open PR with checklist + recording.
---
## 13. Out of scope
- iOS AVIF support (`iosMain/`) there is iOS work in flight on a separate branch; coordinate later.
- Server-side AVIF transcoding tooling. Server choice belongs to the user.
- JNI libavif for API < 31.
- New abstractions or composables.
- Cleanup of existing AVIF references in `RichTextParser.kt` / `FeedBasis.kt` to use the new `MediaMimeTypes` helpers. Follow-up only.

View File

@@ -0,0 +1,156 @@
# Design — AVIF instrumented tests
**Date:** 2026-05-27
**Status:** Design (pre-implementation)
**Owning module:** `amethyst/`
**Parent work:** `feat/avif-support` (Phase D — instrumented coverage)
**Source handover:** `~/docs/amethyst/2026-05-27-avif-instrumented-tests-handover.md`
**Related plan:** `amethyst/plans/2026-05-26-avif-support.md` §9, `amethyst/plans/2026-05-26-avif-implementation-plan.md` Phase D
---
## 1. Goal
Add an Android on-device test layer that catches regressions in the AVIF upload pipeline and the
Phase E bugs that were fixed manually (`C2``C8` plus follow-ups in commits `df84475d4`,
`5a760c046`, `b9112550b`, `1db110cdf`). JVM unit tests already cover what they can; the rest of
this design covers what can only be exercised on a real Android runtime — `android.graphics.ImageDecoder`,
Coil's image-loader pipeline, `ExifInterface`, and the real upload-side ViewModels.
Scope is deliberately narrow: **AVIF upload pipeline + the specific Phase E regressions**, not
broader instrumented coverage of the app.
## 2. Constraints
- **No new tooling on CI.** Fixtures are pre-generated and committed as binary blobs; tests never
shell out to `avifenc`/`ffmpeg` at run time.
- **No Compose UI tests.** Amethyst has zero Compose UI tests today
(confirmed via grep for `createComposeRule`); we keep that pattern. Compose-only AVIF behaviors
(slider hide, avatar `contentScale` default, animate-by-URL-extension) are explicitly out of
scope here.
- **No CI wiring.** Tests run locally on an emulator before merging an AVIF-touching change.
Adding an emulator job to CI is a separate, future change.
## 3. Test inventory
Five test files (four instrumented + one JVM unit) plus three fixtures.
### 3.1 Fixtures (committed once)
Under `amethyst/src/androidTest/assets/avif/`:
| File | Content | Size | Purpose |
|---|---|---|---|
| `still-tiny-8x8.avif` | 8×8 single-frame, red | ~300 B | Still-AVIF pipeline + decode paths |
| `animated-tiny-3frames.avif` | 8×8 3-frame (red/green/blue, 10 fps) | ~1 KB | Animated detection + `AnimatedImageDrawable` decode + animated-profile-pic cache skip |
| `still-tiny-8x8-exif-gps.avif` | Still fixture with GPS EXIF metadata | ~1 KB | Triggers `AvifMetadataNotVerifiableException` fail-closed path in `MetadataStripper` |
Generated once with:
```bash
mkdir -p ~/avif-test-assets && cd ~/avif-test-assets
# Still + animated
ffmpeg -y -hide_banner -loglevel error -f lavfi -i 'color=red:s=8x8' -frames:v 1 still-8x8.png
avifenc -q 60 --min 30 --max 50 still-8x8.png still-tiny-8x8.avif
ffmpeg -y -hide_banner -loglevel error -f lavfi -i 'color=red:s=8x8' -frames:v 1 a1.png
ffmpeg -y -hide_banner -loglevel error -f lavfi -i 'color=green:s=8x8' -frames:v 1 a2.png
ffmpeg -y -hide_banner -loglevel error -f lavfi -i 'color=blue:s=8x8' -frames:v 1 a3.png
avifenc --fps 10 a1.png a2.png a3.png animated-tiny-3frames.avif
# EXIF-poisoned fixture (GPS metadata is the canonical sensitive tag)
cp still-tiny-8x8.avif still-tiny-8x8-exif-gps.avif
exiftool -overwrite_original -GPSLatitude=12.345 -GPSLongitude=67.89 still-tiny-8x8-exif-gps.avif
```
Requires `brew install libavif ffmpeg exiftool` locally. The generated bytes are the source of truth;
tests do not depend on these tools at run time.
### 3.2 Test files
**From handover (mostly verbatim; pipeline test gains one assertion on the EXIF-poisoned fixture):**
| File | Covers |
|---|---|
| `service/uploads/AvifUploadPipelineInstrumentedTest.kt` | `MediaCompressor` byte preservation (still + animated), `MetadataStripper.stripImageMetadata` clean-AVIF path **and** fail-closed `AvifMetadataNotVerifiableException` on the EXIF-poisoned fixture, `PreviewMetadataCalculator` blurhash/thumbhash/dim on API 31+ |
| `service/images/AvifAnimatedDecodeInstrumentedTest.kt` | Custom `AvifAnimatedDecoderFactory` — still AVIF → `Bitmap` 8×8, animated AVIF → `AnimatedImageDrawable` (not `BitmapDrawable`) |
**New (this design):**
| File | Location | Covers / regression source |
|---|---|---|
| `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
`assumeTrue(SDK_INT >= 31)` skips decode-required tests cleanly.
```bash
# JVM unit test (no emulator needed)
./gradlew :amethyst:testPlayDebugUnitTest --tests 'com.vitorpamplona.amethyst.service.uploads.MediaMimeTypesTest'
# Instrumented tests, by package
./gradlew :amethyst:connectedPlayDebugAndroidTest \
'-Pandroid.testInstrumentationRunnerArguments.package=com.vitorpamplona.amethyst.service.uploads'
./gradlew :amethyst:connectedPlayDebugAndroidTest \
'-Pandroid.testInstrumentationRunnerArguments.package=com.vitorpamplona.amethyst.service.images'
```
A short "AVIF — running instrumented tests" subsection will be added to
`amethyst/plans/2026-05-26-avif-support.md` §9 with the same commands.
When CI gains an emulator step for any reason in the future, these tests run automatically with
no further change — they are standard `androidTest/` files.
## 6. Risks / unknowns
Most risks from the original draft were resolved by audit during design. Remaining items:
1. **API 28 + `MetadataStripper` on the clean fixtures.** `ExifInterface` on API 28 may throw
`AvifMetadataNotVerifiableException` even for the clean fixtures (the fail-closed path triggers
on "unreadable EXIF", which older ExifInterface may produce for AVIF). The pipeline test
should accept either the clean-strip result or the fail-closed exception on API 28 —
`assumeTrue(SDK_INT >= 31)` is the simplest gate.
2. **EXIF-poisoned fixture portability.** `exiftool` writing EXIF into an AVIF container is
well-supported but exact byte layout varies across exiftool versions. The test should only
assert that the exception class + message-prefix are correct, not byte-for-byte stripper
output.
3. **`ThumbnailDiskCache` cache-dir setup.** The cache uses a singleton-ish `cacheDir`. The test
must point it at a fresh `context.cacheDir` subdir per test and clean up afterwards. Confirm
the constructor/init shape during implementation; if singleton-only, the test uses
`context.cacheDir` directly and asserts on filenames there.
## 7. Definition of done
- 4 instrumented test files compile under `./gradlew :amethyst:compilePlayDebugAndroidTestKotlin`.
- 1 JVM unit test file passes under `./gradlew :amethyst:testPlayDebugUnitTest`.
- All instrumented tests pass on an API 35 emulator. On API 28, decode-dependent tests skip
cleanly via `assumeTrue`; non-decode tests pass.
- All 3 AVIF fixtures committed under `amethyst/src/androidTest/assets/avif/`, < 10 KB total.
- `amethyst/plans/2026-05-26-avif-support.md` §9 updated with run instructions.
- `./gradlew spotlessApply` clean.
## 8. Implementation order (preview — full plan to follow)
1. Generate and commit the 3 fixtures (isolated, reviewable commit).
2. Drop in handover D3 + D4 test files (pipeline + decode); compile; run on API 35.
3. Extend the pipeline test to assert `AvifMetadataNotVerifiableException` on the poisoned fixture.
4. Add `MediaMimeTypesTest` (JVM unit, under `amethyst/src/test/`).
5. Add `ThumbnailDiskCacheAvifInstrumentedTest`.
6. Add `AvifMetadataStripperPoisonedFixtureInstrumentedTest`.
7. Update `amethyst/plans/2026-05-26-avif-support.md` §9 with run commands.
8. Spotless + final API 35 pass + API 28 spot-check.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,332 @@
# DM loading: live tail + per-relay history paging
> **Status:** authoritative as of 2026-06-05. The "Current architecture"
> section below describes the code as it actually stands. The original
> time-slice design and the round-model history are kept at the bottom under
> **Design evolution (historical)** — they are superseded and no longer match
> the code; don't trust them for how it works today.
## Problem
The DM loaders used a single subscription whose `since` floor grew as the user
scrolled (`loadMore`: 7d → 14d → 28d → …). The filter carried **only `since`,
no `until`**, so every widen re-requested the whole window and the relay
re-streamed the entire history from the new floor. Traces showed this directly:
```
[giftwrap] load summary: 589 event(s) (14d)
[giftwrap] load summary: 1486 event(s) (28d)
[giftwrap] load summary: 2609 event(s) (56d)
```
Each step re-downloaded everything it already had plus the new slice — the
"getting all events over and over again" the owner reported. It also cascaded:
a few pixels of scroll walked the window to the 10-year backstop, because
widening pulls older *messages* but the rooms list is keyed by *conversation*,
so a handful of busy correspondents flood thousands of events without adding a
single new row, and the "scrolled near the oldest room" trigger never clears.
---
## Current architecture
### Two layers per protocol
Each DM protocol — **NIP-17** gift wraps (kind 1059) and **NIP-04** legacy DMs
(kind 4) — is split into two independent responsibilities:
1. **Live tail** — a fixed ~1-week floor with **no `until`**, open to the
future. Never widens. New messages always arrive here. Backed by the
**round model** (`WindowLoadTracker`): one REQ fanned to every relay, "done"
when all settle, drives the boot spinner.
2. **History** — everything *older* than the week floor, paged **backward by
`until`+`limit`, per relay, on demand**. Backed by the **per-relay model**
(`RelayLoadingCursors` + `PerRelayLoadTracker`), driven by on-screen markers.
The two are disjoint in time, so re-issuing a history page never re-streams the
live tail, and consecutive history pages never re-stream each other.
| Surface | Live-tail manager (round) | History manager (per-relay) |
|---|---|---|
| Account gift wraps (NIP-17) | `AccountGiftWrapsEoseManager` | `AccountGiftWrapsHistoryEoseManager` |
| Conversation NIP-04 | `ChatroomNip04SubAssembler` | `ChatroomNip04HistorySubAssembler` |
| Rooms-list NIP-04 | `ChatroomListNip04SubAssembler` | `ChatroomListNip04HistorySubAssembler` |
Accessed from the UI via `accountViewModel.dataSources()` as
`.account.giftWrapsHistory`, `.chatroom.nip04History`,
`.chatroomList.nip04History`.
### The history paging primitive: `RelayLoadingCursors`
The time-window model can't tell "this relay is empty" from "this is a gap" — a
`since`/`until` slice that returns nothing might just be a quiet stretch above
older messages. Paging by `until`+`limit` removes that ambiguity: a relay
returns up to `limit` (**10000**) of its newest events older than the cursor,
**skipping gaps**, so an **empty page + EOSE is a gap-proof "nothing older"**.
Per relay, two cursors are kept deliberately decoupled:
- `requestedUntil` — the `until` the REQ carries. Moves **only** in `advance()`.
Leaving it untouched on EOSE is what makes paging demand-driven: a relay that
finished a page just **parks** at the same filter (no re-REQ) until advanced.
- `reachedUntil` — the oldest `created_at` actually delivered. Moves on EOSE.
The in-stream markers sit here; the next page starts at `reachedUntil 1`.
Stop signals: an empty page marks the relay **`done`**. A relay returning fewer
than `limit` is treated as its own cap, **not** exhaustion. A misbehaving relay
that returns events but none older than already reached (echoing its newest
events) is also treated as the bottom, so its marker can't re-request the same
window forever. Tested in `RelayLoadingCursorsTest.kt`.
### The two completion models (and where each lives)
**Round model — `WindowLoadTracker` (live tail only).** One REQ is fanned to all
relays; the window is "done" only when **every** expected relay reaches a
terminal signal (`settled ⊇ expected`), with backstops for stragglers (idle /
silence / connect-grace / absolute cap). `loading` starts **`true`**. This is a
*barrier*: nobody moves on until the cohort answers. It is the right shape for
the one-shot fixed-window backfill the live tail does.
> Note: the silence + connect-grace backstops are gated behind `tracksReqSends`,
> and **none of the three live-tail managers pass `tracksReqSends = true`**, so
> in current use only the settle / idle / cap paths ever fire. The REQ-aware
> machinery is dormant in production — see "Things to scrutinize".
**Per-relay model — `RelayLoadingCursors` + `PerRelayLoadTracker` (all history).**
Each relay advances to its next page the instant *it* EOSEs, independent of the
others; the subscription layer diffs per relay, so re-issuing only re-REQs the
relay whose cursor moved. `loading` starts **`false`** (a `true` start would
wedge the scroll loader's `!loading` gate on first open). Fast relays race to
the bottom in back-to-back pages; slow / auth-walled relays catch up at their
own pace and **none are abandoned** — a stalled relay keeps its subscription
open and resumes when re-advanced. This removes the round model's
slowest-relay coupling, which matters most on the conversation screen where the
fan-out includes correspondents' (often auth-walled, slow) relays.
`exhausted` (per history manager) flips true when **every relay is `done` OR
`stalled`** — "nothing more reachable right now". A merely *parked* relay (more
to load, just not advancing) keeps it false.
> All three history managers (`AccountGiftWrapsHistoryEoseManager`,
> `ChatroomNip04HistorySubAssembler`, `ChatroomListNip04HistorySubAssembler`)
> were structurally the same per-relay loader, so that bookkeeping is now a
> single reusable engine — **`BackwardRelayPager`** (keyless, single-active). It
> does **not** hold the cursors: the per-relay `RelayLoadingCursors` live on the
> scope's own domain object (a `Chatroom` per conversation, a `ChatroomList` per
> account), so they share the cached messages' lifetime and survive an account
> switch. The orchestrator owns only the transient bits — in-flight + silence
> tracking, the stalled set, and the display flows — and
> `bind(cursors, scope, relaysFor)`s to whichever scope is active. Each manager
> supplies its REQ-filter builder, a `relaysFor` lookup, and the subscription
> wiring (forwards relay callbacks via `onEvent`/`onEose`/`onClosed`/`onCannotConnect`,
> re-issues filters after `advance`/`advanceAll`). The earlier round-model history
> (and the rooms-list "stall-gate") was fully removed — see Design evolution.
### What drives `advance()`: on-screen markers, off viewport visibility
History paging is demand-driven by **per-relay window-limit markers** placed in
the message stream, not by a scroll-position trigger:
- **`RelayReachCursor`** — one per (protocol, relay): its `reachedUntil` depth,
its `RelayReachState` (`REACHING ↓` / `STALLED …` / `DONE ✓`), and the
`advance()` that pulls *that relay's* next page. Built in the feed views from
each history manager's `relayProgress` map (gift wraps + NIP-04 combined; a
protocol drops out of the list once `exhausted`).
- **`RelayReachSentinels`** — the load *driver*, **hoisted above the
`LazyColumn`** (via `ChatFeedView`'s `sentinels` slot). Each non-done limit
gets one stable effect (keyed by `protocol:url`) that watches `listState` and
fires `advance()` when its gap is among the **currently visible rows** AND
either it just scrolled into view OR its `reachedUntil` moved (a page landed —
keep paging while visible). Driving off **viewport visibility** instead of row
composition is deliberate: an earlier version placed the sentinel *inside* the
hosting row, so any feed reorder (a live DM, a slow relay dribbling a page)
tore the effect down and re-fired `advance()` on a static screen — re-arming
stalled relays into a silence-watchdog storm. (commit `0394ec2a`)
- **`RelayReachMarkers` / `RelayReachMarker`** — pure UI (via the
`markersInGap` slot): the "Relay sync: ✓ 8 · ↓ 1" divider at each relay's
reached depth. Can be re-placed on every reorder without triggering paging.
- **`BootstrapHistoryWhenEmpty`** — when the feed is genuinely `Empty` (the live
tail came back empty for a thread/list whose newest message is older than a
week) there are no rows to host markers, so this steps every relay one page at
a time (debounced 1200ms, gated per loader on `!loading && !exhausted`) until
messages appear and the markers take over, or the protocol exhausts.
### NIP-04 per-relay filter scoping (`Nip04DmRelayRouting`)
A conversation's NIP-04 filters previously named the whole participant set on
every relay, so a relay belonging to one correspondent was asked about all of
them, and the `from-me` leg (`authors:[me]`) was sent to correspondents' inbox
relays — which auth-walled relays reject outright ("all authors must be
authenticated"), stalling the load.
`Nip04DmRelayRouting` (in `FilterNip04DMs.kt`) is now two **per-relay key maps**
(`relay → which keys to name there`), built from the outbox model:
- **to me** (`#p:[me]`) — my inbox carries the whole group; each correspondent's
outbox carries only that correspondent.
- **from me** (`authors:[me]`) — my outbox carries the whole group; each
correspondent's inbox carries only that correspondent.
So a relay only ever sees the keys that actually own it. The **conversation**
history manager scopes its REQ to the armed relays' key sets this way; the
**rooms-list** and **gift-wrap** history managers query only the account's *own*
relays (home outbox `from-me` + DM inbox `to-me`, via `filterNip04DMsFromMe` /
`filterNip04DMsToMe` and `filterGiftWrapsToPubkey`), which is why their fan-out
stays fast and reachable.
### Status card terminal states (`DmHistoryLoadingCard`)
One card per protocol at its oldest-loaded boundary. While paging it shows the
protocol tag, "N relays" being asked, and the reach-back date; it is tappable
into a per-relay popup (`DmHistoryRelayDialog`) listing every relay with
`✓` done / `…` stalled / `↓` reaching and how far back each paged.
Because `exhausted` conflates `done` and `stalled`, the terminal state is split
on `stalledCount` so it can't overclaim (commit `813110cc`):
- **caught up** (every relay `done`, `stalledCount == 0`) → "All caught up",
lingers ~2.2s then collapses.
- **incomplete** (≥1 stalled) → "Some relays didn't respond · N unreachable",
error-coloured `…`, **stays put** (no collapse), tappable to see which.
### Reply placeholder (`LoadingReplyNote`)
A reply whose target message hasn't been paged in yet isn't *missing*, it's
older than the loaded window (and for gift wraps the rumor id isn't even
queryable — only the outer 1059 wrap is). Instead of the generic `BlankNote`
("post not found"), `LoadingReplyNote` actively walks the relevant protocol's
history backward (kicking `advanceAll` each time a page settles) until the
target decrypts (the surrounding `WatchNoteEvent` crossfades the real message in
and disposes this) or the protocol exhausts. Its terminal state mirrors the
card: "Couldn't find this message" + an honest subtitle ("N relays unreachable ·
tap to see which" when stalled, "Searched every relay · tap to see" when
genuinely done), tappable into the same per-relay popup. Wired via
`ChatMessageCompose.RenderReply``WatchNoteEvent(onBlank = …)`, with the pager
chosen by the parent event's protocol (`DmReplyProtocol.NIP17` / `NIP04`).
### Diagnostics
Everything logs under one tag, **`DMPagination`** (debug builds):
`DmRelayDiagnosticsLogger` folds the per-relay connection timeline (REQ sent,
connect/disconnect, CLOSED/NOTICE/OK-fail) into it; `DmRelayLog` prints the
"relays by source" breakdown (NIP-65 in/out, DM list, private storage, local)
per subscription so an unexpected relay can be traced to the list it leaks in
from; and each assembler logs its milestones (paging start, a relay reaching
the bottom / stalling with the reason, the "window settled" summary of
done-vs-still-trying, each marker fire).
### Related fix: Tor guard-sample self-heal
`TorService` gained a `noUsableGuards()` check that, on init, inspects Arti's
persisted `guards.json` and wipes the on-disk state if a non-empty guard set has
**zero** usable guards (all `disabled` / `unlisted`). This recovers the
long-standing "can't connect to Tor → relays permanently unreachable" wedge
(Arti disables guards past a 0.7 indeterminate-failure ratio, never re-enables
them, and can't replenish once the 60-slot sample is full). Orthogonal to
pagination, but it lived here because unreachable relays were part of the same
"DM history stuck / relays never answer" symptom this branch chased.
---
## Component map (vs `origin/main`)
**Paging primitives (quartz, `nip01Core/relay/client/paging/`, `commonMain`)**
the pure, protocol-level paging *state*; iOS-clean, reusable by any KMP target.
- `RelayLoadingCursors.kt` — per-relay `until`+`limit` cursor state + pinned floor;
held on the scope's domain object. *(+ `RelayLoadingCursorsTest` in amethyst; the
`until`+`limit` wire contract is covered by `UntilLimitPagingRelayTest` against
the quartz `jvmAndroidTest` geode relay)*
- `RelayPagingProgress.kt``(reachedUntil, done, stalled)` per relay.
**Paging orchestrators (commons, `relayClient/paging/`, `jvmAndroid`)** — the
StateFlow-backed, subscription-loading state holders. Moved out of amethyst **and**
out of quartz (per `commons/ARCHITECTURE.md`: the relay-subscription client +
`StateFlow` state holders live in commons) so desktop / CLI / any feed can reuse
them; `jvmAndroid` (uses `java.util.concurrent` + `@Synchronized`) → Android +
Desktop, not iOS.
- `BackwardRelayPager.kt` — the keyless single-active orchestrator the three
history managers `bind` to. *(+ `BackwardRelayPagerTest` state-machine in commons
`jvmTest`)*
- `PerRelayLoadTracker.kt` — per-relay in-flight tracker + silence watchdog.
- `WindowLoadTracker.kt` — round/barrier completion tracker (live tail). *(+ `WindowLoadTrackerIdleTest` in amethyst)*
**Diagnostics (amethyst)**`service/relayClient/eoseManagers/DmRelayLog.kt`,
`service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt` — the `DMPagination` logs.
**Managers / assemblers**
- `AccountGiftWrapsEoseManager.kt` (live tail) + `AccountGiftWrapsHistoryEoseManager.kt` (new, history).
- `ChatroomNip04SubAssembler.kt` (live tail) + `ChatroomNip04HistorySubAssembler.kt` (new, history).
- `ChatroomListNip04SubAssembler.kt` (live tail) + `ChatroomListNip04HistorySubAssembler.kt` (new, history).
- `FilterNip04DMs.kt` (per-relay `Nip04DmRelayRouting`, live + history builders), `FilterNip04DMsFromMe/ToMe.kt`, `FilterGiftWrapsToPubkey.kt``until`/`limit` added.
- `AccountFilterAssembler`, `ChatroomFilterAssembler`, `ChatroomListFilterAssembler` — wire the new managers.
**Shared UI (commons, `commons/ui/feeds/`)** — extracted from amethyst so Android +
Desktop (and any per-relay feed) render the same widgets; CMP `composeResources`
strings, no app-theme / `java.time` deps.
- `RelayReachMarker.kt``RelayReachCursor` + sentinels (the hoisted, visibility-driven
paging driver) + markers (pure UI) + `RelayReachMarker`/`RelayReachState`.
- `DmHistoryLoadingCard.kt` — the boundary status card + per-relay tap dialog +
`historySubtitle`/`incompleteSubtitle`. Takes a `formatReachDate: (epochSeconds) -> String`
so each platform supplies its locale date formatter.
**Android UI (`amethyst/ui/screen/loggedIn/chats/`)**
- `feed/LoadingReplyNote.kt` — history-walking reply placeholder (uses the shared subtitle helpers/dialog).
- `feed/HistoryDateFormat.kt``formatHistoryReachDate`, the Android locale formatter passed into the shared card.
- `feed/ChatFeedView.kt``markersInGap` + `sentinels` slots.
- `feed/ChatMessageCompose.kt` — reply `onBlank` wiring.
- `privateDM/ChatroomView.kt`, `rooms/feed/ChatroomListFeedView.kt` — assemble cards/markers/sentinels, `BootstrapHistoryWhenEmpty`.
- `res/values/strings.xml``chats_reply_*` (the card's `chats_history_*` now live in commons).
---
## Things to scrutinize (review notes)
1. **`exhausted` conflates `done` + `stalled`** at the manager level. The cards
now distinguish them via `stalledCount`, but other consumers (the scroll
`!loading` gates, `LoadingReplyNote`'s advance loop) treat stalled as
terminal. Intentional (don't hammer dead relays), but confirm it's desired.
2. **`PerRelayLoadTracker.lastActivityMs` is global, not per-relay** — once the
chatty relays finish, a legitimately-slow relay gets the full 15s silence
window and can be marked stalled mid-delivery of a 10000-event page.
3. **"All caught up" can still be technically-true-but-misleading** when
`stalledCount == 0` yet a chat's messages live on a relay *not in the
account's NIP-17 inbox list* — an outbox-coverage gap the card can't detect.
4. **`WindowLoadTracker`'s REQ-aware backstops are dormant** in production
(no live-tail manager sets `tracksReqSends`). Either the live tail should
adopt them or the round model could be slimmer for its current role.
5. **`PAGE_LIMIT = 10000`** caps per-request volume but a single page can still
be a large payload on a dense relay.
---
## Design evolution (historical — superseded, do not trust for current behavior)
These sections describe earlier iterations, kept for context. The code has
moved past all of them.
### v1 — time-slice history (superseded by `RelayLoadingCursors`)
History was first loaded in bounded `since`+`until` **time slices**
(`TimeWindowPagination`, now deleted): `loadMore` fetched only the new band
`[newFloor, previousFloor]`, with a NIP-17 ±2-day wrapper-timestamp margin on
the slice `since` for gift wraps. This bounded re-downloads but still couldn't
tell an empty relay from a gap (an empty slice might sit above older messages),
so the only stop was a 10-year `maxLookback`, and a wide late slice could pull a
20k-event firehose. Replaced by per-relay `until`+`limit` paging.
### v2 — round-model history + rooms-list "stall-gate" (both removed)
History paging once used the **round model** (`WindowLoadTracker`): each
`loadMore` issued one page to all active relays and waited for the slowest to
settle before the next — pacing every relay at the slowest one. The rooms list
additionally had a **stall-gate**: an auto-fill loop that widened only while it
brought in new private rooms, stopping once a widen added none (to avoid the
conversation-keyed cascade).
Both are gone. All history paging is now per-relay independent
(`PerRelayLoadTracker`), and the rooms list pages to exhaustion off marker
visibility like the conversation (commit `98fb8720` dropped the stall-gate;
`60b8629a` / `9f0ecd54` moved gift-wrap and rooms-list history onto the per-relay
model). `WindowLoadTracker` survives **only** as the live-tail completion
barrier. An earlier revision of this doc ("Update 3") still claimed rooms-list
and gift-wrap history used the round model — that is no longer true.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 B

View File

@@ -0,0 +1,59 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst
import android.content.Context
import android.net.Uri
import androidx.core.content.FileProvider
import androidx.test.platform.app.InstrumentationRegistry
import java.io.File
import java.util.UUID
/**
* Shared helpers for AVIF instrumented tests.
*
* [copyAssetToCache] always uses a UUID-prefixed filename so concurrent or
* back-to-back test runs cannot collide on a shared cache path.
*
* [contentUriFor] wraps a file with FileProvider so `ContentResolver.getType()`
* returns the correct MIME — it returns null for `file://` URIs, which makes
* `MetadataStripper` skip the AVIF branch entirely.
*/
object AvifInstrumentedTestSupport {
val appContext: Context get() = InstrumentationRegistry.getInstrumentation().targetContext
val testContext: Context get() = InstrumentationRegistry.getInstrumentation().context
fun copyAssetToCache(assetPath: String): File {
val cacheDir = appContext.cacheDir.also { it.mkdirs() }
val out = File(cacheDir, "${UUID.randomUUID()}-${assetPath.substringAfterLast('/')}")
testContext.assets.open(assetPath).use { input ->
out.outputStream().use { output -> input.copyTo(output) }
}
return out
}
fun contentUriFor(file: File): Uri =
FileProvider.getUriForFile(
appContext,
"${appContext.packageName}.provider",
file,
)
}

View File

@@ -0,0 +1,463 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst
import android.util.Log
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
import com.vitorpamplona.quartz.nip44Encryption.Nip44v2
import com.vitorpamplona.quartz.nip60Cashu.bdhke.Bdhke
import com.vitorpamplona.quartz.utils.secp256k1.Secp256k1
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import org.junit.Test
import org.junit.runner.RunWith
/**
* Regression suite for the ART JIT optimizer SIGSEGV that hit Cashu's
* cryptographic hot path on Android 15+ (Jit thread pool SIGSEGV at
* `HBasicBlock::RemoveInstruction +32`, fault addr `0x48`, inside
* `InstructionSimplifierVisitor::Run`).
*
* Root cause: `U256.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 — and 12 sequential
* `if (uLtInline(...)) 1L else 0L` patterns inside `ScalarN.reduceWideTo`
* alone — ART's InstructionSimplifier tried to fold them as a group and
* null-deref'd. Fix: drop `inline`, so the function-call boundary blocks
* the fold path.
*
* Each test runs 2048 iterations to force ART tier-1 JIT (threshold for
* this code is ~48 invocations). Crash-mode tests check for `Jit thread
* pool` SIGSEGV; correctness-mode tests cross-check against
* `fr.acinq.secp256k1` JNI or do roundtrip verification.
*
* Run all:
* ./gradlew :amethyst:connectedPlayDebugAndroidTest \
* -Pandroid.testInstrumentationRunnerArguments.class=com.vitorpamplona.amethyst.BdhkeJitCrashTest
*
* Run single variant:
* adb shell am instrument -w -e class \
* com.vitorpamplona.amethyst.BdhkeJitCrashTest#a_warmupOnly \
* com.vitorpamplona.amethyst.debug.test/androidx.test.runner.AndroidJUnitRunner
*
* On crash:
* adb logcat -d | grep -E "SIGSEGV|tombstone|BdhkeJitCrashTest"
* adb pull /data/tombstones/tombstone_NN /tmp/
*/
@RunWith(AndroidJUnit4::class)
class BdhkeJitCrashTest {
private val tag = "BdhkeJitCrashTest"
/** Generator point G in 33-byte compressed form — always parses, never touches wallet state. */
private val mintPubKey =
byteArrayOf(
0x02.toByte(),
0x79.toByte(),
0xBE.toByte(),
0x66.toByte(),
0x7E.toByte(),
0xF9.toByte(),
0xDC.toByte(),
0xBB.toByte(),
0xAC.toByte(),
0x55.toByte(),
0xA0.toByte(),
0x62.toByte(),
0x95.toByte(),
0xCE.toByte(),
0x87.toByte(),
0x0B.toByte(),
0x07.toByte(),
0x02.toByte(),
0x9B.toByte(),
0xFC.toByte(),
0xDB.toByte(),
0x2D.toByte(),
0xCE.toByte(),
0x28.toByte(),
0xD9.toByte(),
0x59.toByte(),
0xF2.toByte(),
0x81.toByte(),
0x5B.toByte(),
0x16.toByte(),
0xF8.toByte(),
0x17.toByte(),
0x98.toByte(),
)
/**
* The startup-shape regression. An earlier branch had a `Bdhke.warmup()`
* that ran 2048 blind+unblind cycles eagerly from
* `CashuWalletState.start()` to dodge what was thought to be a load-
* sensitive JIT crash. The real bug was [uLtInline][com.vitorpamplona.quartz.utils.secp256k1.uLtInline]
* inline-expansion (see `U256.kt`), and the warmup is gone — but the
* workload it ran is exactly what the unblind path stresses, so we
* keep it as a regression test under the same name.
*/
@Test
fun a_warmupOnly() {
runBlindUnblind(2048)
}
/**
* Raw loop — bypasses the at-most-once guard so it always actually runs.
* Lets you bisect the iteration count where the JIT crash kicks in.
*/
@Test
fun b_blindUnblindLoop_2048() {
runBlindUnblind(2048)
}
/**
* Original commit history blamed [Bdhke.unblind] specifically. Isolate it:
* mint one B' up front, then unblind it N times against the same fixed
* mintPubKey / r. Same allocation profile as the real restore loop.
*/
@Test
fun e_unblindOnly_2048() {
val r = Bdhke.randomScalar()
val secret = Bdhke.randomScalar()
val bTick = Bdhke.blind(secret, r)
Log.i(tag, "unblindOnly_2048: priming done, beginning loop")
repeat(2048) { i ->
Bdhke.unblind(bTick, r, mintPubKey)
if (i % 256 == 255) Log.i(tag, "unblindOnly_2048: ${i + 1} done")
}
Log.i(tag, "unblindOnly_2048: survived")
}
/**
* Multiple coroutines on Dispatchers.Default hammering blind+unblind in
* parallel — closer to the real startup shape (two accounts each kicking
* off CashuWalletState.start concurrently) and a higher-stress JIT load.
*/
@Test
fun f_blindUnblindLoop_concurrent_x4() {
runBlocking {
Log.i(tag, "concurrent_x4: 4 workers × 512 cycles on Dispatchers.Default")
val workers =
(0 until 4).map { id ->
async(Dispatchers.Default) {
repeat(512) { i ->
val secret = Bdhke.randomScalar()
val r = Bdhke.randomScalar()
val bTick = Bdhke.blind(secret, r)
Bdhke.unblind(bTick, r, mintPubKey)
if (i % 128 == 127) Log.i(tag, "concurrent_x4: worker=$id ${i + 1} done")
}
}
}
workers.awaitAll()
Log.i(tag, "concurrent_x4: survived")
}
}
private fun runBlindUnblind(iterations: Int) {
Log.i(tag, "runBlindUnblind: $iterations cycles")
repeat(iterations) { i ->
val secret = Bdhke.randomScalar()
val r = Bdhke.randomScalar()
val bTick = Bdhke.blind(secret, r)
Bdhke.unblind(bTick, r, mintPubKey)
if (i % 256 == 255) Log.i(tag, "runBlindUnblind: ${i + 1} / $iterations done")
}
Log.i(tag, "runBlindUnblind: $iterations survived")
}
// ==================== Isolation tests ====================
//
// Each variant hammers ONE public entry point so the JIT can only compile
// that entry's call tree. If a variant crashes with the same `Jit thread
// pool` SIGSEGV signature, the offending method is reachable from that
// entry and not from any narrower one. Order from leaf to deep:
//
// g_hashToCurveOnly → tests hashToCurveInto (SHA256 + KeyCodec.liftX + Int-side shift+mask)
// h_blindOnly → adds ECPoint.mulG + ECPoint.addPoints
// i_signOnly → tests ONLY Secp256k1.pubKeyTweakMul (JNI, control)
// j_secp256k1PubkeyOnly → tests ONLY Secp256k1.pubkeyCreate (JNI, control)
//
// Run order: g, h, i, j.
@Test
fun g_hashToCurveOnly() {
Log.i(tag, "hashToCurveOnly: 2048 calls to Bdhke.hashToCurveCompressed")
repeat(2048) { i ->
val secret = Bdhke.randomScalar()
Bdhke.hashToCurveCompressed(secret)
if (i % 256 == 255) Log.i(tag, "hashToCurveOnly: ${i + 1} done")
}
Log.i(tag, "hashToCurveOnly: survived")
}
@Test
fun h_blindOnly() {
Log.i(tag, "blindOnly: 2048 calls to Bdhke.blind")
repeat(2048) { i ->
val secret = Bdhke.randomScalar()
val r = Bdhke.randomScalar()
Bdhke.blind(secret, r)
if (i % 256 == 255) Log.i(tag, "blindOnly: ${i + 1} done")
}
Log.i(tag, "blindOnly: survived")
}
@Test
fun i_signOnly() {
Log.i(tag, "signOnly: 2048 calls to Bdhke.sign (Secp256k1.pubKeyTweakMul, JNI)")
val r = Bdhke.randomScalar()
val bTick = Bdhke.blind(Bdhke.randomScalar(), r)
val mintPriv = Bdhke.randomScalar()
repeat(2048) { i ->
Bdhke.sign(bTick, mintPriv)
if (i % 256 == 255) Log.i(tag, "signOnly: ${i + 1} done")
}
Log.i(tag, "signOnly: survived")
}
@Test
fun j_secp256k1PubkeyOnly() {
Log.i(tag, "secp256k1PubkeyOnly: 2048 calls to Secp256k1.pubkeyCreate (pure Kotlin)")
val priv = Bdhke.randomScalar()
repeat(2048) { i ->
Secp256k1.pubkeyCreate(priv)
if (i % 256 == 255) Log.i(tag, "secp256k1PubkeyOnly: ${i + 1} done")
}
Log.i(tag, "secp256k1PubkeyOnly: survived")
}
// ==================== Cashu cryptographic coverage ====================
//
// Two-axis regression net for the rest of the crypto surface Cashu actually
// touches (beyond blind/unblind). Each test does both:
//
// 1. CRASH: 2048 iterations to force ART tier-1 JIT compile (the same
// threshold that surfaced the InstructionSimplifier bug on
// ECPoint.mul → uLtInline).
// 2. CORRECTNESS: every iteration cross-checks against
// fr.acinq.secp256k1 JNI, or self-verifies via roundtrip / sign+
// verify, depending on whether the primitive is standard secp256k1
// (ACINQ-equivalent) or Cashu-specific (no native reference).
//
// A JIT miscompile that produces wrong-but-non-crashing code would fail
// here even though no SIGSEGV fires.
/** NIP-44 ECDH path. Our [Secp256k1.ecdhXOnly] vs acinq's pubKeyTweakMul. */
@Test
fun n_ecdhXOnly_2048() {
val acinq =
fr.acinq.secp256k1.Secp256k1
.get()
val scalar = Bdhke.randomScalar()
val peerPriv = Bdhke.randomScalar()
val peerPubUncompressed = Secp256k1.pubkeyCreate(peerPriv)
val peerPubCompressed = byteArrayOf(0x02.toByte()) + peerPubUncompressed.copyOfRange(1, 33)
val peerXOnly = peerPubUncompressed.copyOfRange(1, 33)
// ACINQ reference: x-coordinate of (scalar · peerPubCompressed)
val acinqOut = acinq.pubKeyTweakMul(peerPubCompressed, scalar)
val acinqX = acinqOut.copyOfRange(1, 33)
Log.i(tag, "ecdhXOnly: 2048 calls, comparing X coord vs acinq")
repeat(2048) { i ->
val ours = Secp256k1.ecdhXOnly(peerXOnly, scalar)
check(ours.contentEquals(acinqX)) {
"iter $i: ecdhXOnly diverged from acinq.pubKeyTweakMul X"
}
if (i % 256 == 255) Log.i(tag, "ecdhXOnly: ${i + 1} done")
}
}
/** NIP-01 / NUT-20 Schnorr sign. Deterministic — bytes must match acinq. */
@Test
fun o_signSchnorr_2048() {
val acinq =
fr.acinq.secp256k1.Secp256k1
.get()
val sk = Bdhke.randomScalar()
val msg = Bdhke.randomScalar() // 32 bytes
val aux = Bdhke.randomScalar()
val acinqSig = acinq.signSchnorr(msg, sk, aux)
Log.i(tag, "signSchnorr: 2048 calls, comparing 64-byte sig vs acinq")
repeat(2048) { i ->
val oursSig = Secp256k1.signSchnorr(msg, sk, aux)
check(oursSig.contentEquals(acinqSig)) {
"iter $i: signSchnorr diverged from acinq"
}
if (i % 256 == 255) Log.i(tag, "signSchnorr: ${i + 1} done")
}
}
/** Every consumed Nostr event (Cashu or otherwise) verifies through this. */
@Test
fun p_verifySchnorr_2048() {
val acinq =
fr.acinq.secp256k1.Secp256k1
.get()
val sk = Bdhke.randomScalar()
val msg = Bdhke.randomScalar()
val aux = Bdhke.randomScalar()
val sig = acinq.signSchnorr(msg, sk, aux)
val pubXOnly = Secp256k1.pubkeyCreate(sk).copyOfRange(1, 33)
// Sanity: acinq must accept its own signature.
check(acinq.verifySchnorr(sig, msg, pubXOnly))
Log.i(tag, "verifySchnorr: 2048 calls, must return true each time")
repeat(2048) { i ->
val ok = Secp256k1.verifySchnorr(sig, msg, pubXOnly)
check(ok) { "iter $i: verifySchnorr returned false on valid sig" }
if (i % 256 == 255) Log.i(tag, "verifySchnorr: ${i + 1} done")
}
}
/** NUT-13 deterministic-secret derivation tweaks scalars; must match acinq byte-for-byte. */
@Test
fun q_privKeyTweakAdd_2048() {
val acinq =
fr.acinq.secp256k1.Secp256k1
.get()
val sk = Bdhke.randomScalar()
val tweak = Bdhke.randomScalar()
val acinqOut = acinq.privKeyTweakAdd(sk.copyOf(), tweak)
Log.i(tag, "privKeyTweakAdd: 2048 calls, comparing 32 bytes vs acinq")
repeat(2048) { i ->
val ours = Secp256k1.privKeyTweakAdd(sk, tweak)
check(ours.contentEquals(acinqOut)) {
"iter $i: privKeyTweakAdd diverged from acinq"
}
if (i % 256 == 255) Log.i(tag, "privKeyTweakAdd: ${i + 1} done")
}
}
/**
* NUT-12 mint-side DLEQ verification (Alice). Cashu-specific — no JNI
* reference exists. Self-consistency: a freshly signed DLEQ tuple must
* verify; corrupting any byte must reject.
*/
@Test
fun r_verifyDleq_2048() {
val mintPriv = Bdhke.randomScalar()
val mintPubCompressed = Secp256k1.pubKeyCompress(Secp256k1.pubkeyCreate(mintPriv))
val r = Bdhke.randomScalar()
val bTick = Bdhke.blind(Bdhke.randomScalar(), r)
val (cTick, e, s) = Bdhke.signFull(bTick, mintPriv)
check(Bdhke.verifyDleq(e, s, bTick, cTick, mintPubCompressed)) {
"setup: signFull→verifyDleq must roundtrip"
}
val tampered = e.copyOf().also { it[0] = (it[0].toInt() xor 0xFF).toByte() }
check(!Bdhke.verifyDleq(tampered, s, bTick, cTick, mintPubCompressed)) {
"setup: tampered e must be rejected"
}
Log.i(tag, "verifyDleq: 2048 calls — accept-valid + reject-tampered each iter")
repeat(2048) { i ->
check(Bdhke.verifyDleq(e, s, bTick, cTick, mintPubCompressed)) {
"iter $i: valid DLEQ rejected"
}
check(!Bdhke.verifyDleq(tampered, s, bTick, cTick, mintPubCompressed)) {
"iter $i: tampered DLEQ accepted"
}
if (i % 256 == 255) Log.i(tag, "verifyDleq: ${i + 1} done")
}
}
/**
* NIP-44 v2 cipher roundtrip. Cashu wallet/token/history events
* (kind:17375, 7375, 7376, 17376) are all encrypted with NIP-44 v2.
* The pipeline: secp256k1 ECDH (pure Kotlin) → HKDF-SHA256 → ChaCha20
* → HMAC-SHA256. ECDH path is already covered by [n_ecdhXOnly_2048] —
* this catches a JIT miscompile in the symmetric layer, or a key-
* derivation drift in the HKDF/HMAC primitives.
*
* Each iteration uses a fresh random nonce so ciphertext varies, but
* decrypt(encrypt(p)) must always return `p`.
*/
@Test
fun t_nip44CipherRoundtrip_2048() {
val nip44 = Nip44v2()
val alicePriv = Bdhke.randomScalar()
val bobPriv = Bdhke.randomScalar()
val bobPub = Nip01Crypto.pubKeyCreate(bobPriv)
val conversationKey = nip44.getConversationKey(alicePriv, bobPub)
// ~200 byte plaintext is representative of a NIP-60 kind:7375 token
// payload (cashu proof array, mint URL, unit, memo).
val plaintext =
"Cashu kind:7375 token: mint=https://mint.example/v1 unit=sat proofs=[" +
"{a:1,id:00aabb,s:" + "deadbeef".repeat(8) + ",C:" + "ab".repeat(33) + "}]"
Log.i(tag, "nip44Cipher: 2048 encrypt+decrypt roundtrips (plaintext=${plaintext.length}B)")
repeat(2048) { i ->
val encrypted = nip44.encrypt(plaintext, conversationKey)
val decrypted = nip44.decrypt(encrypted, conversationKey)
check(decrypted == plaintext) {
"iter $i: nip44 roundtrip diverged — got '${decrypted.take(60)}…'"
}
if (i % 256 == 255) Log.i(tag, "nip44Cipher: ${i + 1} done")
}
Log.i(tag, "nip44Cipher: survived")
}
/**
* NUT-12 §3 Carol-side DLEQ verification. Composite path: internally
* calls `blind`, `addRTimesA`, and `verifyDleq`, so covers the private
* `addRTimesA` (also `ECPoint.mul`-bound) which has no other entry point.
*/
@Test
fun s_verifyDleqCarol_2048() {
val mintPriv = Bdhke.randomScalar()
val mintPubCompressed = Secp256k1.pubKeyCompress(Secp256k1.pubkeyCreate(mintPriv))
val r = Bdhke.randomScalar()
val secret = Bdhke.randomScalar()
val bTick = Bdhke.blind(secret, r)
val (cTick, e, s) = Bdhke.signFull(bTick, mintPriv)
val unblindedC = Bdhke.unblind(cTick, r, mintPubCompressed)
check(Bdhke.verifyDleqCarol(secret, r, e, s, unblindedC, mintPubCompressed)) {
"setup: Carol-side DLEQ must roundtrip"
}
Log.i(tag, "verifyDleqCarol: 2048 calls — must verify each iter")
repeat(2048) { i ->
check(Bdhke.verifyDleqCarol(secret, r, e, s, unblindedC, mintPubCompressed)) {
"iter $i: Carol-side DLEQ rejected"
}
if (i % 256 == 255) Log.i(tag, "verifyDleqCarol: ${i + 1} done")
}
}
/**
* Same workload as [i_signOnly] but uses ACINQ's libsecp256k1 JNI binding
* instead of our pure-Kotlin [Secp256k1.pubKeyTweakMul]. If this is clean
* while `i_signOnly` crashes, we have direct proof the JIT crash lives in
* Kotlin code that ART tries to optimize — and the JNI swap is the fix.
*/
@Test
fun l_signOnlyAcinq() {
val acinq =
fr.acinq.secp256k1.Secp256k1
.get()
Log.i(tag, "signOnlyAcinq: 2048 calls to acinq.pubKeyTweakMul (JNI libsecp256k1)")
val r = Bdhke.randomScalar()
val bTick = Bdhke.blind(Bdhke.randomScalar(), r)
val mintPriv = Bdhke.randomScalar()
repeat(2048) { i ->
acinq.pubKeyTweakMul(bTick, mintPriv)
if (i % 256 == 255) Log.i(tag, "signOnlyAcinq: ${i + 1} done")
}
Log.i(tag, "signOnlyAcinq: survived")
}
}

View File

@@ -21,8 +21,8 @@
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuToken
import com.vitorpamplona.amethyst.service.cashu.CashuParser
import com.vitorpamplona.amethyst.service.cashu.CashuToken
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.runBlocking

View File

@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
@@ -52,9 +54,10 @@ import org.junit.runner.RunWith
* Asserts the three contracts the feature relies on:
* 1. `feedKey` is mode-discriminated so each pinned tab caches independently.
* 2. `followList()` honors `modeOverride` when set; falls back to the spinner setting otherwise.
* 3. `buildFilterParams()` returns a GlobalTopNavFilter-backed FilterByListParams for
* `TopFilter.Global` (so `isGlobal()` is true, allowing non-follower notifications through),
* and a non-Global filter for `TopFilter.AllFollows` (forcing the follow-membership gate).
* 3. `buildFilterParams()` returns a GlobalTopNavFilter-backed FilterByListParams for both
* `TopFilter.Global` and `TopFilter.Selected` (so `isGlobal()` is true, allowing
* non-follower notifications through), and a non-Global filter for `TopFilter.AllFollows`
* (forcing the follow-membership gate).
*/
@RunWith(AndroidJUnit4::class)
class NotificationFeedFilterModeOverrideTest {
@@ -80,6 +83,9 @@ class NotificationFeedFilterModeOverrideTest {
signer = NostrSignerInternal(keyPair),
geolocationFlow = { MutableStateFlow<LocationState.LocationResult>(LocationState.LocationResult.Loading) },
nwcFilterAssembler = { NWCPaymentFilterAssembler(client) },
cashuWalletFilterAssembler = { CashuWalletFilterAssembler(client) },
cashuMintDirectoryFilterAssembler = { CashuMintDirectoryFilterAssembler(client) },
okHttpClientForMoney = { OkHttpClient() },
otsResolverBuilder = { EmptyOtsResolverBuilder.build() },
cache = LocalCache,
client = client,
@@ -125,8 +131,8 @@ class NotificationFeedFilterModeOverrideTest {
fun followListFallsBackToSpinnerWhenOverrideNull() {
val spinner = NotificationFeedFilter(account)
account.settings.defaultNotificationFollowList.value = TopFilter.Global
assertEquals(TopFilter.Global, spinner.followList())
account.settings.defaultNotificationFollowList.value = TopFilter.Selected
assertEquals(TopFilter.Selected, spinner.followList())
account.settings.defaultNotificationFollowList.value = TopFilter.AllFollows
assertEquals(TopFilter.AllFollows, spinner.followList())
@@ -146,6 +152,22 @@ class NotificationFeedFilterModeOverrideTest {
)
}
@Test
fun buildFilterParamsForSelectedOverrideReportsGlobal() {
val selected = NotificationFeedFilter(account, TopFilter.Selected)
val params = selected.buildFilterParams(account)
// Selected rides the same GlobalFeedFlow relay set as Global, so it must
// also report isGlobal and let non-followers through; the difference is
// that acceptableEvent applies the per-kind relevance heuristics, which
// Global skips.
assertTrue(
"Selected mode's FilterByListParams must report isGlobal so non-followers pass the gate",
params.isGlobal(),
)
}
@Test
fun buildFilterParamsForAllFollowsOverrideIsNotGlobal() {
val following = NotificationFeedFilter(account, TopFilter.AllFollows)

View File

@@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler
import com.vitorpamplona.amethyst.commons.viewmodels.thread.ThreadFeedFilter
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
@@ -72,6 +74,9 @@ class ThreadDualAxisChartAssemblerTest {
signer = NostrSignerInternal(keyPair),
geolocationFlow = { MutableStateFlow<LocationState.LocationResult>(LocationState.LocationResult.Loading) },
nwcFilterAssembler = { NWCPaymentFilterAssembler(client) },
cashuWalletFilterAssembler = { CashuWalletFilterAssembler(client) },
cashuMintDirectoryFilterAssembler = { CashuMintDirectoryFilterAssembler(client) },
okHttpClientForMoney = { OkHttpClient() },
otsResolverBuilder = { EmptyOtsResolverBuilder.build() },
cache = LocalCache,
client = client,

View File

@@ -1,225 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.actions.buildAnnotatedStringWithUrlHighlighting
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class UrlUserTagTransformationTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.vitorpamplona.amethyst", appContext.packageName.removeSuffix(".debug"))
}
@Test
fun testKeepTransformedIndexFullyInsideTransformedText() {
val user =
LocalCache.getOrCreateUser(
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
.toHexKey(),
)
user.metadata().newMetadata(
UserMetadata().also {
it.displayName = "Vitor Pamplona"
},
MetadataEvent(
id = "",
pubKey = "",
createdAt = 0,
tags = emptyArray(),
content = "",
sig = "",
),
)
val original = "@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"
val transformedText =
buildAnnotatedStringWithUrlHighlighting(
AnnotatedString(original),
Color.Red,
)
val expected = "@Vitor Pamplona"
assertEquals(expected, transformedText.text.text)
// The mention is treated as an atomic wedge: any cursor strictly inside the
// underlying npub snaps to the trailing edge of the displayed "@Vitor Pamplona"
// (and vice versa). This prevents an IME from placing the cursor in the middle
// of the bech32 and corrupting it on backspace.
assertEquals(0, transformedText.offsetMapping.originalToTransformed(0))
for (i in 1..63) {
assertEquals("originalToTransformed($i)", 15, transformedText.offsetMapping.originalToTransformed(i))
}
assertEquals(15, transformedText.offsetMapping.originalToTransformed(64))
assertEquals(0, transformedText.offsetMapping.transformedToOriginal(0))
for (i in 1..14) {
assertEquals("transformedToOriginal($i)", 64, transformedText.offsetMapping.transformedToOriginal(i))
}
assertEquals(64, transformedText.offsetMapping.transformedToOriginal(15))
}
@Test
fun transformationText() {
val user =
LocalCache.getOrCreateUser(
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
.toHexKey(),
)
user.metadata().newMetadata(
UserMetadata().also {
it.displayName = "Vitor Pamplona"
},
MetadataEvent(
id = "",
pubKey = "",
createdAt = 0,
tags = emptyArray(),
content = "",
sig = "",
),
)
val transformedText =
buildAnnotatedStringWithUrlHighlighting(
AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"),
Color.Red,
)
assertEquals("New Hey @Vitor Pamplona", transformedText.text.text)
// Outside the wedge: identity mapping.
assertEquals(0, transformedText.offsetMapping.originalToTransformed(0)) // Before N
assertEquals(4, transformedText.offsetMapping.originalToTransformed(4)) // Before H
assertEquals(8, transformedText.offsetMapping.originalToTransformed(8)) // Before @ (boundary)
// Strictly inside the underlying npub: snaps to the end of "@Vitor Pamplona" (offset 23).
assertEquals(23, transformedText.offsetMapping.originalToTransformed(9)) // Before n
assertEquals(23, transformedText.offsetMapping.originalToTransformed(12)) // Before b
assertEquals(23, transformedText.offsetMapping.originalToTransformed(13)) // Before 1
assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) // Before z
// End-of-wedge boundary maps to end of displayed mention.
assertEquals(23, transformedText.offsetMapping.originalToTransformed(72))
// Outside the wedge in displayed: identity.
assertEquals(0, transformedText.offsetMapping.transformedToOriginal(0))
assertEquals(4, transformedText.offsetMapping.transformedToOriginal(4))
assertEquals(8, transformedText.offsetMapping.transformedToOriginal(8)) // Before @ (boundary)
// Strictly inside displayed "@Vitor Pamplona": snaps to end of underlying npub (offset 72).
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(9))
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(22))
// End-of-wedge boundary maps to end of underlying mention; past it shifts by deltas.
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23))
assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24))
}
@Test
fun transformationTextTwoKeys() {
val user =
LocalCache.getOrCreateUser(
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
.toHexKey(),
)
user.metadata().newMetadata(
UserMetadata().also {
it.displayName = "Vitor Pamplona"
},
MetadataEvent(
id = "",
pubKey = "",
createdAt = 0,
tags = emptyArray(),
content = "",
sig = "",
),
)
val transformedText =
buildAnnotatedStringWithUrlHighlighting(
AnnotatedString(
"New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z and @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z",
),
Color.Red,
)
assertEquals("New Hey @Vitor Pamplona and @Vitor Pamplona", transformedText.text.text)
// Strictly inside the first underlying npub [8, 72): snap to end of first
// displayed "@Vitor Pamplona" (offset 23).
assertEquals(23, transformedText.offsetMapping.originalToTransformed(11))
assertEquals(23, transformedText.offsetMapping.originalToTransformed(12))
assertEquals(23, transformedText.offsetMapping.originalToTransformed(13))
assertEquals(23, transformedText.offsetMapping.originalToTransformed(70))
assertEquals(23, transformedText.offsetMapping.originalToTransformed(71))
// Boundary at end of first wedge: end of first displayed mention.
assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) // Before <space>
assertEquals(24, transformedText.offsetMapping.originalToTransformed(73)) // Before a
assertEquals(25, transformedText.offsetMapping.originalToTransformed(74)) // Before n
assertEquals(26, transformedText.offsetMapping.originalToTransformed(75)) // Before d
assertEquals(27, transformedText.offsetMapping.originalToTransformed(76)) // Before <space>
assertEquals(28, transformedText.offsetMapping.originalToTransformed(77)) // Before @ (boundary, second wedge)
// Strictly inside the second underlying npub [77, 141): snap to end of second
// displayed "@Vitor Pamplona" (offset 43).
assertEquals(43, transformedText.offsetMapping.originalToTransformed(78)) // Before n
assertEquals(43, transformedText.offsetMapping.originalToTransformed(140))
// Strictly inside first displayed "@Vitor Pamplona" [8, 23): snap to end of
// first underlying npub (offset 72).
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(22)) // Before a (display)
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) // Before <space>
assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) // Before a
assertEquals(74, transformedText.offsetMapping.transformedToOriginal(25)) // Before n
assertEquals(75, transformedText.offsetMapping.transformedToOriginal(26)) // Before d
assertEquals(76, transformedText.offsetMapping.transformedToOriginal(27)) // Before <space>
assertEquals(77, transformedText.offsetMapping.transformedToOriginal(28)) // Before @ (boundary, second wedge)
// Strictly inside second displayed "@Vitor Pamplona" [28, 43): snap to end of
// second underlying npub (offset 141).
assertEquals(141, transformedText.offsetMapping.transformedToOriginal(29))
assertEquals(141, transformedText.offsetMapping.transformedToOriginal(42))
}
}

View File

@@ -0,0 +1,98 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.images
import android.graphics.drawable.Animatable
import android.graphics.drawable.AnimatedImageDrawable
import android.os.Build
import androidx.core.net.toUri
import androidx.test.ext.junit.runners.AndroidJUnit4
import coil3.ImageLoader
import coil3.asDrawable
import coil3.gif.AnimatedImageDecoder
import coil3.request.ImageRequest
import coil3.request.SuccessResult
import coil3.size.ScaleDrawable
import coil3.toBitmap
import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.appContext
import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.copyAssetToCache
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class AvifAnimatedDecodeInstrumentedTest {
private val avifLoader: ImageLoader by lazy {
ImageLoader
.Builder(appContext)
.components {
add(AvifAnimatedDecoderFactory())
add(AnimatedImageDecoder.Factory())
}.build()
}
@Test
fun stillAvifDecodesToBitmap() =
runBlocking {
assumeTrue("AVIF requires API 31+", Build.VERSION.SDK_INT >= 31)
val avif = copyAssetToCache("avif/still-tiny-8x8.avif")
val result =
avifLoader.execute(
ImageRequest.Builder(appContext).data(avif.toUri()).build(),
)
assertTrue("Expected SuccessResult, got ${result::class.simpleName}", result is SuccessResult)
val bitmap = (result as SuccessResult).image.toBitmap()
assertEquals(8, bitmap.width)
assertEquals(8, bitmap.height)
}
@Test
fun animatedAvifDecodesToAnimatedImageDrawable() =
runBlocking {
assumeTrue("Animated AVIF requires API 31+", Build.VERSION.SDK_INT >= 31)
val avif = copyAssetToCache("avif/animated-tiny-3frames.avif")
val result =
avifLoader.execute(
ImageRequest.Builder(appContext).data(avif.toUri()).build(),
)
assertTrue("Expected SuccessResult", result is SuccessResult)
val drawable = (result as SuccessResult).image.asDrawable(appContext.resources)
// AnimatedImageDecoder wraps AnimatedImageDrawable in a ScaleDrawable.
// Both are Animatable; a BitmapDrawable (the failure case) is not.
assertTrue(
"Animated AVIF must be Animatable — Coil's AvifAnimatedDecoderFactory was not invoked if this fails (got ${drawable::class.simpleName})",
drawable is Animatable,
)
// Unwrap ScaleDrawable to confirm the inner drawable is AnimatedImageDrawable.
val inner = if (drawable is ScaleDrawable) drawable.child else drawable
assertTrue(
"Inner drawable must be AnimatedImageDrawable, got ${inner::class.simpleName}",
inner is AnimatedImageDrawable,
)
}
}

View File

@@ -0,0 +1,89 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.images
import android.os.Build
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.appContext
import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.copyAssetToCache
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class ThumbnailDiskCacheAvifInstrumentedTest {
private lateinit var cacheDir: File
private lateinit var cache: ThumbnailDiskCache
@Before
fun setUp() {
cacheDir = File(appContext.cacheDir, "thumbnail-test-${UUID.randomUUID()}")
cache = ThumbnailDiskCache(cacheDir)
}
@After
fun tearDown() {
cacheDir.deleteRecursively()
}
@Test
fun animatedAvifIsNotCached() {
val url = "https://example.com/profile-pic-${UUID.randomUUID()}.avif"
val source = copyAssetToCache("avif/animated-tiny-3frames.avif")
val saved = cache.generateFromFile(url, source)
assertFalse(
"generateFromFile must return false for animated AVIF (would otherwise freeze avatar on first frame)",
saved,
)
assertNull(
"load() must return null for an animated-AVIF URL that was skipped",
cache.load(url),
)
}
@Test
fun stillAvifIsCachedAsBitmap() {
// generateFromFile uses BitmapFactory.decodeFile to read the source,
// which requires platform AVIF decode support (API 31+). On older
// devices the still-AVIF path returns false because decode fails,
// not because of the animated-skip branch.
assumeTrue("Still AVIF decode requires API 31+", Build.VERSION.SDK_INT >= 31)
val url = "https://example.com/profile-pic-${UUID.randomUUID()}.avif"
val source = copyAssetToCache("avif/still-tiny-8x8.avif")
val saved = cache.generateFromFile(url, source)
assertTrue("generateFromFile must return true for still AVIF", saved)
val bitmap = cache.load(url)
assertNotNull("load() must return a non-null Bitmap for a cached still AVIF", bitmap)
}
}

View File

@@ -0,0 +1,80 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.images
import android.graphics.Bitmap
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.After
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class ThumbnailDiskCacheInstrumentedTest {
private val appContext = InstrumentationRegistry.getInstrumentation().targetContext
private lateinit var cacheDir: File
private lateinit var cache: ThumbnailDiskCache
private lateinit var sourceFile: File
@Before
fun setUp() {
cacheDir = File(appContext.cacheDir, "thumbnail-test-${UUID.randomUUID()}")
cache = ThumbnailDiskCache(cacheDir)
sourceFile = File(appContext.cacheDir, "source-${UUID.randomUUID()}.jpg")
val bitmap = Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888)
sourceFile.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) }
bitmap.recycle()
}
@After
fun tearDown() {
cacheDir.deleteRecursively()
sourceFile.delete()
}
@Test
fun generatesThumbnailFromJpeg() {
val url = "https://example.com/profile-pic-${UUID.randomUUID()}.jpg"
assertTrue("generateFromFile must return true for a valid JPEG", cache.generateFromFile(url, sourceFile))
assertNotNull("load() must return a non-null Bitmap for a cached JPEG", cache.load(url))
}
@Test
fun recreatesCacheDirClearedAtRuntime() {
// The system (cache trim) or the user (Settings > Clear cache) can delete
// the cache dir while the app runs; the next write must recreate it
// instead of failing with ENOENT.
val url = "https://example.com/profile-pic-${UUID.randomUUID()}.jpg"
assertTrue(cacheDir.deleteRecursively())
assertTrue(
"generateFromFile must recreate the cache dir after it is cleared at runtime",
cache.generateFromFile(url, sourceFile),
)
assertNotNull("load() must return the thumbnail written after dir recreation", cache.load(url))
}
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.uploads
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.appContext
import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.contentUriFor
import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.copyAssetToCache
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertThrows
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class AvifMetadataStripperPoisonedFixtureInstrumentedTest {
@Test
fun stripDispatcherThrowsAvifMetadataNotVerifiableExceptionForPoisonedAvif() {
// Regression for commit b9112550b: NewUserMetadataViewModel calls
// MetadataStripper.strip(uri, "image/avif", context) at line 218.
// The viewmodel only catches AvifMetadataNotVerifiableException to
// surface the AVIF-specific error string; any other exception type
// would fall through to "Upload cancelled" and confuse the user.
//
// Note: strip() dispatches by mimeType param, then internally
// stripImageMetadata() re-resolves via contentResolver.getType(uri).
// A content:// URI (FileProvider) is required so getType() returns
// "image/avif" and the AVIF inspection branch is reached.
val avif = copyAssetToCache("avif/still-tiny-8x8-exif-gps.avif")
val uri = contentUriFor(avif)
val ex =
assertThrows(AvifMetadataNotVerifiableException::class.java) {
MetadataStripper.strip(uri, AVIF_MIME, appContext)
}
assertNotNull("Exception must have a non-null message for UI display", ex.message)
}
}

View File

@@ -0,0 +1,123 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.uploads
import android.os.Build
import androidx.core.net.toUri
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.appContext
import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.contentUriFor
import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.copyAssetToCache
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
@RunWith(AndroidJUnit4::class)
class AvifUploadPipelineInstrumentedTest {
/**
* Drives [assetPath] through [MediaCompressor.compress] with [AVIF_MIME] and asserts the
* bytes survive unchanged. AVIF must bypass JPEG re-encoding regardless of whether the
* source is still or animated.
*/
private fun assertAvifBytesPreserved(
assetPath: String,
description: String,
) = runBlocking {
val avif = copyAssetToCache(assetPath)
val originalBytes = avif.readBytes()
val result =
MediaCompressor().compress(
uri = avif.toUri(),
contentType = AVIF_MIME,
applicationContext = appContext,
mediaQuality = CompressorQuality.MEDIUM,
)
assertEquals(AVIF_MIME, result.contentType)
val resultBytes = File(result.uri.path!!).readBytes()
assertTrue(description, originalBytes.contentEquals(resultBytes))
}
@Test
fun stillAvifPassesThroughMediaCompressorUnchanged() =
assertAvifBytesPreserved(
"avif/still-tiny-8x8.avif",
"Still AVIF bytes must be preserved through MediaCompressor",
)
@Test
fun animatedAvifPassesThroughMediaCompressorUnchanged() =
assertAvifBytesPreserved(
"avif/animated-tiny-3frames.avif",
"Animated AVIF bytes must be preserved (headline regression to prevent)",
)
@Test
fun avifMetadataStripperReturnsCleanFile() {
// Use a content:// URI via FileProvider so contentResolver.getType() returns AVIF_MIME.
// ContentResolver.getType() returns null for file:// URIs even on API 36 (AVIF is not
// recognised via MimeTypeMap for the file:// scheme). FileProvider uses
// MimeTypeMap.getSingleton().getMimeTypeFromExtension("avif") which returns "image/avif"
// on API 31+, matching production behaviour (gallery pickers always deliver content://).
val avif = copyAssetToCache("avif/still-tiny-8x8.avif")
val uri = contentUriFor(avif)
val result = MetadataStripper.stripImageMetadata(uri, appContext)
assertTrue("Clean AVIF should be marked stripped=true", result.stripped)
assertEquals("Clean AVIF URI should be the original", uri, result.uri)
}
@Test
fun avifPreviewMetadataGeneratesBlurhashAndThumbhash() {
assumeTrue("AVIF decoding requires API 31+", Build.VERSION.SDK_INT >= 31)
val avif = copyAssetToCache("avif/still-tiny-8x8.avif")
val result =
PreviewMetadataCalculator.computeFromUri(
context = appContext,
uri = avif.toUri(),
mimeType = AVIF_MIME,
)
assertNotNull("PreviewMetadataCalculator must return non-null on API 31+", result)
assertNotNull("AVIF blurhash should be generated via ImageDecoder", result!!.blurhash)
assertNotNull("AVIF thumbhash should be generated via ImageDecoder", result.thumbhash)
assertNotNull("AVIF dimensions should be returned", result.dim)
assertEquals(8, result.dim!!.width)
assertEquals(8, result.dim.height)
}
@Test
fun poisonedAvifMetadataStripperThrowsAvifMetadataNotVerifiableException() {
val avif = copyAssetToCache("avif/still-tiny-8x8-exif-gps.avif")
val ex =
assertThrows(AvifMetadataNotVerifiableException::class.java) {
MetadataStripper.stripImageMetadata(contentUriFor(avif), appContext)
}
assertNotNull("Exception must have a non-null message", ex.message)
}
}

View File

@@ -0,0 +1,176 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.tor
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.ui.tor.TorService
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import okhttp3.OkHttpClient
import okhttp3.Request
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
import java.net.InetSocketAddress
import java.net.Proxy
import java.util.concurrent.TimeUnit
import kotlin.system.measureTimeMillis
/**
* Real-Arti bootstrap + SOCKS round trip on-device. Verifies that the self-heal /
* destroy / re-init paths work end-to-end against the actual native lib.
*
* **This test is [Ignore]'d by default** because:
* - It needs network egress to the Tor network from the device/emulator. Many CI
* environments don't have it.
* - Bootstrap on a cold device can take 30-120s; the test costs real wall-clock time.
* - It depends on `check.torproject.org` being reachable.
*
* **To run manually:**
* 1. Connect a device or start an emulator that has internet egress to Tor.
* 2. Remove the `@Ignore` annotation below.
* 3. `./gradlew :amethyst:connectedPlayDebugAndroidTest -P android.testInstrumentationRunnerArguments.class=com.vitorpamplona.amethyst.tor.TorBootstrapInstrumentedTest`
*
* **What it covers that [TorManagerTest] does not:**
* - Real `ArtiNative.initialize` → `create_bootstrapped` → SOCKS listener bind.
* - Real rustls `CryptoProvider` install (regression check after the arti-v2.3.0 bump).
* - Real `destroy()` releasing the state file lock so a second `initialize()` succeeds.
* - OkHttp routing traffic through the SOCKS port and Arti exiting through the
* Tor network.
*
* **Companion fast tests:** `amethyst/src/test/.../tor/TorManagerTest.kt` covers the
* Kotlin-side self-heal logic (watchdog, cooldown, network change, status routing)
* with virtual time and in-memory fakes — no Arti required.
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
@Ignore("Tier-3 integration test — requires on-device network access to Tor. See class kdoc to enable.")
class TorBootstrapInstrumentedTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val torService = TorService(context)
@After
fun tearDown() =
runBlocking {
// Drop the native client so this test's state file lock doesn't bleed into
// the next instrumented run on the same device.
torService.reset()
}
/**
* Cold-start bootstrap. The whole point of the custom Arti build is that this
* works at all — if create_bootstrapped panics (e.g., because we forgot to install
* a rustls CryptoProvider after an arti bump) the test catches it.
*/
@Test
fun bootstraps_to_Active_within_120s() =
runBlocking(Dispatchers.IO) {
val elapsed =
measureTimeMillis {
torService.start()
val active =
withTimeout(BOOTSTRAP_TIMEOUT_MS) {
torService.status.first { it is TorServiceStatus.Active }
} as TorServiceStatus.Active
assertTrue("SOCKS port should be > 0", active.port > 0)
}
// Logged via assertEquals failure-on-too-slow; an actual `Log.i` would be invisible.
// Bootstrap should comfortably fit in 120s on a healthy network.
assertTrue("Bootstrap took ${elapsed}ms, expected < ${BOOTSTRAP_TIMEOUT_MS}ms", elapsed < BOOTSTRAP_TIMEOUT_MS)
}
/**
* SOCKS round-trip through Tor. Hits `check.torproject.org` which returns a JSON
* payload including `"IsTor":true` when the request actually exited via Tor.
* Catches regressions where the listener binds but no traffic flows (e.g., a
* broken handler-spawn race, or a crypto provider mismatch on the TLS handshake).
*/
@Test
fun proxies_HTTPS_through_Tor_and_reports_IsTor_true() =
runBlocking(Dispatchers.IO) {
torService.start()
val active =
withTimeout(BOOTSTRAP_TIMEOUT_MS) {
torService.status.first { it is TorServiceStatus.Active }
} as TorServiceStatus.Active
val client =
OkHttpClient
.Builder()
.proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", active.port)))
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
val request =
Request
.Builder()
.url("https://check.torproject.org/api/ip")
.build()
val body =
client.newCall(request).execute().use { resp ->
assertEquals("HTTP 200", 200, resp.code)
resp.body.string()
}
assertTrue(
"Response should report IsTor:true — actual body: $body",
body.contains("\"IsTor\":true"),
)
}
/**
* Verifies the destroy → re-init cycle that backs the self-heal path. After
* [TorService.reset], the next [TorService.start] must rebuild the TorClient and
* bring SOCKS back to Active — without a "state file already locked" error from
* the still-alive previous client.
*/
@Test
fun reset_then_re_start_brings_SOCKS_back_to_Active() =
runBlocking(Dispatchers.IO) {
torService.start()
withTimeout(BOOTSTRAP_TIMEOUT_MS) {
torService.status.first { it is TorServiceStatus.Active }
}
torService.reset()
assertEquals(TorServiceStatus.Off, torService.status.value)
torService.start()
val second =
withTimeout(BOOTSTRAP_TIMEOUT_MS) {
torService.status.first { it is TorServiceStatus.Active }
} as TorServiceStatus.Active
assertTrue("Second bootstrap port valid", second.port > 0)
}
companion object {
private const val BOOTSTRAP_TIMEOUT_MS: Long = 120_000L
}
}

View File

@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.service.notifications
import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.service.retryIfException
import com.vitorpamplona.amethyst.commons.util.retryIfException
import kotlinx.coroutines.Dispatchers
import okhttp3.OkHttpClient

View File

@@ -59,7 +59,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsBlockTile
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.Log
import kotlinx.collections.immutable.ImmutableList
@@ -207,24 +207,34 @@ fun LoadDistributors(onInner: @Composable (String, ImmutableList<String>, Immuta
)
}
fun hasPushNotificationProvider(): Boolean = true
@Composable
fun PushNotificationSettingsRow(sharedPrefs: UiSettingsFlow) {
fun PushNotificationProviderTile(sharedPrefs: UiSettingsFlow) {
val context = LocalContext.current
LoadDistributors { currentDistributor, list, readableListWithExplainer ->
SettingsRow(
R.string.push_server_title,
R.string.push_server_explainer,
selectedItems = readableListWithExplainer,
selectedIndex = list.indexOf(currentDistributor),
) { index ->
if (list[index] == "None") {
sharedPrefs.dontAskForNotificationPermissions()
sharedPrefs.dontShowPushNotificationSelector()
PushDistributorHandler.forceRemoveDistributor(context)
} else {
PushDistributorHandler.saveDistributor(list[index])
}
val selectedIndex = list.indexOf(currentDistributor).coerceAtLeast(0)
SettingsBlockTile(
icon = MaterialSymbols.CloudSync,
title = stringRes(R.string.push_server_title),
description = stringRes(R.string.push_server_explainer),
) {
TextSpinner(
label = null,
placeholder = readableListWithExplainer[selectedIndex].title,
options = readableListWithExplainer,
onSelect = { index ->
if (list[index] == "None") {
sharedPrefs.dontAskForNotificationPermissions()
sharedPrefs.dontShowPushNotificationSelector()
PushDistributorHandler.forceRemoveDistributor(context)
} else {
PushDistributorHandler.saveDistributor(list[index])
}
},
modifier = Modifier.fillMaxWidth(),
)
}
}
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
import androidx.compose.ui.platform.UriHandler
// F-Droid distributes Amethyst as MIT-licensed free software; the build must
// not surface links to external (e.g. GitHub-hosted) policy documents.
fun legalSettingsCategory(uriHandler: UriHandler): SettingsCategory? = null

View File

@@ -0,0 +1,35 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedOff.legal
import androidx.compose.runtime.Composable
// F-Droid distributes Amethyst as MIT-licensed free software; there is no
// terms-of-use acceptance layered on top of the source license, and the build
// must not link to any external (e.g. GitHub) policy document.
@Composable
@Suppress("UNUSED_PARAMETER")
fun TermsGate(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
showError: Boolean,
) {
}

View File

@@ -3,6 +3,11 @@
xmlns:tools="http://schemas.android.com/tools">
<queries>
<package android:name="org.torproject.android"/>
<!-- Health Connect data store (Android 813 ships it as a separate system app) -->
<package android:name="com.google.android.apps.healthdata" />
<intent>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
@@ -73,6 +78,16 @@
<!-- Adds Geohash to posts if active -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Reads finished workouts from Health Connect to suggest a kind 1301 post.
Read-only; requested on demand, never on cold start. -->
<uses-permission android:name="android.permission.health.READ_EXERCISE" />
<uses-permission android:name="android.permission.health.READ_DISTANCE" />
<uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED" />
<uses-permission android:name="android.permission.health.READ_TOTAL_CALORIES_BURNED" />
<uses-permission android:name="android.permission.health.READ_HEART_RATE" />
<uses-permission android:name="android.permission.health.READ_STEPS" />
<uses-permission android:name="android.permission.health.READ_ELEVATION_GAINED" />
<!-- Old permission to access media -->
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
@@ -109,6 +124,11 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Health Connect privacy-policy rationale (required by Google when reading health data) -->
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent-filter>
<intent-filter android:label="Amethyst">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@@ -218,6 +238,50 @@
</intent-filter>
</activity>
<!-- "Send as DM" share target. The android:name simple class ("ShareAsDMAlias")
is matched at runtime by ShareIntentRouting.SHARE_AS_DM_ALIAS_SIMPLE_NAME;
keep the two in sync when renaming. -->
<activity-alias
android:name=".ui.ShareAsDMAlias"
android:exported="true"
android:label="@string/share_target_as_dm"
android:targetActivity=".ui.MainActivity">
<intent-filter android:label="@string/share_target_as_dm">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter android:label="@string/share_target_as_dm">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<intent-filter android:label="@string/share_target_as_dm">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
</activity-alias>
<!-- Health Connect privacy-policy rationale on Android 14+. Without this activity-alias
the permission request fails silently (no dialog appears). The system launches it,
guarded by START_VIEW_PERMISSION_USAGE, to show our privacy policy; it routes into
MainActivity. Android 13 and lower use the ACTION_SHOW_PERMISSIONS_RATIONALE
intent-filter declared on MainActivity above. -->
<activity-alias
android:name="ViewPermissionUsageActivity"
android:exported="true"
android:targetActivity=".ui.MainActivity"
android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
</intent-filter>
</activity-alias>
<activity
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="fullSensor"

View File

@@ -26,11 +26,13 @@ import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlEndpointResolver
import com.vitorpamplona.amethyst.commons.tor.TorSettings
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.model.nip03Timestamp.BitcoinExplorerEndpoint
import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier
import com.vitorpamplona.amethyst.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder
import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever
@@ -66,6 +68,7 @@ import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient
import com.vitorpamplona.amethyst.service.relayClient.CacheClientConnector
import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector
import com.vitorpamplona.amethyst.service.relayClient.TorCircuitHealthTracker
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator
@@ -83,6 +86,7 @@ import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
import com.vitorpamplona.amethyst.ui.screen.AccountState
import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
import com.vitorpamplona.amethyst.ui.tor.TorManager
import com.vitorpamplona.amethyst.ui.tor.TorService
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
@@ -91,14 +95,24 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineT
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats.RelayReqStats
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
import com.vitorpamplona.quartz.nip03Timestamp.okhttp.OkHttpBitcoinExplorer
import com.vitorpamplona.quartz.nip03Timestamp.ots.OtsBlockHeightCache
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
import com.vitorpamplona.quartz.nip05DnsIdentifiers.OkHttpNip05Fetcher
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.CompositeNamecoinBackend
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxNameBackend
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.IElectrumXClient
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NameShowResult
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinBackend
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinCoreRpcClient
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.TOR_ELECTRUMX_SERVERS
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.CachingOnchainBackend
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.EsploraBackend
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
@@ -185,12 +199,12 @@ class AppModules(
UiSettingsState(uiPrefs.value, connManager.isMobileOrFalse, applicationIOScope)
}
val torManager = TorManager(torPrefs, appContext, applicationIOScope)
val torManager = TorManager(torPrefs, TorService(appContext), applicationIOScope)
// Whenever the underlying network identity changes (wifi↔cellular, regained from
// offline, etc.) we clear any active Tor session bypass so the manager re-attempts
// bootstrap on the new network. The remembered-approval window is unaffected: if Tor
// stays stuck we will silently bypass again after the timeout fires.
// Network identity change (wifi↔cellular, regained from offline, captive portal
// cleared) — the old network's guards/circuits are dead, and Arti's in-memory
// client + on-disk state/ both need a fresh start. onNetworkChange drops the
// TorClient, clears the bypass + persisted approval, and triggers a full re-init.
init {
applicationIOScope.launch {
connManager.status
@@ -198,7 +212,7 @@ class AppModules(
.filterNotNull()
.distinctUntilChanged()
.drop(1)
.collect { torManager.clearSessionBypass() }
.collect { torManager.onNetworkChange() }
}
}
@@ -260,13 +274,105 @@ class AppModules(
client
}
/**
* Long-lived Namecoin Core JSON-RPC client. The current
* [NamecoinCoreRpcConfig] is pushed in via [setConfig] each time the
* user saves settings; this avoids reading SharedPreferences on the
* hot lookup path.
*/
val namecoinCoreRpcClient by lazy {
Log.d("AppModules", "NamecoinCoreRpcClient Init")
val client =
NamecoinCoreRpcClient(
httpClientForUrl = roleBasedHttpClientBuilder::okHttpClientForNip05,
)
// Bootstrap the active config and the pinned trust store from the
// same shared SharedPreferences entry the ElectrumX client uses.
// Mirrors the ElectrumXClient init path above so user-pinned certs
// are available on both backends after process restart.
applicationIOScope.launch {
try {
client.setConfig(namecoinPrefs.current.namecoinCoreRpc)
val pinnedCerts = namecoinPrefs.loadPinnedCerts()
if (pinnedCerts.isNotEmpty()) {
client.setDynamicCerts(pinnedCerts)
}
} catch (_: Exception) {
// Non-fatal — user can re-pin via Settings.
}
}
client
}
/**
* Compose the active Namecoin lookup backend based on user settings.
*
* The returned [IElectrumXClient] is what the resolver actually calls.
* It dispatches to either Namecoin Core RPC or ElectrumX (custom-only,
* default-only, or both) and applies the user's fallback policy.
*
* The function builds a fresh composite per call so that settings
* changes take effect immediately (no app restart required).
*/
fun buildNamecoinBackend(): IElectrumXClient {
val settings = namecoinPrefs.current
val custom = settings.toElectrumxServers()
val defaults =
if (roleBasedHttpClientBuilder.shouldUseTorForNIP05("https://electrumx.example.com")) {
TOR_ELECTRUMX_SERVERS
} else {
DEFAULT_ELECTRUMX_SERVERS
}
val customExBackend =
custom?.let { servers -> ElectrumxNameBackend(electrumXClient) { servers } }
val defaultExBackend = ElectrumxNameBackend(electrumXClient) { defaults }
return when (settings.backend) {
NamecoinBackend.NAMECOIN_CORE_RPC -> {
// Refresh client config in case the user just saved it.
namecoinCoreRpcClient.setConfig(settings.namecoinCoreRpc)
CompositeNamecoinBackend(
primary = namecoinCoreRpcClient,
customElectrumx = customExBackend,
defaultElectrumx = defaultExBackend,
policy = settings.toFallbackPolicy(),
isPrimaryCoreRpc = true,
)
}
NamecoinBackend.ELECTRUMX -> {
// Custom servers first (if any). If the user only has the public
// defaults configured, primary == defaultElectrumx and the
// fallback toggle is moot.
val primary: com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameBackend =
customExBackend ?: defaultExBackend
CompositeNamecoinBackend(
primary = primary,
customElectrumx = null,
defaultElectrumx = if (customExBackend != null) defaultExBackend else null,
policy = settings.toFallbackPolicy(),
isPrimaryCoreRpc = false,
)
}
}
}
val namecoinResolver by
lazy {
Log.d("AppModules", "Namecoin Resolver Init")
NamecoinNameResolver(
electrumxClient = electrumXClient,
electrumxClient =
object : IElectrumXClient {
override suspend fun nameShowWithFallback(
identifier: String,
servers: List<ElectrumxServer>,
): NameShowResult? = buildNamecoinBackend().nameShowWithFallback(identifier, servers)
},
serverListProvider = {
// User-configured custom servers take priority
// Kept for compatibility with NamecoinNameResolver's API;
// the composite backend ignores this and consults user
// settings directly via buildNamecoinBackend().
namecoinPrefs.customServersOrNull
?: if (roleBasedHttpClientBuilder.shouldUseTorForNIP05("https://electrumx.example.com")) {
TOR_ELECTRUMX_SERVERS
@@ -322,24 +428,80 @@ class AppModules(
// Connects the INostrClient class with okHttp
val websocketBuilder =
OkHttpWebSocket.Builder { url ->
val useTor = torEvaluatorFlow.flow.value.useTor(url)
okHttpClientForRelays.getHttpClient(useTor)
}
OkHttpWebSocket.Builder(
httpClient = { url ->
val useTor = torEvaluatorFlow.shouldUseTorForRelay(url)
okHttpClientForRelays.getHttpClient(useTor)
},
// Don't dial Tor-routed relays until Tor's SOCKS port is up. Otherwise the
// whole Tor-routed relay set is hammered with doomed dials against the dead
// proxy during bootstrap. RelayProxyClientConnector reconnects them (with
// ignoreRetryDelays=true) the instant Tor flips to Active.
canDial = { url ->
!torEvaluatorFlow.shouldUseTorForRelay(url) || torManager.isSocksReady()
},
)
// Caches all events in Memory
val cache: LocalCache = LocalCache
// NIP-BC onchain zap verification backend. Wired up once at app init so
// LocalCache.consume(OnchainZapEvent) can sum the on-chain output values
// that pay the recipient's derived Taproot address. Wrapped in a caching
// decorator so a feed full of onchain zaps doesn't fan out into one HTTP
// request per event.
//
// The explorer endpoint is shared with OpenTimestamps: it honours the same
// user-configured server (OTS settings) and the same Tor-aware default
// selection, via BitcoinExplorerEndpoint — onchain zaps must not silently
// bypass the user's Tor preference.
init {
cache.onchainBackend =
CachingOnchainBackend(
EsploraBackend(
baseUrl = {
BitcoinExplorerEndpoint.resolveNormalized(
customExplorerUrl = otsPrefs.current.normalizedUrl(),
usingTor =
roleBasedHttpClientBuilder.shouldUseTorForMoneyOperations(
OkHttpBitcoinExplorer.MEMPOOL_API_URL,
),
)
},
client = roleBasedHttpClientBuilder.okHttpClientForMoney(OkHttpBitcoinExplorer.MEMPOOL_API_URL),
),
)
// NIP-57 Appendix F: validates incoming zap receipts against the
// recipient's LNURL provider's advertised `nostrPubkey`. Reuses the
// money-tier http client so Tor preferences and proxy settings apply.
cache.lnurlEndpointResolver =
OkHttpLnurlEndpointResolver(roleBasedHttpClientBuilder::okHttpClientForMoney)
}
// Provides a relay pool
val client: INostrClient = NostrClient(websocketBuilder, applicationIOScope)
// Self-heals the "Tor Active but every circuit dead" state the lifecycle watchdogs can't
// see (they only arm while Connecting). Watches Tor-routed relay outcomes and, when enough
// fail with zero successes in the window, pokes TorManager to drop + re-init Arti.
val torCircuitHealthTracker =
TorCircuitHealthTracker(
client = client,
isTorRouted = { torEvaluatorFlow.shouldUseTorForRelay(it) },
isTorActive = { torManager.isSocksReady() },
isConnectivityActive = { connManager.status.value is ConnectivityStatus.Active },
onCircuitsDead = { torManager.onTorCircuitsDead() },
).also { it.register() }
// Watches for changes on Tor and Relay List Settings
val relayProxyClientConnector =
RelayProxyClientConnector(
torEvaluatorFlow.flow,
okHttpClientForRelays,
connManager,
torManager,
okHttpClientForRelays.defaultHttpClient,
okHttpClientForRelays.defaultHttpClientWithoutProxy,
connManager.status,
torManager.status,
client,
applicationIOScope,
)
@@ -372,6 +534,9 @@ class AppModules(
val relayReqStats = if (isDebug) RelayReqStats(client) else null
val logger = if (isDebug) RelaySpeedLogger(client) else null
// Focused timeline for the DM / gift-wrap loading path (tag: DMPagination).
// val dmDiagnostics = if (isDebug) DmRelayDiagnosticsLogger(client) else null
// Coordinates all subscriptions for the Nostr Client
val sources: RelaySubscriptionsCoordinator =
RelaySubscriptionsCoordinator(
@@ -387,6 +552,9 @@ class AppModules(
AccountCacheState(
geolocationFlow = { locationManager.geohashStateFlow },
nwcFilterAssembler = { sources.nwc },
cashuWalletFilterAssembler = { sources.cashuWallet },
cashuMintDirectoryFilterAssembler = { sources.cashuMintDirectory },
okHttpClientForMoney = roleBasedHttpClientBuilder::okHttpClientForMoney,
contentResolverFn = { appContext.contentResolver },
otsResolverBuilder = { otsResolverBuilder.build() },
cache = cache,
@@ -609,6 +777,12 @@ class AppModules(
ScheduledPostWorker.schedule(appContext)
ScheduledPostWorker.scheduleCatchUp(appContext)
// Periodic scan that posts "starting soon" notifications for NIP-52 appointments the
// user has RSVP'd to as ACCEPTED. 15-minute cadence matches both the WorkManager
// periodic minimum and the lead-time window.
com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker
.schedule(appContext)
// Watch for account login and start/stop always-on notification service
applicationIOScope.launch {
sessionManager.accountContent.collectLatest { state ->

View File

@@ -25,11 +25,12 @@ import android.content.Context
import android.content.SharedPreferences
import androidx.compose.runtime.Immutable
import androidx.core.content.edit
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntry
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntry
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcWalletEntry
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
@@ -104,6 +105,8 @@ private object PrefKeys {
const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList"
const val DEFAULT_POLLS_FOLLOW_LIST = "defaultPollsFollowList"
const val DEFAULT_PICTURES_FOLLOW_LIST = "defaultPicturesFollowList"
const val DEFAULT_WORKOUTS_FOLLOW_LIST = "defaultWorkoutsFollowList"
const val DEFAULT_CALENDARS_FOLLOW_LIST = "defaultCalendarsFollowList"
const val DEFAULT_PRODUCTS_FOLLOW_LIST = "defaultProductsFollowList"
const val DEFAULT_SHORTS_FOLLOW_LIST = "defaultShortsFollowList"
const val DEFAULT_PUBLIC_CHATS_FOLLOW_LIST = "defaultPublicChatsFollowList"
@@ -111,13 +114,20 @@ private object PrefKeys {
const val DEFAULT_NESTS_FOLLOW_LIST = "defaultNestsFollowList"
const val DEFAULT_LONGS_FOLLOW_LIST = "defaultLongsFollowList"
const val DEFAULT_ARTICLES_FOLLOW_LIST = "defaultArticlesFollowList"
const val DEFAULT_MUSIC_TRACKS_FOLLOW_LIST = "defaultMusicTracksFollowList"
const val DEFAULT_MUSIC_PLAYLISTS_FOLLOW_LIST = "defaultMusicPlaylistsFollowList"
const val DEFAULT_PODCAST_EPISODES_FOLLOW_LIST = "defaultPodcastEpisodesFollowList"
const val DEFAULT_PODCASTS_FOLLOW_LIST = "defaultPodcastsFollowList"
const val DEFAULT_SOFTWARE_APPS_FOLLOW_LIST = "defaultSoftwareAppsFollowList"
const val DEFAULT_BADGES_FOLLOW_LIST = "defaultBadgesFollowList"
const val DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST = "defaultBrowseEmojiSetsFollowList"
const val DEFAULT_COMMUNITIES_FOLLOW_LIST = "defaultCommunitiesFollowList"
const val DEFAULT_FOLLOW_PACKS_FOLLOW_LIST = "defaultFollowPacksFollowList"
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer" // legacy, kept for migration
const val NWC_WALLETS = "nwcWallets"
const val DEFAULT_NWC_WALLET_ID = "defaultNwcWalletId"
const val DEFAULT_NWC_WALLET_ID = "defaultNwcWalletId" // legacy, migrated into DEFAULT_PAYMENT_SOURCE_ID
const val CLINK_DEBIT_WALLETS = "clinkDebitWallets"
const val DEFAULT_PAYMENT_SOURCE_ID = "defaultPaymentSourceId"
const val LATEST_USER_METADATA = "latestUserMetadata"
const val LATEST_CONTACT_LIST = "latestContactList"
const val LATEST_DM_RELAY_LIST = "latestDMRelayList"
@@ -142,6 +152,10 @@ private object PrefKeys {
const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later
const val ALWAYS_ON_NOTIFICATION_SERVICE = "always_on_notification_service"
const val SPLIT_NOTIFICATIONS_ENABLED = "split_notifications_enabled"
// One-shot stamp: set once an account has gone through the notifications
// Global -> Selected (Curated) migration (or was created after it shipped).
const val NOTIF_GLOBAL_TO_CURATED_MIGRATED = "notif_global_to_curated_migrated"
const val TOR_SETTINGS = "tor_settings"
const val USE_PROXY = "use_proxy"
const val PROXY_PORT = "proxy_port"
@@ -156,6 +170,8 @@ private object PrefKeys {
const val ALL_ACCOUNT_INFO = "all_saved_accounts_info"
const val SHARED_SETTINGS = "shared_settings"
const val LATEST_PAYMENT_TARGETS = "latestPaymentTargets"
const val LATEST_CASHU_WALLET = "latestCashuWallet"
const val LATEST_NUTZAP_INFO = "latestNutzapInfo"
}
object LocalPreferences {
@@ -309,6 +325,9 @@ object LocalPreferences {
suspend fun deleteAccount(accountInfo: AccountInfo) {
Log.d("LocalPreferences") { "Saving to encrypted storage updatePrefsForLogout ${accountInfo.npub}" }
withContext(Dispatchers.IO) {
// Drop the in-memory copy as well; otherwise re-adding the same account later
// would resurrect the deleted settings from this cache.
mutex.withLock { cachedAccounts.remove(accountInfo.npub) }
encryptedPreferences(accountInfo.npub).edit(commit = true) { clear() }
removeAccount(accountInfo)
deleteUserPreferenceFile(accountInfo.npub)
@@ -322,8 +341,16 @@ object LocalPreferences {
}
suspend fun setDefaultAccount(accountSettings: AccountSettings) {
setCurrentAccount(accountSettings)
// Save the per-npub file before emitting onto the savedAccounts flow.
// Otherwise a collector (e.g. AlwaysOnNotificationServiceManager) can race in
// and call loadAccountConfigFromEncryptedStorage(npub) before NOSTR_PUBKEY is
// written, get null back, and poison `cachedAccounts[npub] = null` for the
// rest of the session — making every later switch to this account land on
// LoggedOff instead of LoggedIn.
saveToEncryptedStorage(accountSettings)
val npub = accountSettings.keyPair.pubKey.toNpub()
mutex.withLock { cachedAccounts.put(npub, accountSettings) }
setCurrentAccount(accountSettings)
}
suspend fun allSavedAccounts(): List<AccountInfo> = savedAccounts()
@@ -361,6 +388,8 @@ object LocalPreferences {
putString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPollsFollowList.value))
putString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPicturesFollowList.value))
putString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultWorkoutsFollowList.value))
putString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultCalendarsFollowList.value))
putString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultProductsFollowList.value))
putString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultShortsFollowList.value))
putString(PrefKeys.DEFAULT_PUBLIC_CHATS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPublicChatsFollowList.value))
@@ -368,6 +397,11 @@ object LocalPreferences {
putString(PrefKeys.DEFAULT_NESTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNestsFollowList.value))
putString(PrefKeys.DEFAULT_LONGS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultLongsFollowList.value))
putString(PrefKeys.DEFAULT_ARTICLES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultArticlesFollowList.value))
putString(PrefKeys.DEFAULT_MUSIC_TRACKS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultMusicTracksFollowList.value))
putString(PrefKeys.DEFAULT_MUSIC_PLAYLISTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultMusicPlaylistsFollowList.value))
putString(PrefKeys.DEFAULT_PODCAST_EPISODES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPodcastEpisodesFollowList.value))
putString(PrefKeys.DEFAULT_PODCASTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPodcastsFollowList.value))
putString(PrefKeys.DEFAULT_SOFTWARE_APPS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultSoftwareAppsFollowList.value))
putString(PrefKeys.DEFAULT_BADGES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultBadgesFollowList.value))
putString(PrefKeys.DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultBrowseEmojiSetsFollowList.value))
putString(PrefKeys.DEFAULT_COMMUNITIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultCommunitiesFollowList.value))
@@ -379,9 +413,19 @@ object LocalPreferences {
} else {
remove(PrefKeys.NWC_WALLETS)
}
settings.defaultNwcWalletId.value?.let {
putString(PrefKeys.DEFAULT_NWC_WALLET_ID, it)
} ?: remove(PrefKeys.DEFAULT_NWC_WALLET_ID)
val debitEntries = settings.clinkDebitWallets.value.map { it.denormalize() }
if (debitEntries.isNotEmpty()) {
putString(PrefKeys.CLINK_DEBIT_WALLETS, JsonMapper.toJson(debitEntries))
} else {
remove(PrefKeys.CLINK_DEBIT_WALLETS)
}
settings.defaultPaymentSourceId.value?.let {
putString(PrefKeys.DEFAULT_PAYMENT_SOURCE_ID, it)
} ?: remove(PrefKeys.DEFAULT_PAYMENT_SOURCE_ID)
// Legacy NWC-only default key is superseded by DEFAULT_PAYMENT_SOURCE_ID.
remove(PrefKeys.DEFAULT_NWC_WALLET_ID)
// Remove legacy key after migration
remove(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER)
@@ -414,6 +458,8 @@ object LocalPreferences {
putOrRemove(PrefKeys.LATEST_EPHEMERAL_LIST, settings.backupEphemeralChatList)
putOrRemove(PrefKeys.LATEST_TRUST_PROVIDER_LIST, settings.backupTrustProviderList)
putOrRemove(PrefKeys.LATEST_PAYMENT_TARGETS, settings.backupNipA3PaymentTargets)
putOrRemove(PrefKeys.LATEST_CASHU_WALLET, settings.backupCashuWallet)
putOrRemove(PrefKeys.LATEST_NUTZAP_INFO, settings.backupNutzapInfo)
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, settings.hideDeleteRequestDialog)
putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, settings.hideNIP17WarningDialog)
@@ -421,6 +467,11 @@ object LocalPreferences {
putBoolean(PrefKeys.CALLS_ENABLED, settings.callsEnabled.value)
putBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, settings.alwaysOnNotificationService.value)
putBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, settings.splitNotificationsEnabled.value)
// Any account that reaches a save has its notification filter in its
// post-split meaning, so stamp it as migrated. This keeps the one-shot
// Global -> Selected rewrite from ever touching it again and preserves a
// deliberate raw-Global choice (including on brand-new accounts).
putBoolean(PrefKeys.NOTIF_GLOBAL_TO_CURATED_MIGRATED, true)
// migrating from previous design
remove(PrefKeys.USE_PROXY)
@@ -487,19 +538,20 @@ object LocalPreferences {
suspend fun loadAccountConfigFromEncryptedStorage(npub: String): AccountSettings? {
// if already loaded, return right away
if (cachedAccounts.containsKey(npub)) {
return cachedAccounts[npub]
}
cachedAccounts[npub]?.let { return it }
return withContext(Dispatchers.IO) {
mutex.withLock {
if (cachedAccounts.containsKey(npub)) {
return@withContext cachedAccounts.get(npub)
}
cachedAccounts[npub]?.let { return@withContext it }
val accountSettings = innerLoadCurrentAccountFromEncryptedStorage(npub)
cachedAccounts.put(npub, accountSettings)
// Only cache successful loads. Caching null would leave the account
// permanently unreachable for the rest of the session if a reader
// raced in before the per-npub file finished being written.
if (accountSettings != null) {
cachedAccounts.put(npub, accountSettings)
}
return@withContext accountSettings
}
@@ -540,6 +592,8 @@ object LocalPreferences {
val zapPaymentRequestServerStr = getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)
val nwcWalletsStr = getString(PrefKeys.NWC_WALLETS, null)
val defaultNwcWalletIdStr = getString(PrefKeys.DEFAULT_NWC_WALLET_ID, null)
val clinkDebitWalletsStr = getString(PrefKeys.CLINK_DEBIT_WALLETS, null)
val defaultPaymentSourceIdStr = getString(PrefKeys.DEFAULT_PAYMENT_SOURCE_ID, null)
val defaultFileServerStr = getString(PrefKeys.DEFAULT_FILE_SERVER, null)
val pendingAttestationsStr = getString(PrefKeys.PENDING_ATTESTATIONS, null)
@@ -562,6 +616,8 @@ object LocalPreferences {
val latestEphemeralListStr = getString(PrefKeys.LATEST_EPHEMERAL_LIST, null)
val latestTrustProviderListStr = getString(PrefKeys.LATEST_TRUST_PROVIDER_LIST, null)
val latestPaymentTargetsStr = getString(PrefKeys.LATEST_PAYMENT_TARGETS, null)
val latestCashuWalletStr = getString(PrefKeys.LATEST_CASHU_WALLET, null)
val latestNutzapInfoStr = getString(PrefKeys.LATEST_NUTZAP_INFO, null)
val lastReadPerRouteStr = getString(PrefKeys.LAST_READ_PER_ROUTE, null)
Log.d("LocalPreferences") { "Load account from file $npub - before parsing events" }
@@ -592,6 +648,10 @@ object LocalPreferences {
}
}
}
val clinkDebitsLoaded =
async {
parseOrNull<List<ClinkDebitWalletEntry>>(clinkDebitWalletsStr)?.mapNotNull { it.normalize() } ?: emptyList()
}
val defaultFileServer = async { parseOrNull<ServerName>(defaultFileServerStr) ?: DEFAULT_MEDIA_SERVERS[0] }
val viewedPollResultNoteIds = async { parseOrNull<Map<String, Long>>(viewedPollResultNoteIdsStr) ?: mapOf() }
@@ -615,6 +675,14 @@ object LocalPreferences {
val latestEphemeralList = async { parseEventOrNull<EphemeralChatListEvent>(latestEphemeralListStr) }
val latestTrustProviderList = async { parseEventOrNull<TrustProviderListEvent>(latestTrustProviderListStr) }
val latestPaymentTargets = async { parseEventOrNull<PaymentTargetsEvent>(latestPaymentTargetsStr) }
val latestCashuWallet =
async {
parseEventOrNull<com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent>(latestCashuWalletStr)
}
val latestNutzapInfo =
async {
parseEventOrNull<com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent>(latestNutzapInfoStr)
}
val lastReadPerRoute =
async {
@@ -641,19 +709,34 @@ object LocalPreferences {
defaultDiscoveryFollowList = MutableStateFlow(followListPrefs.discovery),
defaultPollsFollowList = MutableStateFlow(followListPrefs.polls),
defaultPicturesFollowList = MutableStateFlow(followListPrefs.pictures),
defaultWorkoutsFollowList = MutableStateFlow(followListPrefs.workouts),
defaultCalendarsFollowList = MutableStateFlow(followListPrefs.calendars),
defaultProductsFollowList = MutableStateFlow(followListPrefs.products),
defaultShortsFollowList = MutableStateFlow(followListPrefs.shorts),
defaultPublicChatsFollowList = MutableStateFlow(followListPrefs.publicChats),
defaultLiveStreamsFollowList = MutableStateFlow(followListPrefs.liveStreams),
defaultNestsFollowList = MutableStateFlow(followListPrefs.nests),
defaultLongsFollowList = MutableStateFlow(followListPrefs.longs),
defaultMusicTracksFollowList = MutableStateFlow(followListPrefs.musicTracks),
defaultMusicPlaylistsFollowList = MutableStateFlow(followListPrefs.musicPlaylists),
defaultPodcastEpisodesFollowList = MutableStateFlow(followListPrefs.podcastEpisodes),
defaultPodcastsFollowList = MutableStateFlow(followListPrefs.podcasts),
defaultArticlesFollowList = MutableStateFlow(followListPrefs.articles),
defaultSoftwareAppsFollowList = MutableStateFlow(followListPrefs.softwareApps),
defaultBadgesFollowList = MutableStateFlow(followListPrefs.badges),
defaultBrowseEmojiSetsFollowList = MutableStateFlow(followListPrefs.browseEmojiSets),
defaultCommunitiesFollowList = MutableStateFlow(followListPrefs.communities),
defaultFollowPacksFollowList = MutableStateFlow(followListPrefs.followPacks),
nwcWallets = MutableStateFlow(nwcWalletsLoaded.await().first),
defaultNwcWalletId = MutableStateFlow(nwcWalletsLoaded.await().second),
clinkDebitWallets = MutableStateFlow(clinkDebitsLoaded.await()),
// Prefer the new unified default; migrate from the legacy NWC default;
// else fall back to the first configured source (NWC before debits).
defaultPaymentSourceId =
MutableStateFlow(
defaultPaymentSourceIdStr
?: nwcWalletsLoaded.await().second
?: clinkDebitsLoaded.await().firstOrNull()?.id,
),
hideDeleteRequestDialog = hideDeleteRequestDialog,
hideBlockAlertDialog = hideBlockAlertDialog,
hideNIP17WarningDialog = hideNIP17WarningDialog,
@@ -683,6 +766,8 @@ object LocalPreferences {
viewedPollResultNoteIds = MutableStateFlow(viewedPollResultNoteIds.await()),
pendingAttestations = MutableStateFlow(pendingAttestations.await()),
backupNipA3PaymentTargets = latestPaymentTargets.await(),
backupCashuWallet = latestCashuWallet.await(),
backupNutzapInfo = latestNutzapInfo.await(),
callsEnabled = MutableStateFlow(callsEnabled),
)
}
@@ -712,6 +797,8 @@ object LocalPreferences {
val discovery: TopFilter,
val polls: TopFilter,
val pictures: TopFilter,
val workouts: TopFilter,
val calendars: TopFilter,
val products: TopFilter,
val shorts: TopFilter,
val publicChats: TopFilter,
@@ -719,20 +806,51 @@ object LocalPreferences {
val nests: TopFilter,
val longs: TopFilter,
val articles: TopFilter,
val musicTracks: TopFilter,
val musicPlaylists: TopFilter,
val podcastEpisodes: TopFilter,
val podcasts: TopFilter,
val softwareApps: TopFilter,
val badges: TopFilter,
val browseEmojiSets: TopFilter,
val communities: TopFilter,
val followPacks: TopFilter,
)
/**
* One-shot migration of the notifications filter.
*
* The notifications "Global" mode was split into a raw [TopFilter.Global]
* (every event that p-tags the user) and a curated [TopFilter.Selected].
* Existing users who had selected the old, curated "Global" keep a value
* that now deserializes to the much-more-permissive raw Global. Move them to
* [TopFilter.Selected] exactly once, then stamp the account so a later,
* deliberate raw-Global choice is never reverted. Accounts created after the
* split are stamped at save time, so they are never touched here.
*/
private fun SharedPreferences.migrateNotificationFilter(current: TopFilter): TopFilter {
if (getBoolean(PrefKeys.NOTIF_GLOBAL_TO_CURATED_MIGRATED, false)) return current
val migrated = if (current is TopFilter.Global) TopFilter.Selected else current
edit {
if (migrated !== current) {
putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, JsonMapper.toJson(migrated))
}
putBoolean(PrefKeys.NOTIF_GLOBAL_TO_CURATED_MIGRATED, true)
}
return migrated
}
private fun SharedPreferences.loadFollowListPrefs(): FollowListPrefs =
FollowListPrefs(
home = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null), TopFilter.AllFollows),
stories = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null), TopFilter.Global),
notification = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null), TopFilter.Global),
notification = migrateNotificationFilter(parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null), TopFilter.Selected)),
discovery = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null), TopFilter.Global),
polls = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, null), TopFilter.Global),
pictures = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, null), TopFilter.Global),
workouts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, null), TopFilter.Global),
calendars = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, null), TopFilter.Global),
products = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, null), TopFilter.AroundMe),
shorts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, null), TopFilter.Global),
publicChats = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PUBLIC_CHATS_FOLLOW_LIST, null), TopFilter.Global),
@@ -740,6 +858,11 @@ object LocalPreferences {
nests = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NESTS_FOLLOW_LIST, null), TopFilter.Global),
longs = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_LONGS_FOLLOW_LIST, null), TopFilter.Global),
articles = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_ARTICLES_FOLLOW_LIST, null), TopFilter.AllFollows),
musicTracks = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_MUSIC_TRACKS_FOLLOW_LIST, null), TopFilter.Global),
musicPlaylists = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_MUSIC_PLAYLISTS_FOLLOW_LIST, null), TopFilter.Global),
podcastEpisodes = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PODCAST_EPISODES_FOLLOW_LIST, null), TopFilter.Global),
podcasts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PODCASTS_FOLLOW_LIST, null), TopFilter.Global),
softwareApps = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_SOFTWARE_APPS_FOLLOW_LIST, null), TopFilter.Global),
badges = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_BADGES_FOLLOW_LIST, null), TopFilter.Mine),
browseEmojiSets = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST, null), TopFilter.Global),
communities = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_COMMUNITIES_FOLLOW_LIST, null), TopFilter.AllFollows),

View File

@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
import com.vitorpamplona.amethyst.commons.model.IAccount
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
@@ -35,7 +36,18 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListD
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListState
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusAction
import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.hashtagLists.HashtagListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.muteList.MuteListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction
import com.vitorpamplona.amethyst.commons.model.nip72Communities.CommunityListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendError
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSender
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapShare
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
@@ -68,20 +80,16 @@ import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayLis
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListState
import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState
import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListState
import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.interestSets.InterestSetsState
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkListsState
import com.vitorpamplona.amethyst.model.nip51Lists.muteList.MuteListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.muteList.MuteListState
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.FollowListsState
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleListsState
import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListState
@@ -93,9 +101,9 @@ import com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays.TrustedRelayLis
import com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays.TrustedRelayListState
import com.vitorpamplona.amethyst.model.nip62Vanish.VanishRequestsState
import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState
import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListDecryptionCache
import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListState
import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState
import com.vitorpamplona.amethyst.model.nip89AppHandlers.AppRecommendationsState
import com.vitorpamplona.amethyst.model.nipA3PaymentTargets.NipA3PaymentTargetsState
import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState
import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState
@@ -107,7 +115,6 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.FeedDecryptionCaches
import com.vitorpamplona.amethyst.model.topNavFeeds.FeedTopNavFilterState
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxLoaderState
import com.vitorpamplona.amethyst.model.trustedAssertions.TrustProviderListDecryptionCache
import com.vitorpamplona.amethyst.model.trustedAssertions.TrustProviderListState
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
@@ -173,6 +180,8 @@ import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris
import com.vitorpamplona.quartz.nip10Notes.content.findURLs
import com.vitorpamplona.quartz.nip10Notes.threadRootIdOrSelf
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
@@ -188,6 +197,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
import com.vitorpamplona.quartz.nip32Labeling.LabelEvent
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
import com.vitorpamplona.quartz.nip37Drafts.DraftEventCache
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
@@ -216,8 +226,8 @@ import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
import com.vitorpamplona.quartz.nip58Badges.definition.tags.ThumbTag
import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent
import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
@@ -280,6 +290,8 @@ import kotlin.coroutines.cancellation.CancellationException
import com.vitorpamplona.quartz.experimental.nip95.header.thumbhash as nip95thumbhash
import com.vitorpamplona.quartz.experimental.profileGallery.thumbhash as galleryThumbhash
private const val ONCHAIN_BACKEND_NOT_CONFIGURED = "Bitcoin chain backend is not configured"
@OptIn(DelicateCoroutinesApi::class)
@Stable
class Account(
@@ -287,6 +299,9 @@ class Account(
override val signer: NostrSigner,
val geolocationFlow: () -> StateFlow<LocationState.LocationResult>,
val nwcFilterAssembler: () -> NWCPaymentFilterAssembler,
val cashuWalletFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler,
val cashuMintDirectoryFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler,
val okHttpClientForMoney: (String) -> okhttp3.OkHttpClient,
val otsResolverBuilder: () -> OtsResolver,
val cache: LocalCache,
val client: INostrClient,
@@ -379,6 +394,7 @@ class Account(
val labeledBookmarkLists = LabeledBookmarkListsState(signer, cache, scope)
val interestSets = InterestSetsState(signer, cache, scope)
val appRecommendations = AppRecommendationsState(signer, cache, scope)
val oldBookmarkState = OldBookmarkListState(signer, cache, scope)
val bookmarkState = BookmarkListState(signer, cache, scope)
val pinState = PinListState(signer, cache, scope)
@@ -401,7 +417,37 @@ class Account(
val dmRelays = DmInboxRelayState(dmRelayList, nip65RelayList, privateStorageRelayList, localRelayList, scope)
val notificationRelays = NotificationInboxRelayState(nip65RelayList, localRelayList, scope)
val trustedRelays = TrustedRelayListsState(nip65RelayList, privateStorageRelayList, localRelayList, dmRelayList, searchRelayList, trustedRelayList, broadcastRelayList, scope)
val cashuWalletState =
com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState(
pubKey = signer.pubKey,
signer = signer,
cache = cache,
scope = scope,
assembler = cashuWalletFilterAssembler(),
outboxRelaysFlow = outboxRelays.flow,
settings = settings,
okHttpClient = okHttpClientForMoney,
)
/**
* NIP-87 cashu mint directory — populated on-demand while the mint
* picker is on screen. ViewModels call open()/close() ref-counted, the
* relay subscription only runs while at least one opener is active.
*/
val cashuMintDirectoryState =
com.vitorpamplona.amethyst.model.nip60Cashu.CashuMintDirectoryState(
cache = cache,
scope = scope,
assembler = cashuMintDirectoryFilterAssembler(),
followsFlow =
kotlinx.coroutines.flow.MutableStateFlow(kind3FollowList.flow.value.authors).also { authorSet ->
scope.launch {
kind3FollowList.flow.collect { authorSet.value = it.authors }
}
},
)
val trustedRelays = TrustedRelayListsState(nip65RelayList, privateStorageRelayList, localRelayList, dmRelayList, searchRelayList, indexerRelayList, proxyRelayList, trustedRelayList, broadcastRelayList, scope)
// Follows Relays
val followOutboxesOrProxy = FollowListOutboxOrProxyRelays(kind3FollowList, blockedRelayList, proxyRelayList, cache, scope)
@@ -486,6 +532,12 @@ class Account(
val livePicturesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultPicturesFollowList)
val livePicturesFollowListsPerRelay = OutboxLoaderState(livePicturesFollowLists, cache, scope).flow
val liveWorkoutsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultWorkoutsFollowList)
val liveWorkoutsFollowListsPerRelay = OutboxLoaderState(liveWorkoutsFollowLists, cache, scope).flow
val liveCalendarsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultCalendarsFollowList)
val liveCalendarsFollowListsPerRelay = OutboxLoaderState(liveCalendarsFollowLists, cache, scope).flow
val liveProductsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultProductsFollowList)
val liveProductsFollowListsPerRelay = OutboxLoaderState(liveProductsFollowLists, cache, scope).flow
@@ -507,6 +559,21 @@ class Account(
val liveArticlesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultArticlesFollowList)
val liveArticlesFollowListsPerRelay = OutboxLoaderState(liveArticlesFollowLists, cache, scope).flow
val liveMusicTracksFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultMusicTracksFollowList)
val liveMusicTracksFollowListsPerRelay = OutboxLoaderState(liveMusicTracksFollowLists, cache, scope).flow
val liveMusicPlaylistsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultMusicPlaylistsFollowList)
val liveMusicPlaylistsFollowListsPerRelay = OutboxLoaderState(liveMusicPlaylistsFollowLists, cache, scope).flow
val livePodcastEpisodesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultPodcastEpisodesFollowList)
val livePodcastEpisodesFollowListsPerRelay = OutboxLoaderState(livePodcastEpisodesFollowLists, cache, scope).flow
val livePodcastsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultPodcastsFollowList)
val livePodcastsFollowListsPerRelay = OutboxLoaderState(livePodcastsFollowLists, cache, scope).flow
val liveSoftwareAppsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultSoftwareAppsFollowList)
val liveSoftwareAppsFollowListsPerRelay = OutboxLoaderState(liveSoftwareAppsFollowLists, cache, scope).flow
val liveBadgesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultBadgesFollowList)
val liveBadgesFollowListsPerRelay = OutboxLoaderState(liveBadgesFollowLists, cache, scope).flow
@@ -595,6 +662,17 @@ class Account(
}
}
suspend fun changeAudioVisualizer(style: VisualizerStyle) {
if (settings.changeAudioVisualizer(style)) {
sendNewAppSpecificData()
}
}
suspend fun toggleChatroomPin(room: ChatroomKey) {
settings.toggleChatroomPin(room)
sendNewAppSpecificData()
}
suspend fun updateZapAmounts(
amountSet: List<Long>,
selectedZapType: LnZapEvent.ZapType,
@@ -669,8 +747,10 @@ class Account(
val eventHint = note.toEventHint<Event>() ?: return null
// For NIP-17 private groups, we don't support tracked mode (too complex)
if (eventHint.event is NIP17Group) return null
// For NIP-17 private groups, we don't support tracked mode (too complex).
// Unsealed rumors (empty sig) must never get a public reaction —
// the e-tag would leak the private rumor id to public relays.
if (eventHint.event is NIP17Group || eventHint.event.sig.isEmpty()) return null
val event = ReactionAction.reactTo(eventHint, reaction, signer)
val relays = computeRelayListToBroadcast(event)
@@ -686,6 +766,48 @@ class Account(
cache.justConsumeMyOwnEvent(event)
}
/**
* NIP-32: tags [note] with [hashtag] by publishing a kind 1985 label event using the
* `#t` tag-association namespace. Fire-and-forget; signs and broadcasts immediately.
*/
suspend fun labelHashtag(
note: Note,
hashtag: String,
) {
createLabelHashtagEvent(note, hashtag)?.let { (event, relays) ->
cache.justConsumeMyOwnEvent(event)
client.publish(event, relays)
}
}
/**
* Builds and signs a NIP-32 hashtag label event for [note] without sending it.
* Returns the signed event and target relays for tracked broadcasting, or null if
* the account can't write or the note has no underlying event.
*/
suspend fun createLabelHashtagEvent(
note: Note,
hashtag: String,
): Pair<Event, Set<NormalizedRelayUrl>>? {
if (!signer.isWriteable()) return null
val eventHint = note.toEventHint<Event>() ?: return null
val template = LabelEvent.buildHashtagLabel(eventHint, hashtag)
val event = signer.sign(template)
val relays = computeRelayListToBroadcast(event)
return event to relays
}
/**
* Consumes a label event into local cache. Called when tracked broadcasting succeeds.
*/
fun consumeLabelEvent(event: Event) {
cache.justConsumeMyOwnEvent(event)
}
suspend fun createZapRequestFor(
event: Event,
pollOption: Int?,
@@ -693,6 +815,8 @@ class Account(
zapType: LnZapEvent.ZapType,
toUser: User?,
additionalRelays: Set<NormalizedRelayUrl>? = null,
amountMillisats: Long? = null,
lnurl: String? = null,
) = LnZapRequestEvent.create(
zappedEvent = event,
relays = nip65RelayList.inboxFlow.value + (additionalRelays ?: emptySet()),
@@ -701,6 +825,8 @@ class Account(
message = message,
zapType = zapType,
toUserPubHex = toUser?.pubkeyHex,
amountMillisats = amountMillisats,
lnurl = lnurl,
)
suspend fun calculateIfNoteWasZappedByAccount(
@@ -722,11 +848,24 @@ class Account(
walletUri: Nip47WalletConnect.Nip47URINorm,
request: Request,
onResponse: (Response?) -> Unit,
) {
): HexKey {
val (event, relay) = nip47SignerState.sendNwcRequestToWallet(walletUri, request, onResponse)
client.publish(event, setOf(relay))
return event.id
}
/**
* Number of spoofed (wrong-author) NIP-47 replies that have arrived for
* the given request id. 0 if the request is unknown or already resolved.
*/
fun nwcSpoofAttempts(requestId: HexKey): Int = LocalCache.paymentTracker.spoofAttemptsFor(requestId)
/**
* Removes a pending NIP-47 request from the tracker. Call this when the
* UI gives up waiting (timeout) so the entry doesn't stick around.
*/
fun cleanupNwcRequest(requestId: HexKey) = LocalCache.paymentTracker.cleanup(requestId)
suspend fun sendZapPaymentRequestFor(
bolt11: String,
zappedNote: Note?,
@@ -740,6 +879,8 @@ class Account(
user: User,
message: String = "",
zapType: LnZapEvent.ZapType,
amountMillisats: Long? = null,
lnurl: String? = null,
): LnZapRequestEvent {
val zapRequest =
LnZapRequestEvent.create(
@@ -748,17 +889,110 @@ class Account(
signer = signer,
message = message,
zapType = zapType,
amountMillisats = amountMillisats,
lnurl = lnurl,
)
cache.justConsumeMyOwnEvent(zapRequest)
return zapRequest
}
private fun onchainBackendNotConfigured() =
OnchainZapSendResult.Failure(
OnchainZapSendStage.LOADING_UTXOS,
OnchainZapSendError.BACKEND_NOT_CONFIGURED,
ONCHAIN_BACKEND_NOT_CONFIGURED,
)
/**
* Send a NIP-BC onchain zap: build a Bitcoin transaction paying the recipient's
* derived Taproot address, sign it, broadcast it, and publish the kind:8333
* zap receipt. Pass [zappedEvent] to attribute the zap to a specific event, or
* leave it null for a profile zap.
*/
suspend fun sendOnchainZap(
recipientPubKey: HexKey,
amountSats: Long,
feeRateSatPerVByte: Double,
comment: String = "",
zappedEvent: EventHintBundle<out Event>? = null,
): OnchainZapSendResult {
val backend =
cache.onchainBackend
?: return onchainBackendNotConfigured()
return OnchainZapSender.send(
backend = backend,
signer = signer,
senderPubKey = signer.pubKey,
recipientPubKey = recipientPubKey,
amountSats = amountSats,
feeRateSatPerVByte = feeRateSatPerVByte,
comment = comment,
zappedEvent = zappedEvent,
) { template -> signAndComputeBroadcast(template) }
}
/**
* Pay an explicit Bitcoin address (e.g. a profile's NIP-A3 `bitcoin`
* payment target) from the NIP-BC Taproot wallet. A plain wallet send —
* no kind:8333 receipt is published. See [OnchainZapSender.sendToAddress].
*/
suspend fun sendOnchainToAddress(
recipientAddress: String,
amountSats: Long,
feeRateSatPerVByte: Double,
): OnchainZapSendResult {
val backend =
cache.onchainBackend
?: return onchainBackendNotConfigured()
return OnchainZapSender.sendToAddress(
backend = backend,
signer = signer,
senderPubKey = signer.pubKey,
recipientAddress = recipientAddress,
amountSats = amountSats,
feeRateSatPerVByte = feeRateSatPerVByte,
)
}
/**
* Send a NIP-BC onchain split zap: a single Bitcoin transaction paying
* each recipient their precomputed share, plus one kind:8333 receipt per
* recipient. See [OnchainZapSender.sendSplit] for failure semantics.
*/
suspend fun sendOnchainZapWithSplits(
recipients: List<OnchainZapShare>,
feeRateSatPerVByte: Double,
comment: String = "",
zappedEvent: EventHintBundle<out Event>? = null,
): OnchainZapSendResult {
val backend =
cache.onchainBackend
?: return onchainBackendNotConfigured()
return OnchainZapSender.sendSplit(
backend = backend,
signer = signer,
senderPubKey = signer.pubKey,
recipients = recipients,
feeRateSatPerVByte = feeRateSatPerVByte,
comment = comment,
zappedEvent = zappedEvent,
) { template -> signAndComputeBroadcast(template) }
}
suspend fun report(
note: Note,
type: ReportType,
content: String = "",
) = sendMyPublicAndPrivateOutbox(ReportAction.report(note, type, content, userProfile(), signer))
) {
if (note.isPrivateRumor()) {
// A kind-1984 e-tagging the rumor would leak the private id onto
// public relays. Report the author instead (p-tag only).
note.author?.let { report(it, type, content) }
} else {
sendMyPublicAndPrivateOutbox(ReportAction.report(note, type, content, userProfile(), signer))
}
}
suspend fun report(
user: User,
@@ -788,6 +1022,28 @@ class Account(
}
}
/**
* Retracts rumor-only events (private reactions/replies) with a
* gift-wrapped NIP-09 deletion delivered to the same participants as
* the [target] rumor they referenced. A public deletion would e-tag
* the private rumor ids onto public relays.
*/
suspend fun deletePrivately(
notes: List<Note>,
target: Note,
) {
if (!isWriteable()) return
val targetEvent = target.event ?: return
val myRumors = notes.filter { it.author == userProfile() }.mapNotNull { it.event }
if (myRumors.isEmpty()) return
val recipients = (targetEvent.taggedUserIds() + targetEvent.pubKey).distinct().minus(signer.pubKey)
broadcastPrivately(
NIP17Factory().createDeletionNIP17(DeletionEvent.build(myRumors), recipients, signer),
)
}
suspend fun delete(
event: Event,
additionalRelays: Set<NormalizedRelayUrl>,
@@ -988,7 +1244,9 @@ class Account(
emptySet()
}
}
if (event is WrappedEvent) {
// Seals, inner DM messages, and unsigned rumors never get broadcast
// relays: they only travel inside gift wraps.
if (event is SealedRumorEvent || event is BaseDMGroupEvent || event.sig.isEmpty()) {
return emptySet()
}
@@ -1111,26 +1369,39 @@ class Account(
suspend fun broadcast(note: Note) {
note.event?.let { noteEvent ->
if (noteEvent is WrappedEvent && noteEvent.host != null) {
// download the event and send it.
noteEvent.host?.let { host ->
client
.fetchFirst(
filters =
note.relays.associateWith { _ ->
listOf(
Filter(
kinds = listOf(host.kind),
tags = mapOf("p" to listOf(pubKey)),
ids = listOf(host.id),
),
)
},
)?.let { downloadedEvent ->
val toRelays = computeRelayListToBroadcast(downloadedEvent)
client.publish(downloadedEvent, toRelays)
}
}
val host = note.rumorHost
if (host != null) {
// Rumors are rebroadcast as their delivering envelope: the
// cached copy is content-stripped, so download it and send it.
// A just-sent note has no relays until its self-wrap echoes
// back — fall back to our own DM inbox relays. Bare seals
// (kind 13) carry no p tag, so that filter is wrap-only.
val relays = note.relays.ifEmpty { dmRelays.flow.value.toList() }
val filter =
if (host.kind == SealedRumorEvent.KIND) {
Filter(
kinds = listOf(host.kind),
ids = listOf(host.id),
)
} else {
Filter(
kinds = listOf(host.kind),
tags = mapOf("p" to listOf(pubKey)),
ids = listOf(host.id),
)
}
client
.fetchFirst(
filters = relays.associateWith { _ -> listOf(filter) },
)?.let { downloadedEvent ->
val toRelays = computeRelayListToBroadcast(downloadedEvent)
client.publish(downloadedEvent, toRelays)
}
} else if (noteEvent.sig.isEmpty()) {
// Rumor with no known wrap: publishing it would disclose the
// private content to relays even though they reject the
// missing signature.
return
} else {
client.publish(noteEvent, computeRelayListToBroadcast(note))
}
@@ -1710,8 +1981,8 @@ class Account(
suspend fun <T : Event> signAnonymouslyAndBroadcast(
template: EventTemplate<T>,
broadcast: List<Event> = emptyList(),
anonymousSigner: NostrSigner = NostrSignerInternal(KeyPair()),
): T {
val anonymousSigner = NostrSignerInternal(KeyPair())
val event = anonymousSigner.sign(template)
cache.justConsumeMyOwnEvent(event)
@@ -2060,6 +2331,18 @@ class Account(
broadcastPrivately(events)
}
/**
* Publishes a kind-1 note privately: signs the template, then gift-wraps
* the rumor to every p-tagged user plus a self-copy and sends each wrap
* to the recipient's DM relays. Used for private replies (the parent's
* author and participants are already p-tagged) and for private posts
* (the Notify list is the audience). Nothing reaches public relays.
*/
suspend fun sendPrivateNote(template: EventTemplate<TextNoteEvent>) {
if (!isWriteable()) return
broadcastPrivately(NIP17Factory().createNoteNIP17(template, signer))
}
override suspend fun sendGiftWraps(wraps: List<GiftWrapEvent>) {
wraps.forEach { wrap ->
val relayList = computeRelayListToBroadcast(wrap)
@@ -2090,6 +2373,28 @@ class Account(
// --- Marmot Group Messaging ---
/**
* Resolve the relay set for a Marmot group. Prefer the relays carried in
* the MLS GroupContext metadata so every member converges on the same
* canonical set; fall back to the account's outbox relays if the group
* has none (e.g. a group joined before MIP-01 metadata existed).
*
* Lives on Account (not AccountViewModel) so that headless callers —
* notifications' BroadcastReceiver, background workers — can resolve
* relays without spinning up a ViewModel.
*/
fun marmotGroupRelays(nostrGroupId: HexKey): Set<NormalizedRelayUrl> {
val groupRelays =
marmotManager
?.groupMetadata(nostrGroupId)
?.relays
?.mapNotNull {
com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
.normalizeOrNull(it)
}?.toSet()
return if (!groupRelays.isNullOrEmpty()) groupRelays else outboxRelays.flow.value
}
/**
* Send a message to a Marmot MLS group.
* Encrypts the inner event and publishes the GroupEvent to group relays.
@@ -3033,8 +3338,10 @@ class Account(
fun isKnown(user: HexKey): Boolean = user in allFollows.flow.value.authors
private fun hasExcessiveHashtags(note: Note): Boolean {
val limit = settings.syncedSettings.security.maxHashtagLimit.value
fun maxHashtagLimit(): Int = settings.syncedSettings.security.maxHashtagLimit.value
fun hasExcessiveHashtags(note: Note): Boolean {
val limit = maxHashtagLimit()
return limit > 0 && note.event?.hasMoreHashtagsThan(limit) == true
}
@@ -3275,6 +3582,14 @@ class Account(
init {
Log.d("AccountRegisterObservers", "Init")
// Start the Cashu wallet state observers AFTER all field initializers
// complete — auto-redeem can fire as soon as start() returns, and it
// calls back into sendLiterallyEverywhere which depends on
// followPlusAllMineWithIndex (initialized after cashuWalletState).
// Doing this in start() rather than in the state's own init { } closes
// the race where a publish would land on a half-built Account.
cashuWalletState.start { event -> sendLiterallyEverywhere(event) }
// Restore Marmot MLS group state on startup
if (marmotManager != null) {
scope.launch(Dispatchers.IO) {
@@ -3332,11 +3647,12 @@ class Account(
if (isNew) {
innerNote.event = innerEvent
}
marmotGroupList.restoreMessage(groupId, innerNote)
marmotGroupList.addMessage(groupId, innerNote)
} catch (e: Exception) {
Log.w(
"Account",
"Failed to restore persisted Marmot message for $groupId: ${e.message}",
"Failed to restore persisted Marmot message for $groupId",
e,
)
}
}

View File

@@ -21,9 +21,14 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatRepository
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListRepository
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSourceResolver
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
@@ -35,7 +40,9 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
@@ -53,6 +60,8 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType
import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.Permission
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
@@ -80,9 +89,23 @@ val DefaultSignerPermissions =
sealed class TopFilter(
val code: String,
) {
interface AddressableTopFilter {
val address: Address
}
@Serializable
object Global : TopFilter(" Global ")
/**
* Notifications-only curated mode: like [Global] it admits authors the
* user doesn't follow, but it also applies per-kind relevance heuristics
* to remove less interesting notes (reactions/reposts that don't target
* the user's own notes, unrelated thread replies, etc.). In Notifications,
* [Global] shows every event that p-tags the user instead.
*/
@Serializable
object Selected : TopFilter(" Selected ")
@Serializable
object AllFollows : TopFilter(" All Follows ")
@@ -100,18 +123,21 @@ sealed class TopFilter(
@Serializable
class PeopleList(
val address: Address,
) : TopFilter(address.toValue())
override val address: Address,
) : TopFilter(address.toValue()),
AddressableTopFilter
@Serializable
class MuteList(
val address: Address,
) : TopFilter(address.toValue())
override val address: Address,
) : TopFilter(address.toValue()),
AddressableTopFilter
@Serializable
class Community(
val address: Address,
) : TopFilter("Community/${address.toValue()}")
override val address: Address,
) : TopFilter("Community/${address.toValue()}"),
AddressableTopFilter
@Serializable
class Hashtag(
@@ -159,10 +185,12 @@ class AccountSettings(
val hideCommunityRulesViolations: MutableStateFlow<Boolean> = MutableStateFlow(false),
val defaultHomeFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val defaultStoriesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultNotificationFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultNotificationFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Selected),
val defaultDiscoveryFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultPollsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultPicturesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultWorkoutsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultCalendarsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultProductsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AroundMe),
val defaultShortsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultPublicChatsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
@@ -170,12 +198,20 @@ class AccountSettings(
val defaultNestsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultLongsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultArticlesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val defaultMusicTracksFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultMusicPlaylistsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultPodcastEpisodesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultPodcastsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultSoftwareAppsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultBadgesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Mine),
val defaultBrowseEmojiSetsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultCommunitiesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val defaultFollowPacksFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val nwcWallets: MutableStateFlow<List<NwcWalletEntryNorm>> = MutableStateFlow(emptyList()),
val defaultNwcWalletId: MutableStateFlow<String?> = MutableStateFlow(null),
val clinkDebitWallets: MutableStateFlow<List<ClinkDebitWalletEntryNorm>> = MutableStateFlow(emptyList()),
// The unified default spend rail (an NWC wallet OR a CLINK debit). Persisted under a
// new key, migrated from the legacy NWC-only `defaultNwcWalletId`.
val defaultPaymentSourceId: MutableStateFlow<String?> = MutableStateFlow(null),
var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false,
var hideNIP17WarningDialog: Boolean = false,
@@ -201,6 +237,21 @@ class AccountSettings(
var backupGeohashList: GeohashListEvent? = null,
var backupEphemeralChatList: EphemeralChatListEvent? = null,
var backupTrustProviderList: TrustProviderListEvent? = null,
var backupCashuWallet: CashuWalletEvent? = null,
var backupNutzapInfo: NutzapInfoEvent? = null,
/**
* NUT-13 deterministic-secret counter map, keyed by keyset id. The
* wallet derives every blind message from `(seed, keysetId, counter)`,
* incrementing the counter every time it consumes one; reusing a
* counter would expose the secret. Persisted here so the counter
* survives app restart even though the wallet's seed is also stored
* in kind:17375 (which would otherwise be the only persistence).
*
* Empty map = no NUT-13 usage yet (e.g. wallet created before this
* feature shipped). Per-keyset; the same counter under different
* keysets is fine because the derivation includes the keyset id.
*/
var cashuKeysetCounters: MutableMap<String, Long> = mutableMapOf(),
val lastReadPerRoute: MutableStateFlow<Map<String, MutableStateFlow<Long>>> = MutableStateFlow(mapOf()),
val hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
val dismissedPollNoteIds: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
@@ -293,14 +344,26 @@ class AccountSettings(
return false
}
fun defaultNwcWallet(): NwcWalletEntryNorm? {
val id = defaultNwcWalletId.value
val wallets = nwcWallets.value
return if (id != null) {
wallets.firstOrNull { it.id == id }
} else {
wallets.firstOrNull()
fun changeAudioVisualizer(style: VisualizerStyle): Boolean {
if (syncedSettings.media.audioVisualizer.value != style) {
syncedSettings.media.audioVisualizer.tryEmit(style)
saveAccountSettings()
return true
}
return false
}
/** The selected default spend rail across both NWC wallets and CLINK debits. */
fun defaultPaymentSource(): PaymentSource? = PaymentSourceResolver.resolveDefault(nwcWallets.value, clinkDebitWallets.value, defaultPaymentSourceId.value)
/**
* The NWC wallet to use for NWC-only flows (balance display, mint top-up). Resolves
* the unified default when it points at an NWC wallet, otherwise falls back to the
* first NWC wallet so those flows keep working even when a debit is the zap default.
*/
fun defaultNwcWallet(): NwcWalletEntryNorm? {
val wallets = nwcWallets.value
return wallets.firstOrNull { it.id == defaultPaymentSourceId.value } ?: wallets.firstOrNull()
}
fun defaultZapPaymentRequest(): Nip47WalletConnect.Nip47URINorm? = defaultNwcWallet()?.uri
@@ -311,8 +374,10 @@ class AccountSettings(
nwcWallets.tryEmit(nwcWallets.value.toMutableList().apply { set(existing, wallet) })
} else {
nwcWallets.tryEmit(nwcWallets.value + wallet)
if (nwcWallets.value.size == 1) {
defaultNwcWalletId.tryEmit(wallet.id)
// First configured source of any kind becomes the default; adding more never
// silently changes an existing default.
if (defaultPaymentSourceId.value == null) {
defaultPaymentSourceId.tryEmit(wallet.id)
}
}
saveAccountSettings()
@@ -322,16 +387,68 @@ class AccountSettings(
fun removeNwcWallet(walletId: String): Boolean {
val wallets = nwcWallets.value.filter { it.id != walletId }
nwcWallets.tryEmit(wallets)
if (defaultNwcWalletId.value == walletId) {
defaultNwcWalletId.tryEmit(wallets.firstOrNull()?.id)
reassignDefaultIfRemoved(walletId)
saveAccountSettings()
return true
}
fun addClinkDebitWallet(wallet: ClinkDebitWalletEntryNorm): Boolean {
val existing = clinkDebitWallets.value.indexOfFirst { it.id == wallet.id }
if (existing >= 0) {
clinkDebitWallets.tryEmit(clinkDebitWallets.value.toMutableList().apply { set(existing, wallet) })
} else {
clinkDebitWallets.tryEmit(clinkDebitWallets.value + wallet)
if (defaultPaymentSourceId.value == null) {
defaultPaymentSourceId.tryEmit(wallet.id)
}
}
saveAccountSettings()
return true
}
fun setDefaultNwcWallet(walletId: String): Boolean {
if (defaultNwcWalletId.value != walletId && nwcWallets.value.any { it.id == walletId }) {
defaultNwcWalletId.tryEmit(walletId)
fun removeClinkDebitWallet(walletId: String): Boolean {
clinkDebitWallets.tryEmit(clinkDebitWallets.value.filter { it.id != walletId })
reassignDefaultIfRemoved(walletId)
saveAccountSettings()
return true
}
fun renameClinkDebitWallet(
walletId: String,
newName: String,
): Boolean {
val wallets = clinkDebitWallets.value.toMutableList()
val index = wallets.indexOfFirst { it.id == walletId }
if (index >= 0) {
wallets[index] = wallets[index].copy(name = newName)
clinkDebitWallets.tryEmit(wallets)
saveAccountSettings()
return true
}
return false
}
/** When the removed source was the default, fall back to the first remaining source. */
private fun reassignDefaultIfRemoved(walletId: String) {
if (defaultPaymentSourceId.value == walletId) {
defaultPaymentSourceId.tryEmit(PaymentSourceResolver.resolveDefault(nwcWallets.value, clinkDebitWallets.value, null)?.id)
}
}
/** Resets the default to the first remaining source if it no longer points at anything. */
private fun reassignDefaultIfMissing() {
val id = defaultPaymentSourceId.value ?: return
val exists = nwcWallets.value.any { it.id == id } || clinkDebitWallets.value.any { it.id == id }
if (!exists) {
defaultPaymentSourceId.tryEmit(PaymentSourceResolver.resolveDefault(nwcWallets.value, clinkDebitWallets.value, null)?.id)
}
}
/** Selects the unified default across both NWC wallets and CLINK debits. */
fun setDefaultPaymentSource(sourceId: String): Boolean {
val exists = nwcWallets.value.any { it.id == sourceId } || clinkDebitWallets.value.any { it.id == sourceId }
if (defaultPaymentSourceId.value != sourceId && exists) {
defaultPaymentSourceId.tryEmit(sourceId)
saveAccountSettings()
return true
}
@@ -353,26 +470,11 @@ class AccountSettings(
return false
}
fun moveNwcWallet(
fromIndex: Int,
toIndex: Int,
): Boolean {
val wallets = nwcWallets.value.toMutableList()
if (fromIndex in wallets.indices && toIndex in wallets.indices && fromIndex != toIndex) {
val item = wallets.removeAt(fromIndex)
wallets.add(toIndex, item)
nwcWallets.tryEmit(wallets)
saveAccountSettings()
return true
}
return false
}
fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URINorm?): Boolean {
if (newServer == null) {
if (nwcWallets.value.isNotEmpty()) {
nwcWallets.tryEmit(emptyList())
defaultNwcWalletId.tryEmit(null)
reassignDefaultIfMissing()
saveAccountSettings()
return true
}
@@ -529,6 +631,28 @@ class AccountSettings(
}
}
fun changeDefaultWorkoutsFollowList(name: FeedDefinition) {
changeDefaultWorkoutsFollowList(name.code)
}
fun changeDefaultWorkoutsFollowList(name: TopFilter) {
if (defaultWorkoutsFollowList.value != name) {
defaultWorkoutsFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultCalendarsFollowList(name: FeedDefinition) {
changeDefaultCalendarsFollowList(name.code)
}
fun changeDefaultCalendarsFollowList(name: TopFilter) {
if (defaultCalendarsFollowList.value != name) {
defaultCalendarsFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultProductsFollowList(name: FeedDefinition) {
changeDefaultProductsFollowList(name.code)
}
@@ -606,6 +730,61 @@ class AccountSettings(
}
}
fun changeDefaultMusicTracksFollowList(name: FeedDefinition) {
changeDefaultMusicTracksFollowList(name.code)
}
fun changeDefaultMusicTracksFollowList(name: TopFilter) {
if (defaultMusicTracksFollowList.value != name) {
defaultMusicTracksFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultMusicPlaylistsFollowList(name: FeedDefinition) {
changeDefaultMusicPlaylistsFollowList(name.code)
}
fun changeDefaultMusicPlaylistsFollowList(name: TopFilter) {
if (defaultMusicPlaylistsFollowList.value != name) {
defaultMusicPlaylistsFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultPodcastEpisodesFollowList(name: FeedDefinition) {
changeDefaultPodcastEpisodesFollowList(name.code)
}
fun changeDefaultPodcastEpisodesFollowList(name: TopFilter) {
if (defaultPodcastEpisodesFollowList.value != name) {
defaultPodcastEpisodesFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultPodcastsFollowList(name: FeedDefinition) {
changeDefaultPodcastsFollowList(name.code)
}
fun changeDefaultPodcastsFollowList(name: TopFilter) {
if (defaultPodcastsFollowList.value != name) {
defaultPodcastsFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultSoftwareAppsFollowList(name: FeedDefinition) {
changeDefaultSoftwareAppsFollowList(name.code)
}
fun changeDefaultSoftwareAppsFollowList(name: TopFilter) {
if (defaultSoftwareAppsFollowList.value != name) {
defaultSoftwareAppsFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultBadgesFollowList(name: FeedDefinition) {
changeDefaultBadgesFollowList(name.code)
}
@@ -752,6 +931,96 @@ class AccountSettings(
}
}
fun updateCashuWallet(newWallet: CashuWalletEvent?) {
if (newWallet == null) return
// Replaceable: keep the latest by id (id changes on each re-sign).
if (backupCashuWallet?.id != newWallet.id) {
backupCashuWallet = newWallet
saveAccountSettings()
}
}
fun updateNutzapInfo(newNutzapInfo: NutzapInfoEvent?) {
if (newNutzapInfo == null || newNutzapInfo.tags.isEmpty()) return
// A mints-less kind:10019 is the "stop receiving nutzaps" tombstone
// (an empty replacement carrying only an `alt` tag). Don't restore it
// on next launch — backing it up would undo clearNutzapInfo() once the
// empty event round-trips back through LocalCache.
if (newNutzapInfo.mints().isEmpty()) {
clearNutzapInfo()
return
}
if (backupNutzapInfo?.id != newNutzapInfo.id) {
backupNutzapInfo = newNutzapInfo
saveAccountSettings()
}
}
/**
* Drop the cached kind:17375 so a relaunch doesn't restore a wallet the
* user just deleted. Called when the wallet event is NIP-09 deleted —
* without this the [backupCashuWallet] would be re-consumed into
* LocalCache on next launch and resurrect the deleted wallet.
*/
fun clearCashuWallet() {
if (backupCashuWallet != null) {
backupCashuWallet = null
saveAccountSettings()
}
}
/** Drop the cached kind:10019. Mirror of [clearCashuWallet] for the nutzap info. */
fun clearNutzapInfo() {
if (backupNutzapInfo != null) {
backupNutzapInfo = null
saveAccountSettings()
}
}
/**
* NUT-13 keyset counters live in [CashuPreferences], a dedicated
* SharedPreferences file with synchronous (`commit = true`) writes.
* AccountSettings goes through a 1-second debounce on its own save
* path; the cashu counter cannot tolerate that window because the
* mint persists signed (keyset, blind_message) pairs the moment it
* sees them, so any local lag → "outputs already signed" on retry.
* See [CashuPreferences] for the full rationale.
*/
private val cashuPrefs: CashuPreferences by lazy {
CashuPreferences.forAccount(keyPair.pubKey.toNpub())
}
/**
* Reserve [count] consecutive NUT-13 counters for [keysetId],
* returning the first one. Caller derives `(secret, r)` from
* `(seed, keysetId, i)` for `i in [returned .. returned+count-1]`.
* Persisted synchronously before returning — see [CashuPreferences].
*
* One-time migration: when this keyset has a non-zero value in the
* legacy [cashuKeysetCounters] map (from a build that persisted
* counters inside AccountSettings) and the dedicated store is
* still at zero, the legacy value is copied over before we reserve
* so an upgrade doesn't reset the counter.
*/
fun reserveCashuCounters(
keysetId: String,
count: Int,
): Long {
migrateLegacyCashuCounter(keysetId)
return cashuPrefs.reserveCounters(keysetId, count)
}
/** Inspect the next counter for [keysetId] without consuming any. */
fun peekCashuCounter(keysetId: String): Long {
migrateLegacyCashuCounter(keysetId)
return cashuPrefs.peekCounter(keysetId)
}
private fun migrateLegacyCashuCounter(keysetId: String) {
val legacy = cashuKeysetCounters[keysetId] ?: return
cashuPrefs.seedCounterIfMissing(keysetId, legacy)
}
fun updateNIPA3PaymentTargets(newNIPA3PaymentTargets: PaymentTargetsEvent?) {
if (newNIPA3PaymentTargets == null || newNIPA3PaymentTargets.tags.isEmpty()) return
@@ -984,6 +1253,17 @@ class AccountSettings(
}
}
// ---
// pinned chatrooms
// ---
fun toggleChatroomPin(room: ChatroomKey) {
syncedSettings.chats.pinnedChatrooms.update {
if (room in it) it - room else it + room
}
saveAccountSettings()
}
// ---
// viewed poll results
// ---

View File

@@ -21,7 +21,9 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -35,11 +37,11 @@ class AccountSyncedSettings(
val reactions =
AccountReactionPreferences(
MutableStateFlow(internalSettings.reactions.reactionChoices.toImmutableList()),
MutableStateFlow(internalSettings.reactions.reactionRowItems.toImmutableList()),
MutableStateFlow(mergeWithDefaultReactionRowItems(internalSettings.reactions.reactionRowItems).toImmutableList()),
)
val zaps =
AccountZapPreferences(
MutableStateFlow(internalSettings.zaps.zapAmountChoices.toImmutableList()),
MutableStateFlow(mergeZapAmounts(internalSettings.zaps).toImmutableList()),
MutableStateFlow(internalSettings.zaps.defaultZapType),
)
val languages =
@@ -62,6 +64,14 @@ class AccountSyncedSettings(
AccountVideoPlayerPreferences(
MutableStateFlow(mergeWithDefaultVideoPlayerButtons(internalSettings.videoPlayer.buttonItems).toImmutableList()),
)
val media =
AccountMediaPreferences(
MutableStateFlow(VisualizerStyle.fromName(internalSettings.media.audioVisualizer)),
)
val chats =
AccountChatPreferences(
MutableStateFlow(internalSettings.chats.toChatroomKeys()),
)
fun toInternal(): AccountSyncedSettingsInternal =
AccountSyncedSettingsInternal(
@@ -69,6 +79,10 @@ class AccountSyncedSettings(
zaps =
AccountZapPreferencesInternal(
zaps.zapAmountChoices.value,
// Write the on-chain-eligible subset into the legacy field so
// older clients still get sensible on-chain presets. Unioning
// it back on load is idempotent (subset ⊆ full list).
zaps.zapAmountChoices.value.filter { it >= MIN_ONCHAIN_ZAP_SATS },
zaps.defaultZapType.value,
),
languages =
@@ -88,6 +102,8 @@ class AccountSyncedSettings(
security.addClientTag.value,
),
videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value),
media = AccountMediaPreferencesInternal(media.audioVisualizer.value.name),
chats = AccountChatPreferencesInternal(chats.pinnedChatrooms.value.map { it.users.sorted() }),
)
fun updateFrom(syncedSettingsInternal: AccountSyncedSettingsInternal) {
@@ -96,12 +112,13 @@ class AccountSyncedSettings(
reactions.reactionChoices.tryEmit(newReactionChoices)
}
val newReactionRowItems = syncedSettingsInternal.reactions.reactionRowItems.toImmutableList()
val newReactionRowItems =
mergeWithDefaultReactionRowItems(syncedSettingsInternal.reactions.reactionRowItems).toImmutableList()
if (!equalImmutableLists(reactions.reactionRowItems.value, newReactionRowItems)) {
reactions.reactionRowItems.tryEmit(newReactionRowItems)
}
val newZapChoices = syncedSettingsInternal.zaps.zapAmountChoices.toImmutableList()
val newZapChoices = mergeZapAmounts(syncedSettingsInternal.zaps).toImmutableList()
if (!equalImmutableLists(zaps.zapAmountChoices.value, newZapChoices)) {
zaps.zapAmountChoices.tryEmit(newZapChoices)
}
@@ -155,6 +172,16 @@ class AccountSyncedSettings(
if (!equalImmutableLists(videoPlayer.buttonItems.value, newVideoPlayerButtonItems)) {
videoPlayer.buttonItems.tryEmit(newVideoPlayerButtonItems)
}
val newAudioVisualizer = VisualizerStyle.fromName(syncedSettingsInternal.media.audioVisualizer)
if (media.audioVisualizer.value != newAudioVisualizer) {
media.audioVisualizer.tryEmit(newAudioVisualizer)
}
val newPinnedChatrooms = syncedSettingsInternal.chats.toChatroomKeys()
if (chats.pinnedChatrooms.value != newPinnedChatrooms) {
chats.pinnedChatrooms.tryEmit(newPinnedChatrooms)
}
}
fun dontTranslateFromFilteredBySpokenLanguages(): Set<String> = languages.dontTranslateFrom.value - getLanguagesSpokenByUser()
@@ -177,6 +204,15 @@ class AccountZapPreferences(
val defaultZapType: MutableStateFlow<LnZapEvent.ZapType>,
)
/**
* Union the (historically separate) zap and on-chain preset lists into the
* single sorted set the app now uses everywhere. Preserves amounts customized
* on either side and amounts synced from an older client that still writes the
* legacy `onchainZapAmountChoices`. Idempotent: [AccountSyncedSettings.toInternal]
* re-derives the on-chain field as a subset of this list.
*/
internal fun mergeZapAmounts(zaps: AccountZapPreferencesInternal): List<Long> = (zaps.zapAmountChoices + zaps.onchainZapAmountChoices).distinct()
@Stable
class AccountLanguagePreferences(
var dontTranslateFrom: MutableStateFlow<Set<String>>,
@@ -239,13 +275,25 @@ class AccountLanguagePreferences(
): String? = languagePreferences.value["$source,$target"]
}
@Stable
class AccountMediaPreferences(
val audioVisualizer: MutableStateFlow<VisualizerStyle>,
)
@Stable
class AccountChatPreferences(
val pinnedChatrooms: MutableStateFlow<Set<ChatroomKey>>,
)
internal fun AccountChatPreferencesInternal.toChatroomKeys(): Set<ChatroomKey> = pinnedRooms.mapTo(mutableSetOf()) { ChatroomKey(it.toSet()) }
@Stable
class AccountSecurityPreferences(
val showSensitiveContent: MutableStateFlow<Boolean?> = MutableStateFlow(null),
val warnAboutPostsWithReports: MutableStateFlow<Boolean> = MutableStateFlow(true),
val reportWarningThreshold: MutableStateFlow<Int> = MutableStateFlow(DefaultReportWarningThreshold),
var filterSpamFromStrangers: MutableStateFlow<Boolean> = MutableStateFlow(true),
val maxHashtagLimit: MutableStateFlow<Int> = MutableStateFlow(5),
val maxHashtagLimit: MutableStateFlow<Int> = MutableStateFlow(8),
var sendKind0EventsToLocalRelay: MutableStateFlow<Boolean> = MutableStateFlow(false),
val addClientTag: MutableStateFlow<Boolean> = MutableStateFlow(true),
) {

View File

@@ -37,9 +37,27 @@ val DefaultReactions =
"\uD83D\uDE31",
)
val DefaultZapAmounts = listOf(100L, 500L, 1000L)
val DefaultZapAmounts = listOf(21L, 50L, 100L)
val DefaultOnchainZapAmounts = listOf(5_000L)
val DefaultReportWarningThreshold = 5
/**
* Product floor for the on-chain rail — stricter than the protocol-level
* dust threshold (OnchainZapBuilder.DUST_THRESHOLD_SATS). The unified zap
* picker offers the on-chain logo only for amounts at or above this, and the
* on-chain send dialog draws its presets from the single zap-amount list
* filtered by it. Single source of truth for everywhere that gates on-chain
* by amount.
*/
const val MIN_ONCHAIN_ZAP_SATS = 1_000L
/**
* Default on-chain quick-pick amount, used when the user's unified zap-amount
* list contains nothing at or above [MIN_ONCHAIN_ZAP_SATS]. Matches the legacy
* dedicated on-chain default so the send dialog always offers one preset.
*/
const val DEFAULT_ONCHAIN_ZAP_SATS = 5_000L
@Serializable
enum class ReactionRowAction {
Reply,
@@ -67,6 +85,16 @@ val DefaultReactionRowItems =
ReactionRowItem(ReactionRowAction.Share, showCounter = false),
)
// Existing accounts have a reaction-row list serialized before some actions
// existed (e.g. Pay was added later). Append any default items the saved list
// is missing so new actions surface without forcing a settings reset — the
// user's existing order/toggles for actions they already have are preserved.
fun mergeWithDefaultReactionRowItems(saved: List<ReactionRowItem>): List<ReactionRowItem> {
val knownActions = saved.mapTo(mutableSetOf()) { it.action }
val missing = DefaultReactionRowItems.filter { it.action !in knownActions }
return if (missing.isEmpty()) saved else saved + missing
}
@Serializable
enum class VideoPlayerAction {
Fullscreen,
@@ -127,6 +155,8 @@ class AccountSyncedSettingsInternal(
val languages: AccountLanguagePreferencesInternal = AccountLanguagePreferencesInternal(),
val security: AccountSecurityPreferencesInternal = AccountSecurityPreferencesInternal(),
val videoPlayer: AccountVideoPlayerPreferencesInternal = AccountVideoPlayerPreferencesInternal(),
val media: AccountMediaPreferencesInternal = AccountMediaPreferencesInternal(),
val chats: AccountChatPreferencesInternal = AccountChatPreferencesInternal(),
)
@Serializable
@@ -143,6 +173,12 @@ class AccountReactionPreferencesInternal(
@Serializable
class AccountZapPreferencesInternal(
var zapAmountChoices: List<Long> = DefaultZapAmounts,
// Legacy field: the on-chain rail no longer has its own editable preset
// list — amounts live in [zapAmountChoices] and on-chain just filters by
// [MIN_ONCHAIN_ZAP_SATS]. Kept (de)serialized so older clients still sync
// and so the on-chain-eligible subset round-trips back for them; on load it
// is unioned into [zapAmountChoices]. See AccountSyncedSettings.
var onchainZapAmountChoices: List<Long> = DefaultOnchainZapAmounts,
val defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC,
)
@@ -159,7 +195,21 @@ class AccountSecurityPreferencesInternal(
var warnAboutPostsWithReports: Boolean = true,
val reportWarningThreshold: Int = DefaultReportWarningThreshold,
var filterSpamFromStrangers: Boolean = true,
val maxHashtagLimit: Int = 5,
val maxHashtagLimit: Int = 8,
var sendKind0EventsToLocalRelay: Boolean = false,
var addClientTag: Boolean = true,
)
@Serializable
class AccountMediaPreferencesInternal(
// Stored as VisualizerStyle.name; defaults to CLASSIC (the app's classic audio animation).
var audioVisualizer: String = "CLASSIC",
)
@Serializable
class AccountChatPreferencesInternal(
// Rooms pinned to the top of the chat list. Each room is its member
// pubkeys (hex) sorted ascending, so the serialized form is deterministic
// regardless of set iteration order.
var pinnedRooms: List<List<String>> = emptyList(),
)

View File

@@ -52,6 +52,8 @@ data class UiSettings(
val showProfileAppRecommendations: Boolean = true,
val showProfileZapReceivedFeed: Boolean = true,
val showProfileFollowersFeed: Boolean = true,
val dontShowOnchainPublicWarning: Boolean = false,
val suggestWorkoutsFromHealthConnect: BooleanType = BooleanType.ALWAYS,
)
enum class ThemeType(

View File

@@ -52,6 +52,8 @@ class UiSettingsFlow(
val showProfileAppRecommendations: MutableStateFlow<Boolean> = MutableStateFlow(true),
val showProfileZapReceivedFeed: MutableStateFlow<Boolean> = MutableStateFlow(true),
val showProfileFollowersFeed: MutableStateFlow<Boolean> = MutableStateFlow(true),
val dontShowOnchainPublicWarning: MutableStateFlow<Boolean> = MutableStateFlow(false),
val suggestWorkoutsFromHealthConnect: MutableStateFlow<BooleanType> = MutableStateFlow(BooleanType.ALWAYS),
) {
val listOfFlows: List<Flow<Any?>> =
listOf<Flow<Any?>>(
@@ -78,6 +80,8 @@ class UiSettingsFlow(
showProfileAppRecommendations,
showProfileZapReceivedFeed,
showProfileFollowersFeed,
dontShowOnchainPublicWarning,
suggestWorkoutsFromHealthConnect,
)
// emits at every change in any of the propertyes.
@@ -108,6 +112,8 @@ class UiSettingsFlow(
flows[20] as Boolean,
flows[21] as Boolean,
flows[22] as Boolean,
flows[23] as Boolean,
flows[24] as BooleanType,
)
}
@@ -136,6 +142,8 @@ class UiSettingsFlow(
showProfileAppRecommendations.value,
showProfileZapReceivedFeed.value,
showProfileFollowersFeed.value,
dontShowOnchainPublicWarning.value,
suggestWorkoutsFromHealthConnect.value,
)
fun update(torSettings: UiSettings): Boolean {
@@ -233,6 +241,14 @@ class UiSettingsFlow(
showProfileFollowersFeed.tryEmit(torSettings.showProfileFollowersFeed)
any = true
}
if (dontShowOnchainPublicWarning.value != torSettings.dontShowOnchainPublicWarning) {
dontShowOnchainPublicWarning.tryEmit(torSettings.dontShowOnchainPublicWarning)
any = true
}
if (suggestWorkoutsFromHealthConnect.value != torSettings.suggestWorkoutsFromHealthConnect) {
suggestWorkoutsFromHealthConnect.tryEmit(torSettings.suggestWorkoutsFromHealthConnect)
any = true
}
return any
}
@@ -249,6 +265,12 @@ class UiSettingsFlow(
}
}
fun dontShowOnchainPublicWarning() {
if (!dontShowOnchainPublicWarning.value) {
dontShowOnchainPublicWarning.tryEmit(true)
}
}
companion object {
fun build(uiSettings: UiSettings): UiSettingsFlow =
UiSettingsFlow(
@@ -275,6 +297,8 @@ class UiSettingsFlow(
MutableStateFlow(uiSettings.showProfileAppRecommendations),
MutableStateFlow(uiSettings.showProfileZapReceivedFeed),
MutableStateFlow(uiSettings.showProfileFollowersFeed),
MutableStateFlow(uiSettings.dontShowOnchainPublicWarning),
MutableStateFlow(uiSettings.suggestWorkoutsFromHealthConnect),
)
}
}

View File

@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.model
import android.util.LruCache
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.service.previews.UrlPreview
import com.vitorpamplona.amethyst.commons.preview.UrlPreview
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
import okhttp3.OkHttpClient

View File

@@ -22,3 +22,5 @@ package com.vitorpamplona.amethyst.model
// Re-export from commons for backwards compatibility
typealias User = com.vitorpamplona.amethyst.commons.model.User
typealias UserContext = com.vitorpamplona.amethyst.commons.model.UserContext

View File

@@ -53,6 +53,9 @@ import java.io.File
class AccountCacheState(
val geolocationFlow: () -> StateFlow<LocationState.LocationResult>,
val nwcFilterAssembler: () -> NWCPaymentFilterAssembler,
val cashuWalletFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler,
val cashuMintDirectoryFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler,
val okHttpClientForMoney: (String) -> okhttp3.OkHttpClient,
val contentResolverFn: () -> ContentResolver,
val otsResolverBuilder: () -> OtsResolver,
val cache: LocalCache,
@@ -191,6 +194,9 @@ class AccountCacheState(
signer = signerWithClientTag,
geolocationFlow = geolocationFlow,
nwcFilterAssembler = nwcFilterAssembler,
cashuWalletFilterAssembler = cashuWalletFilterAssembler,
cashuMintDirectoryFilterAssembler = cashuMintDirectoryFilterAssembler,
okHttpClientForMoney = okHttpClientForMoney,
otsResolverBuilder = otsResolverBuilder,
cache = cache,
client = client,

View File

@@ -78,6 +78,10 @@ class AndroidMarmotMessageStore(
writeMutex.withLock {
try {
val existing = readAll(nostrGroupId).toMutableList()
if (innerEventJson in existing) {
Log.d(TAG) { "appendMessage($nostrGroupId): duplicate entry skipped" }
return@withLock
}
existing.add(innerEventJson)
writeAll(nostrGroupId, existing)
Log.d(TAG) {

View File

@@ -68,6 +68,7 @@ class UserMetadataState(
nip05: String? = null,
lnAddress: String? = null,
lnURL: String? = null,
clinkOffer: String? = null,
): MetadataEvent {
val latest = getUserMetadataEvent()
@@ -85,6 +86,7 @@ class UserMetadataState(
nip05 = nip05,
lnAddress = lnAddress,
lnURL = lnURL,
clinkOffer = clinkOffer,
)
} else {
MetadataEvent.createNew(
@@ -98,6 +100,7 @@ class UserMetadataState(
nip05 = nip05,
lnAddress = lnAddress,
lnURL = lnURL,
clinkOffer = clinkOffer,
)
}

View File

@@ -0,0 +1,64 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.nip03Timestamp
import com.vitorpamplona.quartz.nip03Timestamp.okhttp.OkHttpBitcoinExplorer
/**
* Picks the Bitcoin block-explorer (Esplora) base URL.
*
* This is the single source of truth shared by two features that both talk to
* the same Esplora API:
* - OpenTimestamps verification ([TorAwareOkHttpOtsResolverBuilder]).
* - NIP-BC onchain zaps (the `EsploraBackend` wired in `AppModules`).
*
* So a user who configures a custom explorer in the OTS settings gets it for
* onchain zaps too, and both honour the same Tor preference: a configured
* [OtsSettings] custom URL always wins; otherwise mempool.space is used when
* Tor is active (it is reachable over Tor) and blockstream.info when it is not.
*/
object BitcoinExplorerEndpoint {
/**
* @param customExplorerUrl A user-configured explorer base URL, or null/blank
* for the automatic Tor-aware default. Typically
* `OtsSettings.normalizedUrl()`.
* @param usingTor Whether Bitcoin/"money" traffic is currently routed over Tor.
*/
fun resolve(
customExplorerUrl: String?,
usingTor: Boolean,
): String =
customExplorerUrl?.takeIf { it.isNotBlank() }
?: if (usingTor) {
OkHttpBitcoinExplorer.MEMPOOL_API_URL
} else {
OkHttpBitcoinExplorer.BLOCKSTREAM_API_URL
}
/**
* Same as [resolve] but with any trailing slash stripped, for callers that
* join request paths with a leading `/` (e.g. `"$base/tx/$txid"`).
*/
fun resolveNormalized(
customExplorerUrl: String?,
usingTor: Boolean,
): String = resolve(customExplorerUrl, usingTor).trimEnd('/')
}

View File

@@ -33,13 +33,7 @@ class TorAwareOkHttpOtsResolverBuilder(
val cache: OtsBlockHeightCache,
val customExplorerUrl: () -> String? = { null },
) : OtsResolverBuilder {
fun getAPI(usingTor: Boolean): String =
customExplorerUrl()
?: if (usingTor) {
OkHttpBitcoinExplorer.MEMPOOL_API_URL
} else {
OkHttpBitcoinExplorer.BLOCKSTREAM_API_URL
}
fun getAPI(usingTor: Boolean): String = BitcoinExplorerEndpoint.resolve(customExplorerUrl(), usingTor)
override fun build(): OtsResolver =
OtsResolver(

View File

@@ -45,46 +45,51 @@ class Nip11Retriever(
relay: NormalizedRelayUrl,
onInfo: (Nip11RelayInformation) -> Unit,
onError: (NormalizedRelayUrl, ErrorCode, String?) -> Unit,
) {
) = withContext(Dispatchers.IO) {
val url = relay.toHttp()
try {
val request: Request =
val request =
try {
Request
.Builder()
.header("Accept", "application/nostr+json")
.url(url)
.build()
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("RelayInfoFail", "Invalid URL ${relay.url}", e)
onError(relay, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message)
return@withContext
}
try {
val client = okHttpClient(relay)
client.newCall(request).executeAsync().use { response ->
withContext(Dispatchers.IO) {
val body = response.body.string()
try {
if (response.isSuccessful) {
if (body.startsWith("{")) {
onInfo(Nip11RelayInformation.fromJson(body))
} else {
onError(relay, ErrorCode.FAIL_TO_PARSE_RESULT, body)
}
val body = response.body.string()
try {
if (response.isSuccessful) {
if (body.startsWith("{")) {
onInfo(Nip11RelayInformation.fromJson(body))
} else {
onError(relay, ErrorCode.FAIL_WITH_HTTP_STATUS, response.code.toString())
onError(relay, ErrorCode.FAIL_TO_PARSE_RESULT, body)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(
"RelayInfoFail",
"Resulting Message from Relay ${relay.url} in not parseable: $body",
e,
)
onError(relay, ErrorCode.FAIL_TO_PARSE_RESULT, e.message)
} else {
onError(relay, ErrorCode.FAIL_WITH_HTTP_STATUS, response.code.toString())
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(
"RelayInfoFail",
"Resulting Message from Relay ${relay.url} in not parseable: $body",
e,
)
onError(relay, ErrorCode.FAIL_TO_PARSE_RESULT, e.message)
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("RelayInfoFail", "Invalid URL ${relay.url}", e)
onError(relay, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message)
Log.e("RelayInfoFail", "Failed to fetch NIP-11 from ${relay.url}", e)
onError(relay, ErrorCode.FAIL_TO_REACH_SERVER, e.message ?: e::class.simpleName)
}
}
}

View File

@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model.nip30CustomEmojis
import com.vitorpamplona.amethyst.commons.model.anyNotNullEvent
import com.vitorpamplona.amethyst.commons.model.eventIdSet
import com.vitorpamplona.amethyst.commons.model.events
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.OwnedEmojiPack
import com.vitorpamplona.amethyst.commons.model.updateFlow
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote

View File

@@ -65,12 +65,10 @@ class NwcSignerState(
* Flow of the default wallet's NWC URI, derived from multi-wallet settings.
*/
val defaultWalletUri: StateFlow<Nip47WalletConnect.Nip47URINorm?> =
combine(settings.nwcWallets, settings.defaultNwcWalletId) { wallets, defaultId ->
if (defaultId != null) {
wallets.firstOrNull { it.id == defaultId }?.uri
} else {
wallets.firstOrNull()?.uri
}
combine(settings.nwcWallets, settings.defaultPaymentSourceId) { wallets, defaultId ->
// Use the NWC wallet the unified default points at; otherwise fall back to the
// first NWC wallet so NWC zap routing is unchanged for NWC-only users.
(wallets.firstOrNull { it.id == defaultId } ?: wallets.firstOrNull())?.uri
}.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, settings.defaultZapPaymentRequest())
@@ -144,7 +142,6 @@ class NwcSignerState(
val filter =
NWCPaymentQueryState(
fromServiceHex = walletService.pubKeyHex,
toUserHex = event.pubKey,
replyingToHex = event.id,
relay = walletService.relayUri,
@@ -152,16 +149,26 @@ class NwcSignerState(
val assembler = nwcFilterAssembler()
assembler.subscribe(filter)
// Synchronous flush so the REQ frame is queued on the WebSocket before
// the EVENT is published. Without this, the bundler may delay REQ up
// to 500ms, and the wallet service's ephemeral kind 23195 reply can
// be missed.
assembler.subscribeAndFlush(filter)
scope.launch(Dispatchers.IO) {
delay(60000)
assembler.unsubscribe(filter)
}
// Safety net: drop the filter after 60s if the wallet never replies.
// The happy path (response arrives) cancels this job and unsubscribes
// through assembler.unsubscribeSoon, which debounces.
val timeoutJob =
scope.launch(Dispatchers.IO) {
delay(60000)
assembler.unsubscribe(filter)
}
val responseCache = NostrWalletConnectResponseCache(walletSigner)
cache.consume(event, null, true, walletService.relayUri) {
timeoutJob.cancel()
onResponse(responseCache.decryptResponse(it))
assembler.unsubscribeSoon(filter)
}
return Pair(event, walletService.relayUri)
@@ -181,7 +188,6 @@ class NwcSignerState(
val filter =
NWCPaymentQueryState(
fromServiceHex = walletService.pubKeyHex,
toUserHex = event.pubKey,
replyingToHex = event.id,
relay = walletService.relayUri,
@@ -189,15 +195,23 @@ class NwcSignerState(
val assembler = nwcFilterAssembler()
assembler.subscribe(filter)
// Synchronous flush so the REQ frame is queued before the EVENT.
// See sendNwcRequestToWallet above for the rationale.
assembler.subscribeAndFlush(filter)
scope.launch(Dispatchers.IO) {
delay(60000) // waits 1 minute to complete payment.
assembler.unsubscribe(filter)
}
// Safety net: drop the filter after 60s if the wallet never replies.
// The happy path (response arrives) cancels this job and instead
// hands off to assembler.unsubscribeSoon, which debounces.
val timeoutJob =
scope.launch(Dispatchers.IO) {
delay(60000) // waits 1 minute to complete payment.
assembler.unsubscribe(filter)
}
cache.consume(event, zappedNote, true, walletService.relayUri) {
timeoutJob.cancel()
onResponse(decryptResponse(it))
assembler.unsubscribeSoon(filter)
}
return Pair(event, walletService.relayUri)

View File

@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists.blockPeopleList
import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleListDecryptionCache
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleListDecryptionCache
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag

View File

@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists
import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListDecryptionCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache

View File

@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists
import com.vitorpamplona.amethyst.commons.model.nip51Lists.hashtagLists.HashtagListDecryptionCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note

View File

@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model.nip51Lists.interestSets
import com.vitorpamplona.amethyst.commons.model.anyNotNullEvent
import com.vitorpamplona.amethyst.commons.model.eventIdSet
import com.vitorpamplona.amethyst.commons.model.events
import com.vitorpamplona.amethyst.commons.model.nip51Lists.interestSets.InterestSet
import com.vitorpamplona.amethyst.commons.model.updateFlow
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote

View File

@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists
import com.vitorpamplona.amethyst.commons.model.anyNotNullEvent
import com.vitorpamplona.amethyst.commons.model.eventIdSet
import com.vitorpamplona.amethyst.commons.model.events
import com.vitorpamplona.amethyst.commons.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
import com.vitorpamplona.amethyst.commons.model.updateFlow
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote

View File

@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists.muteList
import com.vitorpamplona.amethyst.commons.model.nip51Lists.muteList.MuteListDecryptionCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note

View File

@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model.nip51Lists.peopleList
import com.vitorpamplona.amethyst.commons.model.anyNotNullEvent
import com.vitorpamplona.amethyst.commons.model.eventIdSet
import com.vitorpamplona.amethyst.commons.model.events
import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.updateFlow
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote

View File

@@ -0,0 +1,324 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.nip60Cashu
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryQueryState
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent
import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
/**
* One mint as it appears in the NIP-87 directory.
*
* The same mint URL can be announced more than once (different operators,
* different keysets, different metadata). Picker UI typically shows the most
* recent announcement and aggregates recommendation counts across all
* announcements for the same URL.
*/
@Immutable
data class CashuMintDirectoryEntry(
val url: String,
/** Most recent announcement we've seen for this URL, if any. */
val announcement: CashuMintEvent?,
/** Total kind:38000 recommendations targeting this mint's announcement. */
val recommendationCount: Int,
/** Recommendations specifically by [followedPubkeys] — surfaced first in the picker. */
val followsRecommendationCount: Int,
/**
* Pubkeys of followed users who have recommended this mint, capped at
* [MAX_FOLLOWS_RECOMMENDER_AVATARS] for cheap rendering. Length equals
* `min(followsRecommendationCount, MAX_FOLLOWS_RECOMMENDER_AVATARS)`.
* The remaining `followsRecommendationCount - size` followers are
* implied by the count, not enumerated here.
*/
val followsRecommenderPubkeys: List<HexKey>,
) {
companion object {
const val MAX_FOLLOWS_RECOMMENDER_AVATARS: Int = 6
}
}
/**
* Account-scoped index of cashu mint announcements + recommendations.
*
* Reactive: backfills from [LocalCache.notes] on first observer + listens to
* [LocalCache.live.newEventBundles] for incremental updates. The relay-side
* subscription is started/stopped via [open] / [close] so the directory only
* costs network bandwidth while a picker is on screen.
*
* Recommendation counts are derived per refresh from the latest snapshot —
* duplicate kind:38000 events from the same (pubkey, mint) pair are
* deduplicated so a single recommender can't inflate counts by re-posting.
*/
class CashuMintDirectoryState(
private val cache: LocalCache,
private val scope: CoroutineScope,
private val assembler: CashuMintDirectoryFilterAssembler,
private val followsFlow: StateFlow<Set<HexKey>>,
) {
/** Mint announcements keyed by the announcement event id. */
private val announcements = ConcurrentHashMap<HexKey, CashuMintEvent>()
/** Recommendations keyed by event id — value is the parsed event. */
private val recommendations = ConcurrentHashMap<HexKey, MintRecommendationEvent>()
private val _entries = MutableStateFlow<List<CashuMintDirectoryEntry>>(emptyList())
/**
* Mint directory sorted by:
* 1. Number of recommendations from users we follow (descending)
* 2. Total recommendation count (descending)
* 3. Mint URL (ascending — stable tiebreaker)
*/
val entries: StateFlow<List<CashuMintDirectoryEntry>> =
combine(_entries, followsFlow) { all, follows -> rank(all, follows) }
.flowOn(Dispatchers.Default)
.stateIn(scope, SharingStarted.Eagerly, emptyList())
val size: StateFlow<Int> =
entries.map { it.size }.stateIn(scope, SharingStarted.Eagerly, 0)
// ============================================================
// Subscription lifecycle (open while a picker is on screen)
// ============================================================
private var currentSubscription: CashuMintDirectoryQueryState? = null
private val openers = ConcurrentHashMap.newKeySet<Any>()
private val liveJobLock = Any()
private var liveJob: Job? = null
/**
* Begin observing new mint events. Idempotent — multiple openers can
* share one subscription, and the relay subscription is dropped when the
* last opener calls [close]. [opener] is any unique object the caller
* holds (typically the ViewModel instance) so we can ref-count cleanly.
*/
fun open(
opener: Any,
relays: Set<NormalizedRelayUrl>,
) {
if (openers.isEmpty()) {
// First opener — start the live indexer + relay subscription.
startLiveIndex()
backfillFromCacheAsync()
}
openers.add(opener)
syncSubscription(relays)
}
fun close(opener: Any) {
openers.remove(opener)
if (openers.isEmpty()) {
currentSubscription?.let { runCatching { assembler.unsubscribe(it) } }
currentSubscription = null
synchronized(liveJobLock) {
liveJob?.cancel()
liveJob = null
}
}
}
private fun syncSubscription(relays: Set<NormalizedRelayUrl>) {
val previous = currentSubscription
if (relays.isEmpty()) {
previous?.let { runCatching { assembler.unsubscribe(it) } }
currentSubscription = null
return
}
if (previous != null && previous.relays == relays) return
previous?.let { runCatching { assembler.unsubscribe(it) } }
val next = CashuMintDirectoryQueryState(relays)
currentSubscription = next
assembler.subscribe(next)
}
private fun startLiveIndex() {
synchronized(liveJobLock) {
if (liveJob != null) return
liveJob =
scope.launch(Dispatchers.Default) {
cache.live.newEventBundles.collect { notes ->
val touched =
notes.mapNotNull { note ->
when (val e = note.event) {
is CashuMintEvent -> announcements.put(e.id, e).let { true }
is MintRecommendationEvent -> {
if (e.isCashuRecommendation()) {
recommendations.put(e.id, e).let { true }
} else {
false
}
}
else -> false
}
}
if (touched.isNotEmpty()) rebuildEntries()
}
}
}
}
private fun backfillFromCacheAsync() {
scope.launch(Dispatchers.Default) {
cache.notes.forEach { _, note ->
when (val e = note.event) {
is CashuMintEvent -> announcements[e.id] = e
is MintRecommendationEvent -> if (e.isCashuRecommendation()) recommendations[e.id] = e
else -> Unit
}
}
rebuildEntries()
}
}
// ============================================================
// Indexing
// ============================================================
private fun rebuildEntries() {
// 1. Group announcements by mint URL — most recent wins for display.
val byUrl: MutableMap<String, CashuMintEvent> = HashMap()
announcements.values.forEach { e ->
val url = e.mintUrl() ?: return@forEach
val existing = byUrl[url]
if (existing == null || e.createdAt > existing.createdAt) byUrl[url] = e
}
// 2. Build a per-URL set of announcement event ids so a recommendation
// aTag → that mint's bucket. We use the d-tag (mint pubkey) per
// NIP-87 to match recommendations to announcements — but many
// clients also store the mint URL in the `u` tag of the
// recommendation, so we accept both.
val urlByDTag: MutableMap<String, String> = HashMap()
announcements.values.forEach { e ->
val d = e.dTag()
val url = e.mintUrl()
if (d != null && url != null) urlByDTag[d] = url
}
// 3. Count recommendations per URL, deduplicating by (recommender, mint URL).
val perUrlAll: MutableMap<String, MutableSet<HexKey>> = HashMap()
val perUrlFollows: MutableMap<String, MutableSet<HexKey>> = HashMap()
val followSet = currentFollowSnapshot()
recommendations.values.forEach { rec ->
// Mints recommended via `u` tag(s) — directly carry the URL.
val urlsFromU = rec.mintUrls()
// Mints recommended via `a` tag(s) — look up the URL by mint d-tag.
val urlsFromA =
rec
.mintEventAddresses()
.mapNotNull { aTag ->
// a-tag is "kind:pubkey:dTag" — we want the dTag.
aTag.split(":").getOrNull(2)?.let(urlByDTag::get)
}
(urlsFromU + urlsFromA).distinct().forEach { url ->
perUrlAll.getOrPut(url) { mutableSetOf() }.add(rec.pubKey)
if (rec.pubKey in followSet) {
perUrlFollows.getOrPut(url) { mutableSetOf() }.add(rec.pubKey)
}
}
}
// 4. Build the final entries list — every URL with either an
// announcement or a recommendation gets surfaced.
val allUrls = byUrl.keys + perUrlAll.keys
_entries.value =
allUrls.map { url ->
val followsRecs = perUrlFollows[url].orEmpty()
CashuMintDirectoryEntry(
url = url,
announcement = byUrl[url],
recommendationCount = perUrlAll[url]?.size ?: 0,
followsRecommendationCount = followsRecs.size,
// Cap the per-row pubkey list — the row only renders
// a small avatar gallery; the full count is preserved
// in followsRecommendationCount for the "+N more" suffix.
followsRecommenderPubkeys =
followsRecs.take(CashuMintDirectoryEntry.MAX_FOLLOWS_RECOMMENDER_AVATARS).toList(),
)
}
}
private fun currentFollowSnapshot(): Set<HexKey> = followsFlow.value
private fun rank(
all: List<CashuMintDirectoryEntry>,
@Suppress("UNUSED_PARAMETER") follows: Set<HexKey>,
): List<CashuMintDirectoryEntry> =
all.sortedWith(
compareByDescending<CashuMintDirectoryEntry> { it.followsRecommendationCount }
.thenByDescending { it.recommendationCount }
.thenBy { it.url },
)
// ============================================================
// Convenience accessors
// ============================================================
/** Find a directory entry by exact URL match (used to surface metadata for an already-known mint). */
fun lookup(url: String): CashuMintDirectoryEntry? = entries.value.firstOrNull { it.url == url }
/**
* Substring search ranked by the same comparator as [entries] —
* follows recommendations first, then total recommendations, then
* URL. Empty [query] returns the top mints overall, which is what
* the autocomplete dropdown shows before the user starts typing.
*
* Backs both the add-cashu-wallet autocomplete and the wallet
* settings "Add recommendation" flow; merging them onto one row
* composable lets both surfaces show the recommender avatar
* gallery and the +N suffix without each rolling their own join.
*/
fun search(
query: String,
limit: Int = 6,
): List<CashuMintDirectoryEntry> {
val needle = query.trim().lowercase()
val all = entries.value
val filtered =
if (needle.isEmpty()) {
all
} else {
all.filter {
it.url.lowercase().contains(needle) ||
(it.announcement?.content ?: "").lowercase().contains(needle) ||
(it.announcement?.dTag() ?: "").lowercase().contains(needle)
}
}
return filtered.take(limit)
}
}

View File

@@ -0,0 +1,128 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.nip60Cashu
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import com.vitorpamplona.amethyst.Amethyst
/**
* Per-account Cashu state that needs durable, synchronous persistence —
* separate from [com.vitorpamplona.amethyst.model.AccountSettings] which
* batches writes through a 1-second debounced StateFlow.
*
* # Why a separate store
*
* The NUT-13 keyset counter is the critical bit. Every mint / swap /
* melt reserves counter slots, derives deterministic blinded outputs at
* those slots, sends them to the mint, and the mint signs them. The
* mint persists which (keyset, blind_message) pairs it has ever signed;
* a second request to sign the same blind_message returns HTTP 400
* "outputs already signed". So once the wallet hands a counter to the
* mint, the local counter advance MUST survive a crash — otherwise the
* next reservation pulls the same slot and the mint rejects it.
*
* The default settings save path debounces writes by 1000 ms, which is
* exactly the race window between "we asked the mint to sign" and "the
* mint replied". A crash inside that window (OOM, signer dialog dismiss,
* unexpected process death) loses the counter advance and makes the
* wallet unusable. This store writes via `commit = true` so each
* reservation is durable before the function returns.
*
* # Layout
*
* One SharedPreferences file per account, named
* `cashu_prefs_<npub>.xml`. Keys are flat:
* - `counter_<keysetId>` → Long, the next free NUT-13 counter
*
* Plain (non-encrypted) prefs because keyset counters aren't secret —
* they're not the seed, they don't carry value, and a leak would only
* tell an attacker how many proofs the wallet has minted at each
* keyset (a privacy signal at most).
*
* # Migration
*
* Older builds stored counters inside `AccountSettings.cashuKeysetCounters`.
* On first read of a given keyset, callers should pre-seed the store
* from the legacy map (one-time copy) so an upgrade doesn't reset the
* counter to zero. See `AccountSettings.migrateCashuCountersTo` for
* the helper.
*/
class CashuPreferences(
private val prefs: SharedPreferences,
) {
/** Inspect the next free counter for [keysetId] without advancing it. */
@Synchronized
fun peekCounter(keysetId: String): Long = prefs.getLong(counterKey(keysetId), 0L)
/**
* Atomically reserve [count] consecutive NUT-13 counters for
* [keysetId] and return the first reserved index. The write is
* forced to disk with `commit = true` BEFORE returning — see the
* class header for why this isn't optional.
*/
@Synchronized
@SuppressLint("ApplySharedPref")
fun reserveCounters(
keysetId: String,
count: Int,
): Long {
require(count > 0) { "Counter reservation must be positive" }
val current = peekCounter(keysetId)
val next = current + count.toLong()
prefs.edit(commit = true) { putLong(counterKey(keysetId), next) }
return current
}
/**
* Seed [keysetId]'s counter from a legacy value found in
* [AccountSettings.cashuKeysetCounters]. No-op when the store
* already has a value at or above [legacyValue] — never moves the
* counter backwards. Called once at wallet load to carry forward
* pre-migration state.
*/
@Synchronized
@SuppressLint("ApplySharedPref")
fun seedCounterIfMissing(
keysetId: String,
legacyValue: Long,
) {
if (legacyValue <= 0L) return
val current = peekCounter(keysetId)
if (current >= legacyValue) return
prefs.edit(commit = true) { putLong(counterKey(keysetId), legacyValue) }
}
companion object {
private const val FILE_PREFIX = "cashu_prefs_"
private fun counterKey(keysetId: String) = "counter_$keysetId"
/** Per-account instance. [npub] keys the on-disk file so each account is isolated. */
fun forAccount(npub: String): CashuPreferences {
val context = Amethyst.instance.appContext
val prefs = context.getSharedPreferences("$FILE_PREFIX$npub", Context.MODE_PRIVATE)
return CashuPreferences(prefs)
}
}
}

View File

@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.model.nip72Communities
import com.vitorpamplona.amethyst.commons.model.nip72Communities.CommunityListDecryptionCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache

Some files were not shown because too many files have changed in this diff Show More