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
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.
Fixes the perceptual "stale feed on launch" bug: on cold launch the
desktop feed paints with whatever local cache had (up to 7 days old)
before relays catch up. The live updateFeedWith() path already prepends
fresh events silently, but users had no signal that fresh content
arrived unless they were already at the top of the feed (auto-snap via
StickToTopOnPrepend).
This adds a Twitter/Mastodon-style floating pill chip that slides down
from above the search header when fresh events have prepended AND the
user is scrolled below position 0. Tapping it smooth-scrolls to top
and slides the chip back up off-screen. Scrolling to top manually
also dismisses it.
Implementation:
- NewPostsChip + rememberNewPostsChipState in commons/commonMain so any
future feed surface (incl. Android, iOS) can adopt it. Desktop wires
it today; Android continues with the existing auto-stick + bottom-nav
dot pattern.
- Visibility predicate is pure-function and unit-tested (5 cases).
- Predicate mirrors the inverse of StickToTopOnPrepend's "at top" check
so the two systems are mutually exclusive — auto-snap when at top,
chip when not.
- Chip placement: floating Alignment.TopCenter inside FeedScreen's outer
Box, offset by the animated headerSpacerHeight (60.dp normal,
300.dp when search is expanded) so it tracks the header card.
- Hoisted lazyListState + headerSpacerHeight one level so the chip can
share scroll state with the LazyColumn. Existing viewport-aware
metadata loading is unchanged (same lazyListState reference).
- Animation: slideInVertically(tween(280, FastOutSlowInEasing)) + fadeIn
for enter; slideOutVertically(tween(220, FastOutLinearInEasing)) +
fadeOut for exit. Initial/target offset of -fullHeight-16 guarantees
the chip is fully off-screen above its rest position.
- Per-column scope by construction: each FeedScreen instance has its
own chip state (deck mode shows one chip per column).
- Resets cleanly on feed mode switch (Following ↔ Global ↔ Custom)
because rememberNewPostsChipState is keyed on FeedContentState,
which is recreated when viewModel = remember(feedMode, activeFeedId)
recomposes.
Plan: docs/plans/2026-06-02-feat-new-posts-chip-desktop-feed-plan.md
5 issues from davotoula's review on PR #3124:
- #3 (protocol): inline reply emitted a minimal e/p tag set instead of
NIP-10. Extract `commons/actions/ReplyActions.replyTo` wrapping
`TextNoteEvent.build(replyingTo=)` (which already encodes root marker,
reply marker, parent root-e-tag carry) + carry parent's p-tag chain via
`notify(...)`. Replies to deep-thread notes now thread correctly in
Damus/Primal/Coracle. Covered by `ReplyActionsTest`.
- #4 (architecture): reaction/follow/reply each inlined
`localCache.consume + relayManager.broadcastToAll` in 5 sites with
inconsistent ordering. Extract `desktopApp/cache/dispatch(...)` —
canonical local-first order — and route all 5 sites through it.
- #1 (UX): related-content section scanned the cache once via
`DisposableEffect(noteId)` and never refreshed. Switch to `produceState`
collecting `DesktopLocalCache.eventStream.newEventBundles`; re-scan only
when an arriving bundle contains a candidate (matching hashtag or
author). `LargeCache.notes` is a ConcurrentSkipListMap (weakly consistent
iterator) so the scan stays safe on the composition coroutine.
- #2 (UX): `DeckColumnContainer` re-requested focus on every
`currentOverlay` change, stealing focus from sibling columns whenever
any column mutated overlay state. Drop to `LaunchedEffect(Unit)` and
wrap the column in `key(column.id)` in `DeckLayout` so the one-shot
effect survives column reordering.
- #5 (consistency): zap totals bypassed the shared `ZapFormatter`. Wire
`RelatedContentRow`, `CommentItem`, and `NoteActions` to
`commons/util/ZapFormatter.{showAmount,toZapAmount}`; delete
`formatZapAmount` and `formatSats` desktop-local helpers.
`WalletColumnScreen.formatSats` intentionally kept — locale-aware full
precision for wallet balance is by design.
Plan: docs/plans/2026-06-02-fix-desktop-feed-review-findings-plan.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1. LocalRelayStore: use batchInsert() with per-row savepoints instead of
manual transaction — UNIQUE constraint violations skip that row instead
of failing the whole batch
2. Robohash empty hex: guard blank input in CachedRobohash.get() with a
fallback all-zeros hex key instead of passing empty string to assembler
3. GiftWrapEvent decrypt: downgrade from WARN to DEBUG — expected when
gift wraps from local relay cache aren't addressed to current user
(subscription filter is correct, but hydration doesn't filter by p-tag)
4. Relay URL %20: decode percent-encoded spaces before rejection check in
RelayUrlNormalizer.fix() — wss://relay.example.com/%20 now normalizes
to wss://relay.example.com/ instead of being rejected
5. NIP19 Parser: downgrade from ERROR/WARN to DEBUG — malformed bech32
from relay content is expected in the wild, catch+log is correct
6. VLC macOS: add --avcodec-hw=none (disables VideoToolbox that causes
CVPN chroma failures) and --reset-plugins-cache (rebuilds stale cache
on startup instead of logging hundreds of stale-cache errors)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The time-slice history bounded re-downloads but couldn't tell "this relay is
empty" from "this is a gap" — an empty time slice can sit above older messages,
so the only stop was the 10-year maxLookback, and a wide late slice could pull a
20k-event firehose in one request.
History now pages backward by until+limit, per relay (UntilLimitPager). Each
round asks every not-yet-empty relay for up to 10000 events older than its own
cursor, no since, so gaps are skipped: an empty page + EOSE is a gap-proof
"nothing older on this relay" signal. A relay returning fewer than the limit is
treated as its own cap, not exhaustion — only an empty page ends it. A relay
answering CLOSED isn't "empty" (it may answer after the auth handshake), so the
global exhausted flag flips only when a whole round advances no relay at all,
which also stops the loop on a relay that keeps CLOSing. limit caps per-request
volume too.
Both NIP-04 history managers now paginate themselves (per relay, scoped) instead
of following the gift-wrap slice; loadEverything pages to the end by auto-issuing
the next round until exhausted. The live tail and the rooms-list stall-gate are
unchanged. Filter builders gained an optional limit; the conversation NIP-04
helper exposes its outbox relay set + a per-relay until builder.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
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
- 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>
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.
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.
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.
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.
The fixed 15s window-load timeout fired mid-flood on accounts with a large
DM history: a relay streaming thousands of stored gift wraps never EOSE'd
within 15s, so the window was declared "loaded" while events were still
pouring in and before they were decrypted into rooms. The rooms list still
looked empty, so auto-fill widened again — re-issuing an ever-wider REQ that
re-downloaded the whole history, over and over, every 15s.
WindowLoadTracker now completes a window on activity quiescence instead of a
wall clock: it stays loading until every expected relay EOSEs, or the event
stream goes quiet for a few seconds. Every event (stored backfill included)
bumps the idle timer via onActivity, so a relay mid-flood is never mistaken
for a finished window; an absolute cap bounds pathological dribble. Both DM
loaders feed event activity in (the NIP-04 loader now uses a custom listener
so it sees stored events, not just live ones).
Also add a "Load entire history" button to the rooms-list footer: it jumps
the window straight to the max lookback (TimeWindowPagination.loadAll) so a
single REQ pulls everything — the pre-windowing behavior — and marks the
window exhausted so the auto-fill loop stops.
Pure data class (only @Stable) — extract to
commons/model/nip51Lists/interestSets so Desktop/CLI/iOS can reuse the
interest-set model. Re-points the four interest-set UI files and the
sibling InterestSetsState.
https://claude.ai/code/session_01JFbYZdVV4QmDC4eQYvEccb
Pure data class (only @Stable + quartz bookmark tags) — extract to
commons/model/nip51Lists/labeledBookmarkLists so Desktop/CLI/iOS can
reuse the bookmark-group model. Re-points the six bookmark-group UI
files and the sibling LabeledBookmarkListsState.
https://claude.ai/code/session_01JFbYZdVV4QmDC4eQYvEccb
Extract the three remaining keystone-free quartz-only decryption caches
(MuteListDecryptionCache, PeopleListDecryptionCache,
CommunityListDecryptionCache) into commons/model so Desktop/CLI/iOS can
reuse them. Re-points Account, FeedDecryptionCaches, and the sibling
state holders (MuteListState, PeopleListsState, BlockPeopleListState,
CommunityListState).
The remaining relay-list decryption caches depend on
GenericRelayListCache -> amethyst.model.Note (the keystone), so they
stay until Phase A extracts Note/LocalCache.
https://claude.ai/code/session_01JFbYZdVV4QmDC4eQYvEccb
Replace the fire-once scroll detector with a viewport-fill + prefetch loop
so the messages screen stays ahead of the user instead of stranding a
near-empty list.
One condition drives three behaviors: widen the DM time windows when the
feed is empty, or when the last visible row crosses the midpoint of what's
loaded. While the list is short everything is visible, so the midpoint is
always crossed and it keeps widening until the list overflows the viewport
with a buffer below the fold; once full it only fires again as the user
scrolls past the new midpoint, so a fresh chunk lands well before the end.
It stops only when the window is exhausted (reached the 10-year lookback —
nothing older exists), which also gives the empty-account case a real
terminating condition instead of the old runaway cascade.
- TimeWindowPagination: optional geometric step growth + a hard max-lookback
floor with isExhausted(), so a sparse / single-person history converges in
~10 requests. Default stays linear/unbounded; existing callers unchanged.
- WindowLoadTracker: a window counts as loaded only once ALL of its relays
have answered (EOSE / live event) or a timeout fires — not on the first
EOSE. This stops a fast, near-empty relay from clearing the gate and
letting the fill loop outrun the slow relay that holds the conversations.
- Both DM loaders (NIP-17 gift wraps + NIP-04) expose loadingMore (= window
still loading) and exhausted, advance in lockstep, and gate each widen on
the tracker. The rooms screen shows a spinner until history is exhausted
rather than flashing the empty state while older windows are still in
flight.
Pure quartz-only decryption cache — extract to
commons/model/nip51Lists/favoriteAlgoFeedsLists so Desktop/CLI/iOS can
reuse it (mirrors the TrustProviderListDecryptionCache move). Re-points
Account and the sibling FavoriteAlgoFeedsListState.
https://claude.ai/code/session_01JFbYZdVV4QmDC4eQYvEccb
Pure data class (only quartz + a UUID id generator) — extract to
commons so Desktop/CLI/iOS can reuse the NWC wallet entry model.
Swaps java.util.UUID for the multiplatform kotlin.uuid.Uuid (matching
the quartz convention) and re-points the three callers
(LocalPreferences, AccountSettings, WalletViewModel).
https://claude.ai/code/session_01JFbYZdVV4QmDC4eQYvEccb
CashuToken and Proof are pure data classes (only @Immutable +
kotlinx.serialization). Extract to commons so Desktop/CLI/iOS can reuse
them, and re-point all callers.
https://claude.ai/code/session_01H66WwvUYm5KtAWBLgUMcod
Let users tag any post with a hashtag via a NIP-32 kind 1985 label event
(using the `#t` tag-association namespace), and surface follow-labeled
posts in the hashtag feed.
quartz:
- LabelEvent.buildHashtagLabel() + HASHTAG_NAMESPACE ("#t") and
hashtagAssociations() to build/extract hashtag-association labels.
commons:
- Note now carries a `labels` reverse-reference map (hashtag -> labeler
notes) with addLabel/removeLabel and a NoteFlowSet.labels flow,
mirroring reactions/reports.
amethyst:
- LocalCache consumes LabelEvent, attaching hashtag labels to their
target notes and re-notifying feed observers for already-cached
targets.
- Account.createLabelHashtagEvent/labelHashtag/consumeLabelEvent and
AccountViewModel.labelWithHashtag (tracked + direct broadcast).
- Overflow "⋯" menu gains an "Add hashtag" action backed by a new
AddHashtagLabelDialog.
- HashtagFeedFilter also accepts posts a followed user labeled with the
hashtag; a new label sub-assembler subscribes to kind 1985 by `#l`
and fetches missing label targets.
- Hashtag feed shows an attribution banner ("#tag added by @user") above
follow-labeled posts via a custom RefresheableFeedView onLoaded.
https://claude.ai/code/session_019gc3FipVBcndF9fmqCCfVX
Extract the pure (quartz-only) decryption cache into commons. Converge
the commons trustedAssertions package onto the quartz NIP slug
'nip85TrustedAssertions' (per migration plan §10.2), moving the existing
TrustProviderListState interface + UserCardsCache with it and
re-pointing all callers.
https://claude.ai/code/session_01H66WwvUYm5KtAWBLgUMcod
Pure data class (only quartz + @Stable deps) — extract to
commons/model/nip30CustomEmojis so Desktop/CLI/iOS can reuse it.
Moves the unit test to commons commonTest and re-points callers.
https://claude.ai/code/session_01H66WwvUYm5KtAWBLgUMcod
Make the feature-UI vs cross-cutting-UI rule consistent (feature-first):
- ui/nip53LiveActivities -> nip53LiveActivities/ui
- ui/article + ui/editor -> new nip23LongContent/ui (article reader + editor)
ui/ now holds only cross-cutting composables (theme, components, layouts,
elements, markdown, signing, thread, feeds, notifications, screens, state,
text). Tighten ARCHITECTURE.md with the deciding test ('could a second
unrelated feature reuse this as-is?') and reconcile the NIP-second-axis
section so a single-NIP feature owns its UI under <feature>/ui rather than
ui/nipNN.
https://claude.ai/code/session_01KXLzsvx9Gyrm3Yz4Rims55
Rename the two single-NIP feature packages to mirror their quartz
counterparts for 1:1 traceability:
- chess -> nip64Chess
- call -> nipACWebRtcCalls
marmot and nip53LiveActivities already match quartz and are unchanged.
Document the rule in commons/ARCHITECTURE.md: layer is the primary axis,
NIP is the secondary axis (nipNN<slug> matching quartz), and commons is
deliberately NOT reorganized NIP-first at the top level. Also remove
stray markup that leaked into the end of the doc.
https://claude.ai/code/session_01KXLzsvx9Gyrm3Yz4Rims55
Document the commons module's purpose, source-set layout, and the CLI-safe vs
UI boundary in commons/ARCHITECTURE.md, then clean up the clearest package
overlaps that had accumulated:
- merge duplicate util/utils -> util (all source sets)
- unify service/services -> service (jvmAndroid)
- move data/UserMetadataCache -> model/cache
- fold compose/ into ui/ (ui/article, editor, elements, layouts, markdown,
nip53LiveActivities, and Compose helpers in ui/state + ui/text)
- move ProfileBroadcastBanner composable into profile/ui
All changes are whole-file/whole-package moves with import rewrites; no logic
changed. The chess logic/UI split is documented as deferred debt (it needs
file-level surgery, not moves). Marks docs/shared-ui-analysis.md superseded.
https://claude.ai/code/session_01KXLzsvx9Gyrm3Yz4Rims55
The 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
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.
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.
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.
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.
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
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.