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.
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.
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.
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.
Audit follow-up — close three concurrency holes exposed by the auto-fill
loop, which calls into the loaders from the UI thread while the bundled
invalidation runs updateFilter on Dispatchers.IO:
- windows map: was a plain HashMap mutated from both the UI thread
(loadMore/loadEverything) and Dispatchers.IO (updateFilter). Concurrent
getOrPut can corrupt the table. Switch to ConcurrentHashMap.computeIfAbsent.
- WindowLoadTracker watchdog: a stale watchdog waking from delay just as a
new startLoading ran could complete the *new* window (flip loading=false
and cancel the new watchdog), leaving it stuck. Guard each poll with a
generation token so a superseded watchdog bows out.
- scope field: written on IO (newSub), read on the UI thread (loadMore);
marked @Volatile for visibility.
- cold-boot diagnostic maps (bootStartMs/bootEventCount/bootEoseLogged) are
written from the concurrent relay reader callbacks during the boot flood;
switch to ConcurrentHashMap + an atomic merge so they can't corrupt or
hang under that load.
The fixed 15s window-load timeout fired mid-flood on accounts with a large
DM history: a relay streaming thousands of stored gift wraps never EOSE'd
within 15s, so the window was declared "loaded" while events were still
pouring in and before they were decrypted into rooms. The rooms list still
looked empty, so auto-fill widened again — re-issuing an ever-wider REQ that
re-downloaded the whole history, over and over, every 15s.
WindowLoadTracker now completes a window on activity quiescence instead of a
wall clock: it stays loading until every expected relay EOSEs, or the event
stream goes quiet for a few seconds. Every event (stored backfill included)
bumps the idle timer via onActivity, so a relay mid-flood is never mistaken
for a finished window; an absolute cap bounds pathological dribble. Both DM
loaders feed event activity in (the NIP-04 loader now uses a custom listener
so it sees stored events, not just live ones).
Also add a "Load entire history" button to the rooms-list footer: it jumps
the window straight to the max lookback (TimeWindowPagination.loadAll) so a
single REQ pulls everything — the pre-windowing behavior — and marks the
window exhausted so the auto-fill loop stops.
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.
Re-key the rooms list edge-detector on listState only, instead of
(listState, itemCount). Keying on item count re-armed the detector on
every widen: loadMore pulled older conversations, the list grew,
LaunchedEffect restarted, the edge-detector reset, and it fired again —
walking the window 7->14->21->...->112 days back in a few seconds on a
slow connection. Now distinctUntilChanged fires once per reach-the-end
gesture and does not re-fire while parked at the end.
Also surface initialLoadInFlight from both DM loaders (gift wraps +
NIP-04) and keep a spinner up on the rooms screen until the first relay
answers, so cold boot no longer flashes the empty state before the DMs
land.
The 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
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
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
The reported symptom — messages inside the 7-day window never appear, and the
first EOSE takes ~136s on a single-event account — points at the relay/connection
path, not the time filter. Nothing currently logs where that time goes or whether
a relay is silently rejecting the query (CLOSED "auth-required"/"restricted").
Add DmRelayDiagnosticsLogger, a debug-only RelayConnectionListener that folds the
gift-wrap loading timeline into the DMPagination tag with elapsed-time prefixes:
- connecting / connected (ping) / disconnected / cannotConnect per relay
- REQ sent for gift-wrap subscriptions (kind:1059/1060), with the command
- AUTH challenge, NOTICE, and CLOSED (for gift-wrap subs) — the silent-failure tells
- gift-wrap EVENT arrivals (relay, sub, createdAt) and their EOSE
Wired in AppModules next to the other debug loggers.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Previously only the boot window open and per-assembly filter were logged; the
completion of the initial cold-boot load was effectively invisible (the EOSE
log was gated behind the scroll-only loadingMore flag, and newEose can't tell a
real EOSE from a live event because the base listener funnels both into it).
Install a custom SubscriptionListener in newSub so we can distinguish a real
EOSE and count arriving gift wraps:
- "cold boot: … opening gift-wrap subscription, starting to load messages"
- "cold boot: … initial load complete — first EOSE from <relay> after Nms,
M gift wrap(s) received so far"
Boot timing/count state is reset per subscription and cleared on endSub.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Adds Log.d("DMPagination") tracing so the boot window and scroll-driven
backfill can be watched live in logcat:
- initial window opened per account (with depth in days)
- each updateFilter assembly (window `since` + depth + relays)
- loadMore widening the window (old -> new floor, depth before/after)
- EOSE clearing the loadingMore flag (transition only, not every event)
- the rooms list reaching its end (triggered vs skipped-already-loading)
Filter logcat by tag `DMPagination` to follow the whole flow.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
The always-on gift-wrap subscription (AccountGiftWrapsEoseManager) had no
lower bound on first boot: `since` came purely from the per-relay EOSE
cursor, which is null on a cold start, so every DM relay dumped the account's
entire NIP-17 history at once — all of which then had to be unwrapped and
NIP-44 decrypted before the messages list felt usable.
Replace that with a per-account time window:
- New `TimeWindowPagination` primitive (commons): tracks a moving `since`
floor, opens a small window at boot, widens backward one step per
`loadMore()`. The subscription stays open so live messages still stream in
regardless of the window.
- `AccountGiftWrapsEoseManager` now requests gift wraps from the window floor
instead of the EOSE cursor, exposes `loadMore(user)` and a `loadingMore`
flag, and clears the flag on EOSE.
- The rooms list (`ChatroomListFeedView`) widens the window when scrolled near
the end and shows a loading footer while the next window loads. It
re-evaluates as the list grows so a near-empty first screen keeps filling.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
R2 — sendNutzap gains an optional preferredMintUrl; the Top-up screen passes the
just-funded selectedTarget so the nutzap spends from THAT mint instead of whichever
shared mint holds the most (which could leave the top-up sitting idle). Falls back
to the best-balance pick when the preferred mint isn't a valid shared target.
R5 — meltToLightning gains skipScrub; rebalance() (which already scrubbed the
source for its coverage check) passes it to drop the redundant second NUT-07
/checkstate round-trip. The Top-up screen's wallet collector now projects to the
per-mint balance map + distinctUntilChanged, so unrelated wallet activity (an
inbound redeem, a scrub, a token for another mint) no longer re-runs the whole
balances/targets/sources rebuild on every global tokenEntries emission.
R7 — replace ReloadMintScreen's hand-rolled copyToClipboard with the shared
Clipboard.setText helper (drops the android ClipData/ClipboardManager imports);
document the keys-only observed values in the reactive railCapability block so a
future reader doesn't delete them as "unused" and silently break the live rail
loading + relay fetch.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
addAmount() allowed adding the same amount twice; two preset chips then share the
same key(amount) (Compose duplicate-key hazard) and the drag-reorder's
indexOf(amount) resolves to the wrong chip. De-dupe on add.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
Audit found the toppedUp checkpoint was still too coarse: it was set only AFTER
rebalance() / the LN mint fully returned. But funds leave the wallet mid-flow —
the source melt (rebalance) and the invoice payment (Lightning) both happen before
the poll/completeMintFromLightning steps that can throw. A failure there left
toppedUp=false, so "Try again" re-ran the whole move and spent a second time.
- CashuWalletState.rebalance gains an onFundsMoved callback fired immediately after
the melt succeeds; the VM sets toppedUp there.
- The Lightning path sets toppedUp the moment the invoice is confirmed paid, before
issuing ecash.
Either way, once money has moved a retry can only re-send / resume — it can never
move funds again. (The paid-but-unissued quote remains recoverable via the pending
quote banner.)
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
The zap popup computed rail availability once from a synchronous snapshot, so a
recipient whose lnAddress (kind:0) or nutzap info (kind:10019) hadn't loaded yet —
or before our own cashu wallet finished loading — showed no Lightning/cashu logo
and never updated.
Now it observes those inputs and recomputes railCapability as they arrive:
- observeUserInfo(author) → the Lightning logo appears when the lnAddress loads.
- observeNoteEvent<NutzapInfoEvent>(author.nutzapInfoNote) + the cashu wallet's
mints/tokenEntries flows → the cashu logo appears when the recipient's kind:10019
and our proofs load.
The observers also trigger the relay fetch, so a not-yet-seen lnAddress / kind:10019
gets pulled in while the popup is open.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
- Drag-and-drop now works: pointerInput was nested inside the graphicsLayer
translation, so the layer moving under the finger corrupted the per-frame drag
deltas. Moved the gesture outside the transform.
- Preset chip regrouped to match the popup: outlined track, default rail + amount
in a highlighted thumb, alternatives as quiet mono icons, then the X — instead
of everything mashed together.
- Top-up screen header now shows the zap amount between the cashu symbol and the
arrow (you → cashu · N sats → recipient).
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
Bug: a top-up that moved funds but then failed at the nutzap (the freshly minted
proofs hadn't landed in local state yet → "No proofs available") left a Failed
state; tapping "Try again" re-ran the WHOLE pipeline and moved the funds a second
time — two transfers for one zap.
- Add a `toppedUp` checkpoint set the moment funds land at the target (after
rebalance / completeMintFromLightning). confirm()/retry now skips the move
entirely once topped up and only (re)sends the zap — funds can never move twice.
- awaitTargetFunded(): briefly poll the target balance after topping up so the
follow-up nutzap sees the new proofs and succeeds on the first try instead of
needing a manual retry. Best-effort with a timeout; the checkpoint guarantees no
double-move even if it falls through.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
- Toggle border eased from `outline` to `outlineVariant` — present but no longer
heavy.
- Selected cashu (and the reload variant) now tints with the same BitcoinOrange
as the Lightning/on-chain rails instead of the purple accent, so the active
rail colour is consistent across all three.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
Reverts the per-segment selected outline. The real ask was a clearer mark for the
entire 3-rail toggle: give the whole component a 1dp `outline` border so it reads
as one control. The selected segment keeps its subtle primaryContainer thumb.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
- The new monochrome cashu outline read too small; bump it from 0.72× to 0.86×
the symbol size so it matches the bolt/bitcoin marks optically.
- The selected segment's container fill sat too close to the track to read as
"selected", so add an animated primary outline around the active segment — the
state is now unmistakable (outline + fill + the amount label all land on it).
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
Replaces the "tap a rail icon = instant send" model (which read like a toggle but
moved money) with an explicit two-step control, so switching rails never sends:
- The rails are one connected segmented pill (shared surfaceVariant track) — they
visibly belong together. A primary-container "thumb" animates to the selected
segment.
- Only the selected segment shows the amount (+ a send arrow); it expands in on the
chosen rail and shrinks away on the previous one, so the amount reads as
travelling to the icon you tapped.
- Tapping an unselected rail only selects it (no payment). Tapping the selected,
labelled segment is the single thing that sends. Selection starts on the
amount-tier default, so the common case is still one tap. Long-press still edits
the presets.
Removes the old leading-icon + trailing circular-button layout and the now-unused
RailButton.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
- When a chip has no alternative rails, match the right inset to the left so the
icon+amount pill stays symmetric (was cramped against the right edge).
- Alternative-rail circles shrunk toward the amount's font height (28→22dp) with
a smaller icon inside (ZapRailIcon gained a size param; alternatives render at
14dp), so they stay quiet next to the bigger coloured preferred logo.
- The amount text is now neutral (onSurface) instead of taking the rail's brand
colour — only the leading logo carries colour, so amounts don't shout across
the feed. Dropped the now-unused zapRailAccent helper.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
Replaces the per-relay backoff-token approach (opaque WebsocketBuilder
config token + BasicRelayClient bookkeeping) with a much smaller check at
the source.
The connector already holds the two OkHttp clients, and those are rebuilt
only when something connection-relevant changes (Tor's SOCKS port appears,
wifi<->cellular switch). Everything else it wakes on — Tor bootstrap status
churn, connectivity blips, self-heal restarts — leaves the clients
untouched. So instead of threading a config token through quartz, the
connector now forces a backoff-skipping reconnect (ignoreRetryDelays=true)
only when an OkHttp client instance actually changed; otherwise it lets
each relay's exponential backoff decide. That stops the
reconnect-fail-reconnect loop while Tor boots, and still reconnects every
relay (Tor and clearnet alike) the instant the transport changes.
Reverts the quartz/OkHttpWebSocket changes from the previous commit and
wires the connector to take the StateFlows it actually consumes (the two
client flows + connectivity/tor status) instead of the manager objects,
which also decouples it from DualHttpClientManagerForRelays/
ConnectivityManager/TorManager.
https://claude.ai/code/session_01SCz8kdYs2FwesEyzbhmRPY
Bump the pixel sunglasses scale (1.45 -> 1.70) so the shades read bolder and
overhang the slimmed, rounded cashew body more prominently. Body outline and
1.2 stroke weight unchanged.
Long-press a preset chip to pick it up and drag it to a new position in the
FlowRow; release to drop. Hand-rolled (no new dependency):
- VM gains moveAmount(from, to) to reorder the preset list.
- Each chip records its bounds (boundsInParent) and is wrapped in key(amount) so
reordering doesn't restart the in-flight drag gesture; the dragged chip follows
the finger via graphicsLayer translation and lifts with zIndex.
- The drop target is the chip whose bounds contain the pointer; on cross-over the
list reorders and the drag offset is rebased so the chip stays under the finger.
Identity (drag state, bounds map, target) is keyed by the amount value, not the
slot index, to avoid stale-index bugs across reorders.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
The Nostr Wallet Connect section (connect button, paste, QR, manual pubkey/relay/
secret) is removed from the zap-amount settings content. NWC setup now lives only
in the wallet area:
- The `nostr+walletconnect` connect deep link (`dlnwc`) now opens Wallet → Add NWC
with the URI prefilled (Route.WalletAddNwc gained an optional nip47 arg), instead
of the old shared NIP-47 setup screen.
- UpdateZapAmountContent loses its nip47uri parameter and the whole wallet-connect
block; both callers (zap settings + NIP-47 setup) updated. The NIP-47 setup
screen keeps its Lightning-address and payment-targets sections (both also
reachable from profile edit / EditPaymentTargets) and is no longer the deep-link
target.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
Replace the blocky pixel-stepped cashew silhouette with a smooth rounded
outline (corner-cut and emitted as a compact Bézier spline) so the nut reads
as a clean curved shape instead of a staircase. Use a round stroke cap/join at
the same 1.2 weight; the pixel "deal-with-it" sunglasses stay a solid fill.
Quick-zap preset chips now render like the real feed chip instead of a plain
"⚡ N" InputChip: the amount with the rails a preset of that size could use —
the amount-tier default rail in colour on the left, the alternatives (cashu /
Lightning / on-chain) in monochrome — with an X to remove. The bolt emoji is
gone; Lightning is the Material Symbols bolt, matching the feed.
Refactor: ZapRail, ZapRailIcon, the tier thresholds, and a new
previewRailsFor / previewPreferredRail / zapRailAccent are now internal so both
the feed chip and the settings preview share one source of truth for rail
choice, ordering, colouring, and the default highlight.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
Squeeze the cashew outline horizontally (~0.72) and scale the pixel sunglasses
up (~1.45) so the shades overhang the body for a bolder, more recognizable
mark. Stroke weight stays at 1.2 to match the shared Zap outline icon.
- Header is now a visual: your avatar → cashu zap glyph → arrow → the recipient's
avatar, centered, replacing the explanatory subtitle text.
- Multiple Lightning wallets: each configured NWC wallet is its own "Funds from"
option (by name), defaulting to the configured default wallet; the chosen
wallet's URI is used to auto-pay. Falls back to a single external-invoice option
when no NWC wallet is set up.
- Separated the two amounts: the send (zap) amount is fixed; the editable field is
now the top-up amount, defaulting to the shortfall and adjustable upward.
Funding mints/moves the top-up; the nutzap always sends the fixed amount.
- Muted the selected source styling — the radio button carries the signal, so the
card uses a faint primary wash + a thin low-opacity border instead of a heavy
2dp outline and filled container.
- Bottom summary now reads "Top up X sats to <mint> and zap <name> Y sats · fee ≈ Z
sats" (and "Zap <name> Y sats from <mint>" when no top-up is needed), using the
recipient's display name.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
Convert the multi-tone "deez nuts" Cashu/nutzap logo into a single-color,
tintable outline icon so it behaves like a Material Symbol glyph: the cashew
body is a hollow stroke (1.2 weight, matching the shared Zap outline icon) and
the pixel sunglasses stay a solid fill so they read at small sizes.
Drop the `tint = Color.Unspecified` overrides at every call site (zap chips,
nutzap rows/gallery, redeem, wallet screens) so the icon now tints with the
surrounding content colour instead of being locked to the brand browns, and
remove the imports/comments that only existed to preserve the old multi-tone
rendering.
Before Tor is ready, relays were disconnecting and immediately
reconnecting, ignoring BasicRelayClient's exponential backoff.
RelayProxyClientConnector calls reconnect(ignoreRetryDelays = true) on
every infrastructure change. That flag flows pool-wide into
connectAndSyncFiltersIfDisconnected and unconditionally bypassed the
backoff gate. While Tor is still bootstrapping its SOCKS port isn't
listening, so each unrelated infra event (connectivity transitions,
self-heal restarts, Tor status churn) forced an immediate reconnect that
failed again — the backoff was computed (delay kept doubling) but never
consulted.
Make the bypass per-relay and conditional on the relay's transport
config actually changing since its last attempt:
- WebsocketBuilder gains connectionConfig(url): an opaque,
value-comparable token of the transport config (proxy + timeouts).
Default null = untracked, preserving legacy always-bypass behavior for
in-process/standalone/test/desktop builders.
- BasicRelayClient records the token at each attempt and only lets a
forced reconnect skip the backoff when the token changed. A Tor relay
whose SOCKS config is unchanged keeps honoring its backoff; the moment
Tor flips to active (proxy port appears) the token changes and it
reconnects immediately. Clearnet relays still retry immediately
whenever their own client changes.
- OkHttpWebSocket.Builder reports the proxy+timeout fingerprint, reused
by needsReconnect() to avoid drift.
Adds BasicRelayClientBackoffTest covering the honored/bypassed/untracked
cases.
https://claude.ai/code/session_01SCz8kdYs2FwesEyzbhmRPY
- The default action is now one tap target: the preferred rail's logo hugs the
amount (no separate button), and a tap anywhere on icon+amount fires that rail.
- Alternative rails render as distinct circular buttons (filled surfaceVariant
circle) to their right, so it's clear they're individually tappable.
- The amount takes the preferred rail's accent colour (BitcoinOrange for
Lightning/on-chain, primary for reload; neutral for the multi-tone cashu logo,
which has no single brand colour).
- Reworked the preview to exercise every rail combination across the amount tiers
(5 / 100 / 5k / 100k sats): all-rails, cashu-only, lightning-only, and a
funds-split reload scenario.
Refactor: split the per-rail logo (ZapRailIcon, click-free) from the tap target
so it can be reused both inside the default area and inside RailButton.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
- Default rail is now amount-tiered: under 10 sats prefers cashu (Lightning
min/fees make tiny zaps awkward), over 10k sats prefers an on-chain
transaction, and the middle defaults to Lightning — each falling back through
the remaining rails when the tier's rail isn't available. The tap action and
the colored leftmost logo both follow this single `preferred` choice.
- The preferred rail's logo now sits to the LEFT of the amount (in colour); the
alternative rails follow on the right in monochrome.
- Shrank the cashu logo to 15dp (~17%) so it no longer dwarfs the Material
symbol rails it sits beside; the reload "+" badge scaled to match.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
Top-up (formerly "Reload Mint") screen:
- Renamed throughout to "Top up mint" / "Top up & send zap".
- Amount is now editable (numeric field, seeded from the tapped preset). The VM
recomputes shortfall/sources/fees on every edit; if the chosen amount already
fits the target mint, the screen drops the funding step and just sends.
- Selected funding source is now obvious: radio button + 2dp primary border +
primaryContainer fill, instead of a faint 12%-alpha tint.
- Each source's balance/description moved to its own line under the title, so
the Lightning explanation wraps instead of clipping.
- Removed the redundant "Your balances" list (it duplicated the per-mint balance
the Funds-from rows already show); the target's current balance now sits in the
destination card.
- Summary reads "Send AAA sats from XXX to YYY, fee Z sats" (or "Send AAA sats to
YYY" when no top-up is needed).
- Balances are now reactive: the VM observes the wallet's tokenEntries/mints
flows and rebuilds as proofs arrive from relays, instead of a one-time snapshot
that could show only the first-loaded mint.
Zap chip:
- The preferred rail (what a plain tap triggers) is rendered first and in full
colour; the alternative rails follow to its right in monochrome.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
The NEEDS_RELOAD affordance rendered two full 18dp icons side by side (cashu +
"+"), making the chip nearly twice as wide as the other rails. Overlay the "+"
as a small 11dp corner badge on the dimmed cashu logo so it occupies a single
logo's footprint like Lightning/on-chain.
https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
RenderPodcastMetadata and RenderPodcastEpisode rendered their description
tag as a plain Text. Both now use TranslatableRichTextViewer (rich text +
translation), matching the podcast screen header. The metadata card finally
uses its previously-ignored canPreview/backgroundColor params; the episode
card gives its short description a distinct id so it doesn't share remember
state with the markdown body below it.