- Account.kt: collapse three identical backend-not-configured Failure
constructions into one helper
- OnchainZapSendError: declare causeIsUserFacing on the enum so the
sender owns which failures carry a human-readable cause, instead of
the UI mapper hardcoding the list
- SendPaymentScreen: fee chip label is now a single format resource
instead of manual string concatenation
Source-identical values (Cashu, Lightning, On-chain, and per-locale
loanwords like Meditation/Yoga/Workouts in de) are intentionally left
to the English fallback since Crowdin strips them on export anyway.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Surfaces relays unresponsive for 7+ days across the user's NIP-65 (10002),
DM (10050), and Search (10007) relay lists. A non-modal banner appears
above feed columns (and above the single-pane content) whenever the
classifier finds anything; tapping it opens an anchored Popup with one
row per unhealthy relay and per-row Remove / Open Dashboard / Snooze 7d
actions plus a banner-level "Snooze all 7d".
Quartz
- RelayStat gains best-effort lastConnectAt + lastIncomingAt timestamps
(epoch seconds, 0 = never observed). RelayStats listener pushes them
on onConnected / onIncomingMessage. Durable per-relay history lives
outside quartz in the commons RelayHealthStore.
Commons (new commons/relays/health/ package)
- classifyRelayHealth() pure function with the v1 gates:
* first-run grace (don't flag for 7d after firstScanAt)
* offline grace (don't flag if no relay anywhere has responded)
* Tor-mode skip (relay timing is intentionally lossy through Tor)
* per-relay snooze (snoozedUntil > now)
* 10006 (blocked) excluded from detection but still part of the
multi-list Remove action
- RelayHealthStore (account-scoped, supervised scope, 5s debounced
persist, 60s ticker for snooze expiry).
- RelayHealthListener wires the quartz lifecycle into the store.
- RelayHealthPersistence interface (no expect/actual — single impl per
platform via injection).
- RelayListMutator interface + RelayRemovalResult sealed type.
- Shared UnhealthyRelayBanner (errorContainer @ 50% alpha) and
UnhealthyRelayRow (static outlined tag chips, no ripple) composables.
- 8 classifier unit tests covering each gate + multi-list membership.
Desktop wiring
- PreferencesRelayHealthPersistence (java.util.prefs.Preferences, per
account via 8-char pubkey prefix).
- DesktopRelayListMutator runs the 4 sign-and-broadcast jobs in
parallel via async/awaitAll so a slow NIP-46 bunker doesn't multiply
latency by 4.
- Banner placed in DeckColumnContainer + SinglePaneLayout, store +
listener + per-account scan trigger wired in Main.kt's MainContent.
Scope: Desktop only for v1. Android wiring is intentionally not in
this PR — the commons module is platform-neutral and ready for Android
to follow whenever someone wants to pick it up.
Pay from, zap receipt, amount preset, and on-chain fee chips wrap with
the same 8dp row gap as the Receive-on pills. Material chips reserve a
48dp interactive height around their 32dp visual, inflating wrapped-row
gaps to ~24dp; the new shared ChipFlowRow drops that enforcement inside
the chip groups so the spacing matches.
- All chip FlowRows on the Send Payment screen (Receive on, Pay from,
zap types, amount presets, fee tiers) now declare an 8dp vertical
arrangement so wrapped lines get the same gap as the in-row spacing —
the custom Receive-on pills had no intrinsic padding and touched when
the row broke.
- The cashu rail chip and the cashu 'Pay from' wallet chip use the Cashu
vector mark (tinted with the chip content color) instead of the
generic wallet symbol.
The detail text under the Send Payment rail selector (e.g. the lightning
address, already visible in the recipient header) is gone. Instead every
chip carries its destination via long-press copy:
- Send Payment 'Receive on' chips copy the lightning address, the
noffer pointer, the on-chain destination (announced target address or
the pubkey-derived Taproot address — otherwise invisible), and the
shared cashu mint URL. The chips are now custom selectable pills since
M3 FilterChip has no long-press support.
- Profile rail chips gain the same copy values (clink noffer, derived
taproot address, mint URL) alongside the existing lightning and
payment-target copies.
- The announced bitcoin address moved from the detail line into the
on-chain receipt note so it stays visible when it differs from the
derived address.
The wallet-rail chips and the NIP-A3 payment-target chips were two
separately padded FlowRows, so the gap between the two rows was about
double the in-row spacing. The target chips now render inside the same
FlowRow as the rail chips, wrapping together with uniform 6dp spacing.
DisplayPaymentTargets is gone; PaymentTargetChip is rendered by
DisplayPaymentRailChips directly.
- Replace the NewWorkoutDialog with a Route.NewWorkout full screen
(NewGoalScreen pattern: NewWorkoutViewModel + PostingTopBar), which
also fixes the audit-found race where the dialog closed before the
signer finished — the screen now pops back only after a successful
sign/broadcast, keeping external-signer (Amber) flows alive.
- Move template building from Account.sendWorkout into the ViewModel.
- Render workout cards in thread view (ThreadFeedView fall-through gap).
- Snapshot parsed workout tags once per note (remember + WorkoutInfo)
instead of re-scanning the tag array on every recomposition.
- Fix Double.trimmed() Int overflow on absurd distances.
- Flatten the distance-unit chip layout.
https://claude.ai/code/session_01Kpx53UEeJqqR7CASzMu6GB
The Cashu vector is a monochrome black outline meant to be tinted like a
Material Symbol — with Color.Unspecified it rendered black and vanished
on dark backgrounds. Tint it with the chip color and lift the purple
from 0xFF7E57C2 to 0xFFA855F7 so the chip reads on both themes.
Replaces the lightning-address icon+text row with a chip and completes
the rail set, so every way to pay a profile reads as one chip row:
- Lightning chip shows the lud16 (long-press still copies it) and opens
the Send Payment screen on the Lightning rail.
- CLINK Offer chip moves into the same row (same look as before).
- New On-chain chip (when the chain backend is configured) and Cashu
chip (when the logged-in user's cashu wallet shares a mint the
recipient accepts), each opening their rail on the Send Payment
screen.
- All four render through a shared ProfilePaymentChip pill that matches
the NIP-A3 payment-target chips, replacing the old DisplayLNAddress
row and the one-off clink chip in DrawAdditionalInfo.
https://claude.ai/code/session_01UERRsbDoRPz46Qx5HCXgAa
Quartz: new experimental/fitness/workout package shaped like nip88Polls —
WorkoutRecordEvent with per-tag classes (exercise, duration, distance,
elevation, calories, steps, heart rate, splits, strength sets/reps/weight,
source, workout_start_time), TagArrayBuilder/TagArray extensions, lax
RUNSTR-dialect parsing (unit defaults, HH:MM:SS or raw seconds), and
EventFactory + LocalCache registration. Covered by fixture tests.
Amethyst: new Workouts feed (drawer entry, route, follow-list top bar,
per-relay filter assemblers mirroring the Pictures feed) with a + FAB
opening a manual workout composer that publishes canonical kind-1301
events. Workout cards render stats chips and also display inside threads
via NoteCompose. Adds fitness Material Symbols glyphs and regenerates the
subset font.
https://claude.ai/code/session_01Kpx53UEeJqqR7CASzMu6GB
Pinned chatrooms were stored local-only in encrypted SharedPreferences, so
they were lost on uninstall and never reached other devices. Move them into
AccountSyncedSettings as a new 'chats' group in the encrypted settings blob
(each room serialized as its member pubkeys sorted ascending), publishing a
new AppSpecificData event on every pin/unpin like the other synced settings.
Local persistence now comes from the existing latestAppSpecificData event
backup, so the dedicated pinned_chatrooms preference is removed (the local
format never shipped, so no migration is needed). Pins arriving from another
device flow through AccountSyncedSettings.updateFrom and re-sort the chat
list via the existing pinnedChatrooms feed invalidation collector.
https://claude.ai/code/session_0131YwG6bE3yH8Kk9MxjMA5i
All CLINK tests failed on iosSimulatorArm64 with IllegalArgumentException
because OptimizedJsonMapper on native dispatches through
KotlinSerializationMapper, whose fromJsonTo/toJson type lists did not
include the CLINK payload DTOs (Jackson handles them reflectively on
JVM/Android, which is why only iOS failed).
Adds hand-written kotlinx serializers for OfferRequest/OfferResponse/
OfferReceipt, DebitRequest/DebitResponse, and ManageRequest/ManageResponse,
mirroring Jackson behavior: ManageResponse.details coerces a lone object
into a one-element list (ACCEPT_SINGLE_VALUE_AS_ARRAY) and
OfferRequest.payer_data round-trips as a free-form JSON object.
Covered by a JVM test driving KotlinSerializationMapper directly and
cross-checking against Jackson, since the native path shares this code.
https://claude.ai/code/session_01SevV4fUCumKZ1UscSz85vS
Reading pinnedChatrooms.value in the UserRoomCompose body invalidated the
whole function scope on every pin toggle. Keep the single subscription but
read the set only inside the firstRow slot (pin icon) and the dropdown
menu-item text, so those two small scopes are the only ones that recompose.
https://claude.ai/code/session_0131YwG6bE3yH8Kk9MxjMA5i
The screen now shows which wallet the payment will come from and lets
the user switch before paying:
- Lightning and CLINK-offer rails list every configured wallet (NWC +
CLINK debit, via PaymentSourceResolver.all) plus an 'Another wallet
app' entry that hands the invoice to the system via intent. The
selection defaults to the account's default payment source and
re-resolves if the picked wallet is removed while the screen is open.
- On-chain and cashu rails show a fixed, disabled chip naming their
intrinsic wallet so the money's origin is always visible.
- payBolt11 now charges the picked source instead of silently using the
account default.
https://claude.ai/code/session_01UERRsbDoRPz46Qx5HCXgAa
Lightning payment targets already route into the Send Payment screen;
this extends the same treatment to bitcoin targets. Tapping a profile's
bitcoin payment-target chip (or its pay action in the wallet-button
dialog) now opens the Send Payment screen with the on-chain rail locked
to that announced address, paid directly from the user's NIP-BC Taproot
wallet — falling back to the external bitcoin: URI when the chain
backend is missing or the address isn't a payable native-segwit mainnet
address.
- quartz: SegwitAddress.scriptPubKeyFor/isPayableMainnetAddress;
OnchainZapBuilder.buildToScripts core shared by the pubkey paths.
- commons: OnchainZapSender.sendToAddress — plain wallet send with the
same fund-safety signing contract but no kind:8333 receipt (the
destination isn't pubkey-derived, so none is possible); the signing
block is now a single shared helper across send/sendSplit/sendToAddress
and Success.receiptEventId is nullable for receipt-less sends.
- amethyst: Account.sendOnchainToAddress; Route.SendPayment gains
btcAddressOverride; a shared inAppPaymentRouteFor() decides which
payment targets the user's wallets can pay in-app (used by both the
target chips and the payment-targets dialog).
- Send Payment screen: with an address override the on-chain rail shows
the target address, hides the message field (no receipt to carry it),
explains that no zap receipt is published, and dispatches the plain
address send.
https://claude.ai/code/session_01UERRsbDoRPz46Qx5HCXgAa
The profile_thumbnails_v2 dir was only created in ThumbnailDiskCache's
constructor, but Android can delete cache subdirectories while the app
runs (system cache trim under storage pressure, or the user tapping
Clear cache in Settings). After that, every generateFromFile call
failed with ENOENT on the temp-file write until process restart,
silently disabling thumbnail caching.
Recreate the dir right before the write, and add instrumented
regression tests covering the cleared-at-runtime path.
https://claude.ai/code/session_01RQinCw5QKpaYXqtyb4gf3h
Audit fixes for the unified payment screen:
- Re-peek cashu nutzap funding when the profile data refreshes so a
late-arriving kind:10019 doesn't keep the Cashu rail hidden, and read
the on-chain backend availability live instead of freezing it at first
composition.
- Seed the active CLINK offer only while unset so a kind:0 refresh
mid-flow can't discard an expired-or-moved redirect; drop the !!
derefs in the offer range check.
- Marshal payment-callback stage updates to the Main scope (matching the
app's progress-callback convention) and run the on-chain send off the
Main thread since the sender signs the PSBT on the calling thread.
- Launch the external-wallet intent from the Main scope instead of the
invoice fetcher's IO callback.
- Restore the old LN-address error affordance: payment failures now
offer 'Message the recipient about this', opening a DM prefilled with
the failure detail.
- Reuse the wallet sheet's FeeTier instead of a duplicated enum, memoize
the zap-type options, and hoist the lightning target-type set.
https://claude.ai/code/session_01UERRsbDoRPz46Qx5HCXgAa
Catalogs every Nostr kind the RUNSTR app publishes/consumes, the exact
1301 tag dialect, the Supabase-migration caveats, and a phased plan for
Quartz event classes and Amethyst fitness screens.
https://claude.ai/code/session_01Kpx53UEeJqqR7CASzMu6GB
- Rename Nip05Test backticked test name to drop parentheses, which are
illegal identifier characters on Kotlin/Native (iosSimulatorArm64).
- Resolve CLINK budget toast strings at composition time via stringRes
instead of context.getString inside the async callback, fixing the
LocalContextGetResourceValueCall lint errors in WalletScreen.
https://claude.ai/code/session_01UgP8ErzBbQYkTDtkJx5nrt
- Clamp the seeded kind:445 subscription since at wall-clock now: the
inner createdAt is sender-controlled, so a single future-dated message
could push since past the present and silently skip genuinely new
events on every restart. Covered by a new regression test.
- Drop the remember() around the group-list unread count: the chatroom's
message set can shrink without newestMessage or lastReadTime changing
(pruning, kind:5 deletion of an older message), which left the cached
count stale. The set is pruned to ~100 entries, so counting per
recomposition is cheap.
- Extract marmotGroupLastReadRoute(): the "MarmotGroup/<id>" last-read
key was inlined at three call sites; a prefix drift between the
mark-as-read side and the unread checks would silently reintroduce
the bug this branch fixes.
- Derive GROUP_EVENT_REFETCH_OVERLAP_SEC from TimeUtils.ONE_DAY instead
of re-deriving 24*60*60.
Restores the non-null zappedEvent on NutzapEvent.build and adds a
separate buildToUser builder (p tag only, no e/k tags) for nutzaps that
target a profile instead of an event — mirroring NIP-57's profile zap
convention. CashuWalletOps.sendNutzap dispatches between the two.
https://claude.ai/code/session_01UERRsbDoRPz46Qx5HCXgAa
Replaces the click-to-expand payment cards on the profile page with a
dedicated Send Payment screen that collects amount, optional message and
zap type, pays on the spot through the selected rail, and shows the
invoice-request + payment progress in the screen itself before closing.
- New Route.SendPayment(userHex, method, lnAddressOverride) with a
stateless SendPaymentContent (previews for editing, fixed-price clink,
in-progress, success and failure states).
- Rails offered per profile: Lightning (lud16/lud06 or a lightning
payment target), CLINK offer (kind-0 / NIP-05, with expired-or-moved
redirect), on-chain NIP-BC (fee tier selector), and NIP-61 cashu
nutzaps gated on a shared funded mint.
- Lightning rail keeps the Public/Private/Anonymous zap types and adds
the Non-Zap (plain payment) option; clink is a direct payment; cashu
and on-chain receipts are inherent to their protocols and noted as such.
- Paying from this screen skips the extra in-app wallet confirmation
dialog: the explicit amount + Pay tap is the confirmation.
- Profile LN-address row, CLINK chip, lightning payment-target chips and
the wallet button's pay action now navigate to the new screen; other
target types keep their external payto/URI behavior.
- NutzapEvent.build / CashuWalletState.sendNutzap now accept a null
zapped event so nutzaps can target a profile (p-tag only), and
AccountViewModel gains sendNutzapToUser + a zapType override on
sendSats.
https://claude.ai/code/session_01UERRsbDoRPz46Qx5HCXgAa
Two bugs kept CLINK offer/debit round-trips from completing over the shared
account relay client:
- The offer relay was treated as a generic "new" relay, so with Tor on it
was dialed through the proxy and failed on services that block Tor exits.
Register the offer/debit relays as money-operation relays for the duration
of the round-trip; the subscribe()-triggered reconnect plus the
BasicRelayClient wrong-transport rebuild then move the socket to clearnet.
- The subscription id "clink-offer-<event id>" was 76 chars; relays cap REQ
subscription ids at 64 (NIP-01) and reject the over-long REQ outright, so
the reply never arrived. Use newSubId(); the reply is matched by request
id in the listener, not by subscription id.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Relay-socket Tor routing only had localhost/onion/DM/trusted/new buckets,
so a wallet or payment-service relay fell through to newRelaysViaTor and
got forced over Tor regardless of the "Money operations via Tor" toggle
(which previously governed only HTTP clients). On services that block Tor
exits this silently broke NIP-47 and CLINK payments.
Add a moneyOperationsViaTor field to TorRelaySettings and a moneyOpRelay
bucket to TorRelayEvaluation (taking precedence over DM/trusted/new, after
the onion reachability check). TorRelayState gains a persistent money-op
relay set — fed across all accounts from NIP-47 wallet relays and saved
CLINK debit relays via AccountsTorStateConnector — plus a reference-counted
ad-hoc registry for one-off payment relays (e.g. an noffer pointer). The
websocket builder resolves the per-relay decision from live source values
so ad-hoc registration takes effect on the next connect with no race.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
connectAndSyncFiltersIfDisconnected() bailed whenever a socket already
existed, so a still-connecting socket built for the wrong transport (e.g.
a relay whose Tor classification changed since the dial started) could
never be preempted — it blocked until the hung dial timed out. The
connected-relay path in RelayPool.reconnectIfNeedsTo already rebuilds
ready sockets via needsToReconnect(); this covers the connecting state it
cannot see (isConnectionStarted() true but isConnected() false).
Now: if a socket exists but reports needsReconnect() (transport/proxy
mismatch against the current builder decision), drop it and redial on the
correct transport; otherwise leave it. Disconnected relays still honor
their reconnect backoff.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>