refactor(dm): unify per-relay reach vocabulary + clarify Nip04 routing name

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) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-06-05 17:10:38 -04:00
co-authored by Claude Opus 4.8
parent e9f2f1d7aa
commit cd6537bfd5
6 changed files with 45 additions and 45 deletions
@@ -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`
@@ -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 }
}
},
)
@@ -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<NormalizedRelayUrl>? = allKeys().firstOrNull { convoKey(it) == pk }?.let { nip04DMRelays(it.room.users, it.account)?.all }
private fun relaysFor(pk: ConvoKey): Collection<NormalizedRelayUrl>? = allKeys().firstOrNull { convoKey(it) == pk }?.let { nip04DmRelayRouting(it.room.users, it.account)?.all }
private val pager = BackwardRelayPager<ConvoKey>("convo.nip04.history", relaysFor = ::relaysFor)
@@ -83,7 +83,7 @@ class ChatroomNip04HistorySubAssembler(
since: SincePerRelayMap?,
): List<RelayBasedFilter>? {
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 },
)
@@ -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<NormalizedRelayUrl, Set<HexKey>>,
val fromMeRelays: Map<NormalizedRelayUrl, Set<HexKey>>,
) {
@@ -57,10 +57,10 @@ private fun addAll(
keys: Collection<HexKey>,
) = relays.forEach { map.getOrPut(it) { mutableSetOf() }.addAll(keys) }
fun nip04DMRelays(
fun nip04DmRelayRouting(
group: Set<HexKey>?,
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<RelayBasedFilter>? {
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<RelayBasedFilter> =
@@ -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(),
@@ -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<RelayWindowLimit>,
fun RelayReachSentinels(
limits: List<RelayReachCursor>,
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<RelayWindowLimit>,
fun RelayReachMarkers(
limits: List<RelayReachCursor>,
newerCreatedAt: Long?,
olderCreatedAt: Long?,
) {