diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt index caede915f3..a211e94111 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt @@ -24,11 +24,11 @@ import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToP import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.WindowLoadTracker -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.trackingListener import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.WindowLoadTracker +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.trackingListener import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription import com.vitorpamplona.quartz.utils.Log 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 d84c9cbe36..0752d5b9b7 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 @@ -24,15 +24,15 @@ import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToP import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerRelayLoadTracker import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.RelayPagingProgress -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.UntilLimitPager 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.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.PerRelayLoadTracker +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.UntilLimitPager 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DmLoadMoreIndicator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DmLoadMoreIndicator.kt index 802c1981e5..be71ae466a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DmLoadMoreIndicator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DmLoadMoreIndicator.kt @@ -60,7 +60,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.RelayPagingProgress +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.delay import java.text.SimpleDateFormat 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 82e419beb6..d606811988 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,9 +49,9 @@ 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.service.relayClient.eoseManagers.RelayPagingProgress 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 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 3941b836d6..f809fe168c 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 @@ -41,7 +41,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.RelayPagingProgress import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel @@ -61,6 +60,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.ChatNe import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.PrivateMessageEditFieldRow import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent 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 9d90ac0d3a..71ceebcd16 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,15 +21,15 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerRelayLoadTracker import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.RelayPagingProgress -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.UntilLimitPager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.PerRelayLoadTracker +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.UntilLimitPager 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04SubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04SubAssembler.kt index d58db5032c..dad1896efa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04SubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04SubAssembler.kt @@ -22,11 +22,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.WindowLoadTracker -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.trackingListener import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.WindowLoadTracker +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.trackingListener import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription import com.vitorpamplona.quartz.utils.Log 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 43e433d4a6..c325a676db 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 @@ -23,15 +23,15 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerRelayLoadTracker import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.RelayPagingProgress -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.UntilLimitPager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.PerRelayLoadTracker +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.UntilLimitPager 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04SubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04SubAssembler.kt index 747af475a4..b0aead3399 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04SubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04SubAssembler.kt @@ -23,11 +23,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.WindowLoadTracker -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.trackingListener import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.WindowLoadTracker +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.trackingListener import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription import com.vitorpamplona.quartz.utils.Log 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 7b3d12603e..3792ab56e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt @@ -40,7 +40,6 @@ import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.RelayPagingProgress import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedError @@ -60,6 +59,7 @@ import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPagerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPagerTest.kt index ce8b4fb083..cd436b8371 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPagerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPagerTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.UntilLimitPager import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTrackerSilenceTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTrackerSilenceTest.kt index c7a31f1e8d..7d922288ea 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTrackerSilenceTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTrackerSilenceTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.WindowLoadTracker import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/BackwardRelayPager.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/BackwardRelayPager.kt new file mode 100644 index 0000000000..586e45a517 --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/BackwardRelayPager.kt @@ -0,0 +1,331 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.paging + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.concurrent.ConcurrentHashMap + +/** + * 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` ([UntilLimitPager]) — with each + * relay advancing independently the moment *it* settles, never paced by the slowest one. This is the + * generic core extracted from the DM history loaders (gift-wrap, conversation NIP-04, rooms-list + * NIP-04), which were ~80% identical; any feed that wants demand-driven, gap-proof, per-relay history + * paging can build one of these instead of re-deriving the cursor/stall/exhausted bookkeeping. + * + * What it owns: the per-relay cursors ([UntilLimitPager]), the in-flight + silence tracking + * ([PerRelayLoadTracker]), the stalled-relay set, the per-key "exhausted" memo, the session-pinned + * history floor, and the display [StateFlow]s ([relayProgress], [exhausted], [reachedBack], + * [relayCount], [stalledCount]). + * + * What it does NOT own (the caller supplies these — they are protocol- and framework-specific): + * - **Building the actual REQ filters.** The caller reads [armedRelays] + [requestedUntilFor] and + * assembles its own `RelayBasedFilter`s (the kinds / authors / `#p` tags differ per feed). + * - **The subscription lifecycle.** The caller wires its `INostrClient` subscription and forwards + * relay callbacks here via [onEvent] / [onEose] / [onClosed] / [onCannotConnect], then re-issues + * its filter (e.g. `invalidateFilters()`) after [advance] / [advanceAll] return true. + * - **Which relays a key fans out to.** Supplied once as [relaysFor]; the engine reads it whenever it + * needs the active key's relay set (status recompute, exhaustion, membership checks). + * + * ### Keying ([K]) + * State is partitioned by an opaque key [K] — e.g. an account pubkey, or `(account, conversation)` — + * so several independently-paged scopes can share one engine without leaking cursors across them, and + * switching the on-screen scope just repoints the display flows ([activate]) instead of resetting + * progress. Exactly one key is "active" (its state is mirrored into the display flows) at a time. + * + * ### Done vs stalled (read [exhausted] with care) + * A relay is **done** once it answers an empty page (gap-proof: nothing older). A relay that won't + * answer right now — auth CLOSE, unreachable, or silent past the tracker's window — is flagged + * **stalled** but kept (its subscription stays open; re-[advance] retries it). [exhausted] flips true + * once every relay is *done or stalled* — "nothing more reachable right now", which is NOT the same as + * "fully caught up". Callers that render a terminal state should split on [stalledCount]: `exhausted && + * stalledCount == 0` is genuinely caught up; `exhausted && stalledCount > 0` stopped early and may be + * missing messages. + * + * Not internally synchronized beyond the primitives it composes; intended to be driven from one owning + * scope with relay callbacks serialized per relay (as the relay IO layer delivers them). + */ +class BackwardRelayPager( + // Short label for the DMPagination logs (e.g. "giftwrap.history", "convo.nip04.history"). + private val name: String, + // Asked of every relay per page; large on purpose (a whole band in one page), and caps per-request + // volume. A relay returning fewer is its own cap, NOT exhaustion — only an empty page ends a relay. + val pageLimit: Int = DEFAULT_PAGE_LIMIT, + // How far below "now" the history floor sits — paging starts here and walks backward. Defaults to + // the one-week live-tail boundary: everything newer is the always-on tail's job. + private val liveTailSeconds: Long = DEFAULT_LIVE_TAIL_SECONDS, + // The relay set a key currently fans out to. Read on every status/exhaustion recompute, so it must + // reflect the key's live relay list. Null/empty means "no relays known yet" (no-op). + private val relaysFor: (K) -> Collection?, +) { + private val pager = UntilLimitPager() + private val loadTracker = PerRelayLoadTracker(name, onSilenced = ::onSilenced) + + // Relays not currently advancing for a key (auth CLOSE / unreachable / silent). Kept (not given up) + // and surfaced as stalled; they resume if the key re-advances them. + private val stalledRelays = ConcurrentHashMap>() + + // Per-key exhausted memo, so a backgrounded key keeps its terminal state and switching back to it + // restores the right flag instead of flashing "loading". + private val exhaustedByKey = ConcurrentHashMap() + + // History starts just below the live-tail floor and pages backward. Pinned per key for the session: + // it must NOT drift forward on every recompute, or an un-delivered relay's marker (which sits at this + // floor) would keep changing and re-trigger its on-screen sentinel. + private val pinnedFloor = ConcurrentHashMap() + + // The key whose state is currently mirrored into the display flows (the one on screen). A background + // key's late EOSE still advances its cursors in [pager] but must not overwrite the display flows. + @Volatile + private var activeKey: K? = null + + /** True while any relay is mid-page. Starts false (an idle engine isn't "loading"). */ + val loadingMore: StateFlow = loadTracker.loading + + private val _exhausted = MutableStateFlow(false) + + /** 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) — the data on-screen reach markers render. */ + val relayProgress: StateFlow> = _relayProgress.asStateFlow() + + /** The session-pinned floor for [key] — where its paging starts (just below the live tail). */ + fun floorFor(key: K): Long = pinnedFloor.getOrPut(key) { TimeUtils.now() - liveTailSeconds } + + // --- Filter building support: the caller assembles the actual REQ from these. --- + + /** Relays of [key] that have been advanced (armed) and aren't done — i.e. that should carry a REQ. */ + fun armedRelays( + key: K, + relays: Collection, + ): List = pager.armedRelays(key, relays) + + /** The `until` [relay]'s next page should carry for [key] (null if it isn't armed). */ + fun requestedUntilFor( + key: K, + relay: NormalizedRelayUrl, + ): Long? = pager.requestedUntilFor(key, relay) + + // --- Demand-driven advance (the caller re-issues its filter when these return true). --- + + /** Steps a single [relay] to its next, older page for [key]. @return true if it actually advanced. */ + fun advance( + key: K, + relay: NormalizedRelayUrl, + scope: CoroutineScope, + ): Boolean { + if (!arm(key, relay, scope)) return false + if (activeKey == key) _exhausted.value = false + updateStatus(key) + return true + } + + /** Steps every not-done, not-in-flight relay of [key] one page. For a scope too small to scroll. */ + fun advanceAll( + key: K, + scope: CoroutineScope, + ): Boolean { + val relays = relaysFor(key) ?: return false + var any = false + relays.forEach { if (arm(key, it, scope)) any = true } + if (any) { + if (activeKey == key) _exhausted.value = false + updateStatus(key) + } + return any + } + + // Moves one relay's cursor to its next page and marks it in-flight. Returns false if it can't advance + // (unknown relay, already fetching, or already done). Does NOT recompute status — the caller batches. + private fun arm( + key: K, + relay: NormalizedRelayUrl, + scope: CoroutineScope, + ): Boolean { + val relays = relaysFor(key) ?: return false + if (relay !in relays) return false + if (loadTracker.isInFlight(relay)) return false + if (!pager.advance(key, relay, floorFor(key))) return false + stalledRelays[key]?.remove(relay) + loadTracker.bind(scope) + loadTracker.onAdvance(relay) + return true + } + + // --- Subscription callbacks: the owner forwards these from its SubscriptionListener. --- + + /** Records one delivered event for [relay] (a sign of life + a page tally entry). */ + fun onEvent( + key: K, + relay: NormalizedRelayUrl, + createdAt: Long, + ) { + loadTracker.onActivity() + pager.onEvent(key, relay, createdAt) + stalledRelays[key]?.remove(relay) + } + + /** Finalizes [relay]'s page on EOSE. @return true if this EOSE is the one that marked it done. */ + fun onEose( + key: K, + relay: NormalizedRelayUrl, + ): Boolean { + stalledRelays[key]?.remove(relay) + pager.onEose(key, relay) + loadTracker.onSettled(relay) + val done = pager.isDone(key, relay) + updateStatus(key) + recomputeExhausted(key) + return done + } + + /** [relay] rejected the REQ (e.g. auth-required): settle it and flag it stalled (kept, retryable). */ + fun onClosed( + key: K, + relay: NormalizedRelayUrl, + message: String, + ) { + loadTracker.onSettled(relay) + markStalled(key, relay, "CLOSED: $message") + updateStatus(key) + recomputeExhausted(key) + } + + /** [relay] is unreachable right now: settle it and flag it stalled (kept, retryable). */ + fun onCannotConnect( + key: K, + relay: NormalizedRelayUrl, + message: String, + ) { + loadTracker.onSettled(relay) + markStalled(key, relay, "cannot connect: $message") + updateStatus(key) + recomputeExhausted(key) + } + + // The tracker's silence watchdog fired: the still-pending relays went quiet after their REQ. Flag the + // active key's of them stalled (kept) so the window can settle instead of hanging on a dead relay. + private fun onSilenced(relays: Set) { + val key = activeKey ?: return + relays.forEach { markStalled(key, it, "no response (silence timeout)") } + updateStatus(key) + recomputeExhausted(key) + } + + private fun markStalled( + key: K, + relay: NormalizedRelayUrl, + reason: String, + ) { + val firstTime = stalledRelays.getOrPut(key) { ConcurrentHashMap.newKeySet() }.add(relay) + if (firstTime) Log.d(TAG) { "[$name] ${relay.url} stalled — $reason (kept, advance to retry)" } + } + + // --- Display-flow management. --- + + /** + * Repoints the display flows to [key] (call on subscribe / when the on-screen scope changes), then + * refreshes them. A no-op repoint (same key) just refreshes. Cursors in [pager] are untouched, so a + * previously-paged key restores its progress instead of restarting. + */ + fun activate(key: K) { + if (activeKey != key) { + activeKey = key + loadTracker.reset() + _exhausted.value = exhaustedByKey[key] ?: false + _relayCount.value = 0 + _stalledCount.value = 0 + _reachedBack.value = null + _relayProgress.value = emptyMap() + } + updateStatus(key) + } + + /** Recomputes the display flows from [key]'s cursors. No-op when [key] is not the active key. */ + fun updateStatus(key: K) { + if (activeKey != key) return + val relays = relaysFor(key) ?: emptySet() + _relayCount.value = loadTracker.count() + val floor = floorFor(key) + _reachedBack.value = pager.deepestReached(key, relays, floor) + val stalled = stalledRelays[key] ?: emptySet() + _stalledCount.value = relays.count { it in stalled && !pager.isDone(key, it) } + _relayProgress.value = + relays.associateWith { relay -> + RelayPagingProgress( + reachedUntil = pager.reachedUntilFor(key, relay, floor), + done = pager.isDone(key, relay), + stalled = relay in stalled && !pager.isDone(key, relay), + ) + } + } + + // 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(key: K) { + val relays = relaysFor(key) ?: return + if (relays.isEmpty()) return + val stalled = stalledRelays[key] ?: emptySet() + val pending = relays.any { !pager.isDone(key, it) && it !in stalled } + val ex = !pending + val was = exhaustedByKey[key] ?: false + exhaustedByKey[key] = ex + if (ex && !was) { + val done = relays.filter { pager.isDone(key, it) }.map { it.url } + val stuck = relays.filter { it in stalled && !pager.isDone(key, it) }.map { it.url } + Log.d(TAG) { "[$name] window settled (nothing more reachable) — done=$done stalled=$stuck" } + } + if (activeKey == key) _exhausted.value = ex + } + + companion object { + private const val TAG = "DMPagination" + + const val DEFAULT_PAGE_LIMIT = 10000 + + // One week — matches the DM live-tail floor (everything newer is the always-on tail's job). + const val DEFAULT_LIVE_TAIL_SECONDS = 7L * TimeUtils.ONE_DAY + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerRelayLoadTracker.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/PerRelayLoadTracker.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerRelayLoadTracker.kt rename to quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/PerRelayLoadTracker.kt index 14a504ae48..5b97e2fefe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerRelayLoadTracker.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/PerRelayLoadTracker.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relayClient.eoseManagers +package com.vitorpamplona.quartz.nip01Core.relay.client.paging import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.Log diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/RelayPagingProgress.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayPagingProgress.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/RelayPagingProgress.kt rename to quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayPagingProgress.kt index ed69b5cf46..584a0eeeec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/RelayPagingProgress.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayPagingProgress.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relayClient.eoseManagers +package com.vitorpamplona.quartz.nip01Core.relay.client.paging /** How far back one relay has paged a DM history, for the per-relay progress markers. */ data class RelayPagingProgress( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPager.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt rename to quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPager.kt index c650fb2ac5..28e05484ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPager.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relayClient.eoseManagers +package com.vitorpamplona.quartz.nip01Core.relay.client.paging import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import java.util.concurrent.ConcurrentHashMap diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/WindowLoadTracker.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt rename to quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/WindowLoadTracker.kt index 9c69818324..51ac163be6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/WindowLoadTracker.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.relayClient.eoseManagers +package com.vitorpamplona.quartz.nip01Core.relay.client.paging import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener