The URL detector stripped a single trailing punctuation char unconditionally,
which dropped the closing ")" from legitimate URLs such as
https://en.wikipedia.org/wiki/Bitcoin_(disambiguation).
Make the trailing strip balance-aware: a trailing ")", "}" or "]" is kept when
the URL contains its matching opener (balanced), and only stripped when it is
unbalanced wrapping/sentence punctuation (e.g. "(see example.com)" or
"http://test.com)"). Commas without surrounding spaces were already kept inside
paths; this also adds "]" to the begin/end punctuation sets so an unbalanced
bracket is handled symmetrically with parens and braces.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AzZzVcMcuzjSdhD3xqCE87
The Blossom auth-header encoding (`Nostr <base64-event>`), the `/upload`
endpoint path, and the `X-Reason` failure header were each re-derived in
both the commons JVM `BlossomClient`/`BlossomAuth` and the Android
`BlossomUploader`, using two different Base64 APIs. Move these
protocol-level facts into the quartz `nipB7Blossom` package, where the
rest of the Blossom protocol lives:
- `BlossomAuthorizationEvent.toAuthorizationHeader()` / `rawToken()` +
`AUTH_HEADER_SCHEME`, mirroring NIP-98's
`HTTPAuthorizationEvent.toAuthToken()` that Blossom auth reuses.
- new `BlossomServerUrl` with `upload()` / `blob()` endpoint builders and
the `REASON_HEADER` constant.
Both transports now call these helpers instead of hand-building strings.
No behavior change for upload (existing desktop BlossomClientTest still
green); the Android delete URL now omits the trailing dot when no file
extension is known, matching BUD-02's `DELETE /<sha256>`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJgwV4Y99brVa97v7p3jJb
Wires the quartz NIP-5A resolver end-to-end so it can be exercised against
real manifests (interop / agents), without building the security-sensitive
WebView shell yet.
- commons BlossomClient: add download(url) — a Blossom GET returning raw bytes
(null on non-2xx; connection failures propagate so callers try the next
server). Does not verify the hash; that is the resolver's job.
- cli NsiteCommands: `amy nsite fetch AUTHOR [--d ID] [--path P] [--server …]
[--relay …] [--out FILE] [--timeout SECS] [--max-inline-bytes N]`. Fetches
the manifest (kind 15128 root, or 35128 named with --d) from relays, then
resolves one path through StaticSiteResolver, downloading from the manifest's
Blossom servers (plus any --server fallbacks) and accepting only the first
blob whose sha256 matches the manifest pin. Emits the verified path's bytes
(inlined for small text, or written to --out) with hash/server/content-type,
or a structured not_found / path_not_found / unresolvable error.
Thin-assembly only: all resolution + verification stays in quartz, the byte
fetch in commons. Smoke-tested offline: bad-args, help, and a dead-relay run
that resolves cleanly to not_found in both text and --json modes.
Also converts the StaticSitePathLookup file-overview KDoc to a plain block
comment to satisfy ktlint no-consecutive-comments.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdAJMbnHJfiMY7UcS99T6C
Follow-up to the V4Encoder move. Quartz could encode a cashuB string but
could not parse cashuA/cashuB back, and the parsing lived in amethyst even
though it is pure NUT-00 wire-format protocol. Worse, commons.RichTextParser
already detects cashuA/cashuB words while the parser sat up in the app, so
Desktop (its own rich-text viewer) could not parse a received token at all.
Consolidate the legacy out-of-band redeem stack onto quartz:
- new quartz CashuTokenB64Parser parses cashuA (standard-Base64 JSON, rewritten
off Jackson onto kotlinx.serialization to satisfy quartz's no-Jackson rule)
and cashuB (Base64URL CBOR, reusing the V4Token models), returning quartz
types. It is the inverse of V4Encoder.
- move the CashuToken container model from commons to quartz, switching its
proofs from the duplicate commons Proof (field C, amount Int) onto the
canonical quartz CashuProof (field c, amount Long). The duplicate Proof
type is deleted.
- delete amethyst V3Parser/V3Token/V4Parser; CashuParser/CachedCashuParser
stay as thin amethyst adapters (off-main-thread guard + GenericLoadable +
LruCache) over the quartz parser.
- this removes the manual Proof -> CashuProof conversion shims that
MeltProcessor and CashuWalletViewModel previously carried.
Tests: full cashuA + cashuB vector coverage moves to quartz commonTest
(CashuTokenB64ParserTest, runs on JVM) plus an encode/parse round-trip; the
superseded amethyst CashuV4ParserTest is removed and CashuBTest stays as the
adapter integration test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UMNKix4qEfiAPP9s2a4gB
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>
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
PublicChatChannel.relays() was `info.relays?.toSet() ?: super.relays()`.
An empty (non-null) declared-relay list — `emptyList()?.toSet()` — yields an
empty set and short-circuits the elvis, so the channel reported zero relays
instead of falling back to the relays it was actually observed on. Both the
message-send path and the broadcast path (computeRelaysForChannels /
wantsBroadcastRelays) read relays(), so the message was published to nowhere
while a manual broadcast still reached the user's personal relays — matching
the reported symptom.
Treat an empty declared list like "no declared relays" via ifEmpty, so it
falls back to observed relays. Adds PublicChatChannelRelayTest covering the
declared-relay round-trip, message->channel resolution, and the empty-list
fallback regression.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYtHYEob2THu74inTxZxCh
A device log showed the foreground feeds (and ~150 relays) staying
connected for a full ~60s after the app was paused, then collapsing to
the 11-relay floor all at once:
11:26:02 HomeOutboxEventsEoseManager — keys=2, relays=344 (paused here)
… 60s of silence …
11:27:02 grace-start(HomeFilterAssembler) — lifecycle=CREATED
11:27:02 updatePool done — flowConnected=9, inPool=11
The lifecycle-aware subscription detected ON_STOP by collecting
lifecycle.currentStateFlow on Dispatchers.Default. Backgrounded, that
collector wasn't resumed until the next NostrClient keep-alive tick
(KEEP_ALIVE_INTERVAL_MS = 60s), so teardown — and the relay disconnects
it drives — lagged a minute behind the actual pause.
Switch detection to a main-thread LifecycleEventObserver, which fires
synchronously during onStop. Only the grace delay still runs on the
background scope (so it isn't gated by the stopped UI frame clock).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae
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
Phase 2 of relay-latency-health: hook the Phase 1 tracker into the existing
RelayHealthStore lifecycle and persist its rings via the existing
PreferencesRelayHealthPersistence so samples survive restarts.
commonMain:
- RelayLatencyProvider: small interface so the store can drive a tracker
that lives in jvmAndroidMain (the impl needs ConcurrentHashMap).
- RelayHealthSnapshot: optional `latencySamples` field. Default empty;
older saved snapshots load cleanly without it.
- RelayHealthStore now takes optional `latencyTracker` / `nip11Provider`
/ `authProvider` constructor params:
* exposes `latencySnapshots: StateFlow<ImmutableMap<Url, RelayLatencySnapshot>>`
— MutableStateFlow updated inside the existing 60 s reclassify tick
(one timer, not two — the tracker is scope-less and gets
`sweep(now)` called from reclassify).
* exposes `slowRelays: StateFlow<ImmutableMap<Url, SlowReason>>` —
derived via `_latencySnapshots.map(classifySlowRelays).stateIn(
scope, SharingStarted.Eagerly, persistentMapOf())`. The classifier
reads `nip11Provider()` / `authProvider` live, so paid/auth-only
relays only join the cohort once their auth completes.
* `init {}` restores persisted samples into the tracker; the
existing `schedulePersist()` now bundles `tracker.samplesForPersistence()`
into the saved snapshot via a new private `snapshotForPersist()`
helper. The same helper feeds the final flush in `close()`.
No new dispatcher / scope / timer — everything piggybacks on the
existing infra (single SupervisorJob, 5 s persist debounce, 60 s tick).
jvmAndroidMain:
- RelayLatencyTracker now implements RelayLatencyProvider. Overrides drop
the inline `System.currentTimeMillis()` default; callers from commonMain
pass `TimeUtils.nowMillis()` explicitly.
desktopApp (jvmMain):
- PreferencesRelayHealthPersistence persists per-relay latency rings in
separate keys (`lat_<account-prefix>_<sha256(url)[..16]>`) so the 8 KB
Preferences ceiling on the main `health_<account>` key isn't blown by a
user with many relays. Each key holds one relay's four metric rings as
`wss://relay.url\tok:csv|eose:csv|fr:csv|ping:csv`. On save, keys for
relays no longer in the snapshot get removed so the prefs node doesn't
grow unboundedly across account churn.
Notes:
- Persistence still uses the existing 5 s debounce path. The deepened plan
called for 30 s for `lat_*` keys; deferring that micro-optimization
until we observe write thrash in practice. The cap on writes is
one-rewrite-per-5s-of-activity which matches what the existing snooze
persistence already does, so latency adds zero new flush events.
- Tracker is wired only when a `RelayLatencyProvider` is passed to the
store. Existing tests / Android continue to compile and run with
latency unconfigured — `latencySnapshots` stays empty and `slowRelays`
derives to empty. Desktop wiring lands in Phase 3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 of the desktop relay-latency-health feature: add a rolling-window
latency tracker that decorates the quartz RelayConnectionListener, plus a
pure classifier that flags relays whose per-metric p50 exceeds 2× the cohort
median. No store integration or UI yet — those come in follow-up commits.
commons commonMain (CLI-safe, no Compose runtime, no JVM-only deps):
- LatencyMetric: OK_ACK / EOSE / FIRST_RESULT / PING
- MetricSample: @Immutable (p50Ms, count)
- RelayLatencySnapshot: @Immutable, backed by ImmutableMap so strong
skipping engages when unchanged rows are re-emitted
- SlowReason: @Immutable (metric, relayP50, cohortP50, multiplier)
- HealthReason sealed interface: Unresponsive(gap) | Slow(SlowReason)
- classifySlowRelays(): pure. Honors NIP-11 auth_required /
payment_required (paid/auth-only relays are excluded from both cohort
and target until auth completes — otherwise they'd be perpetually
flagged while CLOSED'ing anonymous queries).
commons jvmAndroid (ConcurrentHashMap is JVM-only):
- LatencyRingBuffer: fixed-capacity (default 50) IntArray ring,
synchronized push, snapshotMedian / snapshotSamples / restore.
- RelayLatencyTracker: pending-eventId / pending-subId / firstResultSeen
maps + per-(relay, metric) ring buffers. Handles every pairing rule
the deepened plan called out:
* onSent EventCmd → record eventId timestamp
* onSent ReqCmd → record subId timestamp; clear firstResultSeen
* onSent CloseCmd → drop pending subId (no sample) — prevents
ComposeSubscriptionManager's sub-id reuse from pairing late
events with a new REQ
* success=false → no-op (websocket buffer was full)
* OkMessage → pair by eventId, push OK_ACK
* EventMessage → first-only, push FIRST_RESULT
* EoseMessage → pair by subId, push EOSE
* ClosedMessage → drop pending (fast negative response, not a
latency signal — was previously recording 300s TTL samples for
any auth-required relay)
* onConnected → push PING
* onDisconnected → drop all pending (no TTL samples)
* sweep(now) → TTL-expire pending entries (60s OK / 300s REQ),
record TTL value as the sample
AUTH retries: the second onSent overwrites the timestamp, so samples
reflect the retry leg — matches the user's mental model of "speed of
the actual publish". Pending maps are size-capped at 256 entries per
relay as a safety net against adversarial relays. Tracker owns no
CoroutineScope — RelayHealthStore drives sweep + snapshot from its
existing 60s reclassify tick (Phase 2).
- RelayLatencyListener: thin RelayConnectionListener decorator,
installInto / uninstallFrom paralleling RelayHealthListener.
Tests:
- LatencyRingBufferTest (7): wrap, median odd/even, restore from larger
or smaller arrays, chronological snapshotSamples.
- RelayLatencyTrackerTest (13): OK pairing, EOSE + FIRST_RESULT pairing,
success=false ignore, CloseCmd drops pending, ClosedMessage drops
pending, AUTH retry overwrites timestamp, disconnect drops all,
sweep TTL semantics (OK vs REQ), FIRST_RESULT only sampled when not
yet seen, ping, per-relay isolation, 256-entry cap, restore
round-trip.
- ClassifySlowRelaysTest (9): empty, Tor short-circuit, cohort < 2,
2× flag, count-below-min excludes from cohort, NIP-11 auth_required
excludes / includes once auth complete, payment_required excludes,
worst-metric-multiplier wins when multiple flag, exact-2× does not
flag (strict greater-than).
Note: a pre-existing RelayHealthStoreCloseTest case on the base branch
(fix/relay-health-threading-and-sleep-resume) hangs in advanceUntilIdle.
Not related to this commit; new tests pass cleanly with a tighter test
filter. Will revisit when integrating Phase 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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
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
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>
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>
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.
UrlParser.parseValidUrls filtered every detected URL through
isValidTopLevelDomain(), which requires the TLD's first character to be
an ASCII letter. IPv6 literal hosts are bracketed (e.g. [2001:db8::1])
and have no dotted TLD, so the whole bracketed host became the candidate
"TLD", starting with '[' and failing the check. As a result, valid
IPv6 URLs like http://[302:68d0:f0d5:b88d::bdb]/<hash> were dropped and
rendered as plain text instead of links.
The UrlDetector already validates the bracketed address as syntactically
correct IPv6, so accept bracketed hosts directly in isValidTopLevelDomain.
The "Updates all dependencies" commit (ece719445) bumped Kotlin to 2.4.0,
which removed the `DisableCacheInKotlinVersion.2_3_21` enum value. Rather
than bumping the guard, the whole `disableUiKitPrebuiltCache()` workaround
was deleted — which reintroduced the iOS test link failure on CI
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
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
- 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
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.
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
- 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
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
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
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
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
Merge-readiness audit follow-ups:
- Account.report(note): reporting a private rumor now reports the AUTHOR
(p-tag only) instead of publishing a kind-1984 that e-tags the private
rumor id onto public relays (the one confirmed leak)
- Defense-in-depth guards at the model layer so the invariant no longer
relies on UI gating alone: RepostAction.repost returns null / throws
for empty-sig targets (covers Account.boost, createBoostEvent, and the
desktop call path), ReactionAction.reactTo (simple overload) throws,
and Account.broadcast no-ops for unsigned non-wrapped events — without
the guard it would disclose the rumor JSON to relays even though they
reject the signature
- Hide remaining actions that can't work on private rumors, per review:
share buttons (action row, both note menus) and all bookmark/playlist/
emoji-list rows (their lists reference an id other devices can't
resolve; public lists would also leak it)
- ZapCustomDialog: remember(accountViewModel, baseNote) so the
preselected zap type can't go stale on lazy-list slot reuse
Broadcast of the gift wrap itself (like DMs do via WrappedEvent.host)
needs host tracking for non-WrappedEvent rumor kinds in quartz — left
as a follow-up; the broadcast row stays hidden for kind-1 rumors.
https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo
Gift-wrapped un-react:
- NIP17Factory.createDeletionNIP17 wraps a NIP-09 deletion to explicit
recipients + self-copy, so the retracted rumor id never reaches public
relays; DeletionIndex keys by (id, pubkey) and rumor pubkeys are forced
to the seal's, so wrapped deletions are authenticated on receive
- Account.deletePrivately sends the wrapped deletion to the target
rumor's participants (author + tagged users)
- AccountViewModel.reactToOrDelete now partitions reactions: public ones
get a public NIP-09, rumor reactions get a wrapped one — un-react on
private notes and NIP-17 chats works instead of no-op
Force-private zaps on private rumors:
- AccountViewModel.zap forces ZapType.PRIVATE for empty-sig targets
(NONZAP kept: no receipt at all is even more private)
- ZapCustomDialog only offers Private/None for private targets
- Zap button re-enabled on private rumors; nutzap (public kind 9321) is
refused with an explanatory error and the onchain rail is hidden, as
both would e-tag the rumor id publicly
- Note: the LN provider's public 9735 receipt still carries the e-tag —
the private zap type protects sender identity and comment, not the
zapped id itself
Also verified: ReactionEvent consume counts empty-sig rumors (wasVerified
path) so wrapped reactions tally correctly.
https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo
Phase 2 of the private-notes plan: the short-note composer gains a
private (lock) toggle that gift-wraps the kind-1 to its p-tagged users
plus a self-copy instead of publishing it.
- NIP17Factory.createNoteNIP17: wraps a TextNoteEvent template to its
taggedUserIds + the sender (only the unsigned rumor form travels)
- Account.sendPrivateNote: signs, wraps, and routes each wrap to the
recipient's DM relays via the existing broadcastPrivately path
- ShortNotePostViewModel: wantsPrivateNote/privateNoteLocked state;
forced ON and locked when replying to an unsealed rumor (and when
reloading a drafted private reply); private wins over anonymous and
scheduled modes so a locked reply can never fall through to a public
publish path
- ShortNotePostScreen: lock toggle in the bottom action row; mutually
exclusive with polls; schedule and anonymous hidden while private
- ReactionsRow: reply re-enabled on private rumors now that the
composer locks privacy for them
Drafts stay enabled: TextNoteEvent does not implement ExposeInDraft, so
draft wrappers carry no anchor e-tags — the parent rumor id only exists
inside the NIP-44 encrypted draft content.
Verified by PrivateNoteFactoryTest: wraps cover p-tags + self, and the
recipient's unwrap yields a rumor with the same id and an empty sig
(the Note.isPrivateRumor() discriminator).
https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo
Unsealed NIP-59 rumors (private replies/posts arriving in gift wraps
from other clients) were indexed as ordinary notes: public reactions,
reposts, edits, pins, OTS timestamps, labels, public bookmarks, and
deletion requests could all e-tag the private rumor id onto public
relays.
- Note.isPrivateRumor(): empty-signature discriminator (rumors are the
only notes materialized with an empty sig; draft inners are never
indexed as standalone notes)
- ReactionAction: reactions inherit the target's privacy — empty-sig
targets get gift-wrapped kind-7s fanned to the rumor author, every
tagged user, and the sender's self-copy (add-only; un-react would
need a public NIP-09 deletion that leaks the rumor id)
- AccountViewModel.reactToOrDelete: never NIP-09-delete rumor reactions
(also fixes the same leak for existing NIP-17 chat reactions),
tracked-broadcast mode excluded for rumor targets
- ReactionsRow: hide reply/boost/zap on private rumors (each publishes
a public e-tag of the target); like stays, now wrapped
- DropDownMenu/NoteQuickActionMenu: hide broadcast, edit, timestamp,
pin, hashtag label, public bookmarks, deletion request for rumors;
private bookmarks and block/report stay available
- Lock badge in the note header (reuses existing Lock glyph, no font
regen needed)
Covered by ReactionActionTest (public vs rumor fan-out, jvmTest green).
Plan: commons/plans/2026-06-10-private-replies-reactions-posts.md
https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo