From cd6537bfd590b91bba85a94bd76fc05d768b8c1f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 5 Jun 2026 17:10:38 -0400 Subject: [PATCH] refactor(dm): unify per-relay reach vocabulary + clarify Nip04 routing name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two naming families described the same concept — a relay's position in its backward history walk — and the UI name overloaded the heavily-used "window" and REQ "limit" terms. Collapse onto one vocabulary ("Reach"): - RelayWindowLimit -> RelayReachCursor - RelayWindowLimitMarkers -> RelayReachMarkers - RelayWindowLimitSentinels -> RelayReachSentinels And rename the NIP-04 per-relay routing map so it reads as a map, not a list: - Nip04DmRelays (class) / nip04DMRelays (factory) -> Nip04DmRelayRouting / nip04DmRelayRouting Pure rename — no behavior change. Design doc updated to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-06-01-dm-live-tail-and-history-slices.md | 14 +++++------ .../loggedIn/chats/privateDM/ChatroomView.kt | 18 +++++++------- .../ChatroomNip04HistorySubAssembler.kt | 6 ++--- .../privateDM/datasource/FilterNip04DMs.kt | 12 +++++----- .../chats/rooms/feed/ChatroomListFeedView.kt | 16 ++++++------- .../commons/ui/feeds/RelayReachMarker.kt | 24 +++++++++---------- 6 files changed, 45 insertions(+), 45 deletions(-) diff --git a/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md b/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md index 6fe5b37aef..2f51b44378 100644 --- a/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md +++ b/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md @@ -124,12 +124,12 @@ to load, just not advancing) keeps it false. History paging is demand-driven by **per-relay window-limit markers** placed in the message stream, not by a scroll-position trigger: -- **`RelayWindowLimit`** — one per (protocol, relay): its `reachedUntil` depth, +- **`RelayReachCursor`** — one per (protocol, relay): its `reachedUntil` depth, its `RelayReachState` (`REACHING ↓` / `STALLED …` / `DONE ✓`), and the `advance()` that pulls *that relay's* next page. Built in the feed views from each history manager's `relayProgress` map (gift wraps + NIP-04 combined; a protocol drops out of the list once `exhausted`). -- **`RelayWindowLimitSentinels`** — the load *driver*, **hoisted above the +- **`RelayReachSentinels`** — the load *driver*, **hoisted above the `LazyColumn`** (via `ChatFeedView`'s `sentinels` slot). Each non-done limit gets one stable effect (keyed by `protocol:url`) that watches `listState` and fires `advance()` when its gap is among the **currently visible rows** AND @@ -139,7 +139,7 @@ the message stream, not by a scroll-position trigger: hosting row, so any feed reorder (a live DM, a slow relay dribbling a page) tore the effect down and re-fired `advance()` on a static screen — re-arming stalled relays into a silence-watchdog storm. (commit `0394ec2a`) -- **`RelayWindowLimitMarkers` / `RelayReachMarker`** — pure UI (via the +- **`RelayReachMarkers` / `RelayReachMarker`** — pure UI (via the `markersInGap` slot): the "Relay sync: ✓ 8 · ↓ 1" divider at each relay's reached depth. Can be re-placed on every reorder without triggering paging. - **`BootstrapHistoryWhenEmpty`** — when the feed is genuinely `Empty` (the live @@ -148,7 +148,7 @@ the message stream, not by a scroll-position trigger: a time (debounced 1200ms, gated per loader on `!loading && !exhausted`) until messages appear and the markers take over, or the protocol exhausts. -### NIP-04 per-relay filter scoping (`Nip04DmRelays`) +### NIP-04 per-relay filter scoping (`Nip04DmRelayRouting`) A conversation's NIP-04 filters previously named the whole participant set on every relay, so a relay belonging to one correspondent was asked about all of @@ -156,7 +156,7 @@ them, and the `from-me` leg (`authors:[me]`) was sent to correspondents' inbox relays — which auth-walled relays reject outright ("all authors must be authenticated"), stalling the load. -`Nip04DmRelays` (in `FilterNip04DMs.kt`) is now two **per-relay key maps** +`Nip04DmRelayRouting` (in `FilterNip04DMs.kt`) is now two **per-relay key maps** (`relay → which keys to name there`), built from the outbox model: - **to me** (`#p:[me]`) — my inbox carries the whole group; each correspondent's @@ -244,13 +244,13 @@ source set (uses `java.util.concurrent`), visible to amethyst + desktop + quartz - `AccountGiftWrapsEoseManager.kt` (live tail) + `AccountGiftWrapsHistoryEoseManager.kt` (new, history). - `ChatroomNip04SubAssembler.kt` (live tail) + `ChatroomNip04HistorySubAssembler.kt` (new, history). - `ChatroomListNip04SubAssembler.kt` (live tail) + `ChatroomListNip04HistorySubAssembler.kt` (new, history). -- `FilterNip04DMs.kt` (per-relay `Nip04DmRelays`, live + history builders), `FilterNip04DMsFromMe/ToMe.kt`, `FilterGiftWrapsToPubkey.kt` — `until`/`limit` added. +- `FilterNip04DMs.kt` (per-relay `Nip04DmRelayRouting`, live + history builders), `FilterNip04DMsFromMe/ToMe.kt`, `FilterGiftWrapsToPubkey.kt` — `until`/`limit` added. - `AccountFilterAssembler`, `ChatroomFilterAssembler`, `ChatroomListFilterAssembler` — wire the new managers. **Shared UI (commons, `commons/ui/feeds/`)** — extracted from amethyst so Android + Desktop (and any per-relay feed) render the same widgets; CMP `composeResources` strings, no app-theme / `java.time` deps. -- `RelayReachMarker.kt` — `RelayWindowLimit` + sentinels (the hoisted, visibility-driven +- `RelayReachMarker.kt` — `RelayReachCursor` + sentinels (the hoisted, visibility-driven paging driver) + markers (pure UI) + `RelayReachMarker`/`RelayReachState`. - `DmHistoryLoadingCard.kt` — the boundary status card + per-relay tap dialog + `historySubtitle`/`incompleteSubtitle`. Takes a `formatReachDate: (epochSeconds) -> String` diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt index decd0d2efe..4134d58681 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt @@ -42,10 +42,10 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachSentinels import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState -import com.vitorpamplona.amethyst.commons.ui.feeds.RelayWindowLimit -import com.vitorpamplona.amethyst.commons.ui.feeds.RelayWindowLimitMarkers -import com.vitorpamplona.amethyst.commons.ui.feeds.RelayWindowLimitSentinels import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel @@ -230,19 +230,19 @@ fun ChatroomViewUI( val user = accountViewModel.userProfile() // Both protocols' per-relay window limits, each carrying the advance() that pulls its own next page. - // Placed in the stream as sentinels (see RelayWindowLimitMarkers): a relay pages only while its + // Placed in the stream as sentinels (see RelayReachMarkers): a relay pages only while its // marker is on screen, and keeps paging while it stays there. A protocol drops out once exhausted. val limits = remember(nip04Progress, giftWrapsProgress, nip04Exhausted, giftWrapsExhausted, user) { buildList { if (!giftWrapsExhausted) { giftWrapsProgress.forEach { (relay, p) -> - add(RelayWindowLimit("17:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p)) { giftWrapsHistory.advance(user, relay) }) + add(RelayReachCursor("17:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p)) { giftWrapsHistory.advance(user, relay) }) } } if (!nip04Exhausted) { nip04Progress.forEach { (relay, p) -> - add(RelayWindowLimit("04:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p)) { nip04History.advance(relay) }) + add(RelayReachCursor("04:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p)) { nip04History.advance(relay) }) } } } @@ -284,16 +284,16 @@ fun ChatroomViewUI( if (limits.isEmpty()) { null } else { - { newer, older -> RelayWindowLimitMarkers(limits, newer, older) } + { newer, older -> RelayReachMarkers(limits, newer, older) } }, // The hoisted load driver that pulls each relay's next page while its marker is on screen, - // off viewport visibility (see RelayWindowLimitSentinels) so feed reorders don't re-page. + // off viewport visibility (see RelayReachSentinels) so feed reorders don't re-page. sentinels = if (limits.isEmpty()) { null } else { { items, listState -> - RelayWindowLimitSentinels(limits, listState) { index -> items.getOrNull(index)?.event?.createdAt } + RelayReachSentinels(limits, listState) { index -> items.getOrNull(index)?.event?.createdAt } } }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04HistorySubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04HistorySubAssembler.kt index 6244c2218c..f777c318f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04HistorySubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04HistorySubAssembler.kt @@ -63,7 +63,7 @@ class ChatroomNip04HistorySubAssembler( private fun convoKey(key: ChatroomQueryState) = ConvoKey(user(key).pubkeyHex, key.room) // The conversation's relay set for a key, resolved via the outbox model (per-relay scoped). - private fun relaysFor(pk: ConvoKey): Collection? = allKeys().firstOrNull { convoKey(it) == pk }?.let { nip04DMRelays(it.room.users, it.account)?.all } + private fun relaysFor(pk: ConvoKey): Collection? = allKeys().firstOrNull { convoKey(it) == pk }?.let { nip04DmRelayRouting(it.room.users, it.account)?.all } private val pager = BackwardRelayPager("convo.nip04.history", relaysFor = ::relaysFor) @@ -83,7 +83,7 @@ class ChatroomNip04HistorySubAssembler( since: SincePerRelayMap?, ): List? { val pk = convoKey(key) - val relays = nip04DMRelays(key.room.users, key.account) + val relays = nip04DmRelayRouting(key.room.users, key.account) if (!key.account.isWriteable() || relays == null) return emptyList() // Only armed (advanced, not done) relays carry a REQ, each at its own requested cursor. A parked @@ -92,7 +92,7 @@ class ChatroomNip04HistorySubAssembler( if (armed.isEmpty()) return emptyList() DmRelayLog.log("convo.nip04.history", key.account) val scoped = - Nip04DmRelays( + Nip04DmRelayRouting( toMeRelays = relays.toMeRelays.filterKeys { it in armed }, fromMeRelays = relays.fromMeRelays.filterKeys { it in armed }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt index a1217546c8..5072ce8db0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt @@ -44,7 +44,7 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent * Scoping the key set per relay is what keeps us from sending, e.g., `authors=[bob]` to a relay that * is only charlie's — a filter that relay has no reason to serve. */ -class Nip04DmRelays( +class Nip04DmRelayRouting( val toMeRelays: Map>, val fromMeRelays: Map>, ) { @@ -57,10 +57,10 @@ private fun addAll( keys: Collection, ) = relays.forEach { map.getOrPut(it) { mutableSetOf() }.addAll(keys) } -fun nip04DMRelays( +fun nip04DmRelayRouting( group: Set?, account: Account?, -): Nip04DmRelays? { +): Nip04DmRelayRouting? { if (group.isNullOrEmpty() || account == null) return null val userOutboxRelays = account.homeRelays.flow.value @@ -98,7 +98,7 @@ fun nip04DMRelays( addAll(fromMe, inbox, listOf(it)) } - return Nip04DmRelays(toMe, fromMe) + return Nip04DmRelayRouting(toMe, fromMe) } private fun toMeFilter( @@ -148,7 +148,7 @@ fun filterNip04DMs( windowStart: Long, ): List? { if (group.isNullOrEmpty() || account == null) return null - val relays = nip04DMRelays(group, account) ?: return null + val relays = nip04DmRelayRouting(group, account) ?: return null return relays.toMeRelays.map { (relay, authors) -> toMeFilter(relay, authors, account, since = windowStart, until = null, limit = null) } + relays.fromMeRelays.map { (relay, pTags) -> fromMeFilter(relay, pTags, account, since = windowStart, until = null, limit = null) } } @@ -160,7 +160,7 @@ fun filterNip04DMs( */ fun filterNip04DMsHistory( account: Account, - relays: Nip04DmRelays, + relays: Nip04DmRelayRouting, limit: Int, untilFor: (NormalizedRelayUrl) -> Long?, ): List = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt index 923c20473e..3a4e54e0ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt @@ -40,10 +40,10 @@ import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachSentinels import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState -import com.vitorpamplona.amethyst.commons.ui.feeds.RelayWindowLimit -import com.vitorpamplona.amethyst.commons.ui.feeds.RelayWindowLimitMarkers -import com.vitorpamplona.amethyst.commons.ui.feeds.RelayWindowLimitSentinels import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty @@ -185,7 +185,7 @@ private fun FeedLoaded( val oldestNip04Index = items.list.indexOfLast { it.event is PrivateDmEvent } // Each relay's window limit, carrying the advance() that pulls its OWN next page. Placed in the list - // at its reached depth as a sentinel (see RelayWindowLimitMarkers): a relay pages only while its + // at its reached depth as a sentinel (see RelayReachMarkers): a relay pages only while its // marker is on screen and keeps paging while it stays there, so a spam-dense relay never floods — // you have to scroll through its messages to pull more. A protocol drops out once exhausted. val limits = @@ -193,12 +193,12 @@ private fun FeedLoaded( buildList { if (!giftWrapsExhausted) { giftWrapsProgress.forEach { (relay, p) -> - add(RelayWindowLimit("17:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p)) { giftWrapsHistory.advance(user, relay) }) + add(RelayReachCursor("17:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p)) { giftWrapsHistory.advance(user, relay) }) } } if (!nip04Exhausted) { nip04Progress.forEach { (relay, p) -> - add(RelayWindowLimit("04:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p)) { nip04History.advance(user, relay) }) + add(RelayReachCursor("04:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p)) { nip04History.advance(user, relay) }) } } } @@ -206,7 +206,7 @@ private fun FeedLoaded( // Hoisted load driver: pulls each relay's next page off viewport visibility, so feed reorders // (a live DM bumping a room) no longer re-fire paging. The markers below are pure UI. - RelayWindowLimitSentinels(limits, listState) { index -> items.list.getOrNull(index)?.createdAt() } + RelayReachSentinels(limits, listState) { index -> items.list.getOrNull(index)?.createdAt() } LazyColumn( contentPadding = rememberFeedContentPadding(FeedPadding), @@ -240,7 +240,7 @@ private fun FeedLoaded( // Per-relay window-limit markers/sentinels belonging in the gap toward the next-older room: // each pulls its relay's next page while it's on screen. olderCreatedAt is null past the // oldest loaded room, so relays that have reached the bottom of the list sit there. - RelayWindowLimitMarkers( + RelayReachMarkers( limits, item.createdAt(), items.list.getOrNull(index + 1)?.createdAt(), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt index f886f93383..a136bdbd09 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt @@ -54,8 +54,8 @@ private val DividerThickness = 0.25.dp * True when [reachedUntil] falls in the gap between a newer message (at [newerCreatedAt]) and its * next-older neighbour (at [olderCreatedAt], null past the oldest end): the newer side is strictly * newer than the cursor and the older side is at or below it (or absent). This single predicate both - * places the marker ([RelayWindowLimitMarkers]) and decides when its paging sentinel is on screen - * ([RelayWindowLimitSentinels]), so the two can never disagree about which gap a cursor lives in. + * places the marker ([RelayReachMarkers]) and decides when its paging sentinel is on screen + * ([RelayReachSentinels]), so the two can never disagree about which gap a cursor lives in. */ internal fun reachedFallsInGap( reachedUntil: Long, @@ -87,12 +87,12 @@ data class RelayReach( /** * One relay's window-limit: places a marker and carries the [advance] that pulls that relay's next, * older page. The marker sits at [reachedUntil] (the oldest point the relay has paged to); - * [RelayWindowLimitMarkers] draws it and [RelayWindowLimitSentinels] fires [advance] while it is on + * [RelayReachMarkers] draws it and [RelayReachSentinels] fires [advance] while it is on * screen. * * @param key stable identity (protocol tag + relay url) so the sentinel survives list reorders. */ -data class RelayWindowLimit( +data class RelayReachCursor( val key: String, val name: String, val reachedUntil: Long, @@ -103,7 +103,7 @@ data class RelayWindowLimit( /** * Drives demand-driven paging for every limit, **hoisted above the list** so its identity does not ride * on which row currently hosts the marker. Each non-done limit gets one stable effect (keyed by - * [RelayWindowLimit.key]) that watches the [listState] and pulls that relay's next page when its marker + * [RelayReachCursor.key]) that watches the [listState] and pulls that relay's next page when its marker * is on screen. * * Why hoisted: the marker for a limit lives in exactly one gap (between the two rows straddling its @@ -120,11 +120,11 @@ data class RelayWindowLimit( * A done relay drives nothing. * * @param createdAtAt createdAt of the list item at an index (null past the ends / for non-message rows), - * so the visible-gap test mirrors [RelayWindowLimitMarkers]'s placement against only the on-screen rows. + * so the visible-gap test mirrors [RelayReachMarkers]'s placement against only the on-screen rows. */ @Composable -fun RelayWindowLimitSentinels( - limits: List, +fun RelayReachSentinels( + limits: List, listState: LazyListState, createdAtAt: (index: Int) -> Long?, ) { @@ -139,7 +139,7 @@ fun RelayWindowLimitSentinels( val r = reached.value val at = getAt.value // Visible if any on-screen row is the "newer" side of the gap holding this cursor — - // the same predicate RelayWindowLimitMarkers uses to place the marker, but over the + // the same predicate RelayReachMarkers uses to place the marker, but over the // visible rows only. val onScreen = listState.layoutInfo.visibleItemsInfo.any { info -> @@ -165,12 +165,12 @@ fun RelayWindowLimitSentinels( /** * Renders the window-limit markers for the relays whose limit falls in the gap between a newer message * (at [newerCreatedAt]) and its next-older neighbour (at [olderCreatedAt], null at the oldest end). Pure - * UI: the load driving lives in [RelayWindowLimitSentinels], so this can be (re)placed freely per row on + * UI: the load driving lives in [RelayReachSentinels], so this can be (re)placed freely per row on * every feed reorder without triggering any paging. */ @Composable -fun RelayWindowLimitMarkers( - limits: List, +fun RelayReachMarkers( + limits: List, newerCreatedAt: Long?, olderCreatedAt: Long?, ) {