mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 00:37:41 +00:00
refactor(commons): collapse pager status flows into one PagingStatus snapshot
BackwardRelayPager exposed five independently-updated StateFlows (exhausted, relayCount, stalledCount, reachedBack, relayProgress) that are all recomputed together on every page settle. Co-located consumers therefore paid up to five separate recompositions per settle and could observe a torn read (e.g. an updated relayCount against a still-stale relayProgress). Combine them into one atomic PagingStatus snapshot, emitted by a single publish(), collected once. updateStatus()/recomputeExhausted() merge into that publish() (exhausted computed inline). loadingMore stays separate: its falling edge is debounced on its own timer in PerRelayLoadTracker, decoupled from the status recompute, so folding it in would miss that delayed transition. Threaded through the 3 history managers and the 3 feed consumers (ChatroomListFeedView, ChatroomView, LoadingReplyNote): 12 collectors -> 4 at the heaviest views. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b21aecdfca
commit
75315095ee
+2
-6
@@ -22,13 +22,13 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59G
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
||||
@@ -64,11 +64,7 @@ class AccountGiftWrapsHistoryEoseManager(
|
||||
private val pager = BackwardRelayPager("giftwrap.history")
|
||||
|
||||
val loadingMore: StateFlow<Boolean> = pager.loadingMore
|
||||
val exhausted: StateFlow<Boolean> = pager.exhausted
|
||||
val relayCount: StateFlow<Int> = pager.relayCount
|
||||
val stalledCount: StateFlow<Int> = pager.stalledCount
|
||||
val reachedBack: StateFlow<Long?> = pager.reachedBack
|
||||
val relayProgress: StateFlow<Map<NormalizedRelayUrl, RelayPagingProgress>> = pager.relayProgress
|
||||
val status: StateFlow<PagingStatus> = pager.status
|
||||
|
||||
private fun daysAgo(epochSeconds: Long) = (TimeUtils.now() - epochSeconds) / TimeUtils.ONE_DAY
|
||||
|
||||
|
||||
+13
-32
@@ -49,13 +49,12 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryRelayDialog
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.historySubtitle
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.incompleteSubtitle
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
@@ -102,30 +101,10 @@ fun LoadingReplyNote(
|
||||
DmReplyProtocol.NIP17 -> giftWrapsHistory.loadingMore
|
||||
DmReplyProtocol.NIP04 -> nip04History.loadingMore
|
||||
}
|
||||
val exhaustedFlow: StateFlow<Boolean> =
|
||||
val statusFlow: StateFlow<PagingStatus> =
|
||||
when (protocol) {
|
||||
DmReplyProtocol.NIP17 -> giftWrapsHistory.exhausted
|
||||
DmReplyProtocol.NIP04 -> nip04History.exhausted
|
||||
}
|
||||
val relayCountFlow: StateFlow<Int> =
|
||||
when (protocol) {
|
||||
DmReplyProtocol.NIP17 -> giftWrapsHistory.relayCount
|
||||
DmReplyProtocol.NIP04 -> nip04History.relayCount
|
||||
}
|
||||
val stalledCountFlow: StateFlow<Int> =
|
||||
when (protocol) {
|
||||
DmReplyProtocol.NIP17 -> giftWrapsHistory.stalledCount
|
||||
DmReplyProtocol.NIP04 -> nip04History.stalledCount
|
||||
}
|
||||
val reachedBackFlow: StateFlow<Long?> =
|
||||
when (protocol) {
|
||||
DmReplyProtocol.NIP17 -> giftWrapsHistory.reachedBack
|
||||
DmReplyProtocol.NIP04 -> nip04History.reachedBack
|
||||
}
|
||||
val relayProgressFlow: StateFlow<Map<NormalizedRelayUrl, RelayPagingProgress>> =
|
||||
when (protocol) {
|
||||
DmReplyProtocol.NIP17 -> giftWrapsHistory.relayProgress
|
||||
DmReplyProtocol.NIP04 -> nip04History.relayProgress
|
||||
DmReplyProtocol.NIP17 -> giftWrapsHistory.status
|
||||
DmReplyProtocol.NIP04 -> nip04History.status
|
||||
}
|
||||
val protocolTag =
|
||||
when (protocol) {
|
||||
@@ -133,17 +112,19 @@ fun LoadingReplyNote(
|
||||
DmReplyProtocol.NIP04 -> "NIP-04"
|
||||
}
|
||||
|
||||
val exhausted by exhaustedFlow.collectAsStateWithLifecycle()
|
||||
val relayCount by relayCountFlow.collectAsStateWithLifecycle()
|
||||
val stalledCount by stalledCountFlow.collectAsStateWithLifecycle()
|
||||
val reachedBack by reachedBackFlow.collectAsStateWithLifecycle()
|
||||
val relayProgress by relayProgressFlow.collectAsStateWithLifecycle()
|
||||
// One snapshot collector instead of five; the fields below are plain reads off it (downstream unchanged).
|
||||
val status by statusFlow.collectAsStateWithLifecycle()
|
||||
val exhausted = status.exhausted
|
||||
val relayCount = status.relayCount
|
||||
val stalledCount = status.stalledCount
|
||||
val reachedBack = status.reachedBack
|
||||
val relayProgress = status.relayProgress
|
||||
|
||||
LaunchedEffect(protocol, loadingFlow, exhaustedFlow) {
|
||||
LaunchedEffect(protocol, loadingFlow, statusFlow) {
|
||||
// Step the next, older page whenever the previous one has settled and history isn't exhausted.
|
||||
// The target may surface mid-page (this composable then leaves composition and cancels us); if
|
||||
// not, we keep walking until the protocol bottoms out and the filter stops passing.
|
||||
combine(loadingFlow, exhaustedFlow) { loading, exhaustedNow -> !loading && !exhaustedNow }
|
||||
combine(loadingFlow, statusFlow) { loading, s -> !loading && !s.exhausted }
|
||||
.distinctUntilChanged()
|
||||
.filter { it }
|
||||
.collect {
|
||||
|
||||
+13
-19
@@ -181,7 +181,7 @@ private fun BootstrapHistoryWhenEmpty(
|
||||
LaunchedEffect(needsBootstrap, giftWrapsHistory) {
|
||||
if (!needsBootstrap) return@LaunchedEffect
|
||||
delay(BOOTSTRAP_DEBOUNCE_MS)
|
||||
combine(giftWrapsHistory.loadingMore, giftWrapsHistory.exhausted) { loading, exhausted -> !loading && !exhausted }
|
||||
combine(giftWrapsHistory.loadingMore, giftWrapsHistory.status) { loading, s -> !loading && !s.exhausted }
|
||||
.distinctUntilChanged()
|
||||
.filter { it }
|
||||
.collect { giftWrapsHistory.advanceAll() }
|
||||
@@ -189,7 +189,7 @@ private fun BootstrapHistoryWhenEmpty(
|
||||
LaunchedEffect(needsBootstrap, nip04History) {
|
||||
if (!needsBootstrap) return@LaunchedEffect
|
||||
delay(BOOTSTRAP_DEBOUNCE_MS)
|
||||
combine(nip04History.loadingMore, nip04History.exhausted) { loading, exhausted -> !loading && !exhausted }
|
||||
combine(nip04History.loadingMore, nip04History.status) { loading, s -> !loading && !s.exhausted }
|
||||
.distinctUntilChanged()
|
||||
.filter { it }
|
||||
.collect { nip04History.advanceAll() }
|
||||
@@ -219,31 +219,25 @@ fun ChatroomViewUI(
|
||||
val nip04History = remember(accountViewModel) { accountViewModel.dataSources().chatroom.nip04History }
|
||||
val loadingGiftWraps by giftWrapsHistory.loadingMore.collectAsStateWithLifecycle()
|
||||
val loadingNip04 by nip04History.loadingMore.collectAsStateWithLifecycle()
|
||||
val giftWrapsExhausted by giftWrapsHistory.exhausted.collectAsStateWithLifecycle()
|
||||
val nip04Exhausted by nip04History.exhausted.collectAsStateWithLifecycle()
|
||||
val giftWrapsRelays by giftWrapsHistory.relayCount.collectAsStateWithLifecycle()
|
||||
val giftWrapsStalled by giftWrapsHistory.stalledCount.collectAsStateWithLifecycle()
|
||||
val giftWrapsReached by giftWrapsHistory.reachedBack.collectAsStateWithLifecycle()
|
||||
val nip04Relays by nip04History.relayCount.collectAsStateWithLifecycle()
|
||||
val nip04Stalled by nip04History.stalledCount.collectAsStateWithLifecycle()
|
||||
val nip04Reached by nip04History.reachedBack.collectAsStateWithLifecycle()
|
||||
val nip04Progress by nip04History.relayProgress.collectAsStateWithLifecycle()
|
||||
val giftWrapsProgress by giftWrapsHistory.relayProgress.collectAsStateWithLifecycle()
|
||||
// One atomic snapshot per protocol (exhausted + relays + reached + per-relay progress) instead of six
|
||||
// separate collectors — the status card and the per-relay markers read all of it together anyway.
|
||||
val giftWrapsStatus by giftWrapsHistory.status.collectAsStateWithLifecycle()
|
||||
val nip04Status by nip04History.status.collectAsStateWithLifecycle()
|
||||
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 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) {
|
||||
remember(nip04Status, giftWrapsStatus, user) {
|
||||
buildList {
|
||||
if (!giftWrapsExhausted) {
|
||||
giftWrapsProgress.forEach { (relay, p) ->
|
||||
if (!giftWrapsStatus.exhausted) {
|
||||
giftWrapsStatus.relayProgress.forEach { (relay, p) ->
|
||||
add(RelayReachCursor("17:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p), "NIP-17") { giftWrapsHistory.advance(relay) })
|
||||
}
|
||||
}
|
||||
if (!nip04Exhausted) {
|
||||
nip04Progress.forEach { (relay, p) ->
|
||||
if (!nip04Status.exhausted) {
|
||||
nip04Status.relayProgress.forEach { (relay, p) ->
|
||||
add(RelayReachCursor("04:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p), "NIP-04") { nip04History.advance(relay) })
|
||||
}
|
||||
}
|
||||
@@ -282,8 +276,8 @@ fun ChatroomViewUI(
|
||||
// while it pages and crossfades to "All caught up" when that protocol runs dry.
|
||||
olderBoundary = {
|
||||
Column {
|
||||
DmHistoryLoadingCard(nip17Name, "NIP-17", loadingGiftWraps, giftWrapsExhausted, giftWrapsRelays, giftWrapsStalled, giftWrapsReached, giftWrapsProgress, ::formatHistoryReachDate)
|
||||
DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Exhausted, nip04Relays, nip04Stalled, nip04Reached, nip04Progress, ::formatHistoryReachDate)
|
||||
DmHistoryLoadingCard(nip17Name, "NIP-17", loadingGiftWraps, giftWrapsStatus.exhausted, giftWrapsStatus.relayCount, giftWrapsStatus.stalledCount, giftWrapsStatus.reachedBack, giftWrapsStatus.relayProgress, ::formatHistoryReachDate)
|
||||
DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Status.exhausted, nip04Status.relayCount, nip04Status.stalledCount, nip04Status.reachedBack, nip04Status.relayProgress, ::formatHistoryReachDate)
|
||||
}
|
||||
},
|
||||
// Each relay's window-limit marker, placed at its reached cursor (pure UI). Hidden once
|
||||
|
||||
+2
-6
@@ -21,12 +21,12 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
||||
@@ -56,11 +56,7 @@ class ChatroomNip04HistorySubAssembler(
|
||||
private val pager = BackwardRelayPager("convo.nip04.history")
|
||||
|
||||
val loadingMore: StateFlow<Boolean> = pager.loadingMore
|
||||
val exhausted: StateFlow<Boolean> = pager.exhausted
|
||||
val relayCount: StateFlow<Int> = pager.relayCount
|
||||
val stalledCount: StateFlow<Int> = pager.stalledCount
|
||||
val reachedBack: StateFlow<Long?> = pager.reachedBack
|
||||
val relayProgress: StateFlow<Map<NormalizedRelayUrl, RelayPagingProgress>> = pager.relayProgress
|
||||
val status: StateFlow<PagingStatus> = pager.status
|
||||
|
||||
override fun user(key: ChatroomQueryState) = key.account.userProfile()
|
||||
|
||||
|
||||
+2
-6
@@ -21,13 +21,13 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
||||
@@ -56,11 +56,7 @@ class ChatroomListNip04HistorySubAssembler(
|
||||
private val pager = BackwardRelayPager("rooms.nip04.history")
|
||||
|
||||
val loadingMore: StateFlow<Boolean> = pager.loadingMore
|
||||
val exhausted: StateFlow<Boolean> = pager.exhausted
|
||||
val relayCount: StateFlow<Int> = pager.relayCount
|
||||
val stalledCount: StateFlow<Int> = pager.stalledCount
|
||||
val reachedBack: StateFlow<Long?> = pager.reachedBack
|
||||
val relayProgress: StateFlow<Map<NormalizedRelayUrl, RelayPagingProgress>> = pager.relayProgress
|
||||
val status: StateFlow<PagingStatus> = pager.status
|
||||
|
||||
override fun user(key: ChatroomListState) = key.account.userProfile()
|
||||
|
||||
|
||||
+20
-29
@@ -39,6 +39,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
@@ -110,9 +111,9 @@ private fun CrossFadeState(
|
||||
// not "no conversations" — keep the spinner up rather than flash empty.
|
||||
val giftWrapsHistory = remember(accountViewModel) { accountViewModel.dataSources().account.giftWrapsHistory }
|
||||
val nip04History = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04History }
|
||||
val giftWrapsExhausted by giftWrapsHistory.exhausted.collectAsStateWithLifecycle()
|
||||
val nip04Exhausted by nip04History.exhausted.collectAsStateWithLifecycle()
|
||||
val historyExhausted = giftWrapsExhausted && nip04Exhausted
|
||||
val giftWrapsStatus by giftWrapsHistory.status.collectAsStateWithLifecycle()
|
||||
val nip04Status by nip04History.status.collectAsStateWithLifecycle()
|
||||
val historyExhausted = giftWrapsStatus.exhausted && nip04Status.exhausted
|
||||
|
||||
// A *genuinely* empty list has no rows to host the per-relay window-limit markers, so we step every
|
||||
// relay one page at a time to hunt for the first rooms. Gated on FeedState.Empty only (never the
|
||||
@@ -120,8 +121,8 @@ private fun CrossFadeState(
|
||||
// already loaded does NOT kick a hunt. Once rooms appear the markers take over, demand-driven.
|
||||
val user = accountViewModel.userProfile()
|
||||
val bootstrap = feedState is FeedState.Empty
|
||||
BootstrapHistoryWhenEmpty(bootstrap, giftWrapsHistory.loadingMore, giftWrapsHistory.exhausted) { giftWrapsHistory.advanceAll() }
|
||||
BootstrapHistoryWhenEmpty(bootstrap, nip04History.loadingMore, nip04History.exhausted) { nip04History.advanceAll() }
|
||||
BootstrapHistoryWhenEmpty(bootstrap, giftWrapsHistory.loadingMore, giftWrapsHistory.status) { giftWrapsHistory.advanceAll() }
|
||||
BootstrapHistoryWhenEmpty(bootstrap, nip04History.loadingMore, nip04History.status) { nip04History.advanceAll() }
|
||||
|
||||
CrossfadeIfEnabled(
|
||||
targetState = feedState,
|
||||
@@ -167,21 +168,11 @@ private fun FeedLoaded(
|
||||
val nip04History = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04History }
|
||||
val loadingGiftWraps by giftWrapsHistory.loadingMore.collectAsStateWithLifecycle()
|
||||
val loadingNip04 by nip04History.loadingMore.collectAsStateWithLifecycle()
|
||||
val giftWrapsExhausted by giftWrapsHistory.exhausted.collectAsStateWithLifecycle()
|
||||
val nip04Exhausted by nip04History.exhausted.collectAsStateWithLifecycle()
|
||||
// One atomic snapshot per protocol (exhausted + relays + reached + per-relay progress) instead of six
|
||||
// separate collectors — the status card and the per-relay markers read all of it together anyway.
|
||||
val giftWrapsStatus by giftWrapsHistory.status.collectAsStateWithLifecycle()
|
||||
val nip04Status by nip04History.status.collectAsStateWithLifecycle()
|
||||
val user = accountViewModel.userProfile()
|
||||
|
||||
// One status card PER protocol, at that protocol's oldest loaded room: it shows what the app is
|
||||
// reaching for (relays + how far back it has paged) while it loads, then crossfades to "All caught
|
||||
// up" and collapses when it runs dry.
|
||||
val giftWrapsRelays by giftWrapsHistory.relayCount.collectAsStateWithLifecycle()
|
||||
val giftWrapsStalled by giftWrapsHistory.stalledCount.collectAsStateWithLifecycle()
|
||||
val giftWrapsReached by giftWrapsHistory.reachedBack.collectAsStateWithLifecycle()
|
||||
val nip04Relays by nip04History.relayCount.collectAsStateWithLifecycle()
|
||||
val nip04Stalled by nip04History.stalledCount.collectAsStateWithLifecycle()
|
||||
val nip04Reached by nip04History.reachedBack.collectAsStateWithLifecycle()
|
||||
val giftWrapsProgress by giftWrapsHistory.relayProgress.collectAsStateWithLifecycle()
|
||||
val nip04Progress by nip04History.relayProgress.collectAsStateWithLifecycle()
|
||||
val nip17Name = stringResource(R.string.chats_history_proto_nip17)
|
||||
val nip04Name = stringResource(R.string.chats_history_proto_nip04)
|
||||
val oldestNip17Index = items.list.indexOfLast { it.event is ChatroomKeyable && it.event !is PrivateDmEvent }
|
||||
@@ -192,15 +183,15 @@ private fun FeedLoaded(
|
||||
// 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 =
|
||||
remember(giftWrapsProgress, nip04Progress, giftWrapsExhausted, nip04Exhausted, user) {
|
||||
remember(giftWrapsStatus, nip04Status, user) {
|
||||
buildList {
|
||||
if (!giftWrapsExhausted) {
|
||||
giftWrapsProgress.forEach { (relay, p) ->
|
||||
if (!giftWrapsStatus.exhausted) {
|
||||
giftWrapsStatus.relayProgress.forEach { (relay, p) ->
|
||||
add(RelayReachCursor("17:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p), "NIP-17") { giftWrapsHistory.advance(relay) })
|
||||
}
|
||||
}
|
||||
if (!nip04Exhausted) {
|
||||
nip04Progress.forEach { (relay, p) ->
|
||||
if (!nip04Status.exhausted) {
|
||||
nip04Status.relayProgress.forEach { (relay, p) ->
|
||||
add(RelayReachCursor("04:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p), "NIP-04") { nip04History.advance(relay) })
|
||||
}
|
||||
}
|
||||
@@ -242,10 +233,10 @@ private fun FeedLoaded(
|
||||
// Rendered unconditionally at the protocol's oldest room so the card can run its own
|
||||
// "All caught up" crossfade-and-collapse when that protocol exhausts.
|
||||
if (index == oldestNip17Index) {
|
||||
DmHistoryLoadingCard(nip17Name, "NIP-17", loadingGiftWraps, giftWrapsExhausted, giftWrapsRelays, giftWrapsStalled, giftWrapsReached, giftWrapsProgress, ::formatHistoryReachDate)
|
||||
DmHistoryLoadingCard(nip17Name, "NIP-17", loadingGiftWraps, giftWrapsStatus.exhausted, giftWrapsStatus.relayCount, giftWrapsStatus.stalledCount, giftWrapsStatus.reachedBack, giftWrapsStatus.relayProgress, ::formatHistoryReachDate)
|
||||
}
|
||||
if (index == oldestNip04Index) {
|
||||
DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Exhausted, nip04Relays, nip04Stalled, nip04Reached, nip04Progress, ::formatHistoryReachDate)
|
||||
DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Status.exhausted, nip04Status.relayCount, nip04Status.stalledCount, nip04Status.reachedBack, nip04Status.relayProgress, ::formatHistoryReachDate)
|
||||
}
|
||||
|
||||
// Per-relay window-limit markers/sentinels belonging in the gap toward the next-older room:
|
||||
@@ -272,13 +263,13 @@ private fun FeedLoaded(
|
||||
private fun BootstrapHistoryWhenEmpty(
|
||||
active: Boolean,
|
||||
loadingMore: StateFlow<Boolean>,
|
||||
exhausted: StateFlow<Boolean>,
|
||||
status: StateFlow<PagingStatus>,
|
||||
advanceAll: () -> Unit,
|
||||
) {
|
||||
LaunchedEffect(active, loadingMore, exhausted) {
|
||||
LaunchedEffect(active, loadingMore, status) {
|
||||
if (!active) return@LaunchedEffect
|
||||
delay(BOOTSTRAP_DEBOUNCE_MS)
|
||||
combine(loadingMore, exhausted) { loading, exhaustedNow -> !loading && !exhaustedNow }
|
||||
combine(loadingMore, status) { loading, s -> !loading && !s.exhausted }
|
||||
.distinctUntilChanged()
|
||||
.filter { it }
|
||||
.collect { advanceAll() }
|
||||
|
||||
+71
-67
@@ -32,6 +32,26 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Atomic snapshot of a [BackwardRelayPager]'s display state. Every field is recomputed together in one
|
||||
* pass ([BackwardRelayPager.status]'s producer), so a consumer collects ONE flow and never sees a torn
|
||||
* mix (e.g. an updated [relayCount] against a still-stale [relayProgress]) or pays for several separate
|
||||
* recompositions per page settle. [BackwardRelayPager.loadingMore] is deliberately NOT folded in here: it
|
||||
* is debounced on its own timer in the load tracker, decoupled from this recompute.
|
||||
*/
|
||||
data class PagingStatus(
|
||||
// Nothing more reachable right now: every relay is done or stalled. See the pager doc — not "caught up".
|
||||
val exhausted: Boolean = false,
|
||||
// Relays currently fetching a page (for an "asking N relays" status line).
|
||||
val relayCount: Int = 0,
|
||||
// Not-done relays that can't be reached right now (auth CLOSE / unreachable / silent).
|
||||
val stalledCount: Int = 0,
|
||||
// Oldest `createdAt` reached across all relays (the deepest cursor), or null before any delivery.
|
||||
val reachedBack: Long? = null,
|
||||
// Per-relay window position (reached / done / stalled) — what a caller's per-relay progress UI renders.
|
||||
val relayProgress: Map<NormalizedRelayUrl, RelayPagingProgress> = emptyMap(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Reusable **per-relay backward pagination** engine: pages a set of relays back through history,
|
||||
* **one page at a time, per relay, on demand**, by `until`+`limit` ([RelayLoadingCursors]) — with each
|
||||
@@ -44,8 +64,8 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
* scope (e.g. the one the user is viewing), so a backgrounded scope produces no callbacks to mis-route.
|
||||
*
|
||||
* What it owns (all transient, recomputed on each [bind]): the in-flight + silence tracking
|
||||
* ([PerRelayLoadTracker]), the stalled-relay set, and the display [StateFlow]s ([relayProgress],
|
||||
* [exhausted], [reachedBack], [relayCount], [stalledCount]). The persistent cursors and the pinned
|
||||
* ([PerRelayLoadTracker]), the stalled-relay set, and the display flows — one atomic [status] snapshot
|
||||
* ([PagingStatus]) plus the separately-debounced [loadingMore]. The persistent cursors and the pinned
|
||||
* history floor live on the bound [RelayLoadingCursors].
|
||||
*
|
||||
* What it does NOT own (the caller supplies these — they are protocol- and framework-specific):
|
||||
@@ -92,33 +112,18 @@ class BackwardRelayPager(
|
||||
// and recomputed on each [bind]; a stalled relay is kept (its sub stays open) and retried on advance.
|
||||
private val stalledRelays = ConcurrentHashMap.newKeySet<NormalizedRelayUrl>()
|
||||
|
||||
/** True while any relay is mid-page. Starts false (an idle engine isn't "loading"). */
|
||||
/**
|
||||
* True while any relay is mid-page. Starts false (an idle engine isn't "loading"). Kept apart from
|
||||
* [status] on purpose: the load tracker debounces this flow's falling edge on its own timer, decoupled
|
||||
* from the [publishStatus] recompute, so folding it into the snapshot would miss that delayed flip.
|
||||
*/
|
||||
val loadingMore: StateFlow<Boolean> = loadTracker.loading
|
||||
|
||||
private val _exhausted = MutableStateFlow(false)
|
||||
private val _status = MutableStateFlow(PagingStatus())
|
||||
|
||||
/** Nothing more reachable right now: every relay is done or stalled. See class doc — not "caught up". */
|
||||
val exhausted: StateFlow<Boolean> = _exhausted.asStateFlow()
|
||||
|
||||
private val _relayCount = MutableStateFlow(0)
|
||||
|
||||
/** Relays currently fetching a page (for an "asking N relays" status line). */
|
||||
val relayCount: StateFlow<Int> = _relayCount.asStateFlow()
|
||||
|
||||
private val _stalledCount = MutableStateFlow(0)
|
||||
|
||||
/** Not-done relays that can't be reached right now (auth CLOSE / unreachable / silent). */
|
||||
val stalledCount: StateFlow<Int> = _stalledCount.asStateFlow()
|
||||
|
||||
private val _reachedBack = MutableStateFlow<Long?>(null)
|
||||
|
||||
/** Oldest `createdAt` reached across all relays (the deepest cursor), or null before any delivery. */
|
||||
val reachedBack: StateFlow<Long?> = _reachedBack.asStateFlow()
|
||||
|
||||
private val _relayProgress = MutableStateFlow<Map<NormalizedRelayUrl, RelayPagingProgress>>(emptyMap())
|
||||
|
||||
/** Per-relay window position (reached / done / stalled) — what a caller's per-relay progress UI renders. */
|
||||
val relayProgress: StateFlow<Map<NormalizedRelayUrl, RelayPagingProgress>> = _relayProgress.asStateFlow()
|
||||
/** One atomic snapshot of the display state (exhausted / counts / reached / per-relay progress), all
|
||||
* recomputed together in [publishStatus] so consumers collect ONE flow and never see a torn mix. */
|
||||
val status: StateFlow<PagingStatus> = _status.asStateFlow()
|
||||
|
||||
// The session-pinned floor for the active scope — kept on its cursors so it persists with the scope
|
||||
// and does not drift forward on recompute (which would re-trigger an undelivered relay's loader).
|
||||
@@ -144,8 +149,7 @@ class BackwardRelayPager(
|
||||
loadTracker.bind(scope)
|
||||
loadTracker.reset()
|
||||
stalledRelays.clear()
|
||||
updateStatus()
|
||||
recomputeExhausted()
|
||||
publishStatus()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,8 +174,7 @@ class BackwardRelayPager(
|
||||
/** Steps a single [relay] to its next, older page. @return true if it actually advanced. */
|
||||
fun advance(relay: NormalizedRelayUrl): Boolean {
|
||||
if (!arm(relay)) return false
|
||||
_exhausted.value = false
|
||||
updateStatus()
|
||||
publishStatus()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -180,10 +183,7 @@ class BackwardRelayPager(
|
||||
val relays = relaysFor() ?: return false
|
||||
var any = false
|
||||
relays.forEach { if (arm(it)) any = true }
|
||||
if (any) {
|
||||
_exhausted.value = false
|
||||
updateStatus()
|
||||
}
|
||||
if (any) publishStatus()
|
||||
return any
|
||||
}
|
||||
|
||||
@@ -219,8 +219,7 @@ class BackwardRelayPager(
|
||||
c.onEose(relay)
|
||||
loadTracker.onSettled(relay)
|
||||
val done = c.isDone(relay)
|
||||
updateStatus()
|
||||
recomputeExhausted()
|
||||
publishStatus()
|
||||
return done
|
||||
}
|
||||
|
||||
@@ -231,8 +230,7 @@ class BackwardRelayPager(
|
||||
) {
|
||||
loadTracker.onSettled(relay)
|
||||
markStalled(relay, "CLOSED: $message")
|
||||
updateStatus()
|
||||
recomputeExhausted()
|
||||
publishStatus()
|
||||
}
|
||||
|
||||
/** [relay] is unreachable right now: settle it and flag it stalled (kept, retryable). */
|
||||
@@ -242,16 +240,14 @@ class BackwardRelayPager(
|
||||
) {
|
||||
loadTracker.onSettled(relay)
|
||||
markStalled(relay, "cannot connect: $message")
|
||||
updateStatus()
|
||||
recomputeExhausted()
|
||||
publishStatus()
|
||||
}
|
||||
|
||||
// The tracker's silence watchdog fired: the still-pending relays went quiet after their REQ. Flag them
|
||||
// stalled but kept, so the window can settle instead of hanging on a dead relay.
|
||||
private fun onSilenced(relays: Set<NormalizedRelayUrl>) {
|
||||
relays.forEach { markStalled(it, "no response (silence timeout)") }
|
||||
updateStatus()
|
||||
recomputeExhausted()
|
||||
publishStatus()
|
||||
}
|
||||
|
||||
private fun markStalled(
|
||||
@@ -261,40 +257,48 @@ class BackwardRelayPager(
|
||||
if (stalledRelays.add(relay)) Log.d(TAG) { "[$name] ${relay.url} stalled — $reason (kept, advance to retry)" }
|
||||
}
|
||||
|
||||
// --- Display-flow recompute (from the bound cursors). ---
|
||||
// --- Display-state recompute (one atomic snapshot from the bound cursors). ---
|
||||
|
||||
/** Recomputes the display flows from the active scope's cursors. */
|
||||
fun updateStatus() {
|
||||
/**
|
||||
* Recomputes the whole [status] snapshot from the active scope's cursors and publishes it in one
|
||||
* emission, so consumers never see a torn mix of fields nor pay for several recompositions per settle.
|
||||
*
|
||||
* `exhausted` is computed here too: nothing more is reachable once every relay is done (empty page) or
|
||||
* stalled (unreachable) — a merely parked relay (more to load, just not advancing) keeps it false. An
|
||||
* empty / unbound scope leaves `exhausted` at its previous value (mirrors the old recompute's
|
||||
* early-return), so a transient empty relay set never flips it spuriously.
|
||||
*/
|
||||
private fun publishStatus() {
|
||||
val c = cursors
|
||||
val relays = relaysFor() ?: emptySet()
|
||||
_relayCount.value = loadTracker.count()
|
||||
val floor = floor()
|
||||
_reachedBack.value = c?.deepestReached(relays, floor)
|
||||
_stalledCount.value = relays.count { it in stalledRelays && c?.isDone(it) != true }
|
||||
_relayProgress.value =
|
||||
relays.associateWith { relay ->
|
||||
RelayPagingProgress(
|
||||
reachedUntil = c?.reachedUntilFor(relay, floor) ?: floor,
|
||||
done = c?.isDone(relay) ?: false,
|
||||
stalled = relay in stalledRelays && c?.isDone(relay) != true,
|
||||
)
|
||||
val prev = _status.value
|
||||
val exhausted =
|
||||
if (c == null || relays.isEmpty()) {
|
||||
prev.exhausted
|
||||
} else {
|
||||
relays.none { !c.isDone(it) && it !in stalledRelays }
|
||||
}
|
||||
}
|
||||
|
||||
// Exhausted once every relay is either done (empty page) or stalled (unreachable) — nothing more is
|
||||
// reachable right now. A merely parked relay (more to load, just not advancing) keeps this false.
|
||||
private fun recomputeExhausted() {
|
||||
val c = cursors ?: return
|
||||
val relays = relaysFor() ?: return
|
||||
if (relays.isEmpty()) return
|
||||
val pending = relays.any { !c.isDone(it) && it !in stalledRelays }
|
||||
val ex = !pending
|
||||
if (ex && !_exhausted.value) {
|
||||
if (exhausted && !prev.exhausted && c != null) {
|
||||
val done = relays.filter { c.isDone(it) }.map { it.url }
|
||||
val stuck = relays.filter { it in stalledRelays && !c.isDone(it) }.map { it.url }
|
||||
Log.d(TAG) { "[$name] window settled (nothing more reachable) — done=$done stalled=$stuck" }
|
||||
}
|
||||
_exhausted.value = ex
|
||||
_status.value =
|
||||
PagingStatus(
|
||||
exhausted = exhausted,
|
||||
relayCount = loadTracker.count(),
|
||||
stalledCount = relays.count { it in stalledRelays && c?.isDone(it) != true },
|
||||
reachedBack = c?.deepestReached(relays, floor),
|
||||
relayProgress =
|
||||
relays.associateWith { relay ->
|
||||
RelayPagingProgress(
|
||||
reachedUntil = c?.reachedUntilFor(relay, floor) ?: floor,
|
||||
done = c?.isDone(relay) ?: false,
|
||||
stalled = relay in stalledRelays && c?.isDone(relay) != true,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
+31
-31
@@ -66,7 +66,7 @@ class BackwardRelayPagerTest {
|
||||
@Test
|
||||
fun firstPageRequestsTheFloorAndAnEmptyPageIsCaughtUp() {
|
||||
val (p, cursors) = pagerOf(r1)
|
||||
assertFalse(p.exhausted.value)
|
||||
assertFalse(p.status.value.exhausted)
|
||||
|
||||
assertTrue(p.advance(r1))
|
||||
// The very first page asks `until = floor` (pinned on the bound cursors).
|
||||
@@ -75,12 +75,12 @@ class BackwardRelayPagerTest {
|
||||
// Empty page + EOSE → that relay is done; the only relay is done → genuinely caught up.
|
||||
assertTrue(p.onEose(r1))
|
||||
assertTrue(
|
||||
p.relayProgress.value
|
||||
p.status.value.relayProgress
|
||||
.getValue(r1)
|
||||
.done,
|
||||
)
|
||||
assertTrue(p.exhausted.value)
|
||||
assertEquals(0, p.stalledCount.value)
|
||||
assertTrue(p.status.value.exhausted)
|
||||
assertEquals(0, p.status.value.stalledCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -94,12 +94,12 @@ class BackwardRelayPagerTest {
|
||||
p.onEvent(r1, 90)
|
||||
assertFalse(p.onEose(r1))
|
||||
assertFalse(
|
||||
p.relayProgress.value
|
||||
p.status.value.relayProgress
|
||||
.getValue(r1)
|
||||
.done,
|
||||
)
|
||||
assertEquals(80L, p.reachedBack.value)
|
||||
assertFalse(p.exhausted.value)
|
||||
assertEquals(80L, p.status.value.reachedBack)
|
||||
assertFalse(p.status.value.exhausted)
|
||||
|
||||
// The next page must start strictly below the oldest reached (80 → until 79).
|
||||
assertTrue(p.advance(r1))
|
||||
@@ -107,8 +107,8 @@ class BackwardRelayPagerTest {
|
||||
|
||||
// Empty page now → done → caught up.
|
||||
assertTrue(p.onEose(r1))
|
||||
assertTrue(p.exhausted.value)
|
||||
assertEquals(0, p.stalledCount.value)
|
||||
assertTrue(p.status.value.exhausted)
|
||||
assertEquals(0, p.status.value.stalledCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -119,24 +119,24 @@ class BackwardRelayPagerTest {
|
||||
|
||||
// r1 genuinely bottoms out; r2 is still pending, so not exhausted yet.
|
||||
p.onEose(r1)
|
||||
assertFalse(p.exhausted.value)
|
||||
assertFalse(p.status.value.exhausted)
|
||||
|
||||
// r2 auth-walls the REQ → stalled (kept, not done).
|
||||
p.onClosed(r2, "auth-required")
|
||||
assertTrue(
|
||||
p.relayProgress.value
|
||||
p.status.value.relayProgress
|
||||
.getValue(r2)
|
||||
.stalled,
|
||||
)
|
||||
assertFalse(
|
||||
p.relayProgress.value
|
||||
p.status.value.relayProgress
|
||||
.getValue(r2)
|
||||
.done,
|
||||
)
|
||||
|
||||
// Every relay is now done-or-stalled → exhausted, but it is INCOMPLETE: one relay unreachable.
|
||||
assertTrue(p.exhausted.value)
|
||||
assertEquals(1, p.stalledCount.value)
|
||||
assertTrue(p.status.value.exhausted)
|
||||
assertEquals(1, p.status.value.stalledCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -145,12 +145,12 @@ class BackwardRelayPagerTest {
|
||||
p.advance(r1)
|
||||
p.onCannotConnect(r1, "offline")
|
||||
assertTrue(
|
||||
p.relayProgress.value
|
||||
p.status.value.relayProgress
|
||||
.getValue(r1)
|
||||
.stalled,
|
||||
)
|
||||
assertTrue(p.exhausted.value)
|
||||
assertEquals(1, p.stalledCount.value)
|
||||
assertTrue(p.status.value.exhausted)
|
||||
assertEquals(1, p.status.value.stalledCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -158,18 +158,18 @@ class BackwardRelayPagerTest {
|
||||
val (p, _) = pagerOf(r1)
|
||||
p.advance(r1)
|
||||
p.onClosed(r1, "auth-required")
|
||||
assertTrue(p.exhausted.value)
|
||||
assertEquals(1, p.stalledCount.value)
|
||||
assertTrue(p.status.value.exhausted)
|
||||
assertEquals(1, p.status.value.stalledCount)
|
||||
|
||||
// Retrying it re-arms the relay: no longer stalled, no longer exhausted.
|
||||
assertTrue(p.advance(r1))
|
||||
assertFalse(
|
||||
p.relayProgress.value
|
||||
p.status.value.relayProgress
|
||||
.getValue(r1)
|
||||
.stalled,
|
||||
)
|
||||
assertFalse(p.exhausted.value)
|
||||
assertEquals(0, p.stalledCount.value)
|
||||
assertFalse(p.status.value.exhausted)
|
||||
assertEquals(0, p.status.value.stalledCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -185,7 +185,7 @@ class BackwardRelayPagerTest {
|
||||
p.onEose(r2) // r2 reached 300
|
||||
|
||||
// Deepest = the oldest point any relay has reached.
|
||||
assertEquals(300L, p.reachedBack.value)
|
||||
assertEquals(300L, p.status.value.reachedBack)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -219,25 +219,25 @@ class BackwardRelayPagerTest {
|
||||
p.advance(r2)
|
||||
p.onEose(r1)
|
||||
p.onClosed(r2, "auth-required")
|
||||
assertTrue(p.exhausted.value)
|
||||
assertEquals(1, p.stalledCount.value)
|
||||
assertTrue(p.status.value.exhausted)
|
||||
assertEquals(1, p.status.value.stalledCount)
|
||||
|
||||
// Bind to a fresh scope B: the flows reflect B's own (empty) state — nothing stalled, and its
|
||||
// reach sits at B's floor (no history fetched yet — markers start at the live-tail boundary).
|
||||
p.bind(cursorsB, scope) { listOf(r3) }
|
||||
assertFalse(p.exhausted.value)
|
||||
assertEquals(0, p.stalledCount.value)
|
||||
assertEquals(cursorsB.floor, p.reachedBack.value)
|
||||
assertFalse(p.status.value.exhausted)
|
||||
assertEquals(0, p.status.value.stalledCount)
|
||||
assertEquals(cursorsB.floor, p.status.value.reachedBack)
|
||||
|
||||
// Rebind to A: r1 is still DONE (its cursor persisted on cursorsA), but r2's stall is gone — stall
|
||||
// is transient, so r2 is pending again and A is no longer exhausted (it will retry the auth relay).
|
||||
p.bind(cursorsA, scope) { listOf(r1, r2) }
|
||||
assertTrue(
|
||||
p.relayProgress.value
|
||||
p.status.value.relayProgress
|
||||
.getValue(r1)
|
||||
.done,
|
||||
)
|
||||
assertEquals(0, p.stalledCount.value)
|
||||
assertFalse(p.exhausted.value)
|
||||
assertEquals(0, p.status.value.stalledCount)
|
||||
assertFalse(p.status.value.exhausted)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user