Visualises the per-relay-independent engine: each relay gets a thin
marker in the message stream at the depth (createdAt) it has paged down
to, sitting just below the oldest message it has loaded. As a relay
pages older its reached-back cursor drops and the marker slides down; a
relay that races ahead leaves its marker deep while slower relays' trail
higher and converge as they catch up — ✓ done (empty-EOSE), … stalled
(auth CLOSE / unreachable, still trying), ↓ reaching. Hidden once the
conversation is fully converged (every relay done or stalled).
Adds an optional markersInGap slot to the shared chat feed view
(no-op for public-chat callers) invoked per message gap with its
createdAt bounds; ChatroomView renders the markers from the
relayProgress flow.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Replaces the lock-step round model (every relay advanced one page per
global round, gated by the slowest) with per-relay continuous paging:
each relay continues to its next page the instant it EOSEs, off its own
cursor. The subscription layer diffs per relay, so re-issuing only
re-REQs the relay whose cursor moved — others' in-flight REQs are
untouched. Fast relays race to the bottom in back-to-back pages while
slow / auth-walled relays catch up at their own pace; none are
abandoned (this reverts the give-up behaviour — slow relays keep their
subscription open and keep trying), so every relay converges on the same
window.
A relay is done on an empty page; one that won't answer (auth CLOSE,
unreachable, silent) is marked stalled but keeps trying. loadingMore
clears once every relay is done or stalled. Exposes per-relay
RelayPagingProgress (reached-back / done / stalled) for the upcoming
in-stream progress markers.
Splits the convo widen loop so each protocol pages on its own loader
state — NIP-04's continuous loading no longer starves gift-wrap paging.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
The silence + connect-grace backstops are only meaningful when the owner
feeds onReqSent, which only the convo NIP-04 path does. But connectStalled
keyed off the ABSENCE of a recorded REQ, so for giftwrap/rooms (which never
call onReqSent) every relay looked connect-stalled after connectGrace —
the window completed at ~15s before its REQs had even gone out during a
slow connect storm ('giftwrap.live load done: settled/silent' 45s before
the REQ), prematurely declaring an empty round done and tripping the
no-progress guard, so giftwraps stopped loading.
Gate both REQ-aware backstops behind a tracksReqSends flag that only the
convo manager sets; everyone else keeps the plain settle / idle / cap
behavior. Adds a regression test that a non-tracking tracker keeps
blocking a never-heard-from relay until it actually settles.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
After the connect-grace fix the convo history no longer hangs, but it
could linger forever showing 'N relays' with no progress bar and
exhausted=false: a correspondent's auth-walled relay (ditto, which only
ever CLOSEs 'all authors must be authenticated') never reaches the
pager's done/given-up state, and the no-progress guard merely *skipped*
re-issuing the round — stopping the loop without ever reflecting that
we're finished.
When the guard trips (same active relays, zero events two rounds
running) give up on those relays and recompute exhaustion, so the
conversation reports as fully loaded and the relay count clears. My own
reachable relays empty-EOSE to 'done' and never reach this branch, so
only genuinely stuck correspondent relays are dropped.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
The convo history spinner could sit for the full 5-minute absoluteCap
('load done: cap' in the logs). A correspondent relay (ditto) dropped
after its auth-required CLOSEs and got stuck reconnecting on a flaky
network, so its round-2 REQ was never delivered. It therefore reached
neither a terminal signal (no CLOSE without a REQ) nor the silence
backstop (which measures from REQ-delivery), and blocked the round until
the cap.
Add a connect-grace backstop: a relay that has been expected past
connectGrace (15s) without even receiving its REQ — i.e. stuck
connecting — stops blocking the round. Unlike the silence backstop it
does NOT give the relay up (it may be a genuinely slow connect), so the
owner keeps it and retries it next round; only relays that accepted a
REQ and then went silent are abandoned.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
The convo NIP-04 history spinner stayed up for minutes against 9 relays
even when the conversation was fully loaded. Auth-walled relays (ditto,
nostr.wine, …) accept the REQ (success=true) and then send nothing — no
event, no EOSE, no CLOSED. WindowLoadTracker only completed once every
relay reached a terminal signal, an idle gate that required hearing from
*all* relays, or the 5-minute cap; a silent relay armed none of those,
so only the cap freed the spinner. The pager likewise kept the silent
relay 'active' every round, so the count never dropped and exhaustion
never completed.
Add a silence backstop keyed off onSubscriptionStarted (REQ delivered,
post-connect — so a slow connect isn't mistaken for a dead relay): a
relay that received its REQ but stays silent past silenceTimeout (10s)
no longer blocks completion and is reported via onAbandoned, which the
convo assembler uses to giveUp() the relay in its pager so it leaves the
active set and lets exhaustion finish. finish() applies the give-up
before flipping loading, so the round collector recomputes exhaustion
after the silent relays are dropped.
Tests cover the pager give-up/exhaustion and the tracker's silence +
connection-gap behavior.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Nip04DmRelays held two flat relay sets and every filter named the whole
conversation group, so a relay that belongs to only one counterpart was
still asked about all of them (e.g. {authors:[bob,charlie]} sent to a
relay that is only charlie's). Restructure it into per-relay key maps so
each relay sees exactly the keys it owns:
fromMe: my outbox -> {authors:[me], #p:[whole group]}
each counterpart inbox -> {authors:[me], #p:[keys reading there]}
toMe: my inbox -> {authors:[whole group], #p:[me]}
each counterpart outbox-> {authors:[keys publishing there], #p:[me]}
Relays shared across roles union their key sets, so my own relays still
carry the full group while a counterpart's relay only ever names that
counterpart.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
A conversation's NIP-04 relay set folded the correspondent's inbox
(read) relays into the from-me filter set, so we sent {authors:[me]} to
relays that belong to the other party (e.g. ditto). Those relays have no
reason to hold my authored messages and auth-walled ones reject the
filter outright ("all authors must be authenticated"), stalling the
load. Scope filters to relay owners: my outbox carries my messages; the
correspondent's outbox (plus my own inbox as a legacy safety net)
carries theirs. Drops groupInbox from the from-me set.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Adds DmRelayLog, a diagnostic that prints — per DM subscription — the
account whose relays are being used and breaks the relay set down by the
source list each relay comes from (NIP-65 inbox/outbox, DM-relay-list,
private-storage outbox, local relays). Wired into all six DM assemblers
(NIP-17 live/history, NIP-04 rooms live/history, NIP-04 convo live/history).
The existing REQ lines now also print the resolved relay URLs (split into
fromMe/outbox and toMe/inbox for the NIP-04 paths), so an unexpected relay
— e.g. a write-only NIP-65 relay that only the NIP-04 home+dm path queries —
can be traced back to the list it leaks in from.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
With "exhausted only when every relay empty-EOSEs", a relay that demands auth we
can't satisfy — e.g. relay.ditto.pub answering "auth-required: all authors must
be authenticated" for a correspondent's pubkey we can't authenticate as — CLOSEs
every round, never finishes, and the conversation loads forever.
UntilLimitPager now tracks a per-relay CLOSED streak; after GIVE_UP_AFTER_CLOSES
(3) consecutive CLOSEDs with no answer in between (so the pool's auth handshake +
a retry have already failed), the relay is marked "given up" and excluded from
activeRelays. It is NOT counted as done (it didn't empty-EOSE — we just can't
read it), but it no longer blocks exhaustion. The streak resets on any event or
EOSE, so a relay whose auth succeeds is never abandoned. onClosed wires into the
pager and re-checks exhaustion when a relay tips into given-up.
NIP-17 only queries the user's own DM relays (auth = self), so it rarely hits
this; it's the NIP-04 conversation fan-out to the correspondent's relays that
trips author-auth-required relays.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Exhaustion was decided by "the round returned zero events," but a round can
return zero because relays CLOSED (auth) or never answered — not because they
reached the end. So "All caught up" appeared before slow/auth relays had
actually finished.
A relay is finished only when it returns an empty page followed by EOSE — the
pager already records exactly that in its per-relay `done` flag (CLOSED /
cannot-connect deliberately don't set it). So the chat is now exhausted only
when every relay is done (activeRelays is empty), per the rule "all relays must
return that EOSE." A post-auth empty EOSE that lands after the round already
settled on the earlier CLOSED now flips exhausted immediately too
(markExhaustedIfAllDone in onEose), not just at the next round boundary.
Because a chat is no longer "done" while a relay keeps CLOSING, the conversation
auto-fill (no stall-gate) would otherwise re-issue identical rounds and hammer
that relay. A no-progress guard skips re-issuing a round whose relay set and
zero-event result are unchanged; the pool re-auths on the open subscription and
its EOSE clears the guard and finishes the relay. All the new single-valued
state resets on account/conversation switch alongside the existing display flows.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Replaces the "pubkey/listId" string key with a small ConvoKey data class holding
the account pubkey and the ChatroomKey. ChatroomKey is a data class over the
participant set, so it's a collision-free key — unlike listId, which is its
32-bit hashCode as a string and can collide. Including the account keeps the two
accounts' views of the same correspondent on separate cursors (the manager is a
singleton shared across logged-in accounts).
Also lighter on allocation than the string it replaces: the per-relay-event hot
path captures the key once per subscription (no per-event construction), and the
remaining call sites build one small object instead of concatenating + hashing a
string.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
The DM managers live in a single shared coordinator (Amethyst.instance.sources),
so every logged-in account uses the same instances. The per-account paging state
(pager cursors, started set, accounts map) was keyed by pubkey and fine, but the
single display flows — exhausted, relayCount, reachedBack, autoFillRoomMark —
were not. Switching from an account that had exhausted its history to another
left exhausted=true, so the second account's auto-fill was gated shut and its
chats never paged in.
Each history manager now tracks the active account/conversation and repoints its
display flows on switch: exhausted is restored per-account (kept in a small
exhaustedByUser/exhaustedByList map so an already-finished account shows "all
caught up" rather than re-paging), and the cosmetic flows + stall mark reset.
Paging cursors stay in the per-account pager, so progress is preserved.
Also scopes the conversation history pager key by account pubkey: a ChatroomKey
(hence listId) is identical for the same correspondent across accounts, so two
logged-in users viewing the same person would otherwise share one cursor.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Replaces the bare spinner + "Load entire history" link with a status card that
tells the user what the app is actually reaching for: per protocol, it shows
"Older <encrypted|legacy> messages" with a subtitle of "<NIP-17|NIP-04> · N
relays · back to <month>" while it pages. When that protocol runs dry the card
doesn't just vanish — it crossfades to "All caught up · Reached the start of
your <…> messages", holds for a beat, then collapses away.
The history managers now surface the live status the card needs: relayCount
(relays the current page is asking) and reachedBack (oldest point paged to, from
the deepest per-relay cursor), added to all three history managers and computed
via UntilLimitPager.deepestUntil. The "load entire history" action is dropped —
scroll-driven paging already walks to exhaustion, so the link was redundant.
Each protocol's card sits at its own oldest-loaded boundary (rooms list) or both
stack at the conversation's oldest end, so the two protocols' loading is shown
independently at their real depths. New strings use a <plurals> for the relay
count.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
The rooms list had a single auto-fill trigger and a single loading boundary,
both pinned to the oldest private room of EITHER protocol. When NIP-04 history
ran far deeper than NIP-17 (e.g. NIP-04 back to 2023, NIP-17 shallow), that
oldest room was a 2023 NIP-04 row at the very bottom, so gift-wrap loadMore only
fired when the user scrolled all the way down to it — NIP-17 never paged on the
way, and the loading indicator was only visible at the bottom.
Split the trigger and the boundary per protocol. Each protocol now widens on its
OWN oldest-loaded room (gated only on its own loader and its own room-count
stall-gate) and shows its OWN loading indicator at its own depth, so NIP-17 and
NIP-04 page independently as the user scrolls — matching their very different
histories. WidenPrivateWindowWhen is generalized to a per-protocol WidenHistoryWhen
called once per protocol (and once per protocol for the empty-feed hunt). Each
history manager carries its own auto-fill stall mark (autoFillRoomMark).
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
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>
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
The conversation auto-fill only fired when the thread overflowed the screen, so
a one-message room sat at its load-more boundary without ever advancing — you
were at the start of the chat but it wouldn't reach for older messages. That
overflow guard was added back when each widen re-downloaded the whole window
(to stop a short thread auto-walking the gift-wrap firehose); now that history
loads in bounded, non-re-downloading slices that reason is gone.
Drop the overflow requirement: load the next slice whenever the oldest end is in
view, including a thread too short to scroll. A one-message room now walks
history back to its real beginning (or until the window is exhausted), one
bounded slice at a time, gated on both loaders being idle.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
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
The wire-level diagnostics logger only recognized gift-wrap kinds (1059/21059),
so kind:4 NIP-04 REQs never produced a `REQ -> wss://…` line and their relays'
connect/CLOSED/NOTICE lines were filtered out — even though the kind:4 REQs are
issued (the `[rooms.nip04] REQ` manager logs show them going out). Broaden the
match to the whole DM path (1059/21059/4) so the wire trail covers both
protocols, and rename the gift-wrap-specific identifiers to dm-path.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
The load summary's "before floor" count compared incoming gift wraps against the
un-margined window floor (window.since), but filterGiftWrapsToPubkey actually
asks relays for `since = window.since - 2 days` to catch wraps whose randomized
outer timestamp dips below the real message time. So the deliberate 2-day margin
band showed up as "before floor" (a boot reported "6 before floor" that were all
legitimate margin-band wraps), conflating the intended margin with a relay that
ignores `since`. Compare against window.since - twoDays() so only a relay that
under-shoots the floor we actually requested is flagged.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
A boot trace reported `[giftwrap] load summary: 1 event(s)` for a 7-day window
that actually holds ~100, because the DM relays connect over a ~35s spread: one
fast relay delivered a single event at +7s, the next 3s were quiet only because
the other four relays were still mid-connect, and the idle heuristic mistook
that gap for "done". The rest streamed in afterwards, past the load boundary.
The clean "all relays answered" completion was also unreachable: relays that
answer `CLOSED auth-required` (and unreachable relays) never produced an EOSE,
and WindowLoadTracker ignored onClosed/onCannotConnect entirely — so the only
completion path was the too-eager idle timer firing in a connection gap.
Completion is now per-relay terminal-state based. A relay is "settled" once it
sends a terminal signal — EOSE, CLOSED, or cannot-connect — and the load is done
when every targeted relay has settled. This is fast when relays are fast
(everyone EOSEs in a couple seconds) and correctly patient when they are not
(waits for the slowest relay), and it cannot trip in a connection-stagger gap.
The idle timer is kept only as a backstop for a relay that streams without ever
EOSE'ing, gated behind "every relay has been heard from" so it too can't fire in
a gap; the absolute cap still bounds a relay that connects then hangs forever.
WindowLoadTracker.trackingListener now wires onClosed and onCannotConnect into
the tracker, and a live event no longer settles a relay (its preceding EOSE
does); forward (newEose) semantics are unchanged.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
When composing an anonymous post (tap pfp to go anon on the short-note
or comment screens), media uploads still authorized against the Blossom /
NIP-96 server with the real account's signer. The server echoes that
pubkey back in the returned media URL (e.g. Blossom's `as=<pubkey>`),
linking the real identity to the supposedly anonymous post.
Thread an optional `forcedSigner` through the upload chain
(MultiOrchestrator -> UploadOrchestrator -> NIP-96/Blossom auth). Both
ShortNotePostViewModel and CommentPostViewModel now hold a single
ephemeral signer per compose session, reused for every photo/voice
upload and for the final anonymous broadcast, so the upload auth event
and the post share one throwaway key. signAnonymouslyAndBroadcast accepts
that signer so the media author matches the post author. Non-anonymous
callers are unaffected (forcedSigner defaults to null).
The signer is reset in cancel() so each new compose session gets a fresh
anonymous identity.
A cold boot trace showed `[giftwrap] load done: idle` firing with 0 events at
+3s while the DM relays had not even connected yet (nos.lol first connected at
+14s). The idle watchdog could not tell "quiet because the relays answered"
from "quiet because nothing has connected", so a slow boot looked finished with
an empty result — and with the Messages screen open that false "done, 0 events"
would trip the auto-fill into widening the window over and over.
WindowLoadTracker now only arms the idle path after the first event or EOSE
(sawActivity). Before that first sign of life, the load can only end via a
generous no-response bound (30s) or the absolute cap, so a still-connecting
boot stays "loading" instead of falsely completing empty. The clean paths are
unchanged: relays that EOSE complete via "all relays", and a stream that starts
then quiets still completes via "idle".
Also makes the gift-wrap load-summary collector a singleton: the tracker is
shared across accounts, so launching it per newSub double-logged every summary
when a second account was logged in. Per-load counters are now reset
synchronously at load start (beginWindowLoad) rather than on the collector's
rising edge, so no in-flight event is counted against the wrong load.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Removes the EVENT <- and AUTH <- per-message lines from the DM diagnostics
logger (too busy, and auth is not relevant to the pagination trail), and adds
a per-load tally to the gift-wrap manager so a relay that ignores `since` and
re-streams the whole history every widen becomes visible.
Each load now counts the gift-wrap events the relays push and, separately,
those whose outer created_at falls before the floor the REQ asked for. A
collector on the window-load flag resets the counters when a load starts and
logs `[giftwrap] load summary: N event(s), X before floor (since=…)` when it
finishes. A total that keeps growing across widens (with a large out-of-window
share) is the fingerprint of "getting all the events over and over again".
The WindowLoadTracker.trackingListener gains an optional onEachEvent hook so
the manager can observe every event (stored or live) for this instrumentation
without changing the EOSE forwarding path.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Add a focused, low-noise log trail (tag "DMPagination") so DM windowing and
auto-fill can be observed while opening/closing rooms and scrolling. Per-event
noise stays off (onActivity is silent).
- WindowLoadTracker: takes a name ("giftwrap" / "rooms.nip04" / "convo.nip04")
and logs "load start" and "load done: <reason>" where reason is one of
all relays / idle / cap / no relays — the key signal for whether a load is
stuck or looping.
- AccountGiftWrapsEoseManager: logs window open, REQ floor (since + days back),
loadMore (from→to days, exhausted), loadEverything.
- NIP-04 followers: log their REQ floor and reload.
- Rooms list: logs OPEN/CLOSE and each widen with its trigger (empty/scroll).
- Conversation: logs room OPEN/CLOSE, each scroll-driven widen, and the
"Load entire history" tap.
The gap-free display floor was hiding the whole thread: until `coveredSince`
settled the floor was Long.MAX_VALUE (reveal nothing), but a thread clipped to
empty made the auto-fill fire (total == 0), which kept the loaders busy so
coveredSince never settled — a vicious cycle that walked the account-wide
gift-wrap window to exhaustion ("loads everything") while the thread stayed
blank ("loading sign and no message whatsoever").
- Remove the display-floor clipping (ChatFeedView no longer takes
oldestVisibleTime; ChatroomView drops rememberConversationDisplayFloor). The
thread renders the cached messages directly again, like before.
- Bound the conversation auto-fill: only load the next older window when the
thread already overflows the screen AND the user has scrolled near the oldest
loaded message, so a short thread is never auto-walked to the start of
history. The oldest-end boundary still offers an explicit "Load entire
history".
The kept-it-honest gap-free guarantee wasn't worth a blank inbox; the transient
NIP-04-before-NIP-17 ordering is the lesser evil. The loading spinner + load-all
boundary added last commit stays.
Replace the literal "CashuWalletState.start() not called" duplicated across
9 call sites (8 check guards + the publish default lambda) with a single
private companion constant.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- fix(dm-share): kotlin-review fixes (alias dot-boundary match + transient feed doc)
- fix(dm-share): address code-review findings (intent consume, media helper, manifest sync)
- fix(dm-share): make the picker one-shot so backing out doesn't duplicate drafts
- fix(dm): avoid duplicate drafts on abort by rotating draft tag after the async save
The conversation showed a perpetual spinner at the oldest end (it was tied to
`!exhausted`, not to actual loading) and had no "load all" escape, while the
rooms list showed a spinner only while loading plus a "Load entire history"
button. Make them consistent and share one component.
- Extract DmLoadMoreIndicator (spinner while loadingMore + "Load entire
history" button while not exhausted), used by both screens.
- ChatFeedView: replace the loadingOlder: Boolean flag with an opt-in
olderBoundary slot rendered at the oldest end; public-chat / channel callers
pass null (unchanged).
- ChatroomView: supply that boundary — spinner only when a window is actually
loading (gift wraps OR this room's NIP-04), button while there's older
history to reach, nothing once exhausted.
- Rooms list: drop its local PrivateChatsLoadMoreFooter in favor of the shared
one.
TagArrayBuilder.addUniqueValueIfNew had an inverted guard:
if (tag.has(1) || tag[0].isEmpty() || tag[1].isEmpty()) return this
Since has(index) == size > index, `tag.has(1)` is true for every
well-formed tag with a value, so the function returned early and never
added it. addUniqueValueIfNew / addAllUniqueValueIfNew are used only by
the quote() / quotes() builders, so every `q` tag (naddr, nevent, note,
nembed, npub, nprofile) has been silently dropped since this file was
introduced. Restore the missing `!` and add a regression test covering an
addressable (naddr) quote plus the guard's accept/skip/dedupe semantics.
https://claude.ai/code/session_01NMavNzJ7VRLhoD3hboCCC7
Collapse the DM windowing system to a single source of truth and remove the
accumulated duplication.
- One window: the gift-wrap (NIP-17) loader owns the only TimeWindowPagination.
The rooms-list NIP-04 loader no longer keeps its own window advanced "in
lockstep" — like the conversation loader, it now follows the gift-wrap
window's `windowSince` and re-requests via `reload()`. Removes its window,
loadMore, loadEverything and exhausted; the rooms screen drives
giftWraps.loadMore() + nip04.reload() and reads giftWraps.exhausted alone.
- Shared listener: extract WindowLoadTracker.trackingListener(forward) — the
one place that feeds onActivity/onRelayResponded — replacing three copies of
subscription-listener boilerplate and two newEose overrides.
- Drop the cold-boot instrumentation (bootStartMs/bootEventCount/bootEoseLogged
+ verbose per-call logs) from the gift-wrap manager; it was development
scaffolding. (The debug-gated DmRelayDiagnosticsLogger stays.)
- Renames for clarity: DMsFromUserFilterSubAssembler -> ChatroomListNip04SubAssembler,
ChatroomFilterSubAssembler -> ChatroomNip04SubAssembler, field nip04Dms -> nip04.
Behavior is unchanged: same auto-fill/prefetch, same all-relays-or-idle gating,
same gap-free conversation reveal. ~250 fewer lines and no more lockstep concept.
A thread renders the LocalCache union as events land, and NIP-04 (one
decrypt) paints faster than NIP-17 (gift-wrap unwrap = two NIP-44 decrypts).
So even with both windows requested to the same depth, a thread could
transiently show kind:4 messages with the kind:1059 messages that belong
between them still missing — and a user could read it as complete.
Introduce a per-conversation display floor: only reveal messages at or newer
than the deepest gift-wrap floor at which BOTH protocols have finished
loading, plus a 2-day margin (NIP-17 randomizes the gift wrap's outer
created_at up to 2 days, and relays filter on that outer time, so fetching
outer >= F only guarantees holding every inner time >= F+2d). The floor is
monotonic (revealed history never retracts) and a "loading older" boundary
shows at the oldest end until the window is exhausted, so incompleteness is
always visible rather than mistaken for "done".
- ChatroomFilterSubAssembler gains a WindowLoadTracker (loadingMore) and a
reload(), so the conversation knows when NIP-04 has covered the floor; the
scroll widen now gates on both protocols and calls reload() instead of a
bare invalidate.
- ChatFeedView gains opt-in oldestVisibleTime (clip) + loadingOlder
(boundary) params; public-chat / channel callers default to no-op.
- ChatroomView computes the floor from both loaders' idle state + windowSince
and passes it down.
Honest limits: a relay that withholds data can't be conjured (we never hide
incompleteness, floor only descends); a sender backdating the gift-wrap outer
timestamp beyond the 2-day spec can still plant a late message, defended only
by "Load entire history". The rooms list is intentionally not clipped this
way — there, hiding a known conversation is worse than a row reordering.
- Fix like: read replyNote.event inside lambda (not captured val)
to avoid stale null reference. Consume reaction into local cache.
- Wire zap on comments: uses zapNote (now internal) with 21 sats default
via NWC connection, same flow as main action row
- Wire like/zap in both FeedScreen (inline expansion) and ThreadScreen
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Wire onLike on CommentItem: ReactionAction.reactTo + broadcast
- Related content clicks use overlay navigation (ThreadScreen) since
related notes may not be in the feed LazyColumn
- Add onNavigateToThreadOverlay param to ExpandedNoteContent
- Zap from comments deferred (requires full NWC flow)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Observe note.flow().replies so replyNotes recomputes when replies arrive
- Use loadMetadataBatched with explicit author pubkeys from reply events
- DisposableEffect for proper flow cleanup
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add expandedNoteId state to FeedScreen — clicking a card expands it
in-place instead of navigating to separate ThreadScreen
- AnimatedVisibility(expandVertically + fadeIn) for smooth expansion
- ExpandedNoteContent composable renders CommentsCard + RelatedContentSection
below the expanded card within the same LazyColumn item
- Auto-scroll expanded card to top of viewport
- Thread reply subscriptions start on expand, cancel on collapse
- Only one card expanded at a time — clicking another collapses current
- Search bar stays visible (floating header above LazyColumn)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix follow pill layout: author row uses weight(1f) so pill has room
(was invisible due to SpaceBetween squeezing)
- Fix comment metadata: observe metadataState so author info recomposes
when kind:0 arrives from relay
- Wire "View all" on related content to navigate to author profile
- Wire reply button on CommentItem to open reply compose dialog
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>