diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt index 8668f8a920..93f94d2af0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt @@ -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 = pager.loadingMore - val exhausted: StateFlow = pager.exhausted - val relayCount: StateFlow = pager.relayCount - val stalledCount: StateFlow = pager.stalledCount - val reachedBack: StateFlow = pager.reachedBack - val relayProgress: StateFlow> = pager.relayProgress + val status: StateFlow = pager.status private fun daysAgo(epochSeconds: Long) = (TimeUtils.now() - epochSeconds) / TimeUtils.ONE_DAY diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/LoadingReplyNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/LoadingReplyNote.kt index 26103f0c43..9c5e3e024e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/LoadingReplyNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/LoadingReplyNote.kt @@ -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 = + val statusFlow: StateFlow = when (protocol) { - DmReplyProtocol.NIP17 -> giftWrapsHistory.exhausted - DmReplyProtocol.NIP04 -> nip04History.exhausted - } - val relayCountFlow: StateFlow = - when (protocol) { - DmReplyProtocol.NIP17 -> giftWrapsHistory.relayCount - DmReplyProtocol.NIP04 -> nip04History.relayCount - } - val stalledCountFlow: StateFlow = - when (protocol) { - DmReplyProtocol.NIP17 -> giftWrapsHistory.stalledCount - DmReplyProtocol.NIP04 -> nip04History.stalledCount - } - val reachedBackFlow: StateFlow = - when (protocol) { - DmReplyProtocol.NIP17 -> giftWrapsHistory.reachedBack - DmReplyProtocol.NIP04 -> nip04History.reachedBack - } - val relayProgressFlow: StateFlow> = - 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 { 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 6a5354ce73..4632448358 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 @@ -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 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 e087790fcf..4cbe77bffb 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 @@ -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 = pager.loadingMore - val exhausted: StateFlow = pager.exhausted - val relayCount: StateFlow = pager.relayCount - val stalledCount: StateFlow = pager.stalledCount - val reachedBack: StateFlow = pager.reachedBack - val relayProgress: StateFlow> = pager.relayProgress + val status: StateFlow = pager.status override fun user(key: ChatroomQueryState) = key.account.userProfile() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt index ffd84d0290..73a31dab16 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt @@ -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 = pager.loadingMore - val exhausted: StateFlow = pager.exhausted - val relayCount: StateFlow = pager.relayCount - val stalledCount: StateFlow = pager.stalledCount - val reachedBack: StateFlow = pager.reachedBack - val relayProgress: StateFlow> = pager.relayProgress + val status: StateFlow = pager.status override fun user(key: ChatroomListState) = key.account.userProfile() 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 84efb0f6c6..a7612c4402 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 @@ -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, - exhausted: StateFlow, + status: StateFlow, 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() } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/BackwardRelayPager.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/BackwardRelayPager.kt index 62e675e244..a38dc61050 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/BackwardRelayPager.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/BackwardRelayPager.kt @@ -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 = 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() - /** 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 = 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 = _exhausted.asStateFlow() - - private val _relayCount = MutableStateFlow(0) - - /** Relays currently fetching a page (for an "asking N relays" status line). */ - val relayCount: StateFlow = _relayCount.asStateFlow() - - private val _stalledCount = MutableStateFlow(0) - - /** Not-done relays that can't be reached right now (auth CLOSE / unreachable / silent). */ - val stalledCount: StateFlow = _stalledCount.asStateFlow() - - private val _reachedBack = MutableStateFlow(null) - - /** Oldest `createdAt` reached across all relays (the deepest cursor), or null before any delivery. */ - val reachedBack: StateFlow = _reachedBack.asStateFlow() - - private val _relayProgress = MutableStateFlow>(emptyMap()) - - /** Per-relay window position (reached / done / stalled) — what a caller's per-relay progress UI renders. */ - val relayProgress: StateFlow> = _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 = _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) { 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 { diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/BackwardRelayPagerTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/BackwardRelayPagerTest.kt index a4f0f72df0..01f9543be7 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/BackwardRelayPagerTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/BackwardRelayPagerTest.kt @@ -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) } }