From 43744e53ad6ecee0ad9ff954fd45e0d2248f88ed Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 14:43:45 +0000 Subject: [PATCH 001/103] feat: bound DM boot loading to a time window with scroll-to-load-more MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The always-on gift-wrap subscription (AccountGiftWrapsEoseManager) had no lower bound on first boot: `since` came purely from the per-relay EOSE cursor, which is null on a cold start, so every DM relay dumped the account's entire NIP-17 history at once — all of which then had to be unwrapped and NIP-44 decrypted before the messages list felt usable. Replace that with a per-account time window: - New `TimeWindowPagination` primitive (commons): tracks a moving `since` floor, opens a small window at boot, widens backward one step per `loadMore()`. The subscription stays open so live messages still stream in regardless of the window. - `AccountGiftWrapsEoseManager` now requests gift wraps from the window floor instead of the EOSE cursor, exposes `loadMore(user)` and a `loadingMore` flag, and clears the flag on EOSE. - The rooms list (`ChatroomListFeedView`) widens the window when scrolled near the end and shows a loading footer while the next window loads. It re-evaluates as the list grows so a near-empty first screen keeps filling. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../account/AccountFilterAssembler.kt | 4 +- .../AccountGiftWrapsEoseManager.kt | 44 ++++++++++++- .../chats/rooms/feed/ChatroomListFeedView.kt | 59 +++++++++++++++++ .../pagination/TimeWindowPagination.kt | 60 ++++++++++++++++++ .../pagination/TimeWindowPaginationTest.kt | 63 +++++++++++++++++++ 5 files changed, 227 insertions(+), 3 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/pagination/TimeWindowPagination.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/pagination/TimeWindowPaginationTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt index 6b28e608be..2e2576eca4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt @@ -49,10 +49,12 @@ class AccountQueryState( class AccountFilterAssembler( client: INostrClient, ) : ComposeSubscriptionManager() { + val giftWraps = AccountGiftWrapsEoseManager(client, ::allKeys) + val group = listOf( AccountMetadataEoseManager(client, ::allKeys), - AccountGiftWrapsEoseManager(client, ::allKeys), + giftWraps, AccountDraftsEoseManager(client, ::allKeys), AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys), MarmotGroupEventsEoseManager(client, ::allKeys), 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 8f5484a1af..78d980c7e7 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 @@ -21,17 +21,24 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey +import com.vitorpamplona.amethyst.commons.relayClient.pagination.TimeWindowPagination import com.vitorpamplona.amethyst.model.User 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.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -41,6 +48,16 @@ class AccountGiftWrapsEoseManager( ) : PerUserEoseManager(client, allKeys) { override fun user(key: AccountQueryState) = key.account.userProfile() + // How far back in time gift wraps are requested, per account. Boot opens a small + // window so the messages list is usable before the whole DM history is fetched and + // decrypted; scrolling to the end of the list widens it via [loadMore]. + private val windows = mutableMapOf() + + private fun windowFor(user: User) = windows.getOrPut(user.pubkeyHex) { TimeWindowPagination() } + + private val _loadingMore = MutableStateFlow(false) + val loadingMore: StateFlow = _loadingMore.asStateFlow() + override fun updateFilter( key: AccountQueryState, since: SincePerRelayMap?, @@ -48,15 +65,16 @@ class AccountGiftWrapsEoseManager( // Only loads DMs if the account is writeable return if (key.account.isWriteable()) { val relays = key.account.dmRelays.flow.value + val windowSince = windowFor(user(key)).since Log.d("MarmotDbg") { "AccountGiftWrapsEoseManager.updateFilter: pubkey=${user(key).pubkeyHex.take(8)}… " + - "subscribing kind:1059 on ${relays.size} dmRelay(s): ${relays.map { it.url }}" + "subscribing kind:1059 since=$windowSince on ${relays.size} dmRelay(s): ${relays.map { it.url }}" } relays.flatMap { relay -> filterGiftWrapsToPubkey( relay = relay, pubkey = user(key).pubkeyHex, - since = since?.get(relay)?.time, + since = windowSince, ) } } else { @@ -65,6 +83,28 @@ class AccountGiftWrapsEoseManager( } } + /** + * Widens the gift-wrap time window for [user] one step back and re-issues the + * subscription so older conversations stream in. Called when the messages list is + * scrolled near its end. + */ + fun loadMore(user: User) { + windowFor(user).loadMore() + _loadingMore.value = true + invalidateFilters() + } + + override fun newEose( + key: AccountQueryState, + relay: NormalizedRelayUrl, + time: Long, + filters: List?, + ) { + // A backfill window finished loading. + _loadingMore.value = false + super.newEose(key, relay, time, filters) + } + val userJobMap = mutableMapOf>() @OptIn(FlowPreview::class) 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 300f36eda9..12fe94b6c0 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 @@ -21,14 +21,21 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom @@ -47,12 +54,16 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChatroomHeaderCompose import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size25dp import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import java.io.Serializable @Composable @@ -114,6 +125,11 @@ private fun FeedLoaded( val myPubKey = accountViewModel.userProfile().pubkeyHex + val giftWraps = remember(accountViewModel) { accountViewModel.dataSources().account.giftWraps } + val loadingMore by giftWraps.loadingMore.collectAsStateWithLifecycle() + + LoadMoreWhenReachingEnd(listState, items.list.size, accountViewModel) + LazyColumn( contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, @@ -134,6 +150,49 @@ private fun FeedLoaded( thickness = DividerThickness, ) } + + if (loadingMore) { + item(key = "loadingMoreFooter") { + Row( + Modifier.fillMaxWidth().padding(vertical = Size10dp), + horizontalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator(Modifier.size(Size25dp)) + } + } + } + } +} + +// Number of items from the end at which scrolling triggers loading the next, +// older time window of conversations. +private const val LOAD_MORE_THRESHOLD = 5 + +/** + * Widens the DM time window when the messages list is scrolled near its end, so + * older conversations stream in on demand instead of all at boot. Re-evaluates as + * the list grows so a near-empty screen keeps filling; the per-account + * [AccountGiftWrapsEoseManager.loadingMore] guard prevents overlapping requests. + */ +@Composable +private fun LoadMoreWhenReachingEnd( + listState: LazyListState, + itemCount: Int, + accountViewModel: AccountViewModel, +) { + LaunchedEffect(listState, itemCount) { + snapshotFlow { + val info = listState.layoutInfo + val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1 + lastVisible >= info.totalItemsCount - LOAD_MORE_THRESHOLD + }.distinctUntilChanged() + .filter { it && itemCount > 0 } + .collect { + val giftWraps = accountViewModel.dataSources().account.giftWraps + if (!giftWraps.loadingMore.value) { + giftWraps.loadMore(accountViewModel.userProfile()) + } + } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/pagination/TimeWindowPagination.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/pagination/TimeWindowPagination.kt new file mode 100644 index 0000000000..a25ef0a96a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/pagination/TimeWindowPagination.kt @@ -0,0 +1,60 @@ +/* + * 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.amethyst.commons.relayClient.pagination + +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Tracks how far back in time a relay subscription should reach. + * + * Boot opens a small window (recent-first) so a screen becomes usable before the + * whole history is fetched and decrypted. Each [loadMore] widens the floor backward + * so scrolling pulls older history on demand. + * + * Only the lower bound ([since]) moves: the subscription stays open so new events + * keep streaming live regardless of the window. The floor is requested in full on + * every assembly (the value is small and bounded), which keeps the window robust + * even if the in-memory note store evicts previously-loaded events under memory + * pressure. + */ +class TimeWindowPagination( + private val initialWindow: Long = ONE_WEEK_IN_SECONDS, + private val step: Long = ONE_WEEK_IN_SECONDS, +) { + /** Epoch seconds; events older than this are not requested from relays. */ + @Volatile + var since: Long = TimeUtils.now() - initialWindow + private set + + /** Widens the window backward by one [step]. */ + fun loadMore() { + since -= step + } + + /** Resets the window back to the initial boot size, anchored at the current time. */ + fun reset() { + since = TimeUtils.now() - initialWindow + } + + companion object { + const val ONE_WEEK_IN_SECONDS = TimeUtils.ONE_WEEK.toLong() + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/pagination/TimeWindowPaginationTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/pagination/TimeWindowPaginationTest.kt new file mode 100644 index 0000000000..3bdcecedbe --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/pagination/TimeWindowPaginationTest.kt @@ -0,0 +1,63 @@ +/* + * 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.amethyst.commons.relayClient.pagination + +import com.vitorpamplona.quartz.utils.TimeUtils +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class TimeWindowPaginationTest { + @Test + fun bootOpensAWindowThatStartsRecent() { + val window = 1000L + val pagination = TimeWindowPagination(initialWindow = window, step = 500L) + + // floor is roughly `now - initialWindow`, never unbounded + val expected = TimeUtils.now() - window + assertTrue("floor should be near now - window", kotlin.math.abs(pagination.since - expected) <= 2) + } + + @Test + fun loadMoreWidensTheFloorBackwardByOneStep() { + val pagination = TimeWindowPagination(initialWindow = 1000L, step = 500L) + val before = pagination.since + + pagination.loadMore() + assertEquals(before - 500L, pagination.since) + + pagination.loadMore() + assertEquals(before - 1000L, pagination.since) + } + + @Test + fun resetReturnsToTheInitialBootWindow() { + val window = 1000L + val pagination = TimeWindowPagination(initialWindow = window, step = 500L) + pagination.loadMore() + pagination.loadMore() + + pagination.reset() + + val expected = TimeUtils.now() - window + assertTrue("reset floor should be near now - window", kotlin.math.abs(pagination.since - expected) <= 2) + } +} From b6e3716711fad3e5763e7f41fbb871712831b449 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 15:27:41 +0000 Subject: [PATCH 002/103] chore: add DMPagination debug logs for the DM time window Adds Log.d("DMPagination") tracing so the boot window and scroll-driven backfill can be watched live in logcat: - initial window opened per account (with depth in days) - each updateFilter assembly (window `since` + depth + relays) - loadMore widening the window (old -> new floor, depth before/after) - EOSE clearing the loadingMore flag (transition only, not every event) - the rooms list reaching its end (triggered vs skipped-already-loading) Filter logcat by tag `DMPagination` to follow the whole flow. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../AccountGiftWrapsEoseManager.kt | 41 +++++++++++++++---- .../chats/rooms/feed/ChatroomListFeedView.kt | 6 ++- 2 files changed, 38 insertions(+), 9 deletions(-) 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 78d980c7e7..e157ed3b25 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 @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscriptio import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -53,7 +54,12 @@ class AccountGiftWrapsEoseManager( // decrypted; scrolling to the end of the list widens it via [loadMore]. private val windows = mutableMapOf() - private fun windowFor(user: User) = windows.getOrPut(user.pubkeyHex) { TimeWindowPagination() } + private fun windowFor(user: User) = + windows.getOrPut(user.pubkeyHex) { + TimeWindowPagination().also { + Log.d(TAG) { "opening initial gift-wrap window for pubkey=${user.pubkeyHex.take(8)}… since=${it.since} (${daysAgo(it.since)}d back)" } + } + } private val _loadingMore = MutableStateFlow(false) val loadingMore: StateFlow = _loadingMore.asStateFlow() @@ -66,9 +72,9 @@ class AccountGiftWrapsEoseManager( return if (key.account.isWriteable()) { val relays = key.account.dmRelays.flow.value val windowSince = windowFor(user(key)).since - Log.d("MarmotDbg") { - "AccountGiftWrapsEoseManager.updateFilter: pubkey=${user(key).pubkeyHex.take(8)}… " + - "subscribing kind:1059 since=$windowSince on ${relays.size} dmRelay(s): ${relays.map { it.url }}" + Log.d(TAG) { + "updateFilter: pubkey=${user(key).pubkeyHex.take(8)}… requesting kind:1059 " + + "since=$windowSince (${daysAgo(windowSince)}d window) on ${relays.size} dmRelay(s): ${relays.map { it.url }}" } relays.flatMap { relay -> filterGiftWrapsToPubkey( @@ -78,7 +84,7 @@ class AccountGiftWrapsEoseManager( ) } } else { - Log.d("MarmotDbg") { "AccountGiftWrapsEoseManager.updateFilter: account not writeable, skipping" } + Log.d(TAG) { "updateFilter: pubkey=${user(key).pubkeyHex.take(8)}… account not writeable, skipping" } emptyList() } } @@ -89,7 +95,13 @@ class AccountGiftWrapsEoseManager( * scrolled near its end. */ fun loadMore(user: User) { - windowFor(user).loadMore() + val window = windowFor(user) + val before = window.since + window.loadMore() + Log.d(TAG) { + "loadMore: pubkey=${user.pubkeyHex.take(8)}… widening window since $before -> ${window.since} " + + "(${daysAgo(window.since)}d back, was ${daysAgo(before)}d), re-issuing subscription" + } _loadingMore.value = true invalidateFilters() } @@ -100,11 +112,18 @@ class AccountGiftWrapsEoseManager( time: Long, filters: List?, ) { - // A backfill window finished loading. - _loadingMore.value = false + // A backfill window finished loading. Only log the transition, not every live event. + if (_loadingMore.value) { + Log.d(TAG) { + "newEose: pubkey=${user(key).pubkeyHex.take(8)}… backfill window finished on ${relay.url}, clearing loadingMore" + } + _loadingMore.value = false + } super.newEose(key, relay, time, filters) } + private fun daysAgo(epochSeconds: Long) = (TimeUtils.now() - epochSeconds) / TimeUtils.ONE_DAY + val userJobMap = mutableMapOf>() @OptIn(FlowPreview::class) @@ -130,4 +149,10 @@ class AccountGiftWrapsEoseManager( super.endSub(key, subId) userJobMap[key]?.forEach { it.cancel() } } + + companion object { + // Shared log tag for the DM time-window pagination. Filter logcat by this + // tag to watch the boot window and scroll-driven backfill in real time. + private const val TAG = "DMPagination" + } } 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 12fe94b6c0..43a1c4e01a 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 @@ -62,6 +62,7 @@ import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import java.io.Serializable @@ -189,7 +190,10 @@ private fun LoadMoreWhenReachingEnd( .filter { it && itemCount > 0 } .collect { val giftWraps = accountViewModel.dataSources().account.giftWraps - if (!giftWraps.loadingMore.value) { + if (giftWraps.loadingMore.value) { + Log.d("DMPagination") { "rooms list near end ($itemCount items) but a window load is already in flight, skipping" } + } else { + Log.d("DMPagination") { "rooms list scrolled near end ($itemCount items), requesting an older window of conversations" } giftWraps.loadMore(accountViewModel.userProfile()) } } From 65b5ad48f633caec8e95ccc5c36c1e8392b4e656 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 15:48:14 +0000 Subject: [PATCH 003/103] chore: log cold-boot DM load start and completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously only the boot window open and per-assembly filter were logged; the completion of the initial cold-boot load was effectively invisible (the EOSE log was gated behind the scroll-only loadingMore flag, and newEose can't tell a real EOSE from a live event because the base listener funnels both into it). Install a custom SubscriptionListener in newSub so we can distinguish a real EOSE and count arriving gift wraps: - "cold boot: … opening gift-wrap subscription, starting to load messages" - "cold boot: … initial load complete — first EOSE from after Nms, M gift wrap(s) received so far" Boot timing/count state is reset per subscription and cleared on endSub. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../AccountGiftWrapsEoseManager.kt | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) 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 e157ed3b25..b61916ff88 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 @@ -26,9 +26,11 @@ import com.vitorpamplona.amethyst.model.User 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.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient 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 import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -126,6 +128,12 @@ class AccountGiftWrapsEoseManager( val userJobMap = mutableMapOf>() + // Cold-boot instrumentation: when the subscription opened (ms), how many gift + // wraps have arrived since, and whether we've already logged the first EOSE. + private val bootStartMs = mutableMapOf() + private val bootEventCount = mutableMapOf() + private val bootEoseLogged = mutableSetOf() + @OptIn(FlowPreview::class) override fun newSub(key: AccountQueryState): Subscription { val user = user(key) @@ -139,7 +147,47 @@ class AccountGiftWrapsEoseManager( }, ) - return super.newSub(key) + // Reset and start the cold-boot timer for this subscription. + val pubkey = user.pubkeyHex + bootStartMs[pubkey] = System.currentTimeMillis() + bootEventCount[pubkey] = 0 + bootEoseLogged.remove(pubkey) + Log.d(TAG) { "cold boot: pubkey=${pubkey.take(8)}… opening gift-wrap subscription, starting to load messages" } + + // Custom listener so we can tell a real EOSE (load finished) apart from live + // events; the base class routes both into newEose, which can't distinguish them. + return requestNewSubscription( + object : SubscriptionListener { + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (bootEoseLogged.add(pubkey)) { + val elapsed = System.currentTimeMillis() - (bootStartMs[pubkey] ?: System.currentTimeMillis()) + val count = bootEventCount[pubkey] ?: 0 + Log.d(TAG) { + "cold boot: pubkey=${pubkey.take(8)}… initial load complete — first EOSE from ${relay.url} " + + "after ${elapsed}ms, $count gift wrap(s) received so far" + } + } + newEose(key, relay, TimeUtils.now(), forFilters) + } + + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pubkey !in bootEoseLogged) { + bootEventCount[pubkey] = (bootEventCount[pubkey] ?: 0) + 1 + } + if (isLive) { + newEose(key, relay, TimeUtils.now(), forFilters) + } + } + }, + ) } override fun endSub( @@ -148,6 +196,9 @@ class AccountGiftWrapsEoseManager( ) { super.endSub(key, subId) userJobMap[key]?.forEach { it.cancel() } + bootStartMs.remove(key.pubkeyHex) + bootEventCount.remove(key.pubkeyHex) + bootEoseLogged.remove(key.pubkeyHex) } companion object { From 3ca40ca5ef50e02428473c500140cf14cfa9d386 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 16:28:50 +0000 Subject: [PATCH 004/103] chore: add DM relay diagnostics timeline (tag: DMPagination) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reported symptom — messages inside the 7-day window never appear, and the first EOSE takes ~136s on a single-event account — points at the relay/connection path, not the time filter. Nothing currently logs where that time goes or whether a relay is silently rejecting the query (CLOSED "auth-required"/"restricted"). Add DmRelayDiagnosticsLogger, a debug-only RelayConnectionListener that folds the gift-wrap loading timeline into the DMPagination tag with elapsed-time prefixes: - connecting / connected (ping) / disconnected / cannotConnect per relay - REQ sent for gift-wrap subscriptions (kind:1059/1060), with the command - AUTH challenge, NOTICE, and CLOSED (for gift-wrap subs) — the silent-failure tells - gift-wrap EVENT arrivals (relay, sub, createdAt) and their EOSE Wired in AppModules next to the other debug loggers. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../com/vitorpamplona/amethyst/AppModules.kt | 4 + .../diagnostics/DmRelayDiagnosticsLogger.kt | 150 ++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 7814e880f6..3c81663921 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -69,6 +69,7 @@ import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient import com.vitorpamplona.amethyst.service.relayClient.CacheClientConnector import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator +import com.vitorpamplona.amethyst.service.relayClient.diagnostics.DmRelayDiagnosticsLogger import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState @@ -511,6 +512,9 @@ class AppModules( val relayReqStats = if (isDebug) RelayReqStats(client) else null val logger = if (isDebug) RelaySpeedLogger(client) else null + // Focused timeline for the DM / gift-wrap loading path (tag: DMPagination). + val dmDiagnostics = if (isDebug) DmRelayDiagnosticsLogger(client) else null + // Coordinates all subscriptions for the Nostr Client val sources: RelaySubscriptionsCoordinator = RelaySubscriptionsCoordinator( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt new file mode 100644 index 0000000000..3421d697ea --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt @@ -0,0 +1,150 @@ +/* + * 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.amethyst.service.relayClient.diagnostics + +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.utils.Log + +/** + * Diagnostic connection listener for the DM / gift-wrap loading path. + * + * It folds the full per-relay timeline — connect, auth challenge, REQ sent, + * gift-wrap events, EOSE, plus any NOTICE/CLOSED rejection — into the single + * `DMPagination` log tag with an elapsed-time prefix, so a slow cold boot can be + * attributed (connection? auth? relay response?) and a silent failure to load + * (e.g. a relay answering CLOSED "auth-required" / "restricted") becomes visible. + * + * Gift-wrap subscriptions are recognised by the kind:1059/1060 filter in the REQ + * we send, so EOSE/CLOSED for those subscriptions can be singled out from the + * rest of the app's relay traffic. + */ +class DmRelayDiagnosticsLogger( + val client: INostrClient, +) { + private val startMs = System.currentTimeMillis() + + private fun at() = System.currentTimeMillis() - startMs + + // Subscription ids whose REQ carried a gift-wrap kind, so we can attribute their EOSE/CLOSED. + private val giftWrapSubIds = mutableSetOf() + + private val listener = + object : RelayConnectionListener { + override fun onConnecting(relay: IRelayClient) { + Log.d(TAG) { "[+${at()}ms] connecting ${relay.url.url}" } + } + + override fun onConnected( + relay: IRelayClient, + pingMillis: Int, + compressed: Boolean, + ) { + Log.d(TAG) { "[+${at()}ms] connected ${relay.url.url} (ping ${pingMillis}ms${if (compressed) ", compressed" else ""})" } + } + + override fun onSent( + relay: IRelayClient, + cmdStr: String, + cmd: Command, + success: Boolean, + ) { + if (!cmdStr.contains("1059") && !cmdStr.contains("1060")) return + reqSubId(cmdStr)?.let { giftWrapSubIds.add(it) } + Log.d(TAG) { "[+${at()}ms] REQ -> ${relay.url.url} success=$success ${cmdStr.take(400)}" } + } + + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + when (msg) { + is AuthMessage -> + Log.d(TAG) { "[+${at()}ms] AUTH <- ${relay.url.url} challenge=${msg.challenge.take(12)}…" } + + is NoticeMessage -> + Log.d(TAG) { "[+${at()}ms] NOTICE <- ${relay.url.url} '${msg.message}'" } + + is ClosedMessage -> + if (msg.subId in giftWrapSubIds) { + Log.d(TAG) { "[+${at()}ms] CLOSED <- ${relay.url.url} sub=${msg.subId} reason='${msg.message}'" } + } + + is EoseMessage -> + if (msg.subId in giftWrapSubIds) { + Log.d(TAG) { "[+${at()}ms] EOSE <- ${relay.url.url} sub=${msg.subId}" } + } + + is EventMessage -> + if (msg.event.kind == GiftWrapEvent.KIND || msg.event.kind == EphemeralGiftWrapEvent.KIND) { + Log.d(TAG) { "[+${at()}ms] EVENT <- ${relay.url.url} kind=${msg.event.kind} sub=${msg.subId} createdAt=${msg.event.createdAt}" } + } + + is OkMessage -> + if (!msg.success) { + Log.d(TAG) { "[+${at()}ms] OK(fail) <- ${relay.url.url} '${msg.message}'" } + } + + else -> {} + } + } + + override fun onDisconnected(relay: IRelayClient) { + Log.d(TAG) { "[+${at()}ms] disconnected ${relay.url.url}" } + } + + override fun onCannotConnect( + relay: IRelayClient, + errorMessage: String, + ) { + Log.d(TAG) { "[+${at()}ms] CANNOT CONNECT ${relay.url.url}: $errorMessage" } + } + } + + init { + client.addConnectionListener(listener) + } + + fun destroy() { + client.removeConnectionListener(listener) + } + + companion object { + private const val TAG = "DMPagination" + + // Extracts the subscription id from a `["REQ","",{...}]` command string. + private val REQ_SUB_ID = Regex("^\\[\"REQ\",\"([^\"]+)\"") + + private fun reqSubId(cmdStr: String) = REQ_SUB_ID.find(cmdStr)?.groupValues?.get(1) + } +} From fca99b68befe66d82a338b6f73da896a5354130e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 17:10:27 +0000 Subject: [PATCH 005/103] chore: scope DM diagnostics to gift-wrap relays only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connection listener fires for every relay the app dials (hundreds, under the outbox model), so logging connect/auth/notice unconditionally drowned the DMPagination tag in unrelated relay traffic. Restrict connect / disconnect / cannotConnect / AUTH / NOTICE / OK(fail) lines to relays on the gift-wrap path — learned the first time we send a kind:1059/1060 REQ to a relay or receive a gift wrap from it. EVENT/EOSE/CLOSED were already scoped by kind/subId. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../diagnostics/DmRelayDiagnosticsLogger.kt | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt index 3421d697ea..6fec646b46 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.Log @@ -38,15 +39,17 @@ import com.vitorpamplona.quartz.utils.Log /** * Diagnostic connection listener for the DM / gift-wrap loading path. * - * It folds the full per-relay timeline — connect, auth challenge, REQ sent, - * gift-wrap events, EOSE, plus any NOTICE/CLOSED rejection — into the single + * It folds the per-relay timeline — REQ sent, gift-wrap events, EOSE, plus auth + * challenge / NOTICE / CLOSED rejection and connect/disconnect — into the single * `DMPagination` log tag with an elapsed-time prefix, so a slow cold boot can be * attributed (connection? auth? relay response?) and a silent failure to load * (e.g. a relay answering CLOSED "auth-required" / "restricted") becomes visible. * - * Gift-wrap subscriptions are recognised by the kind:1059/1060 filter in the REQ - * we send, so EOSE/CLOSED for those subscriptions can be singled out from the - * rest of the app's relay traffic. + * The connection listener fires for EVERY relay the app talks to (hundreds, under + * the outbox model). To keep this readable we only log relays that are part of the + * gift-wrap path: a relay is "learned" the first time we send it a kind:1059/1060 + * REQ or receive a gift wrap from it, and only those relays' connect/auth/notice + * lines are emitted thereafter. */ class DmRelayDiagnosticsLogger( val client: INostrClient, @@ -58,10 +61,16 @@ class DmRelayDiagnosticsLogger( // Subscription ids whose REQ carried a gift-wrap kind, so we can attribute their EOSE/CLOSED. private val giftWrapSubIds = mutableSetOf() + // Relays we've seen on the gift-wrap path, so connect/auth/notice noise from the + // hundreds of unrelated follow/outbox relays is filtered out. + private val giftWrapRelays = mutableSetOf() + + private fun isDmRelay(relay: IRelayClient) = relay.url in giftWrapRelays + private val listener = object : RelayConnectionListener { override fun onConnecting(relay: IRelayClient) { - Log.d(TAG) { "[+${at()}ms] connecting ${relay.url.url}" } + if (isDmRelay(relay)) Log.d(TAG) { "[+${at()}ms] connecting ${relay.url.url}" } } override fun onConnected( @@ -69,7 +78,9 @@ class DmRelayDiagnosticsLogger( pingMillis: Int, compressed: Boolean, ) { - Log.d(TAG) { "[+${at()}ms] connected ${relay.url.url} (ping ${pingMillis}ms${if (compressed) ", compressed" else ""})" } + if (isDmRelay(relay)) { + Log.d(TAG) { "[+${at()}ms] connected ${relay.url.url} (ping ${pingMillis}ms${if (compressed) ", compressed" else ""})" } + } } override fun onSent( @@ -79,6 +90,7 @@ class DmRelayDiagnosticsLogger( success: Boolean, ) { if (!cmdStr.contains("1059") && !cmdStr.contains("1060")) return + giftWrapRelays.add(relay.url) reqSubId(cmdStr)?.let { giftWrapSubIds.add(it) } Log.d(TAG) { "[+${at()}ms] REQ -> ${relay.url.url} success=$success ${cmdStr.take(400)}" } } @@ -90,10 +102,10 @@ class DmRelayDiagnosticsLogger( ) { when (msg) { is AuthMessage -> - Log.d(TAG) { "[+${at()}ms] AUTH <- ${relay.url.url} challenge=${msg.challenge.take(12)}…" } + if (isDmRelay(relay)) Log.d(TAG) { "[+${at()}ms] AUTH <- ${relay.url.url} challenge=${msg.challenge.take(12)}…" } is NoticeMessage -> - Log.d(TAG) { "[+${at()}ms] NOTICE <- ${relay.url.url} '${msg.message}'" } + if (isDmRelay(relay)) Log.d(TAG) { "[+${at()}ms] NOTICE <- ${relay.url.url} '${msg.message}'" } is ClosedMessage -> if (msg.subId in giftWrapSubIds) { @@ -107,11 +119,12 @@ class DmRelayDiagnosticsLogger( is EventMessage -> if (msg.event.kind == GiftWrapEvent.KIND || msg.event.kind == EphemeralGiftWrapEvent.KIND) { + giftWrapRelays.add(relay.url) Log.d(TAG) { "[+${at()}ms] EVENT <- ${relay.url.url} kind=${msg.event.kind} sub=${msg.subId} createdAt=${msg.event.createdAt}" } } is OkMessage -> - if (!msg.success) { + if (!msg.success && isDmRelay(relay)) { Log.d(TAG) { "[+${at()}ms] OK(fail) <- ${relay.url.url} '${msg.message}'" } } @@ -120,14 +133,14 @@ class DmRelayDiagnosticsLogger( } override fun onDisconnected(relay: IRelayClient) { - Log.d(TAG) { "[+${at()}ms] disconnected ${relay.url.url}" } + if (isDmRelay(relay)) Log.d(TAG) { "[+${at()}ms] disconnected ${relay.url.url}" } } override fun onCannotConnect( relay: IRelayClient, errorMessage: String, ) { - Log.d(TAG) { "[+${at()}ms] CANNOT CONNECT ${relay.url.url}: $errorMessage" } + if (isDmRelay(relay)) Log.d(TAG) { "[+${at()}ms] CANNOT CONNECT ${relay.url.url}: $errorMessage" } } } From 976c650b2f952b06e28af9d013a82c7a80ff87b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 19:35:29 +0000 Subject: [PATCH 006/103] fix: detect gift-wrap REQs by kinds array, drop EOSE noise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The diagnostics logger tagged a subscription as gift-wrap when its raw REQ string merely *contained* "1059"/"1060" — which matches incidentally inside a pubkey hex or a since/limit number on unrelated feed REQs. Those feed subs then leaked their EOSEs (and some connect/auth lines) into the DMPagination tag. Match the filter's `kinds` array exactly against the real gift-wrap kinds (1059 + 21059) instead. Also drop the per-relay EOSE line entirely — it's redundant with the "cold boot: … initial load complete" summary that already reports the first EOSE and gift-wrap count. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../diagnostics/DmRelayDiagnosticsLogger.kt | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt index 6fec646b46..eb37b39394 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt @@ -25,7 +25,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnection import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage @@ -89,7 +88,7 @@ class DmRelayDiagnosticsLogger( cmd: Command, success: Boolean, ) { - if (!cmdStr.contains("1059") && !cmdStr.contains("1060")) return + if (!isGiftWrapReq(cmdStr)) return giftWrapRelays.add(relay.url) reqSubId(cmdStr)?.let { giftWrapSubIds.add(it) } Log.d(TAG) { "[+${at()}ms] REQ -> ${relay.url.url} success=$success ${cmdStr.take(400)}" } @@ -112,11 +111,6 @@ class DmRelayDiagnosticsLogger( Log.d(TAG) { "[+${at()}ms] CLOSED <- ${relay.url.url} sub=${msg.subId} reason='${msg.message}'" } } - is EoseMessage -> - if (msg.subId in giftWrapSubIds) { - Log.d(TAG) { "[+${at()}ms] EOSE <- ${relay.url.url} sub=${msg.subId}" } - } - is EventMessage -> if (msg.event.kind == GiftWrapEvent.KIND || msg.event.kind == EphemeralGiftWrapEvent.KIND) { giftWrapRelays.add(relay.url) @@ -155,9 +149,24 @@ class DmRelayDiagnosticsLogger( companion object { private const val TAG = "DMPagination" + // The kinds a gift-wrap REQ carries (1059 + 21059). Matched exactly against the + // filter's "kinds" array — never as a substring of the whole command, since a + // pubkey hex or timestamp can incidentally contain "1059". + private val GIFT_WRAP_KINDS = setOf(GiftWrapEvent.KIND, EphemeralGiftWrapEvent.KIND) + + private val KINDS_ARRAY = Regex("\"kinds\":\\[([0-9,\\s]*)]") + // Extracts the subscription id from a `["REQ","",{...}]` command string. private val REQ_SUB_ID = Regex("^\\[\"REQ\",\"([^\"]+)\"") private fun reqSubId(cmdStr: String) = REQ_SUB_ID.find(cmdStr)?.groupValues?.get(1) + + /** True only when one of the REQ's `kinds` arrays actually contains a gift-wrap kind. */ + private fun isGiftWrapReq(cmdStr: String): Boolean = + KINDS_ARRAY.findAll(cmdStr).any { match -> + match.groupValues[1] + .split(',') + .any { it.trim().toIntOrNull() in GIFT_WRAP_KINDS } + } } } From e930cc6ef04e6fca4d2c27b3cdc2374ca13bcb77 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 20:42:06 +0000 Subject: [PATCH 007/103] feat: window NIP-04 DMs in lockstep with gift wraps in the rooms list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rooms list merges NIP-04 (kind 4) and NIP-17 (gift wrap) conversations into one time-sorted list, but only the gift-wrap loader was windowed — NIP-04 (`DMsFromUserFilterSubAssembler`) still used an EOSE-only `since` with no limit, so it loaded all kind-4 history at boot. That asymmetry broke scroll-to-load-more: gift wraps filled only the recent top of the list while NIP-04 filled the whole tail, so reaching the list end (deep in the NIP-04 tail) fired `giftWraps.loadMore()`, and the newly fetched 7-14d gift wraps inserted in the *middle* of the feed instead of extending the end — and could re-fire step after step while the user sat in the NIP-04 tail. Apply the same TimeWindowPagination to the NIP-04 rooms-list loader and advance both windows together from the scroll handler, so the merged list is bounded uniformly and reaching the end extends the actual end. The loading footer now reflects either protocol still loading. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../datasource/ChatroomListFilterAssembler.kt | 4 +- .../DMsFromUserFilterSubAssembler.kt | 40 ++++++++++++++++++- .../chats/rooms/feed/ChatroomListFeedView.kt | 26 ++++++++---- 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt index 2b9c534502..d4876752b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt @@ -35,9 +35,11 @@ class ChatroomListState( class ChatroomListFilterAssembler( client: INostrClient, ) : ComposeSubscriptionManager() { + val nip04Dms = DMsFromUserFilterSubAssembler(client, ::allKeys) + val group = listOf( - DMsFromUserFilterSubAssembler(client, ::allKeys), + nip04Dms, FollowingPublicChatSubAssembler(client, ::allKeys), FollowingEphemeralChatSubAssembler(client, ::allKeys), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt index 410e03d59b..803e97a04d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt @@ -20,15 +20,22 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource +import com.vitorpamplona.amethyst.commons.relayClient.pagination.TimeWindowPagination import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -36,21 +43,50 @@ class DMsFromUserFilterSubAssembler( client: INostrClient, allKeys: () -> Set, ) : PerUserEoseManager(client, allKeys) { + // Same moving time window as the gift-wrap (NIP-17) loader, so the merged rooms list is + // bounded uniformly across both DM protocols. Without this, NIP-04 loaded all history while + // NIP-17 only loaded the recent window, so scroll-to-end (which widens the windows) landed + // new NIP-17 rooms in the middle of the NIP-04 tail instead of extending the list end. + private val windows = mutableMapOf() + + private fun windowFor(user: User) = windows.getOrPut(user.pubkeyHex) { TimeWindowPagination() } + + private val _loadingMore = MutableStateFlow(false) + val loadingMore: StateFlow = _loadingMore.asStateFlow() + override fun updateFilter( key: ChatroomListState, since: SincePerRelayMap?, ): List? = if (key.account.isWriteable()) { + val windowSince = windowFor(user(key)).since key.account.homeRelays.flow.value.map { - filterNip04DMsFromMe(key.account.userProfile(), it, since?.get(it)?.time) + filterNip04DMsFromMe(key.account.userProfile(), it, windowSince) } + key.account.dmRelays.flow.value.map { - filterNip04DMsToMe(key.account.userProfile(), it, since?.get(it)?.time) + filterNip04DMsToMe(key.account.userProfile(), it, windowSince) } } else { emptyList() } + /** Widens the NIP-04 time window for [user] one step back. Kept in lockstep with the gift-wrap window. */ + fun loadMore(user: User) { + windowFor(user).loadMore() + _loadingMore.value = true + invalidateFilters() + } + + override fun newEose( + key: ChatroomListState, + relay: NormalizedRelayUrl, + time: Long, + filters: List?, + ) { + if (_loadingMore.value) _loadingMore.value = false + super.newEose(key, relay, time, filters) + } + override fun user(key: ChatroomListState) = key.account.userProfile() val userJobMap = mutableMapOf>() 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 43a1c4e01a..3a962a1217 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 @@ -127,7 +127,10 @@ private fun FeedLoaded( val myPubKey = accountViewModel.userProfile().pubkeyHex val giftWraps = remember(accountViewModel) { accountViewModel.dataSources().account.giftWraps } - val loadingMore by giftWraps.loadingMore.collectAsStateWithLifecycle() + val nip04Dms = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04Dms } + val loadingGiftWraps by giftWraps.loadingMore.collectAsStateWithLifecycle() + val loadingNip04 by nip04Dms.loadingMore.collectAsStateWithLifecycle() + val loadingMore = loadingGiftWraps || loadingNip04 LoadMoreWhenReachingEnd(listState, items.list.size, accountViewModel) @@ -170,10 +173,16 @@ private fun FeedLoaded( private const val LOAD_MORE_THRESHOLD = 5 /** - * Widens the DM time window when the messages list is scrolled near its end, so + * Widens the DM time windows when the messages list is scrolled near its end, so * older conversations stream in on demand instead of all at boot. Re-evaluates as - * the list grows so a near-empty screen keeps filling; the per-account - * [AccountGiftWrapsEoseManager.loadingMore] guard prevents overlapping requests. + * the list grows so a near-empty screen keeps filling. + * + * Both DM protocols are advanced in lockstep: NIP-17 gift wraps (always-on account + * loader) and NIP-04 (this screen's loader). They must move together — if only one + * were windowed, the merged time-sorted list would mix a deep tail of one protocol + * with a shallow window of the other, and reaching the list end would pull rooms + * that land in the middle of the feed instead of extending the end. The combined + * loadingMore guard prevents overlapping requests. */ @Composable private fun LoadMoreWhenReachingEnd( @@ -190,11 +199,14 @@ private fun LoadMoreWhenReachingEnd( .filter { it && itemCount > 0 } .collect { val giftWraps = accountViewModel.dataSources().account.giftWraps - if (giftWraps.loadingMore.value) { + val nip04Dms = accountViewModel.dataSources().chatroomList.nip04Dms + if (giftWraps.loadingMore.value || nip04Dms.loadingMore.value) { Log.d("DMPagination") { "rooms list near end ($itemCount items) but a window load is already in flight, skipping" } } else { - Log.d("DMPagination") { "rooms list scrolled near end ($itemCount items), requesting an older window of conversations" } - giftWraps.loadMore(accountViewModel.userProfile()) + Log.d("DMPagination") { "rooms list scrolled near end ($itemCount items), widening NIP-17 + NIP-04 windows" } + val user = accountViewModel.userProfile() + giftWraps.loadMore(user) + nip04Dms.loadMore(user) } } } From 80dc2f2d9171d8abfff12d36d6de28d9f8b7160d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 21:11:43 +0000 Subject: [PATCH 008/103] fix: stop scroll-to-end widening from cascading the DM window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-key the rooms list edge-detector on listState only, instead of (listState, itemCount). Keying on item count re-armed the detector on every widen: loadMore pulled older conversations, the list grew, LaunchedEffect restarted, the edge-detector reset, and it fired again — walking the window 7->14->21->...->112 days back in a few seconds on a slow connection. Now distinctUntilChanged fires once per reach-the-end gesture and does not re-fire while parked at the end. Also surface initialLoadInFlight from both DM loaders (gift wraps + NIP-04) and keep a spinner up on the rooms screen until the first relay answers, so cold boot no longer flashes the empty state before the DMs land. --- .../AccountGiftWrapsEoseManager.kt | 8 +++++ .../DMsFromUserFilterSubAssembler.kt | 6 ++++ .../chats/rooms/feed/ChatroomListFeedView.kt | 34 ++++++++++++++----- 3 files changed, 40 insertions(+), 8 deletions(-) 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 b61916ff88..514a2187a4 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 @@ -66,6 +66,12 @@ class AccountGiftWrapsEoseManager( private val _loadingMore = MutableStateFlow(false) val loadingMore: StateFlow = _loadingMore.asStateFlow() + // True from cold boot until the first EOSE arrives. Lets the rooms screen keep + // showing a spinner during the (Tor-slow) initial load instead of flashing the + // empty state before any relay has answered. + private val _initialLoadInFlight = MutableStateFlow(true) + val initialLoadInFlight: StateFlow = _initialLoadInFlight.asStateFlow() + override fun updateFilter( key: AccountQueryState, since: SincePerRelayMap?, @@ -152,6 +158,7 @@ class AccountGiftWrapsEoseManager( bootStartMs[pubkey] = System.currentTimeMillis() bootEventCount[pubkey] = 0 bootEoseLogged.remove(pubkey) + _initialLoadInFlight.value = true Log.d(TAG) { "cold boot: pubkey=${pubkey.take(8)}… opening gift-wrap subscription, starting to load messages" } // Custom listener so we can tell a real EOSE (load finished) apart from live @@ -163,6 +170,7 @@ class AccountGiftWrapsEoseManager( forFilters: List?, ) { if (bootEoseLogged.add(pubkey)) { + _initialLoadInFlight.value = false val elapsed = System.currentTimeMillis() - (bootStartMs[pubkey] ?: System.currentTimeMillis()) val count = bootEventCount[pubkey] ?: 0 Log.d(TAG) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt index 803e97a04d..e0bd2bd436 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt @@ -54,6 +54,11 @@ class DMsFromUserFilterSubAssembler( private val _loadingMore = MutableStateFlow(false) val loadingMore: StateFlow = _loadingMore.asStateFlow() + // True from (re)subscribe until the first relay response, so the rooms screen can + // keep a spinner up during the initial load instead of flashing the empty state. + private val _initialLoadInFlight = MutableStateFlow(true) + val initialLoadInFlight: StateFlow = _initialLoadInFlight.asStateFlow() + override fun updateFilter( key: ChatroomListState, since: SincePerRelayMap?, @@ -84,6 +89,7 @@ class DMsFromUserFilterSubAssembler( filters: List?, ) { if (_loadingMore.value) _loadingMore.value = false + _initialLoadInFlight.value = false super.newEose(key, relay, time, filters) } 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 3a962a1217..18fa91c1e2 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 @@ -90,6 +90,15 @@ private fun CrossFadeState( ) { val feedState by feedContentState.feedContent.collectAsStateWithLifecycle() + // While the first gift-wrap / NIP-04 window is still being fetched and decrypted, an + // empty feed means "not loaded yet", not "no conversations". Keep the spinner up until + // both initial loads answer so cold boot doesn't flash the empty state before the DMs land. + val giftWraps = remember(accountViewModel) { accountViewModel.dataSources().account.giftWraps } + val nip04Dms = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04Dms } + val giftWrapsInitialLoad by giftWraps.initialLoadInFlight.collectAsStateWithLifecycle() + val nip04InitialLoad by nip04Dms.initialLoadInFlight.collectAsStateWithLifecycle() + val initialLoadInFlight = giftWrapsInitialLoad || nip04InitialLoad + CrossfadeIfEnabled( targetState = feedState, animationSpec = tween(durationMillis = 100), @@ -97,7 +106,11 @@ private fun CrossFadeState( ) { state -> when (state) { is FeedState.Empty -> { - FeedEmpty { feedContentState.invalidateData() } + if (initialLoadInFlight) { + LoadingFeed() + } else { + FeedEmpty { feedContentState.invalidateData() } + } } is FeedState.FeedError -> { @@ -132,7 +145,7 @@ private fun FeedLoaded( val loadingNip04 by nip04Dms.loadingMore.collectAsStateWithLifecycle() val loadingMore = loadingGiftWraps || loadingNip04 - LoadMoreWhenReachingEnd(listState, items.list.size, accountViewModel) + LoadMoreWhenReachingEnd(listState, accountViewModel) LazyColumn( contentPadding = rememberFeedContentPadding(FeedPadding), @@ -187,23 +200,28 @@ private const val LOAD_MORE_THRESHOLD = 5 @Composable private fun LoadMoreWhenReachingEnd( listState: LazyListState, - itemCount: Int, accountViewModel: AccountViewModel, ) { - LaunchedEffect(listState, itemCount) { + // Keyed only on listState so the edge-detector is NOT restarted when a widen adds + // rooms. distinctUntilChanged then fires exactly once per reach-the-end gesture: + // staying at the end does not re-fire, and scrolling back up (nearEnd -> false) + // stops further loads. Keying on item count instead would re-arm on every item + // growth and cascade the window back for minutes over a slow connection. + LaunchedEffect(listState) { snapshotFlow { val info = listState.layoutInfo + val total = info.totalItemsCount val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1 - lastVisible >= info.totalItemsCount - LOAD_MORE_THRESHOLD + total > 0 && lastVisible >= total - LOAD_MORE_THRESHOLD }.distinctUntilChanged() - .filter { it && itemCount > 0 } + .filter { it } .collect { val giftWraps = accountViewModel.dataSources().account.giftWraps val nip04Dms = accountViewModel.dataSources().chatroomList.nip04Dms if (giftWraps.loadingMore.value || nip04Dms.loadingMore.value) { - Log.d("DMPagination") { "rooms list near end ($itemCount items) but a window load is already in flight, skipping" } + Log.d("DMPagination") { "rooms list reached end but a window load is already in flight, skipping" } } else { - Log.d("DMPagination") { "rooms list scrolled near end ($itemCount items), widening NIP-17 + NIP-04 windows" } + Log.d("DMPagination") { "rooms list reached end, widening NIP-17 + NIP-04 windows one step" } val user = accountViewModel.userProfile() giftWraps.loadMore(user) nip04Dms.loadMore(user) From d311a01964d931268f43fd159bfd7c80343cb7b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 21:44:26 +0000 Subject: [PATCH 009/103] feat: auto-fill and prefetch the rooms list with a growing DM window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the fire-once scroll detector with a viewport-fill + prefetch loop so the messages screen stays ahead of the user instead of stranding a near-empty list. One condition drives three behaviors: widen the DM time windows when the feed is empty, or when the last visible row crosses the midpoint of what's loaded. While the list is short everything is visible, so the midpoint is always crossed and it keeps widening until the list overflows the viewport with a buffer below the fold; once full it only fires again as the user scrolls past the new midpoint, so a fresh chunk lands well before the end. It stops only when the window is exhausted (reached the 10-year lookback — nothing older exists), which also gives the empty-account case a real terminating condition instead of the old runaway cascade. - TimeWindowPagination: optional geometric step growth + a hard max-lookback floor with isExhausted(), so a sparse / single-person history converges in ~10 requests. Default stays linear/unbounded; existing callers unchanged. - WindowLoadTracker: a window counts as loaded only once ALL of its relays have answered (EOSE / live event) or a timeout fires — not on the first EOSE. This stops a fast, near-empty relay from clearing the gate and letting the fill loop outrun the slow relay that holds the conversations. - Both DM loaders (NIP-17 gift wraps + NIP-04) expose loadingMore (= window still loading) and exhausted, advance in lockstep, and gate each widen on the tracker. The rooms screen shows a spinner until history is exhausted rather than flashing the empty state while older windows are still in flight. --- .../eoseManagers/WindowLoadTracker.kt | 91 ++++++++++++++++ .../AccountGiftWrapsEoseManager.kt | 57 ++++++---- .../DMsFromUserFilterSubAssembler.kt | 54 +++++++--- .../chats/rooms/feed/ChatroomListFeedView.kt | 102 ++++++++++-------- .../pagination/TimeWindowPagination.kt | 26 ++++- .../pagination/TimeWindowPaginationTest.kt | 49 +++++++++ 6 files changed, 293 insertions(+), 86 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt new file mode 100644 index 0000000000..5e759592fd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt @@ -0,0 +1,91 @@ +/* + * 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.amethyst.service.relayClient.eoseManagers + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Tracks when one relay-subscription "window" has finished loading, so callers can wait for the + * WHOLE set of relays to answer instead of declaring victory on the first EOSE. + * + * A subscription fans a single REQ out to several relays. The first EOSE is a misleading "done" + * signal: a fast but near-empty relay can EOSE in milliseconds while the relay that actually holds + * the data is still connecting (or stuck in an auth handshake). An auto-fill loop driven by the + * first EOSE would therefore widen the time window again before the slow relay ever answered, + * walking the window back uselessly. + * + * [loading] stays true until EVERY [setExpectedRelays] relay has answered (an EOSE, or a live event + * that implies the stored set already drained), or until [timeout] elapses as a backstop for relays + * that never answer (down, or looping on `auth-required`). + */ +class WindowLoadTracker( + private val timeout: Duration = 15.seconds, +) { + private val _loading = MutableStateFlow(true) + val loading: StateFlow = _loading.asStateFlow() + + private var expected: Set = emptySet() + private val responded = mutableSetOf() + private var timeoutJob: Job? = null + + /** Begins a fresh window load: clears the responded set, raises [loading], and arms the timeout. */ + @Synchronized + fun startLoading(scope: CoroutineScope) { + responded.clear() + _loading.value = true + timeoutJob?.cancel() + timeoutJob = + scope.launch { + delay(timeout) + finish() + } + } + + /** Records which relays the current REQ was sent to. Completes immediately if there are none. */ + @Synchronized + fun setExpectedRelays(relays: Set) { + expected = relays + if (relays.isEmpty() || responded.containsAll(relays)) finish() + } + + /** Marks [relay] as having answered (EOSE or live event). Completes once all expected have. */ + @Synchronized + fun onRelayResponded(relay: NormalizedRelayUrl) { + responded.add(relay) + if (expected.isNotEmpty() && responded.containsAll(expected)) finish() + } + + @Synchronized + private fun finish() { + _loading.value = false + timeoutJob?.cancel() + timeoutJob = null + } +} 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 514a2187a4..335df0f9e6 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,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToP import com.vitorpamplona.amethyst.commons.relayClient.pagination.TimeWindowPagination import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.WindowLoadTracker import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.Event @@ -36,6 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter 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.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -53,24 +55,31 @@ class AccountGiftWrapsEoseManager( // How far back in time gift wraps are requested, per account. Boot opens a small // window so the messages list is usable before the whole DM history is fetched and - // decrypted; scrolling to the end of the list widens it via [loadMore]. + // decrypted; the rooms screen widens it via [loadMore] to fill the screen and to + // prefetch as the user scrolls. The step grows geometrically so a sparse history + // (or confirming there is nothing older) converges in a handful of requests; it is + // kept in lockstep with the NIP-04 window so both DM protocols advance together. private val windows = mutableMapOf() private fun windowFor(user: User) = windows.getOrPut(user.pubkeyHex) { - TimeWindowPagination().also { + TimeWindowPagination(growthFactor = WINDOW_GROWTH_FACTOR).also { Log.d(TAG) { "opening initial gift-wrap window for pubkey=${user.pubkeyHex.take(8)}… since=${it.since} (${daysAgo(it.since)}d back)" } } } - private val _loadingMore = MutableStateFlow(false) - val loadingMore: StateFlow = _loadingMore.asStateFlow() + // A window is "loading" until every dmRelay it was sent to has answered (EOSE / live event), + // or a timeout fires — not on the first EOSE, which a fast empty relay can trip prematurely. + private val windowLoad = WindowLoadTracker() + val loadingMore: StateFlow = windowLoad.loading - // True from cold boot until the first EOSE arrives. Lets the rooms screen keep - // showing a spinner during the (Tor-slow) initial load instead of flashing the - // empty state before any relay has answered. - private val _initialLoadInFlight = MutableStateFlow(true) - val initialLoadInFlight: StateFlow = _initialLoadInFlight.asStateFlow() + // True once the window has reached the maximum lookback: there is no older history to fetch, + // so the rooms screen can stop the auto-fill loop and show the real empty state. + private val _exhausted = MutableStateFlow(false) + val exhausted: StateFlow = _exhausted.asStateFlow() + + // The account scope to run the window-load timeout on, captured when the subscription opens. + private var scope: CoroutineScope? = null override fun updateFilter( key: AccountQueryState, @@ -79,6 +88,7 @@ class AccountGiftWrapsEoseManager( // Only loads DMs if the account is writeable return if (key.account.isWriteable()) { val relays = key.account.dmRelays.flow.value + windowLoad.setExpectedRelays(relays.toSet()) val windowSince = windowFor(user(key)).since Log.d(TAG) { "updateFilter: pubkey=${user(key).pubkeyHex.take(8)}… requesting kind:1059 " + @@ -92,6 +102,7 @@ class AccountGiftWrapsEoseManager( ) } } else { + windowLoad.setExpectedRelays(emptySet()) Log.d(TAG) { "updateFilter: pubkey=${user(key).pubkeyHex.take(8)}… account not writeable, skipping" } emptyList() } @@ -99,18 +110,21 @@ class AccountGiftWrapsEoseManager( /** * Widens the gift-wrap time window for [user] one step back and re-issues the - * subscription so older conversations stream in. Called when the messages list is - * scrolled near its end. + * subscription so older conversations stream in. Called by the rooms screen to + * fill the screen and to prefetch older history as the user scrolls. No-op once + * the window is [exhausted]. Kept in lockstep with the NIP-04 window. */ fun loadMore(user: User) { val window = windowFor(user) + if (window.isExhausted()) return val before = window.since window.loadMore() + _exhausted.value = window.isExhausted() Log.d(TAG) { "loadMore: pubkey=${user.pubkeyHex.take(8)}… widening window since $before -> ${window.since} " + - "(${daysAgo(window.since)}d back, was ${daysAgo(before)}d), re-issuing subscription" + "(${daysAgo(window.since)}d back, was ${daysAgo(before)}d, exhausted=${_exhausted.value}), re-issuing subscription" } - _loadingMore.value = true + scope?.let { windowLoad.startLoading(it) } invalidateFilters() } @@ -120,13 +134,7 @@ class AccountGiftWrapsEoseManager( time: Long, filters: List?, ) { - // A backfill window finished loading. Only log the transition, not every live event. - if (_loadingMore.value) { - Log.d(TAG) { - "newEose: pubkey=${user(key).pubkeyHex.take(8)}… backfill window finished on ${relay.url}, clearing loadingMore" - } - _loadingMore.value = false - } + windowLoad.onRelayResponded(relay) super.newEose(key, relay, time, filters) } @@ -143,6 +151,7 @@ class AccountGiftWrapsEoseManager( @OptIn(FlowPreview::class) override fun newSub(key: AccountQueryState): Subscription { val user = user(key) + scope = key.account.scope userJobMap[user]?.forEach { it.cancel() } userJobMap[user] = listOf( @@ -158,7 +167,7 @@ class AccountGiftWrapsEoseManager( bootStartMs[pubkey] = System.currentTimeMillis() bootEventCount[pubkey] = 0 bootEoseLogged.remove(pubkey) - _initialLoadInFlight.value = true + windowLoad.startLoading(key.account.scope) Log.d(TAG) { "cold boot: pubkey=${pubkey.take(8)}… opening gift-wrap subscription, starting to load messages" } // Custom listener so we can tell a real EOSE (load finished) apart from live @@ -170,7 +179,6 @@ class AccountGiftWrapsEoseManager( forFilters: List?, ) { if (bootEoseLogged.add(pubkey)) { - _initialLoadInFlight.value = false val elapsed = System.currentTimeMillis() - (bootStartMs[pubkey] ?: System.currentTimeMillis()) val count = bootEventCount[pubkey] ?: 0 Log.d(TAG) { @@ -213,5 +221,10 @@ class AccountGiftWrapsEoseManager( // Shared log tag for the DM time-window pagination. Filter logcat by this // tag to watch the boot window and scroll-driven backfill in real time. private const val TAG = "DMPagination" + + // The window doubles each widen so a sparse history (or confirming there is nothing + // older) reaches the 10-year backstop in ~10 requests instead of crawling weekly. + // Must match the NIP-04 window so both DM protocols advance in lockstep. + const val WINDOW_GROWTH_FACTOR = 2L } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt index e0bd2bd436..79585ba6f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource import com.vitorpamplona.amethyst.commons.relayClient.pagination.TimeWindowPagination import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.WindowLoadTracker +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -30,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -45,40 +48,58 @@ class DMsFromUserFilterSubAssembler( ) : PerUserEoseManager(client, allKeys) { // Same moving time window as the gift-wrap (NIP-17) loader, so the merged rooms list is // bounded uniformly across both DM protocols. Without this, NIP-04 loaded all history while - // NIP-17 only loaded the recent window, so scroll-to-end (which widens the windows) landed - // new NIP-17 rooms in the middle of the NIP-04 tail instead of extending the list end. + // NIP-17 only loaded the recent window, so widening (which fills the screen / prefetches) + // landed new NIP-17 rooms in the middle of the NIP-04 tail instead of extending the list end. + // Same growth factor as the gift-wrap window keeps both advancing in lockstep. private val windows = mutableMapOf() - private fun windowFor(user: User) = windows.getOrPut(user.pubkeyHex) { TimeWindowPagination() } + private fun windowFor(user: User) = + windows.getOrPut(user.pubkeyHex) { + TimeWindowPagination(growthFactor = AccountGiftWrapsEoseManager.WINDOW_GROWTH_FACTOR) + } - private val _loadingMore = MutableStateFlow(false) - val loadingMore: StateFlow = _loadingMore.asStateFlow() + // A window is "loading" until every relay it was sent to has answered (EOSE / live event), + // or a timeout fires — not on the first EOSE, which a fast empty relay can trip prematurely. + private val windowLoad = WindowLoadTracker() + val loadingMore: StateFlow = windowLoad.loading - // True from (re)subscribe until the first relay response, so the rooms screen can - // keep a spinner up during the initial load instead of flashing the empty state. - private val _initialLoadInFlight = MutableStateFlow(true) - val initialLoadInFlight: StateFlow = _initialLoadInFlight.asStateFlow() + // True once the window has reached the maximum lookback: no older history to fetch. + private val _exhausted = MutableStateFlow(false) + val exhausted: StateFlow = _exhausted.asStateFlow() + + // The account scope to run the window-load timeout on, captured when the subscription opens. + private var scope: CoroutineScope? = null override fun updateFilter( key: ChatroomListState, since: SincePerRelayMap?, ): List? = if (key.account.isWriteable()) { + val homeRelays = key.account.homeRelays.flow.value + val dmRelays = key.account.dmRelays.flow.value + windowLoad.setExpectedRelays((homeRelays + dmRelays).toSet()) val windowSince = windowFor(user(key)).since - key.account.homeRelays.flow.value.map { + homeRelays.map { filterNip04DMsFromMe(key.account.userProfile(), it, windowSince) } + - key.account.dmRelays.flow.value.map { + dmRelays.map { filterNip04DMsToMe(key.account.userProfile(), it, windowSince) } } else { + windowLoad.setExpectedRelays(emptySet()) emptyList() } - /** Widens the NIP-04 time window for [user] one step back. Kept in lockstep with the gift-wrap window. */ + /** + * Widens the NIP-04 time window for [user] one step back, kept in lockstep with the + * gift-wrap window. No-op once the window is [exhausted]. + */ fun loadMore(user: User) { - windowFor(user).loadMore() - _loadingMore.value = true + val window = windowFor(user) + if (window.isExhausted()) return + window.loadMore() + _exhausted.value = window.isExhausted() + scope?.let { windowLoad.startLoading(it) } invalidateFilters() } @@ -88,8 +109,7 @@ class DMsFromUserFilterSubAssembler( time: Long, filters: List?, ) { - if (_loadingMore.value) _loadingMore.value = false - _initialLoadInFlight.value = false + windowLoad.onRelayResponded(relay) super.newEose(key, relay, time, filters) } @@ -100,6 +120,8 @@ class DMsFromUserFilterSubAssembler( @OptIn(FlowPreview::class) override fun newSub(key: ChatroomListState): Subscription { val user = user(key) + scope = key.account.scope + windowLoad.startLoading(key.account.scope) userJobMap[user]?.forEach { it.cancel() } userJobMap[user] = listOf( 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 18fa91c1e2..f0620173e5 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 @@ -63,6 +63,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import java.io.Serializable @@ -90,14 +91,18 @@ private fun CrossFadeState( ) { val feedState by feedContentState.feedContent.collectAsStateWithLifecycle() - // While the first gift-wrap / NIP-04 window is still being fetched and decrypted, an - // empty feed means "not loaded yet", not "no conversations". Keep the spinner up until - // both initial loads answer so cold boot doesn't flash the empty state before the DMs land. + // Both DM windows reach the maximum lookback together (lockstep), so the rooms list has + // pulled everything there is once both report exhausted. Until then an empty feed means + // "still filling", not "no conversations" — keep the spinner up rather than flash empty. val giftWraps = remember(accountViewModel) { accountViewModel.dataSources().account.giftWraps } val nip04Dms = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04Dms } - val giftWrapsInitialLoad by giftWraps.initialLoadInFlight.collectAsStateWithLifecycle() - val nip04InitialLoad by nip04Dms.initialLoadInFlight.collectAsStateWithLifecycle() - val initialLoadInFlight = giftWrapsInitialLoad || nip04InitialLoad + val giftWrapsExhausted by giftWraps.exhausted.collectAsStateWithLifecycle() + val nip04Exhausted by nip04Dms.exhausted.collectAsStateWithLifecycle() + val historyExhausted = giftWrapsExhausted && nip04Exhausted + + // Drive auto-fill / prefetch here (not inside FeedLoaded) so it keeps widening even while + // the feed is still Empty and there is no LazyColumn to scroll yet. + AutoFillAndPrefetch(listState, { feedState is FeedState.Empty }, accountViewModel) CrossfadeIfEnabled( targetState = feedState, @@ -106,10 +111,10 @@ private fun CrossFadeState( ) { state -> when (state) { is FeedState.Empty -> { - if (initialLoadInFlight) { - LoadingFeed() - } else { + if (historyExhausted) { FeedEmpty { feedContentState.invalidateData() } + } else { + LoadingFeed() } } @@ -145,8 +150,6 @@ private fun FeedLoaded( val loadingNip04 by nip04Dms.loadingMore.collectAsStateWithLifecycle() val loadingMore = loadingGiftWraps || loadingNip04 - LoadMoreWhenReachingEnd(listState, accountViewModel) - LazyColumn( contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, @@ -181,51 +184,58 @@ private fun FeedLoaded( } } -// Number of items from the end at which scrolling triggers loading the next, -// older time window of conversations. -private const val LOAD_MORE_THRESHOLD = 5 - /** - * Widens the DM time windows when the messages list is scrolled near its end, so - * older conversations stream in on demand instead of all at boot. Re-evaluates as - * the list grows so a near-empty screen keeps filling. + * Keeps the messages list filled and prefetched by widening the DM time windows. * - * Both DM protocols are advanced in lockstep: NIP-17 gift wraps (always-on account - * loader) and NIP-04 (this screen's loader). They must move together — if only one - * were windowed, the merged time-sorted list would mix a deep tail of one protocol - * with a shallow window of the other, and reaching the list end would pull rooms - * that land in the middle of the feed instead of extending the end. The combined - * loadingMore guard prevents overlapping requests. + * One condition drives three behaviors at once: widen when nothing is loaded yet (empty feed), + * or when the last visible row has crossed the midpoint of what's loaded. Because everything + * fits on screen while the list is short, the midpoint is trivially crossed, so it keeps + * widening until the list overflows the viewport with a buffer below the fold — and once it + * does, it only fires again as the user scrolls past the new midpoint, so fresh (geometrically + * larger) windows land well before the user reaches the end. It stops only when the window is + * exhausted (reached max lookback — nothing older exists). + * + * Both DM protocols advance in lockstep: NIP-17 gift wraps (always-on account loader) and NIP-04 + * (this screen's loader). They must move together — if only one were windowed, the merged + * time-sorted list would mix a deep tail of one protocol with a shallow window of the other, and + * a widen would pull rooms that land in the middle of the feed instead of extending the end. + * + * The per-window [loadingMore] guard gates each step on ALL of that window's relays answering + * (or a timeout), not the first EOSE — otherwise a fast, near-empty relay would clear the guard + * and let this loop outrun the slow relay that actually holds the conversations. */ @Composable -private fun LoadMoreWhenReachingEnd( +private fun AutoFillAndPrefetch( listState: LazyListState, + isFeedEmpty: () -> Boolean, accountViewModel: AccountViewModel, ) { - // Keyed only on listState so the edge-detector is NOT restarted when a widen adds - // rooms. distinctUntilChanged then fires exactly once per reach-the-end gesture: - // staying at the end does not re-fire, and scrolling back up (nearEnd -> false) - // stops further loads. Keying on item count instead would re-arm on every item - // growth and cascade the window back for minutes over a slow connection. - LaunchedEffect(listState) { - snapshotFlow { - val info = listState.layoutInfo - val total = info.totalItemsCount - val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1 - total > 0 && lastVisible >= total - LOAD_MORE_THRESHOLD + val giftWraps = remember(accountViewModel) { accountViewModel.dataSources().account.giftWraps } + val nip04Dms = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04Dms } + + LaunchedEffect(listState, giftWraps, nip04Dms) { + combine( + snapshotFlow { + val info = listState.layoutInfo + val total = info.totalItemsCount + val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1 + // Want more when nothing is loaded yet, or when the last visible row has crossed + // the midpoint of what's loaded (prefetch well before reaching the end). + isFeedEmpty() || (total > 0 && lastVisible >= total / 2) + }, + giftWraps.loadingMore, + nip04Dms.loadingMore, + giftWraps.exhausted, + nip04Dms.exhausted, + ) { wantMore, loadingGiftWraps, loadingNip04, giftWrapsExhausted, nip04Exhausted -> + wantMore && !loadingGiftWraps && !loadingNip04 && !(giftWrapsExhausted && nip04Exhausted) }.distinctUntilChanged() .filter { it } .collect { - val giftWraps = accountViewModel.dataSources().account.giftWraps - val nip04Dms = accountViewModel.dataSources().chatroomList.nip04Dms - if (giftWraps.loadingMore.value || nip04Dms.loadingMore.value) { - Log.d("DMPagination") { "rooms list reached end but a window load is already in flight, skipping" } - } else { - Log.d("DMPagination") { "rooms list reached end, widening NIP-17 + NIP-04 windows one step" } - val user = accountViewModel.userProfile() - giftWraps.loadMore(user) - nip04Dms.loadMore(user) - } + Log.d("DMPagination") { "rooms list needs more (auto-fill/prefetch), widening NIP-17 + NIP-04 windows one step" } + val user = accountViewModel.userProfile() + giftWraps.loadMore(user) + nip04Dms.loadMore(user) } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/pagination/TimeWindowPagination.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/pagination/TimeWindowPagination.kt index a25ef0a96a..1daae1e36b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/pagination/TimeWindowPagination.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/pagination/TimeWindowPagination.kt @@ -34,27 +34,49 @@ import com.vitorpamplona.quartz.utils.TimeUtils * every assembly (the value is small and bounded), which keeps the window robust * even if the in-memory note store evicts previously-loaded events under memory * pressure. + * + * The step can grow geometrically ([growthFactor] > 1) so an auto-fill loop that keeps + * widening to fill a screen (or to confirm there is no older history) converges in a + * handful of requests instead of crawling back a fixed slice at a time. [maxLookback] + * is a hard floor: once [since] reaches it, [isExhausted] is true and there is nothing + * older to ask for. */ class TimeWindowPagination( private val initialWindow: Long = ONE_WEEK_IN_SECONDS, private val step: Long = ONE_WEEK_IN_SECONDS, + private val growthFactor: Long = 1L, + private val maxLookback: Long = TEN_YEARS_IN_SECONDS, ) { /** Epoch seconds; events older than this are not requested from relays. */ @Volatile var since: Long = TimeUtils.now() - initialWindow private set - /** Widens the window backward by one [step]. */ + @Volatile + private var currentStep: Long = step + + private fun floor() = TimeUtils.now() - maxLookback + + /** Widens the window backward by the current step, clamped at [maxLookback], then grows the step. */ fun loadMore() { - since -= step + since = maxOf(floor(), since - currentStep) + if (growthFactor > 1L) currentStep *= growthFactor } + /** True once the window has reached [maxLookback] — there is no older history to request. */ + fun isExhausted(): Boolean = since <= floor() + /** Resets the window back to the initial boot size, anchored at the current time. */ fun reset() { since = TimeUtils.now() - initialWindow + currentStep = step } companion object { const val ONE_WEEK_IN_SECONDS = TimeUtils.ONE_WEEK.toLong() + + // Covers the entire history of Nostr (which began ~2021) with margin, so reaching it + // genuinely means "nothing older exists" rather than an arbitrary cutoff. + const val TEN_YEARS_IN_SECONDS = TimeUtils.ONE_YEAR.toLong() * 10 } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/pagination/TimeWindowPaginationTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/pagination/TimeWindowPaginationTest.kt index 3bdcecedbe..18069b6ce8 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/pagination/TimeWindowPaginationTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/pagination/TimeWindowPaginationTest.kt @@ -60,4 +60,53 @@ class TimeWindowPaginationTest { val expected = TimeUtils.now() - window assertTrue("reset floor should be near now - window", kotlin.math.abs(pagination.since - expected) <= 2) } + + @Test + fun growingStepDoublesTheReachEachLoadMore() { + val pagination = TimeWindowPagination(initialWindow = 10L, step = 100L, growthFactor = 2L, maxLookback = Long.MAX_VALUE / 2) + val before = pagination.since + + pagination.loadMore() + assertEquals("first step is the base step", before - 100L, pagination.since) + + pagination.loadMore() + assertEquals("second step is doubled", before - 300L, pagination.since) + + pagination.loadMore() + assertEquals("third step is doubled again", before - 700L, pagination.since) + } + + @Test + fun windowIsNotExhaustedWhileWithinLookback() { + val pagination = TimeWindowPagination(initialWindow = 10L, step = 10L, growthFactor = 2L, maxLookback = 100L) + assertTrue("a fresh window is not exhausted", !pagination.isExhausted()) + + pagination.loadMore() // -> now-20 + assertTrue("still within the 100s lookback", !pagination.isExhausted()) + } + + @Test + fun windowBecomesExhaustedAndClampsAtMaxLookback() { + val maxLookback = 100L + val pagination = TimeWindowPagination(initialWindow = 10L, step = 10L, growthFactor = 2L, maxLookback = maxLookback) + + // Geometric reach 10,20,40,80 crosses the 100s floor within a handful of steps. + repeat(6) { pagination.loadMore() } + + assertTrue("window should report exhausted at the floor", pagination.isExhausted()) + val floor = TimeUtils.now() - maxLookback + assertTrue("since must not go past the floor", pagination.since >= floor - 2 && pagination.since <= floor + 2) + } + + @Test + fun resetClearsStepGrowth() { + val pagination = TimeWindowPagination(initialWindow = 10L, step = 100L, growthFactor = 2L, maxLookback = Long.MAX_VALUE / 2) + pagination.loadMore() // step grows to 200 + pagination.loadMore() // step grows to 400 + + pagination.reset() + val before = pagination.since + pagination.loadMore() + assertEquals("after reset the step is back to the base", before - 100L, pagination.since) + } } From 6f9c5bbdf05389f1e68e4cc7f205a150f54d1706 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 22:05:57 +0000 Subject: [PATCH 010/103] fix: complete DM windows on quiescence, add load-entire-history button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixed 15s window-load timeout fired mid-flood on accounts with a large DM history: a relay streaming thousands of stored gift wraps never EOSE'd within 15s, so the window was declared "loaded" while events were still pouring in and before they were decrypted into rooms. The rooms list still looked empty, so auto-fill widened again — re-issuing an ever-wider REQ that re-downloaded the whole history, over and over, every 15s. WindowLoadTracker now completes a window on activity quiescence instead of a wall clock: it stays loading until every expected relay EOSEs, or the event stream goes quiet for a few seconds. Every event (stored backfill included) bumps the idle timer via onActivity, so a relay mid-flood is never mistaken for a finished window; an absolute cap bounds pathological dribble. Both DM loaders feed event activity in (the NIP-04 loader now uses a custom listener so it sees stored events, not just live ones). Also add a "Load entire history" button to the rooms-list footer: it jumps the window straight to the max lookback (TimeWindowPagination.loadAll) so a single REQ pulls everything — the pre-windowing behavior — and marks the window exhausted so the auto-fill loop stops. --- .../eoseManagers/WindowLoadTracker.kt | 65 ++++++++++++++----- .../AccountGiftWrapsEoseManager.kt | 17 +++++ .../DMsFromUserFilterSubAssembler.kt | 41 +++++++++++- .../chats/rooms/feed/ChatroomListFeedView.kt | 33 ++++++++-- amethyst/src/main/res/values/strings.xml | 1 + .../pagination/TimeWindowPagination.kt | 5 ++ .../pagination/TimeWindowPaginationTest.kt | 13 ++++ 7 files changed, 152 insertions(+), 23 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt index 5e759592fd..7a8de53219 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt @@ -27,47 +27,73 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds /** - * Tracks when one relay-subscription "window" has finished loading, so callers can wait for the - * WHOLE set of relays to answer instead of declaring victory on the first EOSE. + * Tracks when one relay-subscription "window" has finished loading, so callers (the rooms-screen + * auto-fill loop) can wait for the WHOLE response instead of declaring victory on the first EOSE. * * A subscription fans a single REQ out to several relays. The first EOSE is a misleading "done" * signal: a fast but near-empty relay can EOSE in milliseconds while the relay that actually holds - * the data is still connecting (or stuck in an auth handshake). An auto-fill loop driven by the - * first EOSE would therefore widen the time window again before the slow relay ever answered, - * walking the window back uselessly. + * the data is still connecting, stuck in an auth handshake, or busy streaming thousands of stored + * events. An auto-fill loop driven by the first EOSE — or by a fixed wall-clock timeout — would + * widen the window again mid-stream, before the events were even decrypted into rooms, re-issuing + * an ever-wider REQ that re-downloads the whole history over and over. * - * [loading] stays true until EVERY [setExpectedRelays] relay has answered (an EOSE, or a live event - * that implies the stored set already drained), or until [timeout] elapses as a backstop for relays - * that never answer (down, or looping on `auth-required`). + * So completion is **activity-based**: [loading] stays true until either every expected relay has + * EOSE'd, or the event stream has gone quiet for [idleTimeout] (a flood of events keeps resetting + * that timer via [onActivity], so a window that is still streaming is never declared done). An + * [absoluteCap] bounds the wait for pathological relays that dribble forever. */ class WindowLoadTracker( - private val timeout: Duration = 15.seconds, + private val idleTimeout: Duration = 3.seconds, + private val absoluteCap: Duration = 5.minutes, ) { private val _loading = MutableStateFlow(true) val loading: StateFlow = _loading.asStateFlow() private var expected: Set = emptySet() private val responded = mutableSetOf() - private var timeoutJob: Job? = null + private var watchdog: Job? = null - /** Begins a fresh window load: clears the responded set, raises [loading], and arms the timeout. */ + // Wall-clock of the last EOSE or event for the current window; the watchdog completes the + // window once this stops advancing for [idleTimeout]. Volatile so the hot per-event path + // ([onActivity]) stays lock-free. + @Volatile + private var lastActivityMs = 0L + + /** Begins a fresh window load: clears the responded set, raises [loading], and arms the watchdog. */ @Synchronized fun startLoading(scope: CoroutineScope) { responded.clear() + lastActivityMs = System.currentTimeMillis() _loading.value = true - timeoutJob?.cancel() - timeoutJob = + watchdog?.cancel() + watchdog = scope.launch { - delay(timeout) - finish() + val deadline = System.currentTimeMillis() + absoluteCap.inWholeMilliseconds + while (isActive && _loading.value) { + delay(IDLE_CHECK_MS) + val now = System.currentTimeMillis() + if (now - lastActivityMs >= idleTimeout.inWholeMilliseconds || now >= deadline) { + finish() + } + } } } + /** + * Records that the current window is still actively receiving events (stored OR live). Keeps the + * idle watchdog from completing while a relay is mid-flood. Lock-free: just bumps a timestamp. + */ + fun onActivity() { + lastActivityMs = System.currentTimeMillis() + } + /** Records which relays the current REQ was sent to. Completes immediately if there are none. */ @Synchronized fun setExpectedRelays(relays: Set) { @@ -78,6 +104,7 @@ class WindowLoadTracker( /** Marks [relay] as having answered (EOSE or live event). Completes once all expected have. */ @Synchronized fun onRelayResponded(relay: NormalizedRelayUrl) { + lastActivityMs = System.currentTimeMillis() responded.add(relay) if (expected.isNotEmpty() && responded.containsAll(expected)) finish() } @@ -85,7 +112,11 @@ class WindowLoadTracker( @Synchronized private fun finish() { _loading.value = false - timeoutJob?.cancel() - timeoutJob = null + watchdog?.cancel() + watchdog = null + } + + companion object { + private const val IDLE_CHECK_MS = 500L } } 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 335df0f9e6..23c7a274fb 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 @@ -128,6 +128,20 @@ class AccountGiftWrapsEoseManager( invalidateFilters() } + /** + * Jumps the gift-wrap window straight to the maximum lookback so a single REQ pulls the entire + * history (the pre-windowing behavior), and marks it [exhausted] so auto-fill stops. + */ + fun loadEverything(user: User) { + val window = windowFor(user) + if (window.isExhausted()) return + window.loadAll() + _exhausted.value = true + Log.d(TAG) { "loadEverything: pubkey=${user.pubkeyHex.take(8)}… loading full history since ${window.since}" } + scope?.let { windowLoad.startLoading(it) } + invalidateFilters() + } + override fun newEose( key: AccountQueryState, relay: NormalizedRelayUrl, @@ -195,6 +209,9 @@ class AccountGiftWrapsEoseManager( relay: NormalizedRelayUrl, forFilters: List?, ) { + // Every event (stored backfill included) keeps the window-load watchdog alive, + // so a relay mid-flood is never mistaken for a finished window. + windowLoad.onActivity() if (pubkey !in bootEoseLogged) { bootEventCount[pubkey] = (bootEventCount[pubkey] ?: 0) + 1 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt index 79585ba6f0..4767490399 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt @@ -26,12 +26,15 @@ import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseMa import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.WindowLoadTracker 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.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview @@ -103,6 +106,19 @@ class DMsFromUserFilterSubAssembler( invalidateFilters() } + /** + * Jumps the NIP-04 window straight to the maximum lookback so a single REQ pulls the entire + * history (the pre-windowing behavior), and marks it [exhausted] so auto-fill stops. + */ + fun loadEverything(user: User) { + val window = windowFor(user) + if (window.isExhausted()) return + window.loadAll() + _exhausted.value = true + scope?.let { windowLoad.startLoading(it) } + invalidateFilters() + } + override fun newEose( key: ChatroomListState, relay: NormalizedRelayUrl, @@ -137,7 +153,30 @@ class DMsFromUserFilterSubAssembler( }, ) - return super.newSub(key) + // Custom listener (vs super.newSub) so every event — stored backfill included — keeps the + // window-load watchdog alive; otherwise a NIP-04 flood would look "done" mid-stream. + return requestNewSubscription( + object : SubscriptionListener { + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + newEose(key, relay, TimeUtils.now(), forFilters) + } + + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + windowLoad.onActivity() + if (isLive) { + newEose(key, relay, TimeUtils.now(), forFilters) + } + } + }, + ) } override fun endSub( 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 f0620173e5..c814378dda 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 @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -31,13 +31,18 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +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.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState @@ -149,6 +154,9 @@ private fun FeedLoaded( val loadingGiftWraps by giftWraps.loadingMore.collectAsStateWithLifecycle() val loadingNip04 by nip04Dms.loadingMore.collectAsStateWithLifecycle() val loadingMore = loadingGiftWraps || loadingNip04 + val exhaustedGiftWraps by giftWraps.exhausted.collectAsStateWithLifecycle() + val exhaustedNip04 by nip04Dms.exhausted.collectAsStateWithLifecycle() + val historyExhausted = exhaustedGiftWraps && exhaustedNip04 LazyColumn( contentPadding = rememberFeedContentPadding(FeedPadding), @@ -171,13 +179,28 @@ private fun FeedLoaded( ) } - if (loadingMore) { + // Footer: shows the auto-fill / full-load spinner, and — while there is still older history + // to reach — a button to skip the windowed paging and pull the entire history at once. + if (loadingMore || !historyExhausted) { item(key = "loadingMoreFooter") { - Row( + Column( Modifier.fillMaxWidth().padding(vertical = Size10dp), - horizontalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, ) { - CircularProgressIndicator(Modifier.size(Size25dp)) + if (loadingMore) { + CircularProgressIndicator(Modifier.size(Size25dp)) + } + if (!historyExhausted) { + TextButton( + onClick = { + val user = accountViewModel.userProfile() + giftWraps.loadEverything(user) + nip04Dms.loadEverything(user) + }, + ) { + Text(stringResource(R.string.chats_load_entire_history)) + } + } } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 01d3b1c9ba..95fc7847f7 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -274,6 +274,7 @@ Generate a new key Loading feed Loading account + Load entire history "Error loading replies: " Try again No notifications yet. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/pagination/TimeWindowPagination.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/pagination/TimeWindowPagination.kt index 1daae1e36b..6bbabd8cf5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/pagination/TimeWindowPagination.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/pagination/TimeWindowPagination.kt @@ -63,6 +63,11 @@ class TimeWindowPagination( if (growthFactor > 1L) currentStep *= growthFactor } + /** Jumps straight to [maxLookback] so a single request pulls the entire history. */ + fun loadAll() { + since = floor() + } + /** True once the window has reached [maxLookback] — there is no older history to request. */ fun isExhausted(): Boolean = since <= floor() diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/pagination/TimeWindowPaginationTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/pagination/TimeWindowPaginationTest.kt index 18069b6ce8..cb79f93940 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/pagination/TimeWindowPaginationTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/pagination/TimeWindowPaginationTest.kt @@ -98,6 +98,19 @@ class TimeWindowPaginationTest { assertTrue("since must not go past the floor", pagination.since >= floor - 2 && pagination.since <= floor + 2) } + @Test + fun loadAllJumpsStraightToExhaustion() { + val maxLookback = 100L + val pagination = TimeWindowPagination(initialWindow = 10L, step = 10L, growthFactor = 2L, maxLookback = maxLookback) + assertTrue("not exhausted before loadAll", !pagination.isExhausted()) + + pagination.loadAll() + + assertTrue("loadAll exhausts the window in one step", pagination.isExhausted()) + val floor = TimeUtils.now() - maxLookback + assertTrue("since lands at the floor", kotlin.math.abs(pagination.since - floor) <= 2) + } + @Test fun resetClearsStepGrowth() { val pagination = TimeWindowPagination(initialWindow = 10L, step = 100L, growthFactor = 2L, maxLookback = Long.MAX_VALUE / 2) From 9ac7ee8026b9aa43a30b08444fcb78373dbe64f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 22:14:30 +0000 Subject: [PATCH 011/103] fix: race conditions in the DM window loaders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit follow-up — close three concurrency holes exposed by the auto-fill loop, which calls into the loaders from the UI thread while the bundled invalidation runs updateFilter on Dispatchers.IO: - windows map: was a plain HashMap mutated from both the UI thread (loadMore/loadEverything) and Dispatchers.IO (updateFilter). Concurrent getOrPut can corrupt the table. Switch to ConcurrentHashMap.computeIfAbsent. - WindowLoadTracker watchdog: a stale watchdog waking from delay just as a new startLoading ran could complete the *new* window (flip loading=false and cancel the new watchdog), leaving it stuck. Guard each poll with a generation token so a superseded watchdog bows out. - scope field: written on IO (newSub), read on the UI thread (loadMore); marked @Volatile for visibility. - cold-boot diagnostic maps (bootStartMs/bootEventCount/bootEoseLogged) are written from the concurrent relay reader callbacks during the boot flood; switch to ConcurrentHashMap + an atomic merge so they can't corrupt or hang under that load. --- .../eoseManagers/WindowLoadTracker.kt | 29 +++++++++++++++---- .../AccountGiftWrapsEoseManager.kt | 21 +++++++++----- .../DMsFromUserFilterSubAssembler.kt | 11 +++++-- 3 files changed, 46 insertions(+), 15 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt index 7a8de53219..e50fe1514c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt @@ -60,6 +60,10 @@ class WindowLoadTracker( private val responded = mutableSetOf() private var watchdog: Job? = null + // Incremented on every (re)start so a stale watchdog that wakes right as a new load begins + // recognizes it has been superseded and bows out instead of completing the new window. + private var generation = 0 + // Wall-clock of the last EOSE or event for the current window; the watchdog completes the // window once this stops advancing for [idleTimeout]. Volatile so the hot per-event path // ([onActivity]) stays lock-free. @@ -69,6 +73,7 @@ class WindowLoadTracker( /** Begins a fresh window load: clears the responded set, raises [loading], and arms the watchdog. */ @Synchronized fun startLoading(scope: CoroutineScope) { + val gen = ++generation responded.clear() lastActivityMs = System.currentTimeMillis() _loading.value = true @@ -76,16 +81,30 @@ class WindowLoadTracker( watchdog = scope.launch { val deadline = System.currentTimeMillis() + absoluteCap.inWholeMilliseconds - while (isActive && _loading.value) { + while (isActive) { delay(IDLE_CHECK_MS) - val now = System.currentTimeMillis() - if (now - lastActivityMs >= idleTimeout.inWholeMilliseconds || now >= deadline) { - finish() - } + if (!tick(gen, System.currentTimeMillis(), deadline)) break } } } + // One watchdog poll. Returns false (stop polling) when this watchdog has been superseded by a + // newer load, the window already finished, or the idle/cap deadline is reached. Synchronized so + // the generation/loading checks and the completion are atomic against startLoading/finish. + @Synchronized + private fun tick( + gen: Int, + now: Long, + deadline: Long, + ): Boolean { + if (gen != generation || !_loading.value) return false + if (now - lastActivityMs >= idleTimeout.inWholeMilliseconds || now >= deadline) { + _loading.value = false + return false + } + return true + } + /** * Records that the current window is still actively receiving events (stored OR live). Keeps the * idle watchdog from completing while a relay is mid-flood. Lock-free: just bumps a timestamp. 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 23c7a274fb..25417ba141 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 @@ -46,6 +46,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap class AccountGiftWrapsEoseManager( client: INostrClient, @@ -59,10 +60,12 @@ class AccountGiftWrapsEoseManager( // prefetch as the user scrolls. The step grows geometrically so a sparse history // (or confirming there is nothing older) converges in a handful of requests; it is // kept in lockstep with the NIP-04 window so both DM protocols advance together. - private val windows = mutableMapOf() + // Concurrent: windowFor is reached from the UI thread (loadMore / loadEverything) and from + // Dispatchers.IO (updateFilter, via the bundled invalidation), so a plain HashMap would race. + private val windows = ConcurrentHashMap() private fun windowFor(user: User) = - windows.getOrPut(user.pubkeyHex) { + windows.computeIfAbsent(user.pubkeyHex) { TimeWindowPagination(growthFactor = WINDOW_GROWTH_FACTOR).also { Log.d(TAG) { "opening initial gift-wrap window for pubkey=${user.pubkeyHex.take(8)}… since=${it.since} (${daysAgo(it.since)}d back)" } } @@ -78,7 +81,9 @@ class AccountGiftWrapsEoseManager( private val _exhausted = MutableStateFlow(false) val exhausted: StateFlow = _exhausted.asStateFlow() - // The account scope to run the window-load timeout on, captured when the subscription opens. + // The account scope to run the window-load watchdog on, captured when the subscription opens. + // Volatile: written on Dispatchers.IO (newSub), read on the UI thread (loadMore/loadEverything). + @Volatile private var scope: CoroutineScope? = null override fun updateFilter( @@ -158,9 +163,11 @@ class AccountGiftWrapsEoseManager( // Cold-boot instrumentation: when the subscription opened (ms), how many gift // wraps have arrived since, and whether we've already logged the first EOSE. - private val bootStartMs = mutableMapOf() - private val bootEventCount = mutableMapOf() - private val bootEoseLogged = mutableSetOf() + // Concurrent because the listener callbacks below run on the relay reader threads + // (several relays delivering events at once during the cold-boot flood). + private val bootStartMs = ConcurrentHashMap() + private val bootEventCount = ConcurrentHashMap() + private val bootEoseLogged = ConcurrentHashMap.newKeySet() @OptIn(FlowPreview::class) override fun newSub(key: AccountQueryState): Subscription { @@ -213,7 +220,7 @@ class AccountGiftWrapsEoseManager( // so a relay mid-flood is never mistaken for a finished window. windowLoad.onActivity() if (pubkey !in bootEoseLogged) { - bootEventCount[pubkey] = (bootEventCount[pubkey] ?: 0) + 1 + bootEventCount.merge(pubkey, 1, Int::plus) } if (isLive) { newEose(key, relay, TimeUtils.now(), forFilters) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt index 4767490399..1811c8d32f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt @@ -44,6 +44,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap class DMsFromUserFilterSubAssembler( client: INostrClient, @@ -54,10 +55,12 @@ class DMsFromUserFilterSubAssembler( // NIP-17 only loaded the recent window, so widening (which fills the screen / prefetches) // landed new NIP-17 rooms in the middle of the NIP-04 tail instead of extending the list end. // Same growth factor as the gift-wrap window keeps both advancing in lockstep. - private val windows = mutableMapOf() + // Concurrent: windowFor is reached from the UI thread (loadMore / loadEverything) and from + // Dispatchers.IO (updateFilter, via the bundled invalidation), so a plain HashMap would race. + private val windows = ConcurrentHashMap() private fun windowFor(user: User) = - windows.getOrPut(user.pubkeyHex) { + windows.computeIfAbsent(user.pubkeyHex) { TimeWindowPagination(growthFactor = AccountGiftWrapsEoseManager.WINDOW_GROWTH_FACTOR) } @@ -70,7 +73,9 @@ class DMsFromUserFilterSubAssembler( private val _exhausted = MutableStateFlow(false) val exhausted: StateFlow = _exhausted.asStateFlow() - // The account scope to run the window-load timeout on, captured when the subscription opens. + // The account scope to run the window-load watchdog on, captured when the subscription opens. + // Volatile: written on Dispatchers.IO (newSub), read on the UI thread (loadMore/loadEverything). + @Volatile private var scope: CoroutineScope? = null override fun updateFilter( From 58973701d0d9108205e1bf815a30e257e8e45a13 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 22:30:26 +0000 Subject: [PATCH 012/103] feat: load full gift-wrap history when a conversation opens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conversation screen issues its own unbounded NIP-04 REQ (full room history) but has no NIP-17 fetch of its own — it relies on the account-wide gift-wrap loader, which is windowed and only driven by the rooms list. So a thread could show a deep NIP-04 history but only the NIP-17 messages inside the current (possibly 7-day) window, silently hiding older gift-wrapped messages. Gift wraps are addressed to us, not the partner, so a relay can't filter them per-room; the only lever is the shared account window. On opening a conversation, ask the gift-wrap loader to pull everything (loadEverything). It's idempotent via the isExhausted guard, so only the first conversation opened in a session pays the cost; reopening threads is a no-op. --- .../loggedIn/chats/privateDM/ChatroomView.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 5103d1e790..33b6d933e1 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 @@ -122,6 +122,23 @@ fun ChatroomView( ) } +/** + * Pulls the account-wide gift-wrap (NIP-17) history all the way back when a conversation opens, so + * a thread always shows its full history regardless of how narrow the rooms-list window currently is. + * + * Gift wraps are addressed to us (not to the conversation partner), so a relay cannot filter them + * per-room — the only lever is the shared account window. [AccountGiftWrapsEoseManager.loadEverything] + * is idempotent: once the window has reached the maximum lookback this is a no-op, so opening or + * reopening conversations after the first full load costs nothing. + */ +@Composable +private fun EnsureFullGiftWrapHistory(accountViewModel: AccountViewModel) { + LaunchedEffect(accountViewModel) { + val giftWraps = accountViewModel.dataSources().account.giftWraps + giftWraps.loadEverything(accountViewModel.userProfile()) + } +} + @Composable fun ChatroomViewUI( room: ChatroomKey, @@ -132,6 +149,7 @@ fun ChatroomViewUI( ) { WatchLifecycleAndUpdateModel(feedViewModel) ChatroomFilterAssemblerSubscription(room, accountViewModel.dataSources().chatroom, accountViewModel) + EnsureFullGiftWrapHistory(accountViewModel) Column(Modifier.fillMaxHeight()) { ObserveRelayListForDMsAndDisplayIfNotFound(accountViewModel, nav) From 43de099e7ecf9a927534cf9aa888ac8bbbf019aa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 22:41:08 +0000 Subject: [PATCH 013/103] feat: scroll-driven NIP-17 loading in conversations (drop eager load-all) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the eager full gift-wrap load on conversation open with the same scroll-driven widening the rooms list uses. As the user scrolls a thread toward older messages (reverse-laid-out, so older = higher indices), the account-wide gift-wrap window widens one step at a time — prefetching at the midpoint so older messages land before the top is reached — and stops once the window is exhausted. A thread that already fills the viewport doesn't load anything extra until you actually scroll back. The shared chat feed (used by public channels, ephemeral chats, live activities, marmot groups too) stays generic: it gains an opt-in listStateObserver slot, and only the private-DM screen attaches the gift-wrap loader through it. NIP-04 in a conversation is still loaded in full (it was already, and a single room's kind:4 is cheap), so only the windowed NIP-17 side is scroll-driven; the thread is time-sorted so the two merge without reordering. loadEverything stays for the rooms-list button. --- .../loggedIn/chats/feed/ChatFeedView.kt | 4 ++ .../loggedIn/chats/privateDM/ChatroomView.kt | 52 +++++++++++++++---- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt index 9fc8f3aa96..af35f579b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt @@ -58,8 +58,12 @@ fun RefreshingChatroomFeedView( onWantsToEditDraft: (Note) -> Unit, avoidDraft: DraftTagState? = null, scrollStateKey: String? = null, + // Opt-in hook handed the feed's scroll state, so a specific screen (e.g. private DMs) can + // attach scroll-driven loading. No-op for the public-chat / channel callers that don't paginate. + listStateObserver: @Composable (LazyListState) -> Unit = {}, ) { SaveableFeedState(feedContentState, scrollStateKey) { listState -> + listStateObserver(listState) RenderChatFeedView( feedContentState, accountViewModel, 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 33b6d933e1..eaee6ad314 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 @@ -24,10 +24,13 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -46,6 +49,9 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch @Composable @@ -123,19 +129,41 @@ fun ChatroomView( } /** - * Pulls the account-wide gift-wrap (NIP-17) history all the way back when a conversation opens, so - * a thread always shows its full history regardless of how narrow the rooms-list window currently is. + * Scroll-driven NIP-17 history loader for a conversation. The thread is reverse-laid-out (newest at + * the bottom, index 0), so older messages live at higher indices; this widens the account-wide + * gift-wrap window one step whenever nothing is loaded yet or the oldest visible row has crossed the + * midpoint of what's loaded — prefetching older gift wraps before the user reaches the top — and + * stops once the window is exhausted. * - * Gift wraps are addressed to us (not to the conversation partner), so a relay cannot filter them - * per-room — the only lever is the shared account window. [AccountGiftWrapsEoseManager.loadEverything] - * is idempotent: once the window has reached the maximum lookback this is a no-op, so opening or - * reopening conversations after the first full load costs nothing. + * Gift wraps are addressed to us (not the partner), so a relay cannot filter them per-room: the only + * lever is the shared account window. NIP-04 in a conversation is already loaded in full, so only the + * windowed NIP-17 side needs this. The combined [AccountGiftWrapsEoseManager.loadingMore] guard gates + * each step on the previous window finishing, so it advances one step at a time, not in a burst. */ @Composable -private fun EnsureFullGiftWrapHistory(accountViewModel: AccountViewModel) { - LaunchedEffect(accountViewModel) { - val giftWraps = accountViewModel.dataSources().account.giftWraps - giftWraps.loadEverything(accountViewModel.userProfile()) +private fun LoadOlderGiftWrapsWhenScrolling( + listState: LazyListState, + accountViewModel: AccountViewModel, +) { + val giftWraps = remember(accountViewModel) { accountViewModel.dataSources().account.giftWraps } + + LaunchedEffect(listState, giftWraps) { + combine( + snapshotFlow { + val info = listState.layoutInfo + val total = info.totalItemsCount + val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1 + total == 0 || lastVisible >= total / 2 + }, + giftWraps.loadingMore, + giftWraps.exhausted, + ) { wantMore, loadingMore, exhausted -> + wantMore && !loadingMore && !exhausted + }.distinctUntilChanged() + .filter { it } + .collect { + giftWraps.loadMore(accountViewModel.userProfile()) + } } } @@ -149,7 +177,6 @@ fun ChatroomViewUI( ) { WatchLifecycleAndUpdateModel(feedViewModel) ChatroomFilterAssemblerSubscription(room, accountViewModel.dataSources().chatroom, accountViewModel) - EnsureFullGiftWrapHistory(accountViewModel) Column(Modifier.fillMaxHeight()) { ObserveRelayListForDMsAndDisplayIfNotFound(accountViewModel, nav) @@ -169,6 +196,9 @@ fun ChatroomViewUI( avoidDraft = newPostModel.draftTag, onWantsToReply = newPostModel::reply, onWantsToEditDraft = newPostModel::editFromDraft, + listStateObserver = { listState -> + LoadOlderGiftWrapsWhenScrolling(listState, accountViewModel) + }, ) } From cad22689d14038fbb23b93705242fd7af467751d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 22:57:18 +0000 Subject: [PATCH 014/103] feat: window NIP-04 with NIP-17 in lockstep in the conversation screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously a conversation loaded its NIP-04 (kind:4) history in full while NIP-17 was windowed, so a thread could reach deeper on one protocol than the other. Now both follow a single floor: the account-wide gift-wrap window. - AccountGiftWrapsEoseManager exposes windowSince(user) — the current window floor. - The conversation NIP-04 loader (ChatroomFilterSubAssembler / filterNip04DMs) requests kind:4 from that same floor instead of the EOSE cursor, so it never reaches further back than NIP-17. The gift-wrap manager is plumbed in via ChatroomFilterAssembler from RelaySubscriptionsCoordinator. - The conversation scroll handler now advances both: it widens the gift-wrap window (NIP-17) and re-invalidates the chatroom sub so NIP-04 re-requests at the new, wider floor. Gated by the gift-wrap loadingMore so it steps once at a time, stopping at exhaustion. Because the floor is shared (not an independent per-room window), the two protocols stay aligned even when the rooms list has already widened the window. Display still reads from LocalCache, so any messages already cached (e.g. from a prior full load) keep showing regardless of the request floor. --- .../RelaySubscriptionsCoordinator.kt | 2 +- .../AccountGiftWrapsEoseManager.kt | 7 +++++ .../loggedIn/chats/privateDM/ChatroomView.kt | 27 +++++++++++-------- .../datasource/ChatroomFilterAssembler.kt | 4 ++- .../datasource/ChatroomFilterSubAssembler.kt | 6 ++++- .../privateDM/datasource/FilterNip04DMs.kt | 7 +++-- 6 files changed, 35 insertions(+), 18 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 9357f8fee5..d442aca7e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -105,7 +105,7 @@ class RelaySubscriptionsCoordinator( // active depending on the screen. val channel = ChannelFilterAssembler(client) - val chatroom = ChatroomFilterAssembler(client) + val chatroom = ChatroomFilterAssembler(client, account.giftWraps) val community = CommunityFilterAssembler(client) val gitRepository = RepositoryFilterAssembler(client) val thread = ThreadFilterAssembler(client) 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 25417ba141..6dd991b007 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 @@ -71,6 +71,13 @@ class AccountGiftWrapsEoseManager( } } + /** + * The current lower bound (epoch seconds) of this account's gift-wrap window. Exposed so the + * per-conversation NIP-04 loader can request from the SAME floor, keeping both DM protocols + * aligned in a thread instead of one reaching deeper than the other. + */ + fun windowSince(user: User): Long = windowFor(user).since + // A window is "loading" until every dmRelay it was sent to has answered (EOSE / live event), // or a timeout fires — not on the first EOSE, which a fast empty relay can trip prematurely. private val windowLoad = WindowLoadTracker() 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 eaee6ad314..9c606e0fb8 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 @@ -129,25 +129,27 @@ fun ChatroomView( } /** - * Scroll-driven NIP-17 history loader for a conversation. The thread is reverse-laid-out (newest at - * the bottom, index 0), so older messages live at higher indices; this widens the account-wide - * gift-wrap window one step whenever nothing is loaded yet or the oldest visible row has crossed the - * midpoint of what's loaded — prefetching older gift wraps before the user reaches the top — and + * Scroll-driven history loader for a conversation, advancing BOTH DM protocols in lockstep. The + * thread is reverse-laid-out (newest at the bottom, index 0), so older messages live at higher + * indices; this widens the window one step whenever nothing is loaded yet or the oldest visible row + * has crossed the midpoint of what's loaded — prefetching before the user reaches the top — and * stops once the window is exhausted. * - * Gift wraps are addressed to us (not the partner), so a relay cannot filter them per-room: the only - * lever is the shared account window. NIP-04 in a conversation is already loaded in full, so only the - * windowed NIP-17 side needs this. The combined [AccountGiftWrapsEoseManager.loadingMore] guard gates - * each step on the previous window finishing, so it advances one step at a time, not in a burst. + * The account-wide gift-wrap window is the single source of truth for the floor: NIP-17 advances via + * [AccountGiftWrapsEoseManager.loadMore], and the NIP-04 conversation loader reads that same floor, + * so re-invalidating the chatroom sub re-requests kind:4 to the new depth. Both protocols therefore + * stay aligned. The gift-wrap [AccountGiftWrapsEoseManager.loadingMore] guard gates each step on the + * previous window finishing, so it advances one step at a time rather than in a burst. */ @Composable -private fun LoadOlderGiftWrapsWhenScrolling( +private fun LoadOlderMessagesWhenScrolling( listState: LazyListState, accountViewModel: AccountViewModel, ) { val giftWraps = remember(accountViewModel) { accountViewModel.dataSources().account.giftWraps } + val chatroom = remember(accountViewModel) { accountViewModel.dataSources().chatroom } - LaunchedEffect(listState, giftWraps) { + LaunchedEffect(listState, giftWraps, chatroom) { combine( snapshotFlow { val info = listState.layoutInfo @@ -162,7 +164,10 @@ private fun LoadOlderGiftWrapsWhenScrolling( }.distinctUntilChanged() .filter { it } .collect { + // Advance the shared gift-wrap window (NIP-17), then re-issue the NIP-04 sub so it + // re-requests kind:4 from the same, now-wider floor. giftWraps.loadMore(accountViewModel.userProfile()) + chatroom.invalidateFilters() } } } @@ -197,7 +202,7 @@ fun ChatroomViewUI( onWantsToReply = newPostModel::reply, onWantsToEditDraft = newPostModel::editFromDraft, listStateObserver = { listState -> - LoadOlderGiftWrapsWhenScrolling(listState, accountViewModel) + LoadOlderMessagesWhenScrolling(listState, accountViewModel) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt index b5cc945fcd..e10cd2e343 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey @@ -35,10 +36,11 @@ class ChatroomQueryState( class ChatroomFilterAssembler( client: INostrClient, + giftWraps: AccountGiftWrapsEoseManager, ) : ComposeSubscriptionManager() { val group = listOf( - ChatroomFilterSubAssembler(client, ::allKeys), + ChatroomFilterSubAssembler(client, ::allKeys, giftWraps), ) override fun invalidateKeys() = invalidateFilters() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt index 0d451daecf..07f37bf944 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +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.pool.RelayBasedFilter @@ -28,13 +29,16 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter class ChatroomFilterSubAssembler( client: INostrClient, allKeys: () -> Set, + // The account-wide gift-wrap window is the single source of truth for how far back DMs are + // requested; NIP-04 here follows its floor so a thread shows both protocols to the same depth. + private val giftWraps: AccountGiftWrapsEoseManager, ) : PerUserAndFollowListEoseManager(client, allKeys) { override fun updateFilter( key: ChatroomQueryState, since: SincePerRelayMap?, ): List? = if (key.account.isWriteable()) { - filterNip04DMs(key.room.users, key.account, since) + filterNip04DMs(key.room.users, key.account, giftWraps.windowSince(user(key))) } else { emptyList() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt index ec277266de..e0c9a159a0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -33,7 +32,7 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent fun filterNip04DMs( group: Set?, account: Account?, - since: SincePerRelayMap?, + windowStart: Long, ): List? { if (group.isNullOrEmpty() || account == null) return null @@ -75,7 +74,7 @@ fun filterNip04DMs( kinds = listOf(PrivateDmEvent.KIND), authors = group.toList(), tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), - since = since?.get(it)?.time, + since = windowStart, ), ) } + @@ -87,7 +86,7 @@ fun filterNip04DMs( kinds = listOf(PrivateDmEvent.KIND), authors = listOf(account.userProfile().pubkeyHex), tags = mapOf("p" to group.toList()), - since = since?.get(it)?.time, + since = windowStart, ), ) } From 40a6465f3cf2f63e4606f44febeb92ba3c274341 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 23:34:30 +0000 Subject: [PATCH 015/103] fix: base rooms-list window paging only on private chats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Messages list mixes private DMs (windowed) with public, ephemeral and marmot-group rooms, which are membership-based — every room you're in shows regardless of age, loaded by their own always-on loaders, not time-windowed. The auto-fill was using whole-list geometry (lastVisible >= total/2), so an old public chat at the bottom either stalled private paging (it inflated the item count) or, with an oldest-item rule, would have dragged the private window back years. Now the widen trigger ignores non-private rows: it fires as the user approaches the oldest LOADED private chat (event is ChatroomKeyable) within a small prefetch margin, or when no private chat is loaded yet. The loading spinner / "Load entire history" footer moves to that private boundary — between the last loaded private chat and the older public rooms below it — instead of sitting at the absolute bottom under unrelated old channels. Windowing all chat types together was considered but rejected: it would hide followed-but-inactive public channels, which must always appear. --- .../chats/rooms/feed/ChatroomListFeedView.kt | 140 ++++++++++-------- 1 file changed, 81 insertions(+), 59 deletions(-) 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 c814378dda..d4977c07d1 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 @@ -28,7 +28,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text @@ -105,9 +105,10 @@ private fun CrossFadeState( val nip04Exhausted by nip04Dms.exhausted.collectAsStateWithLifecycle() val historyExhausted = giftWrapsExhausted && nip04Exhausted - // Drive auto-fill / prefetch here (not inside FeedLoaded) so it keeps widening even while - // the feed is still Empty and there is no LazyColumn to scroll yet. - AutoFillAndPrefetch(listState, { feedState is FeedState.Empty }, accountViewModel) + // While the whole list is empty there is no LazyColumn to scroll, so keep widening the private + // DM window here until rooms appear or it is exhausted. (Public / ephemeral / group rooms are + // membership-based and load on their own — they are not part of the window.) + WidenPrivateWindowWhen(accountViewModel) { feedState is FeedState.Empty } CrossfadeIfEnabled( targetState = feedState, @@ -158,14 +159,30 @@ private fun FeedLoaded( val exhaustedNip04 by nip04Dms.exhausted.collectAsStateWithLifecycle() val historyExhausted = exhaustedGiftWraps && exhaustedNip04 + // Widen the private DM window only as the user approaches the oldest LOADED private chat — + // ignoring public / group / ephemeral rooms below it. Those are membership-based and can be + // arbitrarily old; counting them would either stall private paging or drag the window back + // years. The lambda reads the live items + scroll state inside the snapshotFlow. + WidenPrivateWindowWhen(accountViewModel) { + val info = listState.layoutInfo + val total = info.totalItemsCount + val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1 + val oldestPrivate = items.list.indexOfLast { it.event is ChatroomKeyable } + total > 0 && (oldestPrivate < 0 || lastVisible >= oldestPrivate - PREFETCH_PRIVATE_CHATS) + } + + // The private-DM loading boundary sits right after the last loaded private chat: that's where + // older private history streams in, while public / group rooms below are shown regardless. + val privateBoundaryIndex = items.list.indexOfLast { it.event is ChatroomKeyable } + LazyColumn( contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, ) { - items( + itemsIndexed( items.list, - key = { item -> chatroomLazyKey(item, myPubKey) }, - ) { item -> + key = { _, item -> chatroomLazyKey(item, myPubKey) }, + ) { index, item -> Row(Modifier.fillMaxWidth()) { ChatroomHeaderCompose( item, @@ -177,85 +194,90 @@ private fun FeedLoaded( HorizontalDivider( thickness = DividerThickness, ) + + if (index == privateBoundaryIndex && (loadingMore || !historyExhausted)) { + PrivateChatsLoadMoreFooter(loadingMore, showLoadAll = !historyExhausted) { + val user = accountViewModel.userProfile() + giftWraps.loadEverything(user) + nip04Dms.loadEverything(user) + } + } } - // Footer: shows the auto-fill / full-load spinner, and — while there is still older history - // to reach — a button to skip the windowed paging and pull the entire history at once. - if (loadingMore || !historyExhausted) { + // No private chat is loaded yet (e.g. only public rooms so far): show the boundary at the end. + if (privateBoundaryIndex < 0 && (loadingMore || !historyExhausted)) { item(key = "loadingMoreFooter") { - Column( - Modifier.fillMaxWidth().padding(vertical = Size10dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (loadingMore) { - CircularProgressIndicator(Modifier.size(Size25dp)) - } - if (!historyExhausted) { - TextButton( - onClick = { - val user = accountViewModel.userProfile() - giftWraps.loadEverything(user) - nip04Dms.loadEverything(user) - }, - ) { - Text(stringResource(R.string.chats_load_entire_history)) - } - } + PrivateChatsLoadMoreFooter(loadingMore, showLoadAll = !historyExhausted) { + val user = accountViewModel.userProfile() + giftWraps.loadEverything(user) + nip04Dms.loadEverything(user) } } } } } +@Composable +private fun PrivateChatsLoadMoreFooter( + loadingMore: Boolean, + showLoadAll: Boolean, + onLoadEverything: () -> Unit, +) { + Column( + Modifier.fillMaxWidth().padding(vertical = Size10dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (loadingMore) { + CircularProgressIndicator(Modifier.size(Size25dp)) + } + if (showLoadAll) { + TextButton(onClick = onLoadEverything) { + Text(stringResource(R.string.chats_load_entire_history)) + } + } + } +} + +// How many rows ahead of the oldest loaded private chat to start widening, so older private +// history lands before the user scrolls into the (membership-based) public/group rooms below it. +private const val PREFETCH_PRIVATE_CHATS = 5 + /** - * Keeps the messages list filled and prefetched by widening the DM time windows. + * Widens the private-DM windows (NIP-17 gift wraps + NIP-04, in lockstep) whenever [wantMore] + * becomes true and a previous widen isn't still loading, stopping once both are exhausted. * - * One condition drives three behaviors at once: widen when nothing is loaded yet (empty feed), - * or when the last visible row has crossed the midpoint of what's loaded. Because everything - * fits on screen while the list is short, the midpoint is trivially crossed, so it keeps - * widening until the list overflows the viewport with a buffer below the fold — and once it - * does, it only fires again as the user scrolls past the new midpoint, so fresh (geometrically - * larger) windows land well before the user reaches the end. It stops only when the window is - * exhausted (reached max lookback — nothing older exists). + * [wantMore] is evaluated inside a snapshotFlow, so it may read live Compose state (scroll position, + * the feed list). Callers decide the policy: the empty feed widens to discover the first rooms; the + * loaded feed widens as the user approaches the oldest loaded PRIVATE chat — public, group and + * ephemeral rooms are membership-based (shown regardless of age) and deliberately excluded, so an + * old public chat at the bottom of the list never drags the private window back with it. * - * Both DM protocols advance in lockstep: NIP-17 gift wraps (always-on account loader) and NIP-04 - * (this screen's loader). They must move together — if only one were windowed, the merged - * time-sorted list would mix a deep tail of one protocol with a shallow window of the other, and - * a widen would pull rooms that land in the middle of the feed instead of extending the end. - * - * The per-window [loadingMore] guard gates each step on ALL of that window's relays answering - * (or a timeout), not the first EOSE — otherwise a fast, near-empty relay would clear the guard - * and let this loop outrun the slow relay that actually holds the conversations. + * The two windows must move together: if only one were widened, the merged time-sorted list would + * mix a deep tail of one protocol with a shallow window of the other. The [loadingMore] guard gates + * each step on ALL of that window's relays answering (or a timeout), not the first EOSE, so a fast + * near-empty relay can't let the loop outrun the slow relay that holds the conversations. */ @Composable -private fun AutoFillAndPrefetch( - listState: LazyListState, - isFeedEmpty: () -> Boolean, +private fun WidenPrivateWindowWhen( accountViewModel: AccountViewModel, + wantMore: () -> Boolean, ) { val giftWraps = remember(accountViewModel) { accountViewModel.dataSources().account.giftWraps } val nip04Dms = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04Dms } - LaunchedEffect(listState, giftWraps, nip04Dms) { + LaunchedEffect(giftWraps, nip04Dms) { combine( - snapshotFlow { - val info = listState.layoutInfo - val total = info.totalItemsCount - val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1 - // Want more when nothing is loaded yet, or when the last visible row has crossed - // the midpoint of what's loaded (prefetch well before reaching the end). - isFeedEmpty() || (total > 0 && lastVisible >= total / 2) - }, + snapshotFlow { wantMore() }, giftWraps.loadingMore, nip04Dms.loadingMore, giftWraps.exhausted, nip04Dms.exhausted, - ) { wantMore, loadingGiftWraps, loadingNip04, giftWrapsExhausted, nip04Exhausted -> - wantMore && !loadingGiftWraps && !loadingNip04 && !(giftWrapsExhausted && nip04Exhausted) + ) { want, loadingGiftWraps, loadingNip04, giftWrapsExhausted, nip04Exhausted -> + want && !loadingGiftWraps && !loadingNip04 && !(giftWrapsExhausted && nip04Exhausted) }.distinctUntilChanged() .filter { it } .collect { - Log.d("DMPagination") { "rooms list needs more (auto-fill/prefetch), widening NIP-17 + NIP-04 windows one step" } + Log.d("DMPagination") { "rooms list needs more private history, widening NIP-17 + NIP-04 windows one step" } val user = accountViewModel.userProfile() giftWraps.loadMore(user) nip04Dms.loadMore(user) From 46a8d1d49061381957e1d4a9386d157814f9e044 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 13:57:26 +0000 Subject: [PATCH 016/103] feat: gap-free conversation timeline (display floor across NIP-04 + NIP-17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A thread renders the LocalCache union as events land, and NIP-04 (one decrypt) paints faster than NIP-17 (gift-wrap unwrap = two NIP-44 decrypts). So even with both windows requested to the same depth, a thread could transiently show kind:4 messages with the kind:1059 messages that belong between them still missing — and a user could read it as complete. Introduce a per-conversation display floor: only reveal messages at or newer than the deepest gift-wrap floor at which BOTH protocols have finished loading, plus a 2-day margin (NIP-17 randomizes the gift wrap's outer created_at up to 2 days, and relays filter on that outer time, so fetching outer >= F only guarantees holding every inner time >= F+2d). The floor is monotonic (revealed history never retracts) and a "loading older" boundary shows at the oldest end until the window is exhausted, so incompleteness is always visible rather than mistaken for "done". - ChatroomFilterSubAssembler gains a WindowLoadTracker (loadingMore) and a reload(), so the conversation knows when NIP-04 has covered the floor; the scroll widen now gates on both protocols and calls reload() instead of a bare invalidate. - ChatFeedView gains opt-in oldestVisibleTime (clip) + loadingOlder (boundary) params; public-chat / channel callers default to no-op. - ChatroomView computes the floor from both loaders' idle state + windowSince and passes it down. Honest limits: a relay that withholds data can't be conjured (we never hide incompleteness, floor only descends); a sender backdating the gift-wrap outer timestamp beyond the 2-day spec can still plant a late message, defended only by "Load entire history". The rooms list is intentionally not clipped this way — there, hiding a known conversation is worse than a row reordering. --- .../loggedIn/chats/feed/ChatFeedView.kt | 54 ++++++++++++- .../loggedIn/chats/privateDM/ChatroomView.kt | 77 +++++++++++++++---- .../datasource/ChatroomFilterAssembler.kt | 4 +- .../datasource/ChatroomFilterSubAssembler.kt | 62 ++++++++++++++- 4 files changed, 175 insertions(+), 22 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt index af35f579b5..c4defcd268 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt @@ -21,10 +21,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -32,6 +38,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState @@ -61,6 +68,12 @@ fun RefreshingChatroomFeedView( // Opt-in hook handed the feed's scroll state, so a specific screen (e.g. private DMs) can // attach scroll-driven loading. No-op for the public-chat / channel callers that don't paginate. listStateObserver: @Composable (LazyListState) -> Unit = {}, + // Only reveal messages at or newer than this epoch-second floor. Private DMs use it to hold back + // a depth until BOTH protocols (NIP-04 + NIP-17) have fully loaded it, so the thread never shows + // a region with one protocol missing. Default reveals everything (public chats / channels). + oldestVisibleTime: Long = Long.MIN_VALUE, + // Show a "loading older messages" boundary at the oldest end while more history may still arrive. + loadingOlder: Boolean = false, ) { SaveableFeedState(feedContentState, scrollStateKey) { listState -> listStateObserver(listState) @@ -73,6 +86,8 @@ fun RefreshingChatroomFeedView( onWantsToReply, onWantsToEditDraft, avoidDraft, + oldestVisibleTime, + loadingOlder, ) } } @@ -87,6 +102,8 @@ fun RenderChatFeedView( onWantsToReply: (Note) -> Unit, onWantsToEditDraft: (Note) -> Unit, avoidDraft: DraftTagState? = null, + oldestVisibleTime: Long = Long.MIN_VALUE, + loadingOlder: Boolean = false, ) { val feedState by feed.feedContent.collectAsStateWithLifecycle() @@ -114,6 +131,8 @@ fun RenderChatFeedView( onWantsToReply, onWantsToEditDraft, avoidDraft, + oldestVisibleTime, + loadingOlder, ) } } @@ -130,10 +149,23 @@ fun ChatFeedLoaded( onWantsToReply: (Note) -> Unit, onWantsToEditDraft: (Note) -> Unit, avoidDraft: DraftTagState? = null, + oldestVisibleTime: Long = Long.MIN_VALUE, + loadingOlder: Boolean = false, ) { val items by loaded.feed.collectAsStateWithLifecycle() - LaunchedEffect(items.list.firstOrNull()) { + // Clip the bottom of the thread to the depth both DM protocols have fully covered. A note whose + // event hasn't loaded yet (null createdAt) is kept visible — fail toward showing, never hiding. + val visibleItems = + remember(items.list, oldestVisibleTime) { + if (oldestVisibleTime == Long.MIN_VALUE) { + items.list + } else { + items.list.filter { (it.createdAt() ?: Long.MAX_VALUE) >= oldestVisibleTime } + } + } + + LaunchedEffect(visibleItems.firstOrNull()) { if (listState.firstVisibleItemIndex <= 1) { listState.animateScrollToItem(0) } @@ -142,7 +174,7 @@ fun ChatFeedLoaded( val scope = rememberCoroutineScope() val highlightedNoteId = remember { mutableStateOf(null) } val onScrollToNote: (Note) -> Unit = { note -> - val index = items.list.indexOfFirst { it.idHex == note.idHex } + val index = visibleItems.indexOfFirst { it.idHex == note.idHex } if (index >= 0) { scope.launch { listState.animateScrollToItem(index) @@ -157,7 +189,7 @@ fun ChatFeedLoaded( reverseLayout = true, state = listState, ) { - itemsIndexed(items.list, key = { _, item -> item.idHex }, contentType = { _, item -> item.event?.kind ?: -1 }) { index, item -> + itemsIndexed(visibleItems, key = { _, item -> item.idHex }, contentType = { _, item -> item.event?.kind ?: -1 }) { index, item -> val noteEvent = item.event if (avoidDraft == null || noteEvent !is DraftWrapEvent || noteEvent.dTag() !in avoidDraft.usedDraftTags) { ChatroomMessageCompose( @@ -172,7 +204,21 @@ fun ChatFeedLoaded( onHighlightFinished = { highlightedNoteId.value = null }, ) - NewDateOrSubjectDivisor(items.list.getOrNull(index + 1), item) + NewDateOrSubjectDivisor(visibleItems.getOrNull(index + 1), item) + } + } + + // Reverse layout: a trailing item sits at the highest index, i.e. the visual TOP (oldest end). + // While older history may still arrive, it both signals "not complete yet" and is where the + // clipped-back depth reveals as both protocols catch up. + if (loadingOlder) { + item(key = "loadingOlderMessages") { + Row( + Modifier.fillMaxWidth().padding(vertical = 8.dp), + horizontalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator(Modifier.size(25.dp)) + } } } } 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 9c606e0fb8..701f6ff42b 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 @@ -28,11 +28,15 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel @@ -128,18 +132,21 @@ fun ChatroomView( ) } +// NIP-17 senders may backdate the gift wrap's OUTER created_at up to two days (randomWithTwoDays), +// and relays filter on that outer time. So once we've fetched outer >= F, we're only guaranteed to +// hold every message whose real (inner) time is >= F + 2d. The revealed floor carries this margin. +private const val GIFT_WRAP_OUTER_JITTER_SECONDS = 2L * 24 * 60 * 60 + /** - * Scroll-driven history loader for a conversation, advancing BOTH DM protocols in lockstep. The - * thread is reverse-laid-out (newest at the bottom, index 0), so older messages live at higher - * indices; this widens the window one step whenever nothing is loaded yet or the oldest visible row - * has crossed the midpoint of what's loaded — prefetching before the user reaches the top — and - * stops once the window is exhausted. + * Scroll-driven history loader for a conversation, advancing BOTH DM protocols together. The thread + * is reverse-laid-out (newest at the bottom, index 0), so older messages live at higher indices; + * this widens one step whenever nothing is loaded yet or the oldest visible row crosses the midpoint + * of what's loaded — prefetching before the user reaches the top — and stops once exhausted. * * The account-wide gift-wrap window is the single source of truth for the floor: NIP-17 advances via - * [AccountGiftWrapsEoseManager.loadMore], and the NIP-04 conversation loader reads that same floor, - * so re-invalidating the chatroom sub re-requests kind:4 to the new depth. Both protocols therefore - * stay aligned. The gift-wrap [AccountGiftWrapsEoseManager.loadingMore] guard gates each step on the - * previous window finishing, so it advances one step at a time rather than in a burst. + * [AccountGiftWrapsEoseManager.loadMore], and the NIP-04 loader [ChatroomFilterSubAssembler.reload] + * re-requests kind:4 from that same floor. The step is gated on BOTH loaders being idle, so it never + * outruns the slower protocol. */ @Composable private fun LoadOlderMessagesWhenScrolling( @@ -147,9 +154,9 @@ private fun LoadOlderMessagesWhenScrolling( accountViewModel: AccountViewModel, ) { val giftWraps = remember(accountViewModel) { accountViewModel.dataSources().account.giftWraps } - val chatroom = remember(accountViewModel) { accountViewModel.dataSources().chatroom } + val nip04 = remember(accountViewModel) { accountViewModel.dataSources().chatroom.nip04 } - LaunchedEffect(listState, giftWraps, chatroom) { + LaunchedEffect(listState, giftWraps, nip04) { combine( snapshotFlow { val info = listState.layoutInfo @@ -158,20 +165,51 @@ private fun LoadOlderMessagesWhenScrolling( total == 0 || lastVisible >= total / 2 }, giftWraps.loadingMore, + nip04.loadingMore, giftWraps.exhausted, - ) { wantMore, loadingMore, exhausted -> - wantMore && !loadingMore && !exhausted + ) { wantMore, loadingGiftWraps, loadingNip04, exhausted -> + wantMore && !loadingGiftWraps && !loadingNip04 && !exhausted }.distinctUntilChanged() .filter { it } .collect { - // Advance the shared gift-wrap window (NIP-17), then re-issue the NIP-04 sub so it - // re-requests kind:4 from the same, now-wider floor. giftWraps.loadMore(accountViewModel.userProfile()) - chatroom.invalidateFilters() + nip04.reload() } } } +/** + * The epoch-second floor at or above which the thread is safe to reveal: the deepest gift-wrap floor + * at which BOTH protocols have finished loading, plus the [GIFT_WRAP_OUTER_JITTER_SECONDS] margin. + * + * It only descends (monotonic), so revealed history never retracts. Until the first completion it is + * [Long.MAX_VALUE] (reveal nothing yet); once the window is exhausted it is [Long.MIN_VALUE] (reveal + * everything). Holding back a depth until both NIP-04 and NIP-17 have covered it is what prevents a + * fast NIP-04 stream from painting a thread that's missing the NIP-17 messages in between. + */ +@Composable +private fun rememberConversationDisplayFloor(accountViewModel: AccountViewModel): Long { + val giftWraps = remember(accountViewModel) { accountViewModel.dataSources().account.giftWraps } + val nip04 = remember(accountViewModel) { accountViewModel.dataSources().chatroom.nip04 } + val loadingGiftWraps by giftWraps.loadingMore.collectAsStateWithLifecycle() + val loadingNip04 by nip04.loadingMore.collectAsStateWithLifecycle() + val exhausted by giftWraps.exhausted.collectAsStateWithLifecycle() + + var coveredSince by remember(accountViewModel) { mutableStateOf(Long.MAX_VALUE) } + LaunchedEffect(loadingGiftWraps, loadingNip04, accountViewModel) { + if (!loadingGiftWraps && !loadingNip04) { + val since = giftWraps.windowSince(accountViewModel.userProfile()) + if (since < coveredSince) coveredSince = since + } + } + + return when { + exhausted -> Long.MIN_VALUE + coveredSince == Long.MAX_VALUE -> Long.MAX_VALUE + else -> coveredSince + GIFT_WRAP_OUTER_JITTER_SECONDS + } +} + @Composable fun ChatroomViewUI( room: ChatroomKey, @@ -183,6 +221,11 @@ fun ChatroomViewUI( WatchLifecycleAndUpdateModel(feedViewModel) ChatroomFilterAssemblerSubscription(room, accountViewModel.dataSources().chatroom, accountViewModel) + // Only reveal a depth once BOTH DM protocols have fully loaded it (gap-free timeline); show a + // "loading older" boundary while more history may still arrive. + val displayFloor = rememberConversationDisplayFloor(accountViewModel) + val loadingOlder = displayFloor != Long.MIN_VALUE + Column(Modifier.fillMaxHeight()) { ObserveRelayListForDMsAndDisplayIfNotFound(accountViewModel, nav) @@ -201,6 +244,8 @@ fun ChatroomViewUI( avoidDraft = newPostModel.draftTag, onWantsToReply = newPostModel::reply, onWantsToEditDraft = newPostModel::editFromDraft, + oldestVisibleTime = displayFloor, + loadingOlder = loadingOlder, listStateObserver = { listState -> LoadOlderMessagesWhenScrolling(listState, accountViewModel) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt index e10cd2e343..02d7210556 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt @@ -38,9 +38,11 @@ class ChatroomFilterAssembler( client: INostrClient, giftWraps: AccountGiftWrapsEoseManager, ) : ComposeSubscriptionManager() { + val nip04 = ChatroomFilterSubAssembler(client, ::allKeys, giftWraps) + val group = listOf( - ChatroomFilterSubAssembler(client, ::allKeys, giftWraps), + nip04, ) override fun invalidateKeys() = invalidateFilters() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt index 07f37bf944..d27cf3e1b1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt @@ -21,10 +21,19 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.WindowLoadTracker 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.relay.client.INostrClient 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 +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow class ChatroomFilterSubAssembler( client: INostrClient, @@ -33,17 +42,68 @@ class ChatroomFilterSubAssembler( // requested; NIP-04 here follows its floor so a thread shows both protocols to the same depth. private val giftWraps: AccountGiftWrapsEoseManager, ) : PerUserAndFollowListEoseManager(client, allKeys) { + // A NIP-04 load is "in flight" until every relay it was sent to has answered (or a timeout). + // The conversation screen reads this alongside the gift-wrap loader's flag to know when BOTH + // protocols have fully covered the current floor, so it never reveals a half-loaded depth. + private val windowLoad = WindowLoadTracker() + val loadingMore: StateFlow = windowLoad.loading + + // Account scope to run the window-load watchdog on, captured when the subscription opens. + @Volatile + private var scope: CoroutineScope? = null + override fun updateFilter( key: ChatroomQueryState, since: SincePerRelayMap?, ): List? = if (key.account.isWriteable()) { - filterNip04DMs(key.room.users, key.account, giftWraps.windowSince(user(key))) + val filters = filterNip04DMs(key.room.users, key.account, giftWraps.windowSince(user(key))) + windowLoad.setExpectedRelays(filters?.mapTo(mutableSetOf()) { it.relay } ?: emptySet()) + filters } else { + windowLoad.setExpectedRelays(emptySet()) emptyList() } + /** Re-issues the NIP-04 subscription at the (now-wider) shared gift-wrap floor and tracks the load. */ + fun reload() { + scope?.let { windowLoad.startLoading(it) } + invalidateFilters() + } + override fun user(key: ChatroomQueryState) = key.account.userProfile() override fun list(key: ChatroomQueryState) = key.listId + + override fun newSub(key: ChatroomQueryState): Subscription { + scope = key.account.scope + windowLoad.startLoading(key.account.scope) + + // Custom listener (vs super.newSub) so every event — stored backfill included — keeps the + // window-load watchdog alive and EOSEs mark relays answered, feeding [loadingMore]. + return requestNewSubscription( + object : SubscriptionListener { + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + windowLoad.onRelayResponded(relay) + newEose(key, relay, TimeUtils.now(), forFilters) + } + + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + windowLoad.onActivity() + if (isLive) { + windowLoad.onRelayResponded(relay) + newEose(key, relay, TimeUtils.now(), forFilters) + } + } + }, + ) + } } From 0fb6f6778d338c286ea812531bc2a75cd079328a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 14:12:02 +0000 Subject: [PATCH 017/103] refactor: one DM window, NIP-04 followers, shared listener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse the DM windowing system to a single source of truth and remove the accumulated duplication. - One window: the gift-wrap (NIP-17) loader owns the only TimeWindowPagination. The rooms-list NIP-04 loader no longer keeps its own window advanced "in lockstep" — like the conversation loader, it now follows the gift-wrap window's `windowSince` and re-requests via `reload()`. Removes its window, loadMore, loadEverything and exhausted; the rooms screen drives giftWraps.loadMore() + nip04.reload() and reads giftWraps.exhausted alone. - Shared listener: extract WindowLoadTracker.trackingListener(forward) — the one place that feeds onActivity/onRelayResponded — replacing three copies of subscription-listener boilerplate and two newEose overrides. - Drop the cold-boot instrumentation (bootStartMs/bootEventCount/bootEoseLogged + verbose per-call logs) from the gift-wrap manager; it was development scaffolding. (The debug-gated DmRelayDiagnosticsLogger stays.) - Renames for clarity: DMsFromUserFilterSubAssembler -> ChatroomListNip04SubAssembler, ChatroomFilterSubAssembler -> ChatroomNip04SubAssembler, field nip04Dms -> nip04. Behavior is unchanged: same auto-fill/prefetch, same all-relays-or-idle gating, same gap-free conversation reveal. ~250 fewer lines and no more lockstep concept. --- .../eoseManagers/WindowLoadTracker.kt | 33 +++ .../RelaySubscriptionsCoordinator.kt | 2 +- .../AccountGiftWrapsEoseManager.kt | 176 +++------------- .../loggedIn/chats/privateDM/ChatroomView.kt | 2 +- .../datasource/ChatroomFilterAssembler.kt | 2 +- ...embler.kt => ChatroomNip04SubAssembler.kt} | 49 ++--- .../datasource/ChatroomListFilterAssembler.kt | 7 +- .../ChatroomListNip04SubAssembler.kt | 115 +++++++++++ .../DMsFromUserFilterSubAssembler.kt | 194 ------------------ .../chats/rooms/feed/ChatroomListFeedView.kt | 53 ++--- 10 files changed, 223 insertions(+), 410 deletions(-) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/{ChatroomFilterSubAssembler.kt => ChatroomNip04SubAssembler.kt} (61%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04SubAssembler.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt index e50fe1514c..ce128884f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt @@ -20,6 +20,9 @@ */ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -139,3 +142,33 @@ class WindowLoadTracker( private const val IDLE_CHECK_MS = 500L } } + +/** + * Builds the standard [SubscriptionListener] that feeds this tracker: every event (stored backfill + * included) marks activity so a relay mid-flood is never mistaken for done, and an EOSE or live + * event marks that relay answered. [forward] is invoked with the same EOSE / live-event signal so + * the owning EOSE manager can record the relay's EOSE timestamp (its usual `newEose`). + */ +fun WindowLoadTracker.trackingListener(forward: (NormalizedRelayUrl, List?) -> Unit): SubscriptionListener = + object : SubscriptionListener { + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + onRelayResponded(relay) + forward(relay, forFilters) + } + + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + onActivity() + if (isLive) { + onRelayResponded(relay) + forward(relay, forFilters) + } + } + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index d442aca7e4..a7b0fc7dae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -90,7 +90,7 @@ class RelaySubscriptionsCoordinator( // always running, feed assemblers. val home = HomeFilterAssembler(client) - val chatroomList = ChatroomListFilterAssembler(client) + val chatroomList = ChatroomListFilterAssembler(client, account.giftWraps) val video = VideoFilterAssembler(client) val discovery = DiscoveryFilterAssembler(client) 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 6dd991b007..5308adab1d 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 @@ -25,17 +25,13 @@ import com.vitorpamplona.amethyst.commons.relayClient.pagination.TimeWindowPagin import com.vitorpamplona.amethyst.model.User 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.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient 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 -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -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.Dispatchers @@ -48,48 +44,37 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap +/** + * Always-on loader for the account's NIP-17 gift wraps (kind 1059). It owns the single DM time + * window: boot opens a small window so the messages list is usable before the whole history is + * fetched and decrypted, and the screens widen it ([loadMore] / [loadEverything]) as the user + * scrolls. The NIP-04 loaders follow this window's [windowSince] so both DM protocols stay aligned. + */ class AccountGiftWrapsEoseManager( client: INostrClient, allKeys: () -> Set, ) : PerUserEoseManager(client, allKeys) { override fun user(key: AccountQueryState) = key.account.userProfile() - // How far back in time gift wraps are requested, per account. Boot opens a small - // window so the messages list is usable before the whole DM history is fetched and - // decrypted; the rooms screen widens it via [loadMore] to fill the screen and to - // prefetch as the user scrolls. The step grows geometrically so a sparse history - // (or confirming there is nothing older) converges in a handful of requests; it is - // kept in lockstep with the NIP-04 window so both DM protocols advance together. - // Concurrent: windowFor is reached from the UI thread (loadMore / loadEverything) and from - // Dispatchers.IO (updateFilter, via the bundled invalidation), so a plain HashMap would race. + // Per-account window floor. Concurrent: windowFor is reached from the UI thread (loadMore / + // loadEverything) and from Dispatchers.IO (updateFilter), so a plain HashMap would race. private val windows = ConcurrentHashMap() - private fun windowFor(user: User) = - windows.computeIfAbsent(user.pubkeyHex) { - TimeWindowPagination(growthFactor = WINDOW_GROWTH_FACTOR).also { - Log.d(TAG) { "opening initial gift-wrap window for pubkey=${user.pubkeyHex.take(8)}… since=${it.since} (${daysAgo(it.since)}d back)" } - } - } + private fun windowFor(user: User) = windows.computeIfAbsent(user.pubkeyHex) { TimeWindowPagination(growthFactor = WINDOW_GROWTH_FACTOR) } - /** - * The current lower bound (epoch seconds) of this account's gift-wrap window. Exposed so the - * per-conversation NIP-04 loader can request from the SAME floor, keeping both DM protocols - * aligned in a thread instead of one reaching deeper than the other. - */ + /** The current lower bound (epoch seconds) of this account's gift-wrap window. */ fun windowSince(user: User): Long = windowFor(user).since - // A window is "loading" until every dmRelay it was sent to has answered (EOSE / live event), - // or a timeout fires — not on the first EOSE, which a fast empty relay can trip prematurely. + // A window load is in flight until every dmRelay it was sent to has answered, the event stream + // goes quiet, or a cap fires — never on just the first EOSE (a fast empty relay would trip it). private val windowLoad = WindowLoadTracker() val loadingMore: StateFlow = windowLoad.loading - // True once the window has reached the maximum lookback: there is no older history to fetch, - // so the rooms screen can stop the auto-fill loop and show the real empty state. + // True once the window reached the maximum lookback: nothing older to fetch. private val _exhausted = MutableStateFlow(false) val exhausted: StateFlow = _exhausted.asStateFlow() - // The account scope to run the window-load watchdog on, captured when the subscription opens. - // Volatile: written on Dispatchers.IO (newSub), read on the UI thread (loadMore/loadEverything). + // Account scope for the window-load watchdog. Volatile: written on IO (newSub), read on UI (loadMore). @Volatile private var scope: CoroutineScope? = null @@ -97,143 +82,56 @@ class AccountGiftWrapsEoseManager( key: AccountQueryState, since: SincePerRelayMap?, ): List { - // Only loads DMs if the account is writeable - return if (key.account.isWriteable()) { - val relays = key.account.dmRelays.flow.value - windowLoad.setExpectedRelays(relays.toSet()) - val windowSince = windowFor(user(key)).since - Log.d(TAG) { - "updateFilter: pubkey=${user(key).pubkeyHex.take(8)}… requesting kind:1059 " + - "since=$windowSince (${daysAgo(windowSince)}d window) on ${relays.size} dmRelay(s): ${relays.map { it.url }}" - } - relays.flatMap { relay -> - filterGiftWrapsToPubkey( - relay = relay, - pubkey = user(key).pubkeyHex, - since = windowSince, - ) - } - } else { + if (!key.account.isWriteable()) { windowLoad.setExpectedRelays(emptySet()) - Log.d(TAG) { "updateFilter: pubkey=${user(key).pubkeyHex.take(8)}… account not writeable, skipping" } - emptyList() + return emptyList() + } + val relays = key.account.dmRelays.flow.value + windowLoad.setExpectedRelays(relays.toSet()) + val windowSince = windowFor(user(key)).since + return relays.flatMap { relay -> + filterGiftWrapsToPubkey(relay = relay, pubkey = user(key).pubkeyHex, since = windowSince) } } - /** - * Widens the gift-wrap time window for [user] one step back and re-issues the - * subscription so older conversations stream in. Called by the rooms screen to - * fill the screen and to prefetch older history as the user scrolls. No-op once - * the window is [exhausted]. Kept in lockstep with the NIP-04 window. - */ + /** Widens the window one (geometric) step back and re-issues the subscription. No-op if exhausted. */ fun loadMore(user: User) { val window = windowFor(user) if (window.isExhausted()) return - val before = window.since window.loadMore() _exhausted.value = window.isExhausted() - Log.d(TAG) { - "loadMore: pubkey=${user.pubkeyHex.take(8)}… widening window since $before -> ${window.since} " + - "(${daysAgo(window.since)}d back, was ${daysAgo(before)}d, exhausted=${_exhausted.value}), re-issuing subscription" - } scope?.let { windowLoad.startLoading(it) } invalidateFilters() } - /** - * Jumps the gift-wrap window straight to the maximum lookback so a single REQ pulls the entire - * history (the pre-windowing behavior), and marks it [exhausted] so auto-fill stops. - */ + /** Jumps the window to the maximum lookback so a single REQ pulls the entire history. */ fun loadEverything(user: User) { val window = windowFor(user) if (window.isExhausted()) return window.loadAll() _exhausted.value = true - Log.d(TAG) { "loadEverything: pubkey=${user.pubkeyHex.take(8)}… loading full history since ${window.since}" } scope?.let { windowLoad.startLoading(it) } invalidateFilters() } - override fun newEose( - key: AccountQueryState, - relay: NormalizedRelayUrl, - time: Long, - filters: List?, - ) { - windowLoad.onRelayResponded(relay) - super.newEose(key, relay, time, filters) - } - - private fun daysAgo(epochSeconds: Long) = (TimeUtils.now() - epochSeconds) / TimeUtils.ONE_DAY - - val userJobMap = mutableMapOf>() - - // Cold-boot instrumentation: when the subscription opened (ms), how many gift - // wraps have arrived since, and whether we've already logged the first EOSE. - // Concurrent because the listener callbacks below run on the relay reader threads - // (several relays delivering events at once during the cold-boot flood). - private val bootStartMs = ConcurrentHashMap() - private val bootEventCount = ConcurrentHashMap() - private val bootEoseLogged = ConcurrentHashMap.newKeySet() + private val userJobMap = mutableMapOf>() @OptIn(FlowPreview::class) override fun newSub(key: AccountQueryState): Subscription { val user = user(key) scope = key.account.scope + windowLoad.startLoading(key.account.scope) userJobMap[user]?.forEach { it.cancel() } userJobMap[user] = listOf( key.account.scope.launch(Dispatchers.IO) { - key.account.dmRelays.flow.collectLatest { - invalidateFilters() - } + key.account.dmRelays.flow + .collectLatest { invalidateFilters() } }, ) - // Reset and start the cold-boot timer for this subscription. - val pubkey = user.pubkeyHex - bootStartMs[pubkey] = System.currentTimeMillis() - bootEventCount[pubkey] = 0 - bootEoseLogged.remove(pubkey) - windowLoad.startLoading(key.account.scope) - Log.d(TAG) { "cold boot: pubkey=${pubkey.take(8)}… opening gift-wrap subscription, starting to load messages" } - - // Custom listener so we can tell a real EOSE (load finished) apart from live - // events; the base class routes both into newEose, which can't distinguish them. return requestNewSubscription( - object : SubscriptionListener { - override fun onEose( - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - if (bootEoseLogged.add(pubkey)) { - val elapsed = System.currentTimeMillis() - (bootStartMs[pubkey] ?: System.currentTimeMillis()) - val count = bootEventCount[pubkey] ?: 0 - Log.d(TAG) { - "cold boot: pubkey=${pubkey.take(8)}… initial load complete — first EOSE from ${relay.url} " + - "after ${elapsed}ms, $count gift wrap(s) received so far" - } - } - newEose(key, relay, TimeUtils.now(), forFilters) - } - - override fun onEvent( - event: Event, - isLive: Boolean, - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - // Every event (stored backfill included) keeps the window-load watchdog alive, - // so a relay mid-flood is never mistaken for a finished window. - windowLoad.onActivity() - if (pubkey !in bootEoseLogged) { - bootEventCount.merge(pubkey, 1, Int::plus) - } - if (isLive) { - newEose(key, relay, TimeUtils.now(), forFilters) - } - } - }, + windowLoad.trackingListener { relay, filters -> newEose(key, relay, TimeUtils.now(), filters) }, ) } @@ -243,19 +141,11 @@ class AccountGiftWrapsEoseManager( ) { super.endSub(key, subId) userJobMap[key]?.forEach { it.cancel() } - bootStartMs.remove(key.pubkeyHex) - bootEventCount.remove(key.pubkeyHex) - bootEoseLogged.remove(key.pubkeyHex) } companion object { - // Shared log tag for the DM time-window pagination. Filter logcat by this - // tag to watch the boot window and scroll-driven backfill in real time. - private const val TAG = "DMPagination" - - // The window doubles each widen so a sparse history (or confirming there is nothing - // older) reaches the 10-year backstop in ~10 requests instead of crawling weekly. - // Must match the NIP-04 window so both DM protocols advance in lockstep. - const val WINDOW_GROWTH_FACTOR = 2L + // The window doubles each widen so a sparse history (or confirming nothing older exists) + // reaches the 10-year backstop in ~10 requests instead of crawling a week at a time. + private const val WINDOW_GROWTH_FACTOR = 2L } } 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 701f6ff42b..91387a28f7 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 @@ -144,7 +144,7 @@ private const val GIFT_WRAP_OUTER_JITTER_SECONDS = 2L * 24 * 60 * 60 * of what's loaded — prefetching before the user reaches the top — and stops once exhausted. * * The account-wide gift-wrap window is the single source of truth for the floor: NIP-17 advances via - * [AccountGiftWrapsEoseManager.loadMore], and the NIP-04 loader [ChatroomFilterSubAssembler.reload] + * [AccountGiftWrapsEoseManager.loadMore], and the NIP-04 loader [ChatroomNip04SubAssembler.reload] * re-requests kind:4 from that same floor. The step is gated on BOTH loaders being idle, so it never * outruns the slower protocol. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt index 02d7210556..03c648dedf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt @@ -38,7 +38,7 @@ class ChatroomFilterAssembler( client: INostrClient, giftWraps: AccountGiftWrapsEoseManager, ) : ComposeSubscriptionManager() { - val nip04 = ChatroomFilterSubAssembler(client, ::allKeys, giftWraps) + val nip04 = ChatroomNip04SubAssembler(client, ::allKeys, giftWraps) val group = listOf( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04SubAssembler.kt similarity index 61% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04SubAssembler.kt index d27cf3e1b1..9905cf334e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04SubAssembler.kt @@ -22,33 +22,32 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource 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.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient 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 -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow -class ChatroomFilterSubAssembler( +/** + * Loads one conversation's NIP-04 DMs (kind 4). Like the rooms-list loader, it follows the account + * gift-wrap window's [AccountGiftWrapsEoseManager.windowSince] floor so a thread shows both DM + * protocols to the same depth. [reload] re-issues at the current floor; [loadingMore] reports when + * this protocol has covered it, which the conversation screen joins with the gift-wrap loader's flag + * to decide how deep the thread is safe to reveal. + */ +class ChatroomNip04SubAssembler( client: INostrClient, allKeys: () -> Set, - // The account-wide gift-wrap window is the single source of truth for how far back DMs are - // requested; NIP-04 here follows its floor so a thread shows both protocols to the same depth. private val giftWraps: AccountGiftWrapsEoseManager, ) : PerUserAndFollowListEoseManager(client, allKeys) { - // A NIP-04 load is "in flight" until every relay it was sent to has answered (or a timeout). - // The conversation screen reads this alongside the gift-wrap loader's flag to know when BOTH - // protocols have fully covered the current floor, so it never reveals a half-loaded depth. private val windowLoad = WindowLoadTracker() val loadingMore: StateFlow = windowLoad.loading - // Account scope to run the window-load watchdog on, captured when the subscription opens. + // Account scope for the watchdog. Volatile: written on IO (newSub), read on UI (reload). @Volatile private var scope: CoroutineScope? = null @@ -65,7 +64,7 @@ class ChatroomFilterSubAssembler( emptyList() } - /** Re-issues the NIP-04 subscription at the (now-wider) shared gift-wrap floor and tracks the load. */ + /** Re-issues at the (now-wider) shared gift-wrap floor and tracks the load. */ fun reload() { scope?.let { windowLoad.startLoading(it) } invalidateFilters() @@ -78,32 +77,8 @@ class ChatroomFilterSubAssembler( override fun newSub(key: ChatroomQueryState): Subscription { scope = key.account.scope windowLoad.startLoading(key.account.scope) - - // Custom listener (vs super.newSub) so every event — stored backfill included — keeps the - // window-load watchdog alive and EOSEs mark relays answered, feeding [loadingMore]. return requestNewSubscription( - object : SubscriptionListener { - override fun onEose( - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - windowLoad.onRelayResponded(relay) - newEose(key, relay, TimeUtils.now(), forFilters) - } - - override fun onEvent( - event: Event, - isLive: Boolean, - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - windowLoad.onActivity() - if (isLive) { - windowLoad.onRelayResponded(relay) - newEose(key, relay, TimeUtils.now(), forFilters) - } - } - }, + windowLoad.trackingListener { relay, filters -> newEose(key, relay, TimeUtils.now(), filters) }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt index d4876752b7..0f1823b71f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient // This allows multiple screen to be listening to tags, even the same tag @@ -34,12 +35,14 @@ class ChatroomListState( @Stable class ChatroomListFilterAssembler( client: INostrClient, + giftWraps: AccountGiftWrapsEoseManager, ) : ComposeSubscriptionManager() { - val nip04Dms = DMsFromUserFilterSubAssembler(client, ::allKeys) + // NIP-04 DMs follow the account gift-wrap window's floor (the single source of truth). + val nip04 = ChatroomListNip04SubAssembler(client, ::allKeys, giftWraps) val group = listOf( - nip04Dms, + nip04, FollowingPublicChatSubAssembler(client, ::allKeys), FollowingEphemeralChatSubAssembler(client, ::allKeys), ) 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 new file mode 100644 index 0000000000..e19200ee55 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04SubAssembler.kt @@ -0,0 +1,115 @@ +/* + * 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.amethyst.ui.screen.loggedIn.chats.rooms.datasource + +import com.vitorpamplona.amethyst.model.User +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.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +/** + * Loads the account's NIP-04 DMs (kind 4) for the rooms list. It does not own a time window: it + * follows the gift-wrap window's [AccountGiftWrapsEoseManager.windowSince] floor, so both DM + * protocols are requested to the same depth. [reload] re-issues at the current floor (called after + * the gift-wrap window widens); [loadingMore] reports when this protocol has covered that floor. + */ +class ChatroomListNip04SubAssembler( + client: INostrClient, + allKeys: () -> Set, + private val giftWraps: AccountGiftWrapsEoseManager, +) : PerUserEoseManager(client, allKeys) { + private val windowLoad = WindowLoadTracker() + val loadingMore: StateFlow = windowLoad.loading + + // Account scope for the watchdog. Volatile: written on IO (newSub), read on UI (reload). + @Volatile + private var scope: CoroutineScope? = null + + override fun updateFilter( + key: ChatroomListState, + since: SincePerRelayMap?, + ): List? = + if (key.account.isWriteable()) { + val homeRelays = key.account.homeRelays.flow.value + val dmRelays = key.account.dmRelays.flow.value + windowLoad.setExpectedRelays((homeRelays + dmRelays).toSet()) + val windowSince = giftWraps.windowSince(user(key)) + homeRelays.map { filterNip04DMsFromMe(key.account.userProfile(), it, windowSince) } + + dmRelays.map { filterNip04DMsToMe(key.account.userProfile(), it, windowSince) } + } else { + windowLoad.setExpectedRelays(emptySet()) + emptyList() + } + + /** Re-issues at the (now-wider) shared gift-wrap floor and tracks the load. */ + fun reload() { + scope?.let { windowLoad.startLoading(it) } + invalidateFilters() + } + + override fun user(key: ChatroomListState) = key.account.userProfile() + + private val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: ChatroomListState): Subscription { + val user = user(key) + scope = key.account.scope + windowLoad.startLoading(key.account.scope) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.account.scope.launch(Dispatchers.IO) { + key.account.homeRelays.flow + .collectLatest { invalidateFilters() } + }, + key.account.scope.launch(Dispatchers.IO) { + key.account.dmRelays.flow + .collectLatest { invalidateFilters() } + }, + ) + + return requestNewSubscription( + windowLoad.trackingListener { relay, filters -> newEose(key, relay, TimeUtils.now(), filters) }, + ) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt deleted file mode 100644 index 1811c8d32f..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt +++ /dev/null @@ -1,194 +0,0 @@ -/* - * 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.amethyst.ui.screen.loggedIn.chats.rooms.datasource - -import com.vitorpamplona.amethyst.commons.relayClient.pagination.TimeWindowPagination -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.WindowLoadTracker -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.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener -import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.utils.TimeUtils -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import java.util.concurrent.ConcurrentHashMap - -class DMsFromUserFilterSubAssembler( - client: INostrClient, - allKeys: () -> Set, -) : PerUserEoseManager(client, allKeys) { - // Same moving time window as the gift-wrap (NIP-17) loader, so the merged rooms list is - // bounded uniformly across both DM protocols. Without this, NIP-04 loaded all history while - // NIP-17 only loaded the recent window, so widening (which fills the screen / prefetches) - // landed new NIP-17 rooms in the middle of the NIP-04 tail instead of extending the list end. - // Same growth factor as the gift-wrap window keeps both advancing in lockstep. - // Concurrent: windowFor is reached from the UI thread (loadMore / loadEverything) and from - // Dispatchers.IO (updateFilter, via the bundled invalidation), so a plain HashMap would race. - private val windows = ConcurrentHashMap() - - private fun windowFor(user: User) = - windows.computeIfAbsent(user.pubkeyHex) { - TimeWindowPagination(growthFactor = AccountGiftWrapsEoseManager.WINDOW_GROWTH_FACTOR) - } - - // A window is "loading" until every relay it was sent to has answered (EOSE / live event), - // or a timeout fires — not on the first EOSE, which a fast empty relay can trip prematurely. - private val windowLoad = WindowLoadTracker() - val loadingMore: StateFlow = windowLoad.loading - - // True once the window has reached the maximum lookback: no older history to fetch. - private val _exhausted = MutableStateFlow(false) - val exhausted: StateFlow = _exhausted.asStateFlow() - - // The account scope to run the window-load watchdog on, captured when the subscription opens. - // Volatile: written on Dispatchers.IO (newSub), read on the UI thread (loadMore/loadEverything). - @Volatile - private var scope: CoroutineScope? = null - - override fun updateFilter( - key: ChatroomListState, - since: SincePerRelayMap?, - ): List? = - if (key.account.isWriteable()) { - val homeRelays = key.account.homeRelays.flow.value - val dmRelays = key.account.dmRelays.flow.value - windowLoad.setExpectedRelays((homeRelays + dmRelays).toSet()) - val windowSince = windowFor(user(key)).since - homeRelays.map { - filterNip04DMsFromMe(key.account.userProfile(), it, windowSince) - } + - dmRelays.map { - filterNip04DMsToMe(key.account.userProfile(), it, windowSince) - } - } else { - windowLoad.setExpectedRelays(emptySet()) - emptyList() - } - - /** - * Widens the NIP-04 time window for [user] one step back, kept in lockstep with the - * gift-wrap window. No-op once the window is [exhausted]. - */ - fun loadMore(user: User) { - val window = windowFor(user) - if (window.isExhausted()) return - window.loadMore() - _exhausted.value = window.isExhausted() - scope?.let { windowLoad.startLoading(it) } - invalidateFilters() - } - - /** - * Jumps the NIP-04 window straight to the maximum lookback so a single REQ pulls the entire - * history (the pre-windowing behavior), and marks it [exhausted] so auto-fill stops. - */ - fun loadEverything(user: User) { - val window = windowFor(user) - if (window.isExhausted()) return - window.loadAll() - _exhausted.value = true - scope?.let { windowLoad.startLoading(it) } - invalidateFilters() - } - - override fun newEose( - key: ChatroomListState, - relay: NormalizedRelayUrl, - time: Long, - filters: List?, - ) { - windowLoad.onRelayResponded(relay) - super.newEose(key, relay, time, filters) - } - - override fun user(key: ChatroomListState) = key.account.userProfile() - - val userJobMap = mutableMapOf>() - - @OptIn(FlowPreview::class) - override fun newSub(key: ChatroomListState): Subscription { - val user = user(key) - scope = key.account.scope - windowLoad.startLoading(key.account.scope) - userJobMap[user]?.forEach { it.cancel() } - userJobMap[user] = - listOf( - key.account.scope.launch(Dispatchers.IO) { - key.account.homeRelays.flow.collectLatest { - invalidateFilters() - } - }, - key.account.scope.launch(Dispatchers.IO) { - key.account.dmRelays.flow.collectLatest { - invalidateFilters() - } - }, - ) - - // Custom listener (vs super.newSub) so every event — stored backfill included — keeps the - // window-load watchdog alive; otherwise a NIP-04 flood would look "done" mid-stream. - return requestNewSubscription( - object : SubscriptionListener { - override fun onEose( - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - newEose(key, relay, TimeUtils.now(), forFilters) - } - - override fun onEvent( - event: Event, - isLive: Boolean, - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - windowLoad.onActivity() - if (isLive) { - newEose(key, relay, TimeUtils.now(), forFilters) - } - } - }, - ) - } - - override fun endSub( - key: User, - subId: String, - ) { - super.endSub(key, subId) - userJobMap[key]?.forEach { it.cancel() } - } -} 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 d4977c07d1..4bd60478c4 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 @@ -67,7 +67,6 @@ import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent -import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter @@ -96,14 +95,11 @@ private fun CrossFadeState( ) { val feedState by feedContentState.feedContent.collectAsStateWithLifecycle() - // Both DM windows reach the maximum lookback together (lockstep), so the rooms list has - // pulled everything there is once both report exhausted. Until then an empty feed means + // The gift-wrap window is the single DM window (NIP-04 follows it), so once it reaches the + // maximum lookback the rooms list has pulled everything there is. Until then an empty feed means // "still filling", not "no conversations" — keep the spinner up rather than flash empty. val giftWraps = remember(accountViewModel) { accountViewModel.dataSources().account.giftWraps } - val nip04Dms = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04Dms } - val giftWrapsExhausted by giftWraps.exhausted.collectAsStateWithLifecycle() - val nip04Exhausted by nip04Dms.exhausted.collectAsStateWithLifecycle() - val historyExhausted = giftWrapsExhausted && nip04Exhausted + val historyExhausted by giftWraps.exhausted.collectAsStateWithLifecycle() // While the whole list is empty there is no LazyColumn to scroll, so keep widening the private // DM window here until rooms appear or it is exhausted. (Public / ephemeral / group rooms are @@ -151,13 +147,11 @@ private fun FeedLoaded( val myPubKey = accountViewModel.userProfile().pubkeyHex val giftWraps = remember(accountViewModel) { accountViewModel.dataSources().account.giftWraps } - val nip04Dms = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04Dms } + val nip04 = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04 } val loadingGiftWraps by giftWraps.loadingMore.collectAsStateWithLifecycle() - val loadingNip04 by nip04Dms.loadingMore.collectAsStateWithLifecycle() + val loadingNip04 by nip04.loadingMore.collectAsStateWithLifecycle() val loadingMore = loadingGiftWraps || loadingNip04 - val exhaustedGiftWraps by giftWraps.exhausted.collectAsStateWithLifecycle() - val exhaustedNip04 by nip04Dms.exhausted.collectAsStateWithLifecycle() - val historyExhausted = exhaustedGiftWraps && exhaustedNip04 + val historyExhausted by giftWraps.exhausted.collectAsStateWithLifecycle() // Widen the private DM window only as the user approaches the oldest LOADED private chat — // ignoring public / group / ephemeral rooms below it. Those are membership-based and can be @@ -199,7 +193,7 @@ private fun FeedLoaded( PrivateChatsLoadMoreFooter(loadingMore, showLoadAll = !historyExhausted) { val user = accountViewModel.userProfile() giftWraps.loadEverything(user) - nip04Dms.loadEverything(user) + nip04.reload() } } } @@ -210,7 +204,7 @@ private fun FeedLoaded( PrivateChatsLoadMoreFooter(loadingMore, showLoadAll = !historyExhausted) { val user = accountViewModel.userProfile() giftWraps.loadEverything(user) - nip04Dms.loadEverything(user) + nip04.reload() } } } @@ -243,19 +237,19 @@ private fun PrivateChatsLoadMoreFooter( private const val PREFETCH_PRIVATE_CHATS = 5 /** - * Widens the private-DM windows (NIP-17 gift wraps + NIP-04, in lockstep) whenever [wantMore] - * becomes true and a previous widen isn't still loading, stopping once both are exhausted. + * Widens the DM window whenever [wantMore] becomes true and the previous step isn't still loading, + * stopping once the window is exhausted. Advances the single gift-wrap window ([loadMore]) and tells + * the NIP-04 follower to re-request at the new floor ([reload]). * * [wantMore] is evaluated inside a snapshotFlow, so it may read live Compose state (scroll position, * the feed list). Callers decide the policy: the empty feed widens to discover the first rooms; the * loaded feed widens as the user approaches the oldest loaded PRIVATE chat — public, group and * ephemeral rooms are membership-based (shown regardless of age) and deliberately excluded, so an - * old public chat at the bottom of the list never drags the private window back with it. + * old public chat at the bottom never drags the window back with it. * - * The two windows must move together: if only one were widened, the merged time-sorted list would - * mix a deep tail of one protocol with a shallow window of the other. The [loadingMore] guard gates - * each step on ALL of that window's relays answering (or a timeout), not the first EOSE, so a fast - * near-empty relay can't let the loop outrun the slow relay that holds the conversations. + * The guard waits on BOTH loaders, gated on all of each one's relays answering (or a timeout) rather + * than the first EOSE, so a fast near-empty relay can't let the loop outrun the slow relay that + * holds the conversations. */ @Composable private fun WidenPrivateWindowWhen( @@ -263,24 +257,21 @@ private fun WidenPrivateWindowWhen( wantMore: () -> Boolean, ) { val giftWraps = remember(accountViewModel) { accountViewModel.dataSources().account.giftWraps } - val nip04Dms = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04Dms } + val nip04 = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04 } - LaunchedEffect(giftWraps, nip04Dms) { + LaunchedEffect(giftWraps, nip04) { combine( snapshotFlow { wantMore() }, giftWraps.loadingMore, - nip04Dms.loadingMore, + nip04.loadingMore, giftWraps.exhausted, - nip04Dms.exhausted, - ) { want, loadingGiftWraps, loadingNip04, giftWrapsExhausted, nip04Exhausted -> - want && !loadingGiftWraps && !loadingNip04 && !(giftWrapsExhausted && nip04Exhausted) + ) { want, loadingGiftWraps, loadingNip04, exhausted -> + want && !loadingGiftWraps && !loadingNip04 && !exhausted }.distinctUntilChanged() .filter { it } .collect { - Log.d("DMPagination") { "rooms list needs more private history, widening NIP-17 + NIP-04 windows one step" } - val user = accountViewModel.userProfile() - giftWraps.loadMore(user) - nip04Dms.loadMore(user) + giftWraps.loadMore(accountViewModel.userProfile()) + nip04.reload() } } } From ec5245c81eed04932c5e81b9f5c36b6c4c6a5b18 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 19:17:21 +0000 Subject: [PATCH 018/103] feat: consistent DM "load more" boundary in the chat (spinner only when loading) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conversation showed a perpetual spinner at the oldest end (it was tied to `!exhausted`, not to actual loading) and had no "load all" escape, while the rooms list showed a spinner only while loading plus a "Load entire history" button. Make them consistent and share one component. - Extract DmLoadMoreIndicator (spinner while loadingMore + "Load entire history" button while not exhausted), used by both screens. - ChatFeedView: replace the loadingOlder: Boolean flag with an opt-in olderBoundary slot rendered at the oldest end; public-chat / channel callers pass null (unchanged). - ChatroomView: supply that boundary — spinner only when a window is actually loading (gift wraps OR this room's NIP-04), button while there's older history to reach, nothing once exhausted. - Rooms list: drop its local PrivateChatsLoadMoreFooter in favor of the shared one. --- .../loggedIn/chats/feed/ChatFeedView.kt | 35 ++++------- .../chats/feed/DmLoadMoreIndicator.kt | 62 +++++++++++++++++++ .../loggedIn/chats/privateDM/ChatroomView.kt | 29 +++++++-- .../chats/rooms/feed/ChatroomListFeedView.kt | 37 +---------- 4 files changed, 101 insertions(+), 62 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DmLoadMoreIndicator.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt index c4defcd268..17ed644601 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt @@ -21,16 +21,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -38,7 +32,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState @@ -72,8 +65,9 @@ fun RefreshingChatroomFeedView( // a depth until BOTH protocols (NIP-04 + NIP-17) have fully loaded it, so the thread never shows // a region with one protocol missing. Default reveals everything (public chats / channels). oldestVisibleTime: Long = Long.MIN_VALUE, - // Show a "loading older messages" boundary at the oldest end while more history may still arrive. - loadingOlder: Boolean = false, + // Optional footer rendered at the oldest end of the thread (a "load more" / spinner affordance). + // Null for callers that load their whole history at once (public chats / channels). + olderBoundary: (@Composable () -> Unit)? = null, ) { SaveableFeedState(feedContentState, scrollStateKey) { listState -> listStateObserver(listState) @@ -87,7 +81,7 @@ fun RefreshingChatroomFeedView( onWantsToEditDraft, avoidDraft, oldestVisibleTime, - loadingOlder, + olderBoundary, ) } } @@ -103,7 +97,7 @@ fun RenderChatFeedView( onWantsToEditDraft: (Note) -> Unit, avoidDraft: DraftTagState? = null, oldestVisibleTime: Long = Long.MIN_VALUE, - loadingOlder: Boolean = false, + olderBoundary: (@Composable () -> Unit)? = null, ) { val feedState by feed.feedContent.collectAsStateWithLifecycle() @@ -132,7 +126,7 @@ fun RenderChatFeedView( onWantsToEditDraft, avoidDraft, oldestVisibleTime, - loadingOlder, + olderBoundary, ) } } @@ -150,7 +144,7 @@ fun ChatFeedLoaded( onWantsToEditDraft: (Note) -> Unit, avoidDraft: DraftTagState? = null, oldestVisibleTime: Long = Long.MIN_VALUE, - loadingOlder: Boolean = false, + olderBoundary: (@Composable () -> Unit)? = null, ) { val items by loaded.feed.collectAsStateWithLifecycle() @@ -209,17 +203,10 @@ fun ChatFeedLoaded( } // Reverse layout: a trailing item sits at the highest index, i.e. the visual TOP (oldest end). - // While older history may still arrive, it both signals "not complete yet" and is where the - // clipped-back depth reveals as both protocols catch up. - if (loadingOlder) { - item(key = "loadingOlderMessages") { - Row( - Modifier.fillMaxWidth().padding(vertical = 8.dp), - horizontalArrangement = Arrangement.Center, - ) { - CircularProgressIndicator(Modifier.size(25.dp)) - } - } + // This is where the clipped-back depth reveals as more history loads, so the caller's + // "load more" affordance / spinner lives here. + if (olderBoundary != null) { + item(key = "olderBoundary") { olderBoundary() } } } } 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 new file mode 100644 index 0000000000..d06363d30c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DmLoadMoreIndicator.kt @@ -0,0 +1,62 @@ +/* + * 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.amethyst.ui.screen.loggedIn.chats.feed + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size25dp + +/** + * The DM "older history" boundary, shared by the rooms list and the conversation screen: a spinner + * while a window load is in flight, and — while there is still older history to reach — a button to + * skip the windowed paging and pull the entire history at once. + */ +@Composable +fun DmLoadMoreIndicator( + loadingMore: Boolean, + showLoadAll: Boolean, + onLoadEntireHistory: () -> Unit, +) { + Column( + Modifier.fillMaxWidth().padding(vertical = Size10dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (loadingMore) { + CircularProgressIndicator(Modifier.size(Size25dp)) + } + if (showLoadAll) { + TextButton(onClick = onLoadEntireHistory) { + Text(stringResource(R.string.chats_load_entire_history)) + } + } + } +} 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 91387a28f7..6a146aadaf 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 @@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMsAndDisplayIfNotFound import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.DmLoadMoreIndicator import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal.ChatroomFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssemblerSubscription @@ -221,10 +222,14 @@ fun ChatroomViewUI( WatchLifecycleAndUpdateModel(feedViewModel) ChatroomFilterAssemblerSubscription(room, accountViewModel.dataSources().chatroom, accountViewModel) - // Only reveal a depth once BOTH DM protocols have fully loaded it (gap-free timeline); show a - // "loading older" boundary while more history may still arrive. + val giftWraps = remember(accountViewModel) { accountViewModel.dataSources().account.giftWraps } + val nip04 = remember(accountViewModel) { accountViewModel.dataSources().chatroom.nip04 } + val loadingGiftWraps by giftWraps.loadingMore.collectAsStateWithLifecycle() + val loadingNip04 by nip04.loadingMore.collectAsStateWithLifecycle() + val historyExhausted by giftWraps.exhausted.collectAsStateWithLifecycle() + + // Only reveal a depth once BOTH DM protocols have fully loaded it (gap-free timeline). val displayFloor = rememberConversationDisplayFloor(accountViewModel) - val loadingOlder = displayFloor != Long.MIN_VALUE Column(Modifier.fillMaxHeight()) { ObserveRelayListForDMsAndDisplayIfNotFound(accountViewModel, nav) @@ -245,7 +250,23 @@ fun ChatroomViewUI( onWantsToReply = newPostModel::reply, onWantsToEditDraft = newPostModel::editFromDraft, oldestVisibleTime = displayFloor, - loadingOlder = loadingOlder, + // While there is older history to reach, show the same spinner / "load all" boundary + // as the rooms list at the oldest end (spinner only while actually loading). + olderBoundary = + if (historyExhausted) { + null + } else { + { + DmLoadMoreIndicator( + loadingMore = loadingGiftWraps || loadingNip04, + showLoadAll = true, + ) { + val user = accountViewModel.userProfile() + giftWraps.loadEverything(user) + nip04.reload() + } + } + }, listStateObserver = { listState -> LoadOlderMessagesWhenScrolling(listState, accountViewModel) }, 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 4bd60478c4..7243607962 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 @@ -21,28 +21,19 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -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.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState @@ -56,11 +47,10 @@ import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.DmLoadMoreIndicator import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChatroomHeaderCompose import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding -import com.vitorpamplona.amethyst.ui.theme.Size10dp -import com.vitorpamplona.amethyst.ui.theme.Size25dp import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable @@ -190,7 +180,7 @@ private fun FeedLoaded( ) if (index == privateBoundaryIndex && (loadingMore || !historyExhausted)) { - PrivateChatsLoadMoreFooter(loadingMore, showLoadAll = !historyExhausted) { + DmLoadMoreIndicator(loadingMore, showLoadAll = !historyExhausted) { val user = accountViewModel.userProfile() giftWraps.loadEverything(user) nip04.reload() @@ -201,7 +191,7 @@ private fun FeedLoaded( // No private chat is loaded yet (e.g. only public rooms so far): show the boundary at the end. if (privateBoundaryIndex < 0 && (loadingMore || !historyExhausted)) { item(key = "loadingMoreFooter") { - PrivateChatsLoadMoreFooter(loadingMore, showLoadAll = !historyExhausted) { + DmLoadMoreIndicator(loadingMore, showLoadAll = !historyExhausted) { val user = accountViewModel.userProfile() giftWraps.loadEverything(user) nip04.reload() @@ -211,27 +201,6 @@ private fun FeedLoaded( } } -@Composable -private fun PrivateChatsLoadMoreFooter( - loadingMore: Boolean, - showLoadAll: Boolean, - onLoadEverything: () -> Unit, -) { - Column( - Modifier.fillMaxWidth().padding(vertical = Size10dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (loadingMore) { - CircularProgressIndicator(Modifier.size(Size25dp)) - } - if (showLoadAll) { - TextButton(onClick = onLoadEverything) { - Text(stringResource(R.string.chats_load_entire_history)) - } - } - } -} - // How many rows ahead of the oldest loaded private chat to start widening, so older private // history lands before the user scrolls into the (membership-based) public/group rooms below it. private const val PREFETCH_PRIVATE_CHATS = 5 From b23dcdf46853cffc5541b657110125c1c28dba39 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 20:25:34 +0000 Subject: [PATCH 019/103] fix: conversation showed only a spinner / loaded everything (drop gap-free clip) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gap-free display floor was hiding the whole thread: until `coveredSince` settled the floor was Long.MAX_VALUE (reveal nothing), but a thread clipped to empty made the auto-fill fire (total == 0), which kept the loaders busy so coveredSince never settled — a vicious cycle that walked the account-wide gift-wrap window to exhaustion ("loads everything") while the thread stayed blank ("loading sign and no message whatsoever"). - Remove the display-floor clipping (ChatFeedView no longer takes oldestVisibleTime; ChatroomView drops rememberConversationDisplayFloor). The thread renders the cached messages directly again, like before. - Bound the conversation auto-fill: only load the next older window when the thread already overflows the screen AND the user has scrolled near the oldest loaded message, so a short thread is never auto-walked to the start of history. The oldest-end boundary still offers an explicit "Load entire history". The kept-it-honest gap-free guarantee wasn't worth a blank inbox; the transient NIP-04-before-NIP-17 ordering is the lesser evil. The loading spinner + load-all boundary added last commit stays. --- .../loggedIn/chats/feed/ChatFeedView.kt | 30 ++-------- .../loggedIn/chats/privateDM/ChatroomView.kt | 56 ++++--------------- 2 files changed, 15 insertions(+), 71 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt index 17ed644601..ab351e4692 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt @@ -61,10 +61,6 @@ fun RefreshingChatroomFeedView( // Opt-in hook handed the feed's scroll state, so a specific screen (e.g. private DMs) can // attach scroll-driven loading. No-op for the public-chat / channel callers that don't paginate. listStateObserver: @Composable (LazyListState) -> Unit = {}, - // Only reveal messages at or newer than this epoch-second floor. Private DMs use it to hold back - // a depth until BOTH protocols (NIP-04 + NIP-17) have fully loaded it, so the thread never shows - // a region with one protocol missing. Default reveals everything (public chats / channels). - oldestVisibleTime: Long = Long.MIN_VALUE, // Optional footer rendered at the oldest end of the thread (a "load more" / spinner affordance). // Null for callers that load their whole history at once (public chats / channels). olderBoundary: (@Composable () -> Unit)? = null, @@ -80,7 +76,6 @@ fun RefreshingChatroomFeedView( onWantsToReply, onWantsToEditDraft, avoidDraft, - oldestVisibleTime, olderBoundary, ) } @@ -96,7 +91,6 @@ fun RenderChatFeedView( onWantsToReply: (Note) -> Unit, onWantsToEditDraft: (Note) -> Unit, avoidDraft: DraftTagState? = null, - oldestVisibleTime: Long = Long.MIN_VALUE, olderBoundary: (@Composable () -> Unit)? = null, ) { val feedState by feed.feedContent.collectAsStateWithLifecycle() @@ -125,7 +119,6 @@ fun RenderChatFeedView( onWantsToReply, onWantsToEditDraft, avoidDraft, - oldestVisibleTime, olderBoundary, ) } @@ -143,23 +136,11 @@ fun ChatFeedLoaded( onWantsToReply: (Note) -> Unit, onWantsToEditDraft: (Note) -> Unit, avoidDraft: DraftTagState? = null, - oldestVisibleTime: Long = Long.MIN_VALUE, olderBoundary: (@Composable () -> Unit)? = null, ) { val items by loaded.feed.collectAsStateWithLifecycle() - // Clip the bottom of the thread to the depth both DM protocols have fully covered. A note whose - // event hasn't loaded yet (null createdAt) is kept visible — fail toward showing, never hiding. - val visibleItems = - remember(items.list, oldestVisibleTime) { - if (oldestVisibleTime == Long.MIN_VALUE) { - items.list - } else { - items.list.filter { (it.createdAt() ?: Long.MAX_VALUE) >= oldestVisibleTime } - } - } - - LaunchedEffect(visibleItems.firstOrNull()) { + LaunchedEffect(items.list.firstOrNull()) { if (listState.firstVisibleItemIndex <= 1) { listState.animateScrollToItem(0) } @@ -168,7 +149,7 @@ fun ChatFeedLoaded( val scope = rememberCoroutineScope() val highlightedNoteId = remember { mutableStateOf(null) } val onScrollToNote: (Note) -> Unit = { note -> - val index = visibleItems.indexOfFirst { it.idHex == note.idHex } + val index = items.list.indexOfFirst { it.idHex == note.idHex } if (index >= 0) { scope.launch { listState.animateScrollToItem(index) @@ -183,7 +164,7 @@ fun ChatFeedLoaded( reverseLayout = true, state = listState, ) { - itemsIndexed(visibleItems, key = { _, item -> item.idHex }, contentType = { _, item -> item.event?.kind ?: -1 }) { index, item -> + itemsIndexed(items.list, key = { _, item -> item.idHex }, contentType = { _, item -> item.event?.kind ?: -1 }) { index, item -> val noteEvent = item.event if (avoidDraft == null || noteEvent !is DraftWrapEvent || noteEvent.dTag() !in avoidDraft.usedDraftTags) { ChatroomMessageCompose( @@ -198,13 +179,12 @@ fun ChatFeedLoaded( onHighlightFinished = { highlightedNoteId.value = null }, ) - NewDateOrSubjectDivisor(visibleItems.getOrNull(index + 1), item) + NewDateOrSubjectDivisor(items.list.getOrNull(index + 1), item) } } // Reverse layout: a trailing item sits at the highest index, i.e. the visual TOP (oldest end). - // This is where the clipped-back depth reveals as more history loads, so the caller's - // "load more" affordance / spinner lives here. + // That's where the caller's "load more" affordance / spinner lives. if (olderBoundary != null) { item(key = "olderBoundary") { olderBoundary() } } 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 6a146aadaf..0a6c62655a 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 @@ -29,7 +29,6 @@ import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -133,16 +132,16 @@ fun ChatroomView( ) } -// NIP-17 senders may backdate the gift wrap's OUTER created_at up to two days (randomWithTwoDays), -// and relays filter on that outer time. So once we've fetched outer >= F, we're only guaranteed to -// hold every message whose real (inner) time is >= F + 2d. The revealed floor carries this margin. -private const val GIFT_WRAP_OUTER_JITTER_SECONDS = 2L * 24 * 60 * 60 +// Rows from the oldest loaded message at which to prefetch the next, older window. +private const val PREFETCH_OLDER_MESSAGES = 3 /** - * Scroll-driven history loader for a conversation, advancing BOTH DM protocols together. The thread - * is reverse-laid-out (newest at the bottom, index 0), so older messages live at higher indices; - * this widens one step whenever nothing is loaded yet or the oldest visible row crosses the midpoint - * of what's loaded — prefetching before the user reaches the top — and stops once exhausted. + * Scroll-driven history loader for a conversation. The thread is reverse-laid-out (newest at the + * bottom, index 0), so older messages live at higher indices. It loads the next, older window only + * when the thread already overflows the screen AND the user has scrolled near the oldest loaded + * message — so a short thread is never auto-walked to the start of history (that would load the whole + * account's gift-wrap history). For more than what scrolling reaches, the oldest-end boundary offers + * an explicit "Load entire history". * * The account-wide gift-wrap window is the single source of truth for the floor: NIP-17 advances via * [AccountGiftWrapsEoseManager.loadMore], and the NIP-04 loader [ChatroomNip04SubAssembler.reload] @@ -163,7 +162,8 @@ private fun LoadOlderMessagesWhenScrolling( val info = listState.layoutInfo val total = info.totalItemsCount val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1 - total == 0 || lastVisible >= total / 2 + val overflowsScreen = info.visibleItemsInfo.size < total + overflowsScreen && lastVisible >= total - PREFETCH_OLDER_MESSAGES }, giftWraps.loadingMore, nip04.loadingMore, @@ -179,38 +179,6 @@ private fun LoadOlderMessagesWhenScrolling( } } -/** - * The epoch-second floor at or above which the thread is safe to reveal: the deepest gift-wrap floor - * at which BOTH protocols have finished loading, plus the [GIFT_WRAP_OUTER_JITTER_SECONDS] margin. - * - * It only descends (monotonic), so revealed history never retracts. Until the first completion it is - * [Long.MAX_VALUE] (reveal nothing yet); once the window is exhausted it is [Long.MIN_VALUE] (reveal - * everything). Holding back a depth until both NIP-04 and NIP-17 have covered it is what prevents a - * fast NIP-04 stream from painting a thread that's missing the NIP-17 messages in between. - */ -@Composable -private fun rememberConversationDisplayFloor(accountViewModel: AccountViewModel): Long { - val giftWraps = remember(accountViewModel) { accountViewModel.dataSources().account.giftWraps } - val nip04 = remember(accountViewModel) { accountViewModel.dataSources().chatroom.nip04 } - val loadingGiftWraps by giftWraps.loadingMore.collectAsStateWithLifecycle() - val loadingNip04 by nip04.loadingMore.collectAsStateWithLifecycle() - val exhausted by giftWraps.exhausted.collectAsStateWithLifecycle() - - var coveredSince by remember(accountViewModel) { mutableStateOf(Long.MAX_VALUE) } - LaunchedEffect(loadingGiftWraps, loadingNip04, accountViewModel) { - if (!loadingGiftWraps && !loadingNip04) { - val since = giftWraps.windowSince(accountViewModel.userProfile()) - if (since < coveredSince) coveredSince = since - } - } - - return when { - exhausted -> Long.MIN_VALUE - coveredSince == Long.MAX_VALUE -> Long.MAX_VALUE - else -> coveredSince + GIFT_WRAP_OUTER_JITTER_SECONDS - } -} - @Composable fun ChatroomViewUI( room: ChatroomKey, @@ -228,9 +196,6 @@ fun ChatroomViewUI( val loadingNip04 by nip04.loadingMore.collectAsStateWithLifecycle() val historyExhausted by giftWraps.exhausted.collectAsStateWithLifecycle() - // Only reveal a depth once BOTH DM protocols have fully loaded it (gap-free timeline). - val displayFloor = rememberConversationDisplayFloor(accountViewModel) - Column(Modifier.fillMaxHeight()) { ObserveRelayListForDMsAndDisplayIfNotFound(accountViewModel, nav) @@ -249,7 +214,6 @@ fun ChatroomViewUI( avoidDraft = newPostModel.draftTag, onWantsToReply = newPostModel::reply, onWantsToEditDraft = newPostModel::editFromDraft, - oldestVisibleTime = displayFloor, // While there is older history to reach, show the same spinner / "load all" boundary // as the rooms list at the oldest end (spinner only while actually loading). olderBoundary = From 4d234788df2f45a2db418217077255ff9a6608b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 20:55:11 +0000 Subject: [PATCH 020/103] chore: DMPagination logs for window/auto-fill behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a focused, low-noise log trail (tag "DMPagination") so DM windowing and auto-fill can be observed while opening/closing rooms and scrolling. Per-event noise stays off (onActivity is silent). - WindowLoadTracker: takes a name ("giftwrap" / "rooms.nip04" / "convo.nip04") and logs "load start" and "load done: " where reason is one of all relays / idle / cap / no relays — the key signal for whether a load is stuck or looping. - AccountGiftWrapsEoseManager: logs window open, REQ floor (since + days back), loadMore (from→to days, exhausted), loadEverything. - NIP-04 followers: log their REQ floor and reload. - Rooms list: logs OPEN/CLOSE and each widen with its trigger (empty/scroll). - Conversation: logs room OPEN/CLOSE, each scroll-driven widen, and the "Load entire history" tap. --- .../eoseManagers/WindowLoadTracker.kt | 27 +++++++++++++++---- .../AccountGiftWrapsEoseManager.kt | 23 +++++++++++++--- .../loggedIn/chats/privateDM/ChatroomView.kt | 9 +++++++ .../datasource/ChatroomNip04SubAssembler.kt | 9 +++++-- .../ChatroomListNip04SubAssembler.kt | 9 +++++-- .../chats/rooms/feed/ChatroomListFeedView.kt | 12 +++++++-- 6 files changed, 75 insertions(+), 14 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt index ce128884f2..3076e0fa3d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -53,6 +54,8 @@ import kotlin.time.Duration.Companion.seconds * [absoluteCap] bounds the wait for pathological relays that dribble forever. */ class WindowLoadTracker( + // Short label for the DMPagination logs (e.g. "giftwrap", "rooms.nip04", "convo.nip04"). + private val name: String = "dm", private val idleTimeout: Duration = 3.seconds, private val absoluteCap: Duration = 5.minutes, ) { @@ -79,7 +82,9 @@ class WindowLoadTracker( val gen = ++generation responded.clear() lastActivityMs = System.currentTimeMillis() + val wasLoading = _loading.value _loading.value = true + Log.d(TAG) { "[$name] load start" + if (!wasLoading) "" else " (restart)" } watchdog?.cancel() watchdog = scope.launch { @@ -101,8 +106,12 @@ class WindowLoadTracker( deadline: Long, ): Boolean { if (gen != generation || !_loading.value) return false - if (now - lastActivityMs >= idleTimeout.inWholeMilliseconds || now >= deadline) { - _loading.value = false + if (now - lastActivityMs >= idleTimeout.inWholeMilliseconds) { + finish("idle") + return false + } + if (now >= deadline) { + finish("cap") return false } return true @@ -120,7 +129,11 @@ class WindowLoadTracker( @Synchronized fun setExpectedRelays(relays: Set) { expected = relays - if (relays.isEmpty() || responded.containsAll(relays)) finish() + if (relays.isEmpty()) { + finish("no relays") + } else if (responded.containsAll(relays)) { + finish("all relays") + } } /** Marks [relay] as having answered (EOSE or live event). Completes once all expected have. */ @@ -128,17 +141,21 @@ class WindowLoadTracker( fun onRelayResponded(relay: NormalizedRelayUrl) { lastActivityMs = System.currentTimeMillis() responded.add(relay) - if (expected.isNotEmpty() && responded.containsAll(expected)) finish() + if (expected.isNotEmpty() && responded.containsAll(expected)) finish("all relays") } + // Idempotent: only the first call after a load actually completes (and logs); later calls no-op. @Synchronized - private fun finish() { + private fun finish(reason: String) { + if (!_loading.value) return _loading.value = false watchdog?.cancel() watchdog = null + Log.d(TAG) { "[$name] load done: $reason" } } companion object { + private const val TAG = "DMPagination" private const val IDLE_CHECK_MS = 500L } } 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 5308adab1d..b824e8ba19 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 @@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -60,14 +61,21 @@ class AccountGiftWrapsEoseManager( // loadEverything) and from Dispatchers.IO (updateFilter), so a plain HashMap would race. private val windows = ConcurrentHashMap() - private fun windowFor(user: User) = windows.computeIfAbsent(user.pubkeyHex) { TimeWindowPagination(growthFactor = WINDOW_GROWTH_FACTOR) } + private fun windowFor(user: User) = + windows.computeIfAbsent(user.pubkeyHex) { + TimeWindowPagination(growthFactor = WINDOW_GROWTH_FACTOR).also { + Log.d(TAG) { "[giftwrap] open window since=${it.since} (${daysAgo(it.since)}d back)" } + } + } /** The current lower bound (epoch seconds) of this account's gift-wrap window. */ fun windowSince(user: User): Long = windowFor(user).since + private fun daysAgo(epochSeconds: Long) = (TimeUtils.now() - epochSeconds) / TimeUtils.ONE_DAY + // A window load is in flight until every dmRelay it was sent to has answered, the event stream // goes quiet, or a cap fires — never on just the first EOSE (a fast empty relay would trip it). - private val windowLoad = WindowLoadTracker() + private val windowLoad = WindowLoadTracker("giftwrap") val loadingMore: StateFlow = windowLoad.loading // True once the window reached the maximum lookback: nothing older to fetch. @@ -89,6 +97,7 @@ class AccountGiftWrapsEoseManager( val relays = key.account.dmRelays.flow.value windowLoad.setExpectedRelays(relays.toSet()) val windowSince = windowFor(user(key)).since + Log.d(TAG) { "[giftwrap] REQ since=$windowSince (${daysAgo(windowSince)}d) on ${relays.size} relay(s)" } return relays.flatMap { relay -> filterGiftWrapsToPubkey(relay = relay, pubkey = user(key).pubkeyHex, since = windowSince) } @@ -97,9 +106,14 @@ class AccountGiftWrapsEoseManager( /** Widens the window one (geometric) step back and re-issues the subscription. No-op if exhausted. */ fun loadMore(user: User) { val window = windowFor(user) - if (window.isExhausted()) return + if (window.isExhausted()) { + Log.d(TAG) { "[giftwrap] loadMore ignored — already exhausted" } + return + } + val before = window.since window.loadMore() _exhausted.value = window.isExhausted() + Log.d(TAG) { "[giftwrap] loadMore ${daysAgo(before)}d -> ${daysAgo(window.since)}d back (exhausted=${_exhausted.value})" } scope?.let { windowLoad.startLoading(it) } invalidateFilters() } @@ -110,6 +124,7 @@ class AccountGiftWrapsEoseManager( if (window.isExhausted()) return window.loadAll() _exhausted.value = true + Log.d(TAG) { "[giftwrap] loadEverything — full history (${daysAgo(window.since)}d back)" } scope?.let { windowLoad.startLoading(it) } invalidateFilters() } @@ -144,6 +159,8 @@ class AccountGiftWrapsEoseManager( } companion object { + private const val TAG = "DMPagination" + // The window doubles each widen so a sparse history (or confirming nothing older exists) // reaches the 10-year backstop in ~10 requests instead of crawling a week at a time. private const val WINDOW_GROWTH_FACTOR = 2L 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 0a6c62655a..5138c17d48 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 @@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -53,6 +54,7 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter @@ -173,6 +175,7 @@ private fun LoadOlderMessagesWhenScrolling( }.distinctUntilChanged() .filter { it } .collect { + Log.d("DMPagination") { "convo: widen (scrolled near oldest) → loadMore + reload" } giftWraps.loadMore(accountViewModel.userProfile()) nip04.reload() } @@ -190,6 +193,11 @@ fun ChatroomViewUI( WatchLifecycleAndUpdateModel(feedViewModel) ChatroomFilterAssemblerSubscription(room, accountViewModel.dataSources().chatroom, accountViewModel) + DisposableEffect(room) { + Log.d("DMPagination") { "convo: OPEN room=${room.hashCode()}" } + onDispose { Log.d("DMPagination") { "convo: CLOSE room=${room.hashCode()}" } } + } + val giftWraps = remember(accountViewModel) { accountViewModel.dataSources().account.giftWraps } val nip04 = remember(accountViewModel) { accountViewModel.dataSources().chatroom.nip04 } val loadingGiftWraps by giftWraps.loadingMore.collectAsStateWithLifecycle() @@ -225,6 +233,7 @@ fun ChatroomViewUI( loadingMore = loadingGiftWraps || loadingNip04, showLoadAll = true, ) { + Log.d("DMPagination") { "convo: Load entire history tapped" } val user = accountViewModel.userProfile() giftWraps.loadEverything(user) nip04.reload() 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 9905cf334e..3193e11f9b 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 @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow @@ -44,7 +45,7 @@ class ChatroomNip04SubAssembler( allKeys: () -> Set, private val giftWraps: AccountGiftWrapsEoseManager, ) : PerUserAndFollowListEoseManager(client, allKeys) { - private val windowLoad = WindowLoadTracker() + private val windowLoad = WindowLoadTracker("convo.nip04") val loadingMore: StateFlow = windowLoad.loading // Account scope for the watchdog. Volatile: written on IO (newSub), read on UI (reload). @@ -56,8 +57,11 @@ class ChatroomNip04SubAssembler( since: SincePerRelayMap?, ): List? = if (key.account.isWriteable()) { - val filters = filterNip04DMs(key.room.users, key.account, giftWraps.windowSince(user(key))) + val windowSince = giftWraps.windowSince(user(key)) + val filters = filterNip04DMs(key.room.users, key.account, windowSince) windowLoad.setExpectedRelays(filters?.mapTo(mutableSetOf()) { it.relay } ?: emptySet()) + val daysAgo = (TimeUtils.now() - windowSince) / TimeUtils.ONE_DAY + Log.d("DMPagination") { "[convo.nip04] REQ since=$windowSince (${daysAgo}d) on ${filters?.size ?: 0} relay-filter(s)" } filters } else { windowLoad.setExpectedRelays(emptySet()) @@ -66,6 +70,7 @@ class ChatroomNip04SubAssembler( /** Re-issues at the (now-wider) shared gift-wrap floor and tracks the load. */ fun reload() { + Log.d("DMPagination") { "[convo.nip04] reload" } scope?.let { windowLoad.startLoading(it) } invalidateFilters() } 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 e19200ee55..70549e1cd5 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 @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -49,7 +50,7 @@ class ChatroomListNip04SubAssembler( allKeys: () -> Set, private val giftWraps: AccountGiftWrapsEoseManager, ) : PerUserEoseManager(client, allKeys) { - private val windowLoad = WindowLoadTracker() + private val windowLoad = WindowLoadTracker("rooms.nip04") val loadingMore: StateFlow = windowLoad.loading // Account scope for the watchdog. Volatile: written on IO (newSub), read on UI (reload). @@ -63,8 +64,11 @@ class ChatroomListNip04SubAssembler( if (key.account.isWriteable()) { val homeRelays = key.account.homeRelays.flow.value val dmRelays = key.account.dmRelays.flow.value - windowLoad.setExpectedRelays((homeRelays + dmRelays).toSet()) + val relays = (homeRelays + dmRelays).toSet() + windowLoad.setExpectedRelays(relays) val windowSince = giftWraps.windowSince(user(key)) + val daysAgo = (TimeUtils.now() - windowSince) / TimeUtils.ONE_DAY + Log.d("DMPagination") { "[rooms.nip04] REQ since=$windowSince (${daysAgo}d) on ${relays.size} relay(s)" } homeRelays.map { filterNip04DMsFromMe(key.account.userProfile(), it, windowSince) } + dmRelays.map { filterNip04DMsToMe(key.account.userProfile(), it, windowSince) } } else { @@ -74,6 +78,7 @@ class ChatroomListNip04SubAssembler( /** Re-issues at the (now-wider) shared gift-wrap floor and tracks the load. */ fun reload() { + Log.d("DMPagination") { "[rooms.nip04] reload" } scope?.let { windowLoad.startLoading(it) } invalidateFilters() } 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 7243607962..5dbcb8b6e8 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 @@ -28,6 +28,7 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -57,6 +58,7 @@ import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter @@ -69,6 +71,10 @@ fun ChatroomListFeedView( accountViewModel: AccountViewModel, nav: INav, ) { + DisposableEffect(Unit) { + Log.d("DMPagination") { "rooms.list: OPEN" } + onDispose { Log.d("DMPagination") { "rooms.list: CLOSE" } } + } RefresheableBox(feedContentState, true) { SaveableFeedContentState(feedContentState, scrollStateKey) { listState -> CrossFadeState(feedContentState, listState, accountViewModel, nav) @@ -94,7 +100,7 @@ private fun CrossFadeState( // While the whole list is empty there is no LazyColumn to scroll, so keep widening the private // DM window here until rooms appear or it is exhausted. (Public / ephemeral / group rooms are // membership-based and load on their own — they are not part of the window.) - WidenPrivateWindowWhen(accountViewModel) { feedState is FeedState.Empty } + WidenPrivateWindowWhen(accountViewModel, "empty") { feedState is FeedState.Empty } CrossfadeIfEnabled( targetState = feedState, @@ -147,7 +153,7 @@ private fun FeedLoaded( // ignoring public / group / ephemeral rooms below it. Those are membership-based and can be // arbitrarily old; counting them would either stall private paging or drag the window back // years. The lambda reads the live items + scroll state inside the snapshotFlow. - WidenPrivateWindowWhen(accountViewModel) { + WidenPrivateWindowWhen(accountViewModel, "scroll") { val info = listState.layoutInfo val total = info.totalItemsCount val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1 @@ -223,6 +229,7 @@ private const val PREFETCH_PRIVATE_CHATS = 5 @Composable private fun WidenPrivateWindowWhen( accountViewModel: AccountViewModel, + trigger: String, wantMore: () -> Boolean, ) { val giftWraps = remember(accountViewModel) { accountViewModel.dataSources().account.giftWraps } @@ -239,6 +246,7 @@ private fun WidenPrivateWindowWhen( }.distinctUntilChanged() .filter { it } .collect { + Log.d("DMPagination") { "rooms.list: widen ($trigger) → loadMore + reload" } giftWraps.loadMore(accountViewModel.userProfile()) nip04.reload() } From 923ad500a7990bea21199e82fef7224a2193673c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 21:44:45 +0000 Subject: [PATCH 021/103] feat: count per-load gift-wraps and flag out-of-window events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the EVENT <- and AUTH <- per-message lines from the DM diagnostics logger (too busy, and auth is not relevant to the pagination trail), and adds a per-load tally to the gift-wrap manager so a relay that ignores `since` and re-streams the whole history every widen becomes visible. Each load now counts the gift-wrap events the relays push and, separately, those whose outer created_at falls before the floor the REQ asked for. A collector on the window-load flag resets the counters when a load starts and logs `[giftwrap] load summary: N event(s), X before floor (since=…)` when it finishes. A total that keeps growing across widens (with a large out-of-window share) is the fingerprint of "getting all the events over and over again". The WindowLoadTracker.trackingListener gains an optional onEachEvent hook so the manager can observe every event (stored or live) for this instrumentation without changing the EOSE forwarding path. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../diagnostics/DmRelayDiagnosticsLogger.kt | 22 +++------- .../eoseManagers/WindowLoadTracker.kt | 11 +++-- .../AccountGiftWrapsEoseManager.kt | 41 ++++++++++++++++++- 3 files changed, 54 insertions(+), 20 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt index eb37b39394..29e3b33c02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt @@ -23,9 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient.diagnostics import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage @@ -38,11 +36,12 @@ import com.vitorpamplona.quartz.utils.Log /** * Diagnostic connection listener for the DM / gift-wrap loading path. * - * It folds the per-relay timeline — REQ sent, gift-wrap events, EOSE, plus auth - * challenge / NOTICE / CLOSED rejection and connect/disconnect — into the single - * `DMPagination` log tag with an elapsed-time prefix, so a slow cold boot can be - * attributed (connection? auth? relay response?) and a silent failure to load - * (e.g. a relay answering CLOSED "auth-required" / "restricted") becomes visible. + * It folds the per-relay timeline — REQ sent, connect/disconnect, NOTICE / CLOSED + * rejection and OK failures — into the single `DMPagination` log tag with an + * elapsed-time prefix, so a slow cold boot can be attributed (connection? relay + * response?) and a silent failure to load (e.g. a relay answering CLOSED + * "auth-required" / "restricted") becomes visible. Per-event and auth-challenge + * lines are intentionally omitted to keep the trail readable. * * The connection listener fires for EVERY relay the app talks to (hundreds, under * the outbox model). To keep this readable we only log relays that are part of the @@ -100,9 +99,6 @@ class DmRelayDiagnosticsLogger( msg: Message, ) { when (msg) { - is AuthMessage -> - if (isDmRelay(relay)) Log.d(TAG) { "[+${at()}ms] AUTH <- ${relay.url.url} challenge=${msg.challenge.take(12)}…" } - is NoticeMessage -> if (isDmRelay(relay)) Log.d(TAG) { "[+${at()}ms] NOTICE <- ${relay.url.url} '${msg.message}'" } @@ -111,12 +107,6 @@ class DmRelayDiagnosticsLogger( Log.d(TAG) { "[+${at()}ms] CLOSED <- ${relay.url.url} sub=${msg.subId} reason='${msg.message}'" } } - is EventMessage -> - if (msg.event.kind == GiftWrapEvent.KIND || msg.event.kind == EphemeralGiftWrapEvent.KIND) { - giftWrapRelays.add(relay.url) - Log.d(TAG) { "[+${at()}ms] EVENT <- ${relay.url.url} kind=${msg.event.kind} sub=${msg.subId} createdAt=${msg.event.createdAt}" } - } - is OkMessage -> if (!msg.success && isDmRelay(relay)) { Log.d(TAG) { "[+${at()}ms] OK(fail) <- ${relay.url.url} '${msg.message}'" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt index 3076e0fa3d..6b2cd4be0b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt @@ -163,10 +163,14 @@ class WindowLoadTracker( /** * Builds the standard [SubscriptionListener] that feeds this tracker: every event (stored backfill * included) marks activity so a relay mid-flood is never mistaken for done, and an EOSE or live - * event marks that relay answered. [forward] is invoked with the same EOSE / live-event signal so - * the owning EOSE manager can record the relay's EOSE timestamp (its usual `newEose`). + * event marks that relay answered. [onEachEvent] is invoked for every event (stored or live) for + * optional instrumentation; [forward] is invoked with the same EOSE / live-event signal so the + * owning EOSE manager can record the relay's EOSE timestamp (its usual `newEose`). */ -fun WindowLoadTracker.trackingListener(forward: (NormalizedRelayUrl, List?) -> Unit): SubscriptionListener = +fun WindowLoadTracker.trackingListener( + onEachEvent: (Event) -> Unit = {}, + forward: (NormalizedRelayUrl, List?) -> Unit, +): SubscriptionListener = object : SubscriptionListener { override fun onEose( relay: NormalizedRelayUrl, @@ -183,6 +187,7 @@ fun WindowLoadTracker.trackingListener(forward: (NormalizedRelayUrl, List?, ) { onActivity() + onEachEvent(event) if (isLive) { onRelayResponded(relay) forward(relay, forFilters) 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 b824e8ba19..1dbd551e81 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 @@ -44,6 +44,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger /** * Always-on loader for the account's NIP-17 gift wraps (kind 1059). It owns the single DM time @@ -86,6 +87,22 @@ class AccountGiftWrapsEoseManager( @Volatile private var scope: CoroutineScope? = null + // Per-load instrumentation. Each gift-wrap event the relays push during a load is counted, and + // those whose (outer, randomized) created_at falls before the floor the REQ asked for are counted + // separately — a relay ignoring `since` re-streams the whole history every widen, so a total that + // keeps growing (and a large out-of-window share) is the fingerprint of "getting all events over + // and over again". [loadSince] is the floor the in-flight load asked for. Volatile/atomic because + // the event hook runs on the relay IO threads while the summary collector reads on the account scope. + @Volatile + private var loadSince = 0L + private val eventsThisLoad = AtomicInteger(0) + private val outOfWindowThisLoad = AtomicInteger(0) + + private fun countEvent(createdAt: Long) { + eventsThisLoad.incrementAndGet() + if (createdAt < loadSince) outOfWindowThisLoad.incrementAndGet() + } + override fun updateFilter( key: AccountQueryState, since: SincePerRelayMap?, @@ -143,10 +160,32 @@ class AccountGiftWrapsEoseManager( key.account.dmRelays.flow .collectLatest { invalidateFilters() } }, + // Resets the per-load counters when a load begins and logs the tally when it ends, so + // the trail shows how many events each widen pulled and how many were below the floor. + key.account.scope.launch { + var wasLoading = false + windowLoad.loading.collect { loading -> + if (loading && !wasLoading) { + loadSince = windowFor(user).since + eventsThisLoad.set(0) + outOfWindowThisLoad.set(0) + } else if (!loading && wasLoading) { + val total = eventsThisLoad.get() + val outOfWindow = outOfWindowThisLoad.get() + Log.d(TAG) { + "[giftwrap] load summary: $total event(s), $outOfWindow before floor " + + "(since=$loadSince, ${daysAgo(loadSince)}d back)" + } + } + wasLoading = loading + } + }, ) return requestNewSubscription( - windowLoad.trackingListener { relay, filters -> newEose(key, relay, TimeUtils.now(), filters) }, + windowLoad.trackingListener( + onEachEvent = { event -> countEvent(event.createdAt) }, + ) { relay, filters -> newEose(key, relay, TimeUtils.now(), filters) }, ) } From 5fb0cc9dd47f10512cd5a246b2cd94d9517960ae Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 22:24:16 +0000 Subject: [PATCH 022/103] fix: don't declare a window load done while relays are still connecting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cold boot trace showed `[giftwrap] load done: idle` firing with 0 events at +3s while the DM relays had not even connected yet (nos.lol first connected at +14s). The idle watchdog could not tell "quiet because the relays answered" from "quiet because nothing has connected", so a slow boot looked finished with an empty result — and with the Messages screen open that false "done, 0 events" would trip the auto-fill into widening the window over and over. WindowLoadTracker now only arms the idle path after the first event or EOSE (sawActivity). Before that first sign of life, the load can only end via a generous no-response bound (30s) or the absolute cap, so a still-connecting boot stays "loading" instead of falsely completing empty. The clean paths are unchanged: relays that EOSE complete via "all relays", and a stream that starts then quiets still completes via "idle". Also makes the gift-wrap load-summary collector a singleton: the tracker is shared across accounts, so launching it per newSub double-logged every summary when a second account was logged in. Per-load counters are now reset synchronously at load start (beginWindowLoad) rather than on the collector's rising edge, so no in-flight event is counted against the wrong load. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../eoseManagers/WindowLoadTracker.kt | 46 ++++++++++--- .../AccountGiftWrapsEoseManager.kt | 66 ++++++++++++------- 2 files changed, 81 insertions(+), 31 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt index 6b2cd4be0b..838dd235d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt @@ -50,13 +50,18 @@ import kotlin.time.Duration.Companion.seconds * * So completion is **activity-based**: [loading] stays true until either every expected relay has * EOSE'd, or the event stream has gone quiet for [idleTimeout] (a flood of events keeps resetting - * that timer via [onActivity], so a window that is still streaming is never declared done). An - * [absoluteCap] bounds the wait for pathological relays that dribble forever. + * that timer via [onActivity], so a window that is still streaming is never declared done). The idle + * path only arms **after the first event or EOSE** — a cold boot whose relays have not finished + * connecting yet is silent for reasons that have nothing to do with the data, and declaring it "done" + * there would empty the screen and trip the auto-fill into widening over and over. Until that first + * sign of life, only [noResponseTimeout] (a generous "nothing answered at all" bound) and the + * [absoluteCap] (for pathological relays that dribble forever) can end the load. */ class WindowLoadTracker( // Short label for the DMPagination logs (e.g. "giftwrap", "rooms.nip04", "convo.nip04"). private val name: String = "dm", private val idleTimeout: Duration = 3.seconds, + private val noResponseTimeout: Duration = 30.seconds, private val absoluteCap: Duration = 5.minutes, ) { private val _loading = MutableStateFlow(true) @@ -76,12 +81,24 @@ class WindowLoadTracker( @Volatile private var lastActivityMs = 0L + // When the current load started, and whether anything (event or EOSE) has arrived for it yet. + // Until the first sign of life the idle timer is meaningless (the relays may still be connecting), + // so completion falls back to the longer [noResponseTimeout]. Volatile for the lock-free hot path. + @Volatile + private var loadStartedMs = 0L + + @Volatile + private var sawActivity = false + /** Begins a fresh window load: clears the responded set, raises [loading], and arms the watchdog. */ @Synchronized fun startLoading(scope: CoroutineScope) { val gen = ++generation responded.clear() - lastActivityMs = System.currentTimeMillis() + val now = System.currentTimeMillis() + lastActivityMs = now + loadStartedMs = now + sawActivity = false val wasLoading = _loading.value _loading.value = true Log.d(TAG) { "[$name] load start" + if (!wasLoading) "" else " (restart)" } @@ -97,7 +114,7 @@ class WindowLoadTracker( } // One watchdog poll. Returns false (stop polling) when this watchdog has been superseded by a - // newer load, the window already finished, or the idle/cap deadline is reached. Synchronized so + // newer load, the window already finished, or a completion deadline is reached. Synchronized so // the generation/loading checks and the completion are atomic against startLoading/finish. @Synchronized private fun tick( @@ -106,9 +123,19 @@ class WindowLoadTracker( deadline: Long, ): Boolean { if (gen != generation || !_loading.value) return false - if (now - lastActivityMs >= idleTimeout.inWholeMilliseconds) { - finish("idle") - return false + if (sawActivity) { + // The stream started and then went quiet: the relays are done sending. + if (now - lastActivityMs >= idleTimeout.inWholeMilliseconds) { + finish("idle") + return false + } + } else { + // Nothing has answered yet. Don't mistake a slow cold-boot connect for "done"; only give + // up once even the generous no-response bound has elapsed. + if (now - loadStartedMs >= noResponseTimeout.inWholeMilliseconds) { + finish("no response") + return false + } } if (now >= deadline) { finish("cap") @@ -119,10 +146,12 @@ class WindowLoadTracker( /** * Records that the current window is still actively receiving events (stored OR live). Keeps the - * idle watchdog from completing while a relay is mid-flood. Lock-free: just bumps a timestamp. + * idle watchdog from completing while a relay is mid-flood, and marks that the stream has started + * (arming the idle path). Lock-free: just bumps a timestamp and a flag. */ fun onActivity() { lastActivityMs = System.currentTimeMillis() + sawActivity = true } /** Records which relays the current REQ was sent to. Completes immediately if there are none. */ @@ -140,6 +169,7 @@ class WindowLoadTracker( @Synchronized fun onRelayResponded(relay: NormalizedRelayUrl) { lastActivityMs = System.currentTimeMillis() + sawActivity = true responded.add(relay) if (expected.isNotEmpty() && responded.containsAll(expected)) finish("all relays") } 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 1dbd551e81..ad8989a9b0 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 @@ -98,11 +98,51 @@ class AccountGiftWrapsEoseManager( private val eventsThisLoad = AtomicInteger(0) private val outOfWindowThisLoad = AtomicInteger(0) + // The single tracker is shared across every account, so the summary collector is launched once + // (not per newSub) — otherwise a second logged-in account would double every summary line. + @Volatile + private var summaryJob: Job? = null + private fun countEvent(createdAt: Long) { eventsThisLoad.incrementAndGet() if (createdAt < loadSince) outOfWindowThisLoad.incrementAndGet() } + /** + * Starts a window load and resets the per-load counters in the same breath (synchronously, before + * [WindowLoadTracker.startLoading] raises `loading`, so no in-flight event is counted against the + * wrong load). The summary is emitted by a single collector that logs on each load's falling edge. + */ + private fun beginWindowLoad( + user: User, + scope: CoroutineScope, + ) { + loadSince = windowFor(user).since + eventsThisLoad.set(0) + outOfWindowThisLoad.set(0) + ensureSummaryLogger(scope) + windowLoad.startLoading(scope) + } + + private fun ensureSummaryLogger(scope: CoroutineScope) { + if (summaryJob?.isActive == true) return + summaryJob = + scope.launch { + var wasLoading = false + windowLoad.loading.collect { loading -> + if (!loading && wasLoading) { + val total = eventsThisLoad.get() + val outOfWindow = outOfWindowThisLoad.get() + Log.d(TAG) { + "[giftwrap] load summary: $total event(s), $outOfWindow before floor " + + "(since=$loadSince, ${daysAgo(loadSince)}d back)" + } + } + wasLoading = loading + } + } + } + override fun updateFilter( key: AccountQueryState, since: SincePerRelayMap?, @@ -131,7 +171,7 @@ class AccountGiftWrapsEoseManager( window.loadMore() _exhausted.value = window.isExhausted() Log.d(TAG) { "[giftwrap] loadMore ${daysAgo(before)}d -> ${daysAgo(window.since)}d back (exhausted=${_exhausted.value})" } - scope?.let { windowLoad.startLoading(it) } + scope?.let { beginWindowLoad(user, it) } invalidateFilters() } @@ -142,7 +182,7 @@ class AccountGiftWrapsEoseManager( window.loadAll() _exhausted.value = true Log.d(TAG) { "[giftwrap] loadEverything — full history (${daysAgo(window.since)}d back)" } - scope?.let { windowLoad.startLoading(it) } + scope?.let { beginWindowLoad(user, it) } invalidateFilters() } @@ -152,7 +192,7 @@ class AccountGiftWrapsEoseManager( override fun newSub(key: AccountQueryState): Subscription { val user = user(key) scope = key.account.scope - windowLoad.startLoading(key.account.scope) + beginWindowLoad(user, key.account.scope) userJobMap[user]?.forEach { it.cancel() } userJobMap[user] = listOf( @@ -160,26 +200,6 @@ class AccountGiftWrapsEoseManager( key.account.dmRelays.flow .collectLatest { invalidateFilters() } }, - // Resets the per-load counters when a load begins and logs the tally when it ends, so - // the trail shows how many events each widen pulled and how many were below the floor. - key.account.scope.launch { - var wasLoading = false - windowLoad.loading.collect { loading -> - if (loading && !wasLoading) { - loadSince = windowFor(user).since - eventsThisLoad.set(0) - outOfWindowThisLoad.set(0) - } else if (!loading && wasLoading) { - val total = eventsThisLoad.get() - val outOfWindow = outOfWindowThisLoad.get() - Log.d(TAG) { - "[giftwrap] load summary: $total event(s), $outOfWindow before floor " + - "(since=$loadSince, ${daysAgo(loadSince)}d back)" - } - } - wasLoading = loading - } - }, ) return requestNewSubscription( From c33a10c945ea18602fd1921f88ca655461c1dd39 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 22:59:52 +0000 Subject: [PATCH 023/103] fix: complete a window load only when every relay has settled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A boot trace reported `[giftwrap] load summary: 1 event(s)` for a 7-day window that actually holds ~100, because the DM relays connect over a ~35s spread: one fast relay delivered a single event at +7s, the next 3s were quiet only because the other four relays were still mid-connect, and the idle heuristic mistook that gap for "done". The rest streamed in afterwards, past the load boundary. The clean "all relays answered" completion was also unreachable: relays that answer `CLOSED auth-required` (and unreachable relays) never produced an EOSE, and WindowLoadTracker ignored onClosed/onCannotConnect entirely — so the only completion path was the too-eager idle timer firing in a connection gap. Completion is now per-relay terminal-state based. A relay is "settled" once it sends a terminal signal — EOSE, CLOSED, or cannot-connect — and the load is done when every targeted relay has settled. This is fast when relays are fast (everyone EOSEs in a couple seconds) and correctly patient when they are not (waits for the slowest relay), and it cannot trip in a connection-stagger gap. The idle timer is kept only as a backstop for a relay that streams without ever EOSE'ing, gated behind "every relay has been heard from" so it too can't fire in a gap; the absolute cap still bounds a relay that connects then hangs forever. WindowLoadTracker.trackingListener now wires onClosed and onCannotConnect into the tracker, and a live event no longer settles a relay (its preceding EOSE does); forward (newEose) semantics are unchanged. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../eoseManagers/WindowLoadTracker.kt | 143 ++++++++++-------- 1 file changed, 77 insertions(+), 66 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt index 838dd235d8..97483a755b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt @@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @@ -48,57 +49,61 @@ import kotlin.time.Duration.Companion.seconds * widen the window again mid-stream, before the events were even decrypted into rooms, re-issuing * an ever-wider REQ that re-downloads the whole history over and over. * - * So completion is **activity-based**: [loading] stays true until either every expected relay has - * EOSE'd, or the event stream has gone quiet for [idleTimeout] (a flood of events keeps resetting - * that timer via [onActivity], so a window that is still streaming is never declared done). The idle - * path only arms **after the first event or EOSE** — a cold boot whose relays have not finished - * connecting yet is silent for reasons that have nothing to do with the data, and declaring it "done" - * there would empty the screen and trip the auto-fill into widening over and over. Until that first - * sign of life, only [noResponseTimeout] (a generous "nothing answered at all" bound) and the - * [absoluteCap] (for pathological relays that dribble forever) can end the load. + * Completion is therefore **per-relay terminal-state** based, not wall-clock based. A relay is + * *settled* once it answers with a terminal signal — an EOSE (stored backfill done), a CLOSED (it + * rejected the REQ, e.g. `auth-required`), or a cannot-connect (it is unreachable). The load is done + * when **every targeted relay has settled** ([settled] ⊇ [expected]). This is the only signal that + * survives the real world: relays connect over a wide spread (tens of seconds on mobile), and some + * answer only with CLOSED — a quiet-time heuristic fires in the gap between two relays connecting and + * mistakes a half-loaded window for a finished one, which is exactly how a load reports "1 event" + * when a hundred are still on the way. + * + * Two backstops cover misbehaving relays. If every relay has at least been *heard from* (any event, + * EOSE, CLOSED, or cannot-connect) but one streamed events without ever sending EOSE, an [idleTimeout] + * of quiet completes the load — the "heard from all" gate is what keeps this from firing in a + * connection gap. And an [absoluteCap] bounds a relay that connects and then dribbles or hangs forever. */ class WindowLoadTracker( // Short label for the DMPagination logs (e.g. "giftwrap", "rooms.nip04", "convo.nip04"). private val name: String = "dm", private val idleTimeout: Duration = 3.seconds, - private val noResponseTimeout: Duration = 30.seconds, private val absoluteCap: Duration = 5.minutes, ) { private val _loading = MutableStateFlow(true) val loading: StateFlow = _loading.asStateFlow() + // Relays the current REQ was sent to. Volatile: written on IO (updateFilter), read on the + // listener threads and the watchdog. + @Volatile private var expected: Set = emptySet() - private val responded = mutableSetOf() + + // Relays that have produced any signal at all (event / EOSE / CLOSED / cannot-connect). The idle + // backstop only arms once this covers [expected], so a still-connecting relay can't be skipped. + private val heardFrom = ConcurrentHashMap.newKeySet() + + // Relays that reached a terminal signal (EOSE / CLOSED / cannot-connect). When this covers + // [expected] the stored backfill is complete on every relay and the load is done. + private val settled = ConcurrentHashMap.newKeySet() + private var watchdog: Job? = null // Incremented on every (re)start so a stale watchdog that wakes right as a new load begins // recognizes it has been superseded and bows out instead of completing the new window. private var generation = 0 - // Wall-clock of the last EOSE or event for the current window; the watchdog completes the - // window once this stops advancing for [idleTimeout]. Volatile so the hot per-event path - // ([onActivity]) stays lock-free. + // Wall-clock of the last signal for the current window; the idle backstop completes the window + // once this stops advancing for [idleTimeout]. Volatile so the hot per-event path stays lock-free. @Volatile private var lastActivityMs = 0L - // When the current load started, and whether anything (event or EOSE) has arrived for it yet. - // Until the first sign of life the idle timer is meaningless (the relays may still be connecting), - // so completion falls back to the longer [noResponseTimeout]. Volatile for the lock-free hot path. - @Volatile - private var loadStartedMs = 0L - - @Volatile - private var sawActivity = false - - /** Begins a fresh window load: clears the responded set, raises [loading], and arms the watchdog. */ + /** Begins a fresh window load: clears the per-relay sets, raises [loading], and arms the watchdog. */ @Synchronized fun startLoading(scope: CoroutineScope) { val gen = ++generation - responded.clear() - val now = System.currentTimeMillis() - lastActivityMs = now - loadStartedMs = now - sawActivity = false + expected = emptySet() + heardFrom.clear() + settled.clear() + lastActivityMs = System.currentTimeMillis() val wasLoading = _loading.value _loading.value = true Log.d(TAG) { "[$name] load start" + if (!wasLoading) "" else " (restart)" } @@ -123,19 +128,11 @@ class WindowLoadTracker( deadline: Long, ): Boolean { if (gen != generation || !_loading.value) return false - if (sawActivity) { - // The stream started and then went quiet: the relays are done sending. - if (now - lastActivityMs >= idleTimeout.inWholeMilliseconds) { - finish("idle") - return false - } - } else { - // Nothing has answered yet. Don't mistake a slow cold-boot connect for "done"; only give - // up once even the generous no-response bound has elapsed. - if (now - loadStartedMs >= noResponseTimeout.inWholeMilliseconds) { - finish("no response") - return false - } + // Every relay has spoken and the stream has gone quiet: a relay that streamed without ever + // EOSE'ing is done. The "heard from all" gate keeps this from firing in a connection gap. + if (expected.isNotEmpty() && heardFrom.containsAll(expected) && now - lastActivityMs >= idleTimeout.inWholeMilliseconds) { + finish("idle") + return false } if (now >= deadline) { finish("cap") @@ -144,34 +141,33 @@ class WindowLoadTracker( return true } - /** - * Records that the current window is still actively receiving events (stored OR live). Keeps the - * idle watchdog from completing while a relay is mid-flood, and marks that the stream has started - * (arming the idle path). Lock-free: just bumps a timestamp and a flag. - */ - fun onActivity() { - lastActivityMs = System.currentTimeMillis() - sawActivity = true - } - /** Records which relays the current REQ was sent to. Completes immediately if there are none. */ @Synchronized fun setExpectedRelays(relays: Set) { expected = relays if (relays.isEmpty()) { finish("no relays") - } else if (responded.containsAll(relays)) { + } else if (settled.containsAll(relays)) { finish("all relays") } } - /** Marks [relay] as having answered (EOSE or live event). Completes once all expected have. */ - @Synchronized - fun onRelayResponded(relay: NormalizedRelayUrl) { + /** A non-terminal sign of life from [relay] (a stored or live event). Keeps the idle timer alive. */ + fun onRelayEvent(relay: NormalizedRelayUrl) { + heardFrom.add(relay) lastActivityMs = System.currentTimeMillis() - sawActivity = true - responded.add(relay) - if (expected.isNotEmpty() && responded.containsAll(expected)) finish("all relays") + } + + /** + * A terminal signal from [relay] — EOSE, CLOSED, or cannot-connect. Once every expected relay has + * settled the stored backfill is complete and the load finishes. + */ + @Synchronized + fun onRelaySettled(relay: NormalizedRelayUrl) { + lastActivityMs = System.currentTimeMillis() + heardFrom.add(relay) + settled.add(relay) + if (expected.isNotEmpty() && settled.containsAll(expected)) finish("all relays") } // Idempotent: only the first call after a load actually completes (and logs); later calls no-op. @@ -191,11 +187,11 @@ class WindowLoadTracker( } /** - * Builds the standard [SubscriptionListener] that feeds this tracker: every event (stored backfill - * included) marks activity so a relay mid-flood is never mistaken for done, and an EOSE or live - * event marks that relay answered. [onEachEvent] is invoked for every event (stored or live) for - * optional instrumentation; [forward] is invoked with the same EOSE / live-event signal so the - * owning EOSE manager can record the relay's EOSE timestamp (its usual `newEose`). + * Builds the standard [SubscriptionListener] that feeds this tracker. Every event (stored backfill + * included) is a non-terminal sign of life from its relay; an EOSE, CLOSED, or cannot-connect settles + * that relay. [onEachEvent] is invoked for every event (stored or live) for optional instrumentation; + * [forward] carries the EOSE / live-event signal so the owning EOSE manager can record the relay's + * timestamp (its usual `newEose`). */ fun WindowLoadTracker.trackingListener( onEachEvent: (Event) -> Unit = {}, @@ -206,7 +202,7 @@ fun WindowLoadTracker.trackingListener( relay: NormalizedRelayUrl, forFilters: List?, ) { - onRelayResponded(relay) + onRelaySettled(relay) forward(relay, forFilters) } @@ -216,11 +212,26 @@ fun WindowLoadTracker.trackingListener( relay: NormalizedRelayUrl, forFilters: List?, ) { - onActivity() + onRelayEvent(relay) onEachEvent(event) if (isLive) { - onRelayResponded(relay) forward(relay, forFilters) } } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + onRelaySettled(relay) + } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + onRelaySettled(relay) + } } From a2033499c90b78a3722a9875cef0d04b756f7f51 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 23:06:10 +0000 Subject: [PATCH 024/103] fix: measure out-of-window events against the margined REQ floor The load summary's "before floor" count compared incoming gift wraps against the un-margined window floor (window.since), but filterGiftWrapsToPubkey actually asks relays for `since = window.since - 2 days` to catch wraps whose randomized outer timestamp dips below the real message time. So the deliberate 2-day margin band showed up as "before floor" (a boot reported "6 before floor" that were all legitimate margin-band wraps), conflating the intended margin with a relay that ignores `since`. Compare against window.since - twoDays() so only a relay that under-shoots the floor we actually requested is flagged. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../nip59GiftWraps/AccountGiftWrapsEoseManager.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) 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 ad8989a9b0..f4738a4b28 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 @@ -88,11 +88,13 @@ class AccountGiftWrapsEoseManager( private var scope: CoroutineScope? = null // Per-load instrumentation. Each gift-wrap event the relays push during a load is counted, and - // those whose (outer, randomized) created_at falls before the floor the REQ asked for are counted - // separately — a relay ignoring `since` re-streams the whole history every widen, so a total that - // keeps growing (and a large out-of-window share) is the fingerprint of "getting all events over - // and over again". [loadSince] is the floor the in-flight load asked for. Volatile/atomic because - // the event hook runs on the relay IO threads while the summary collector reads on the account scope. + // those whose (outer, randomized) created_at falls before the floor the REQ actually asked for are + // counted separately — a relay ignoring `since` re-streams the whole history every widen, so a + // total that keeps growing (and a non-zero out-of-window share) is the fingerprint of "getting all + // events over and over again". [loadSince] is the *margined* floor (window.since − 2 days, matching + // the wire REQ from filterGiftWrapsToPubkey), so the deliberate 2-day randomization band reads as + // in-window and only a relay that under-shoots that floor is flagged. Volatile/atomic because the + // event hook runs on the relay IO threads while the summary collector reads on the account scope. @Volatile private var loadSince = 0L private val eventsThisLoad = AtomicInteger(0) @@ -117,7 +119,7 @@ class AccountGiftWrapsEoseManager( user: User, scope: CoroutineScope, ) { - loadSince = windowFor(user).since + loadSince = windowFor(user).since - TimeUtils.twoDays() eventsThisLoad.set(0) outOfWindowThisLoad.set(0) ensureSummaryLogger(scope) From 9e2e595cac246b3badeb5766a537255c2e5940b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 23:12:19 +0000 Subject: [PATCH 025/103] feat: log NIP-04 (kind 4) REQs in the DM relay diagnostics trail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wire-level diagnostics logger only recognized gift-wrap kinds (1059/21059), so kind:4 NIP-04 REQs never produced a `REQ -> wss://…` line and their relays' connect/CLOSED/NOTICE lines were filtered out — even though the kind:4 REQs are issued (the `[rooms.nip04] REQ` manager logs show them going out). Broaden the match to the whole DM path (1059/21059/4) so the wire trail covers both protocols, and rename the gift-wrap-specific identifiers to dm-path. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../diagnostics/DmRelayDiagnosticsLogger.kt | 41 ++++++++++--------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt index 29e3b33c02..14deeb3313 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt @@ -29,12 +29,14 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.Log /** - * Diagnostic connection listener for the DM / gift-wrap loading path. + * Diagnostic connection listener for the DM loading path — both NIP-17 gift wraps + * (kind 1059 / 21059) and NIP-04 legacy DMs (kind 4). * * It folds the per-relay timeline — REQ sent, connect/disconnect, NOTICE / CLOSED * rejection and OK failures — into the single `DMPagination` log tag with an @@ -45,9 +47,8 @@ import com.vitorpamplona.quartz.utils.Log * * The connection listener fires for EVERY relay the app talks to (hundreds, under * the outbox model). To keep this readable we only log relays that are part of the - * gift-wrap path: a relay is "learned" the first time we send it a kind:1059/1060 - * REQ or receive a gift wrap from it, and only those relays' connect/auth/notice - * lines are emitted thereafter. + * DM path: a relay is "learned" the first time we send it a kind:1059/21059/4 + * REQ, and only those relays' connect/auth/notice lines are emitted thereafter. */ class DmRelayDiagnosticsLogger( val client: INostrClient, @@ -56,14 +57,14 @@ class DmRelayDiagnosticsLogger( private fun at() = System.currentTimeMillis() - startMs - // Subscription ids whose REQ carried a gift-wrap kind, so we can attribute their EOSE/CLOSED. - private val giftWrapSubIds = mutableSetOf() + // Subscription ids whose REQ carried a DM kind, so we can attribute their EOSE/CLOSED. + private val dmSubIds = mutableSetOf() - // Relays we've seen on the gift-wrap path, so connect/auth/notice noise from the + // Relays we've seen on the DM path, so connect/auth/notice noise from the // hundreds of unrelated follow/outbox relays is filtered out. - private val giftWrapRelays = mutableSetOf() + private val dmPathRelays = mutableSetOf() - private fun isDmRelay(relay: IRelayClient) = relay.url in giftWrapRelays + private fun isDmRelay(relay: IRelayClient) = relay.url in dmPathRelays private val listener = object : RelayConnectionListener { @@ -87,9 +88,9 @@ class DmRelayDiagnosticsLogger( cmd: Command, success: Boolean, ) { - if (!isGiftWrapReq(cmdStr)) return - giftWrapRelays.add(relay.url) - reqSubId(cmdStr)?.let { giftWrapSubIds.add(it) } + if (!isDmReq(cmdStr)) return + dmPathRelays.add(relay.url) + reqSubId(cmdStr)?.let { dmSubIds.add(it) } Log.d(TAG) { "[+${at()}ms] REQ -> ${relay.url.url} success=$success ${cmdStr.take(400)}" } } @@ -103,7 +104,7 @@ class DmRelayDiagnosticsLogger( if (isDmRelay(relay)) Log.d(TAG) { "[+${at()}ms] NOTICE <- ${relay.url.url} '${msg.message}'" } is ClosedMessage -> - if (msg.subId in giftWrapSubIds) { + if (msg.subId in dmSubIds) { Log.d(TAG) { "[+${at()}ms] CLOSED <- ${relay.url.url} sub=${msg.subId} reason='${msg.message}'" } } @@ -139,10 +140,10 @@ class DmRelayDiagnosticsLogger( companion object { private const val TAG = "DMPagination" - // The kinds a gift-wrap REQ carries (1059 + 21059). Matched exactly against the - // filter's "kinds" array — never as a substring of the whole command, since a - // pubkey hex or timestamp can incidentally contain "1059". - private val GIFT_WRAP_KINDS = setOf(GiftWrapEvent.KIND, EphemeralGiftWrapEvent.KIND) + // The kinds a DM-path REQ carries: NIP-17 gift wraps (1059 + 21059) and NIP-04 legacy DMs + // (4). Matched exactly against the filter's "kinds" array — never as a substring of the whole + // command, since a pubkey hex or timestamp can incidentally contain "1059" or "4". + private val DM_KINDS = setOf(GiftWrapEvent.KIND, EphemeralGiftWrapEvent.KIND, PrivateDmEvent.KIND) private val KINDS_ARRAY = Regex("\"kinds\":\\[([0-9,\\s]*)]") @@ -151,12 +152,12 @@ class DmRelayDiagnosticsLogger( private fun reqSubId(cmdStr: String) = REQ_SUB_ID.find(cmdStr)?.groupValues?.get(1) - /** True only when one of the REQ's `kinds` arrays actually contains a gift-wrap kind. */ - private fun isGiftWrapReq(cmdStr: String): Boolean = + /** True only when one of the REQ's `kinds` arrays actually contains a DM kind. */ + private fun isDmReq(cmdStr: String): Boolean = KINDS_ARRAY.findAll(cmdStr).any { match -> match.groupValues[1] .split(',') - .any { it.trim().toIntOrNull() in GIFT_WRAP_KINDS } + .any { it.trim().toIntOrNull() in DM_KINDS } } } } From 793860170ff9aa441da0ecf634e43476df33e2d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 23:43:02 +0000 Subject: [PATCH 026/103] feat: split DM loading into a live tail + bounded history slices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every widen re-requested the whole DM window (the filters carried `since` only, no `until`), so a relay re-streamed the entire history from the new floor — a few pixels of scroll walked the window to the 10-year backstop, re-downloading exponentially more each step (589 → 1486 → 2609 events in one session). This splits each DM protocol into two responsibilities: - Live tail (existing managers, now fixed): a one-week floor with no `until`, always open to the future. Never widens, so new messages keep arriving. - History slices (new managers): load the past in bounded `since`+`until` one-shot slices. Widening fetches only the new band `[newFloor, prevFloor]`; consecutive slices are disjoint so advancing the filter never re-streams an earlier slice — they live in the cache. The NIP-17 2-day wrapper-timestamp margin is applied to the slice `since`, overlapping adjacent slices so a randomized outer timestamp can't open a gap. NIP-04 (exact timestamps) needs no margin. New: AccountGiftWrapsHistoryEoseManager owns the geometric window and the bounded slices; ChatroomListNip04HistorySubAssembler / ChatroomNip04History- SubAssembler follow its slice bounds so both protocols page to the same depth. The live managers (AccountGiftWrapsEoseManager and the NIP-04 followers) are reduced to the fixed one-week tail. Also adds the rooms-list stall-gate: the auto-fill remembers the private-room count at the last widen (on the history manager, so it survives reopening the screen) and stops widening once a step brings in no new private room — widening pulls older messages, not rooms, so a few busy correspondents would otherwise flood events without ever filling the list. "Fill until full OR nothing new found", instead of walking to the 10-year backstop. Design: amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- ...6-06-01-dm-live-tail-and-history-slices.md | 75 ++++++ .../RelaySubscriptionsCoordinator.kt | 4 +- .../account/AccountFilterAssembler.kt | 6 + .../AccountGiftWrapsEoseManager.kt | 148 ++--------- .../AccountGiftWrapsHistoryEoseManager.kt | 233 ++++++++++++++++++ .../loggedIn/chats/privateDM/ChatroomView.kt | 30 +-- .../datasource/ChatroomFilterAssembler.kt | 11 +- .../ChatroomNip04HistorySubAssembler.kt | 87 +++++++ .../datasource/ChatroomNip04SubAssembler.kt | 31 +-- .../privateDM/datasource/FilterNip04DMs.kt | 3 + .../datasource/ChatroomListFilterAssembler.kt | 12 +- .../ChatroomListNip04HistorySubAssembler.kt | 118 +++++++++ .../ChatroomListNip04SubAssembler.kt | 35 +-- .../rooms/datasource/FilterNip04DMsFromMe.kt | 2 + .../rooms/datasource/FilterNip04DMsToMe.kt | 2 + .../chats/rooms/feed/ChatroomListFeedView.kt | 86 ++++--- .../nip17Dm/FilterGiftWrapsToPubkey.kt | 6 + 17 files changed, 651 insertions(+), 238 deletions(-) create mode 100644 amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04HistorySubAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt diff --git a/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md b/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md new file mode 100644 index 0000000000..d79bd1a2c9 --- /dev/null +++ b/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md @@ -0,0 +1,75 @@ +# DM loading: live tail + bounded history slices + +## Problem + +The DM loaders used a single subscription whose `since` floor grew as the user +scrolled (`loadMore`: 7d → 14d → 28d → …). The filter carried **only `since`, +no `until`**, so every widen re-requested the whole window and the relay +re-streamed the entire history from the new floor. Traces showed this directly: + +``` +[giftwrap] load summary: 589 event(s) (14d) +[giftwrap] load summary: 1486 event(s) (28d) +[giftwrap] load summary: 2609 event(s) (56d) +``` + +Each step re-downloaded everything it already had plus the new slice — the +"getting all events over and over again" the window owner reported. It also +cascaded: a few pixels of scroll walked the window to the 10-year backstop, +because widening pulls older *messages* but the rooms list is keyed by +*conversation*, so a handful of busy correspondents flood thousands of events +without adding a single new row, and the "scrolled near the oldest room" trigger +never clears. + +## Design (owner's call) + +Split each DM protocol into two responsibilities: + +1. **Live tail** — keep the existing filters at a fixed ~1-week floor with **no + `until`** (open to the future). Never widens. New messages always arrive. +2. **History slices** — new assemblers that load *the past* in **bounded + `since`+`until` slices**, each fetched once. Widening fetches only the new + band `[newFloor, previousFloor]`; the data already held in `[previousFloor, + now]` is never re-requested. + +Because consecutive slices are disjoint, re-issuing the (advanced) historical +filter does not re-stream earlier slices — they live in `LocalCache`. The +NIP-17 ±2-day wrapper-timestamp margin is applied to the slice `since` (via +`filterGiftWrapsToPubkey`), giving a 2-day overlap between adjacent slices so no +gap can open from a randomized outer timestamp. NIP-04 (kind 4) uses exact +timestamps, so its slices need no margin. + +### Slice math (gift-wrap history window) + +`TimeWindowPagination.since` starts at `now − 1week` (= the live-tail floor). + +- `loadMore`: `until = window.since` (current floor); `window.loadMore()` moves + `since` back geometrically; new slice = `[window.since, until]`. +- `loadEverything`: `until = window.since`; `window.loadAll()` → `since = floor`; + slice = `[floor, until]` — one request for the remaining past. +- `updateFilter` returns the **current slice** only (or empty before the first + `loadMore`), so the manager is idle until the user asks for older history. + +### Rooms-list cascade stop (stall-gate) + +The rooms list auto-fill widens only while it makes progress: it remembers the +private-room count at the last widen and stops once a widen brings in no new +private room (kept on the history manager so it survives leaving/reopening the +screen). "Fill until full **or nothing new found**", instead of walking to the +10-year backstop. + +## Touch list + +- `commons/.../FilterGiftWrapsToPubkey.kt`, `amethyst/.../FilterNip04DMsToMe.kt`, + `FilterNip04DMsFromMe.kt` — add optional `until`. +- `AccountGiftWrapsEoseManager` — becomes the live tail (fixed week, no until). +- `AccountGiftWrapsHistoryEoseManager` (new) — owns the window, bounded slices, + `loadMore`/`loadEverything`/`exhausted`, per-load instrumentation. +- `ChatroomListNip04SubAssembler` / `ChatroomNip04SubAssembler` — live tail. +- `ChatroomListNip04HistorySubAssembler` / `ChatroomNip04HistorySubAssembler` + (new) — follow the gift-wrap history slice bounds. +- `AccountFilterAssembler`, `ChatroomListFilterAssembler`, + `ChatroomFilterAssembler`, `RelaySubscriptionsCoordinator` — wire the new + managers. +- `ChatroomListFeedView`, `ChatroomView` — point "load older" at the history + managers; combine live+history `loadingMore` for spinners; add the stall-gate. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index a7b0fc7dae..40a4db2d60 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -90,7 +90,7 @@ class RelaySubscriptionsCoordinator( // always running, feed assemblers. val home = HomeFilterAssembler(client) - val chatroomList = ChatroomListFilterAssembler(client, account.giftWraps) + val chatroomList = ChatroomListFilterAssembler(client, account.giftWrapsHistory) val video = VideoFilterAssembler(client) val discovery = DiscoveryFilterAssembler(client) @@ -105,7 +105,7 @@ class RelaySubscriptionsCoordinator( // active depending on the screen. val channel = ChannelFilterAssembler(client) - val chatroom = ChatroomFilterAssembler(client, account.giftWraps) + val chatroom = ChatroomFilterAssembler(client, account.giftWrapsHistory) val community = CommunityFilterAssembler(client) val gitRepository = RepositoryFilterAssembler(client) val thread = ThreadFilterAssembler(client) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt index 2e2576eca4..e654e9df0c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.marmot. import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata.AccountMetadataEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromInboxRelaysManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsHistoryEoseManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -49,12 +50,17 @@ class AccountQueryState( class AccountFilterAssembler( client: INostrClient, ) : ComposeSubscriptionManager() { + // Live tail: the recent week of gift wraps, always open at the top for new messages. val giftWraps = AccountGiftWrapsEoseManager(client, ::allKeys) + // History: older gift wraps, loaded on demand in bounded one-shot slices. + val giftWrapsHistory = AccountGiftWrapsHistoryEoseManager(client, ::allKeys) + val group = listOf( AccountMetadataEoseManager(client, ::allKeys), giftWraps, + giftWrapsHistory, AccountDraftsEoseManager(client, ::allKeys), AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys), MarmotGroupEventsEoseManager(client, ::allKeys), 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 f4738a4b28..3a7339d59a 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 @@ -21,36 +21,30 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey -import com.vitorpamplona.amethyst.commons.relayClient.pagination.TimeWindowPagination import com.vitorpamplona.amethyst.model.User 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.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicInteger /** - * Always-on loader for the account's NIP-17 gift wraps (kind 1059). It owns the single DM time - * window: boot opens a small window so the messages list is usable before the whole history is - * fetched and decrypted, and the screens widen it ([loadMore] / [loadEverything]) as the user - * scrolls. The NIP-04 loaders follow this window's [windowSince] so both DM protocols stay aligned. + * Always-on **live tail** for the account's NIP-17 gift wraps (kind 1059). It keeps a fixed + * one-week floor with no upper bound, so the messages list is usable on boot and new incoming + * messages always stream in. It deliberately never widens: pulling older history is the job of + * [AccountGiftWrapsHistoryEoseManager], which fetches the past in bounded, one-shot slices so a + * widen never re-streams what this tail already holds. */ class AccountGiftWrapsEoseManager( client: INostrClient, @@ -58,93 +52,11 @@ class AccountGiftWrapsEoseManager( ) : PerUserEoseManager(client, allKeys) { override fun user(key: AccountQueryState) = key.account.userProfile() - // Per-account window floor. Concurrent: windowFor is reached from the UI thread (loadMore / - // loadEverything) and from Dispatchers.IO (updateFilter), so a plain HashMap would race. - private val windows = ConcurrentHashMap() - - private fun windowFor(user: User) = - windows.computeIfAbsent(user.pubkeyHex) { - TimeWindowPagination(growthFactor = WINDOW_GROWTH_FACTOR).also { - Log.d(TAG) { "[giftwrap] open window since=${it.since} (${daysAgo(it.since)}d back)" } - } - } - - /** The current lower bound (epoch seconds) of this account's gift-wrap window. */ - fun windowSince(user: User): Long = windowFor(user).since - - private fun daysAgo(epochSeconds: Long) = (TimeUtils.now() - epochSeconds) / TimeUtils.ONE_DAY - - // A window load is in flight until every dmRelay it was sent to has answered, the event stream - // goes quiet, or a cap fires — never on just the first EOSE (a fast empty relay would trip it). - private val windowLoad = WindowLoadTracker("giftwrap") + // The initial-load tracker drives the boot spinner: it stays true until every DM relay has + // settled (EOSE / CLOSED / cannot-connect) on the one-week tail. + private val windowLoad = WindowLoadTracker("giftwrap.live") val loadingMore: StateFlow = windowLoad.loading - // True once the window reached the maximum lookback: nothing older to fetch. - private val _exhausted = MutableStateFlow(false) - val exhausted: StateFlow = _exhausted.asStateFlow() - - // Account scope for the window-load watchdog. Volatile: written on IO (newSub), read on UI (loadMore). - @Volatile - private var scope: CoroutineScope? = null - - // Per-load instrumentation. Each gift-wrap event the relays push during a load is counted, and - // those whose (outer, randomized) created_at falls before the floor the REQ actually asked for are - // counted separately — a relay ignoring `since` re-streams the whole history every widen, so a - // total that keeps growing (and a non-zero out-of-window share) is the fingerprint of "getting all - // events over and over again". [loadSince] is the *margined* floor (window.since − 2 days, matching - // the wire REQ from filterGiftWrapsToPubkey), so the deliberate 2-day randomization band reads as - // in-window and only a relay that under-shoots that floor is flagged. Volatile/atomic because the - // event hook runs on the relay IO threads while the summary collector reads on the account scope. - @Volatile - private var loadSince = 0L - private val eventsThisLoad = AtomicInteger(0) - private val outOfWindowThisLoad = AtomicInteger(0) - - // The single tracker is shared across every account, so the summary collector is launched once - // (not per newSub) — otherwise a second logged-in account would double every summary line. - @Volatile - private var summaryJob: Job? = null - - private fun countEvent(createdAt: Long) { - eventsThisLoad.incrementAndGet() - if (createdAt < loadSince) outOfWindowThisLoad.incrementAndGet() - } - - /** - * Starts a window load and resets the per-load counters in the same breath (synchronously, before - * [WindowLoadTracker.startLoading] raises `loading`, so no in-flight event is counted against the - * wrong load). The summary is emitted by a single collector that logs on each load's falling edge. - */ - private fun beginWindowLoad( - user: User, - scope: CoroutineScope, - ) { - loadSince = windowFor(user).since - TimeUtils.twoDays() - eventsThisLoad.set(0) - outOfWindowThisLoad.set(0) - ensureSummaryLogger(scope) - windowLoad.startLoading(scope) - } - - private fun ensureSummaryLogger(scope: CoroutineScope) { - if (summaryJob?.isActive == true) return - summaryJob = - scope.launch { - var wasLoading = false - windowLoad.loading.collect { loading -> - if (!loading && wasLoading) { - val total = eventsThisLoad.get() - val outOfWindow = outOfWindowThisLoad.get() - Log.d(TAG) { - "[giftwrap] load summary: $total event(s), $outOfWindow before floor " + - "(since=$loadSince, ${daysAgo(loadSince)}d back)" - } - } - wasLoading = loading - } - } - } - override fun updateFilter( key: AccountQueryState, since: SincePerRelayMap?, @@ -155,46 +67,19 @@ class AccountGiftWrapsEoseManager( } val relays = key.account.dmRelays.flow.value windowLoad.setExpectedRelays(relays.toSet()) - val windowSince = windowFor(user(key)).since - Log.d(TAG) { "[giftwrap] REQ since=$windowSince (${daysAgo(windowSince)}d) on ${relays.size} relay(s)" } + val sinceTime = TimeUtils.now() - LIVE_TAIL_SECONDS + Log.d(TAG) { "[giftwrap.live] REQ since=$sinceTime (7d, no until) on ${relays.size} relay(s)" } return relays.flatMap { relay -> - filterGiftWrapsToPubkey(relay = relay, pubkey = user(key).pubkeyHex, since = windowSince) + filterGiftWrapsToPubkey(relay = relay, pubkey = user(key).pubkeyHex, since = sinceTime) } } - /** Widens the window one (geometric) step back and re-issues the subscription. No-op if exhausted. */ - fun loadMore(user: User) { - val window = windowFor(user) - if (window.isExhausted()) { - Log.d(TAG) { "[giftwrap] loadMore ignored — already exhausted" } - return - } - val before = window.since - window.loadMore() - _exhausted.value = window.isExhausted() - Log.d(TAG) { "[giftwrap] loadMore ${daysAgo(before)}d -> ${daysAgo(window.since)}d back (exhausted=${_exhausted.value})" } - scope?.let { beginWindowLoad(user, it) } - invalidateFilters() - } - - /** Jumps the window to the maximum lookback so a single REQ pulls the entire history. */ - fun loadEverything(user: User) { - val window = windowFor(user) - if (window.isExhausted()) return - window.loadAll() - _exhausted.value = true - Log.d(TAG) { "[giftwrap] loadEverything — full history (${daysAgo(window.since)}d back)" } - scope?.let { beginWindowLoad(user, it) } - invalidateFilters() - } - private val userJobMap = mutableMapOf>() @OptIn(FlowPreview::class) override fun newSub(key: AccountQueryState): Subscription { val user = user(key) - scope = key.account.scope - beginWindowLoad(user, key.account.scope) + windowLoad.startLoading(key.account.scope) userJobMap[user]?.forEach { it.cancel() } userJobMap[user] = listOf( @@ -205,9 +90,7 @@ class AccountGiftWrapsEoseManager( ) return requestNewSubscription( - windowLoad.trackingListener( - onEachEvent = { event -> countEvent(event.createdAt) }, - ) { relay, filters -> newEose(key, relay, TimeUtils.now(), filters) }, + windowLoad.trackingListener { relay, filters -> newEose(key, relay, TimeUtils.now(), filters) }, ) } @@ -222,8 +105,7 @@ class AccountGiftWrapsEoseManager( companion object { private const val TAG = "DMPagination" - // The window doubles each widen so a sparse history (or confirming nothing older exists) - // reaches the 10-year backstop in ~10 requests instead of crawling a week at a time. - private const val WINDOW_GROWTH_FACTOR = 2L + // The live tail's fixed lower bound: one week of recent history, always open at the top. + val LIVE_TAIL_SECONDS = 7L * TimeUtils.ONE_DAY } } 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 new file mode 100644 index 0000000000..fcedc2dc92 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt @@ -0,0 +1,233 @@ +/* + * 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.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps + +import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey +import com.vitorpamplona.amethyst.commons.relayClient.pagination.TimeWindowPagination +import com.vitorpamplona.amethyst.model.User +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.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger + +/** + * Loads the account's NIP-17 gift-wrap **history** — everything older than the one-week live tail + * ([AccountGiftWrapsEoseManager]) — in bounded, one-shot `[since, until]` slices. + * + * It is idle until the screens call [loadMore]: each call advances the floor one geometric step and + * requests **only the new band** `[newFloor, previousFloor]`, never the whole window. Consecutive + * slices are disjoint, so re-issuing the (advanced) filter on a reconnect does not re-stream older + * slices — those already live in the cache. The 2-day NIP-17 margin (applied to the slice `since` by + * [filterGiftWrapsToPubkey]) overlaps adjacent slices so a randomized outer timestamp can't open a gap. + */ +class AccountGiftWrapsHistoryEoseManager( + client: INostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun user(key: AccountQueryState) = key.account.userProfile() + + // The window's [TimeWindowPagination.since] is the oldest floor reached so far; it starts at the + // live-tail floor (now − 1 week) and moves back one geometric step per loadMore. + private val windows = ConcurrentHashMap() + + // The current slice to request per user, or absent until the first loadMore (manager stays idle). + private val slices = ConcurrentHashMap() + + private data class Slice( + val since: Long, + val until: Long, + ) + + private fun windowFor(user: User) = windows.computeIfAbsent(user.pubkeyHex) { TimeWindowPagination(growthFactor = WINDOW_GROWTH_FACTOR) } + + /** The current history slice for [user] (logical, un-margined bounds), or null while idle. */ + fun currentSlice(user: User): Pair? = slices[user.pubkeyHex]?.let { it.since to it.until } + + private val windowLoad = WindowLoadTracker("giftwrap.history") + val loadingMore: StateFlow = windowLoad.loading + + // True once the floor reached the maximum lookback: nothing older to fetch. + private val _exhausted = MutableStateFlow(false) + val exhausted: StateFlow = _exhausted.asStateFlow() + + // Rooms-list auto-fill stall mark: the number of distinct private rooms shown the last time the + // list auto-widened. The list stops widening once a step adds no new room (widening only pulls + // older MESSAGES, which for a few busy correspondents can be thousands of events without a single + // new room). Kept here, beside the window it guards, so the stall survives leaving and reopening + // the Messages screen — a fresh UI-local counter would re-widen on every open. + @Volatile + var autoFillPrivateRoomMark: Int = Int.MIN_VALUE + + // Account scope for the window-load watchdog. Volatile: written on IO (newSub), read on UI (loadMore). + @Volatile + private var scope: CoroutineScope? = null + + override fun updateFilter( + key: AccountQueryState, + since: SincePerRelayMap?, + ): List { + val slice = slices[user(key).pubkeyHex] + if (!key.account.isWriteable() || slice == null) { + windowLoad.setExpectedRelays(emptySet()) + return emptyList() + } + val relays = key.account.dmRelays.flow.value + windowLoad.setExpectedRelays(relays.toSet()) + Log.d(TAG) { "[giftwrap.history] REQ slice [${daysAgo(slice.since)}d, ${daysAgo(slice.until)}d] on ${relays.size} relay(s)" } + return relays.flatMap { relay -> + filterGiftWrapsToPubkey(relay = relay, pubkey = user(key).pubkeyHex, since = slice.since, until = slice.until) + } + } + + private fun daysAgo(epochSeconds: Long) = (TimeUtils.now() - epochSeconds) / TimeUtils.ONE_DAY + + /** Widens the floor one geometric step back and requests only the new slice. No-op if exhausted. */ + fun loadMore(user: User) { + val window = windowFor(user) + if (window.isExhausted()) { + Log.d(TAG) { "[giftwrap.history] loadMore ignored — already exhausted" } + return + } + val until = window.since + window.loadMore() + slices[user.pubkeyHex] = Slice(since = window.since, until = until) + _exhausted.value = window.isExhausted() + Log.d(TAG) { "[giftwrap.history] loadMore slice [${daysAgo(window.since)}d, ${daysAgo(until)}d] (exhausted=${_exhausted.value})" } + scope?.let { beginWindowLoad(user, it) } + invalidateFilters() + } + + /** Requests the entire remaining past in one slice `[maxLookback, currentFloor]`. */ + fun loadEverything(user: User) { + val window = windowFor(user) + if (window.isExhausted()) return + val until = window.since + window.loadAll() + slices[user.pubkeyHex] = Slice(since = window.since, until = until) + _exhausted.value = true + Log.d(TAG) { "[giftwrap.history] loadEverything — slice [${daysAgo(window.since)}d, ${daysAgo(until)}d]" } + scope?.let { beginWindowLoad(user, it) } + invalidateFilters() + } + + // Per-load instrumentation: how many gift wraps a slice pulled, and how many fell outside the band + // the REQ actually asked for ([loadSince], the margined floor) — a non-zero out-of-band share means + // a relay ignored the bounds. Volatile/atomic: the event hook runs on relay IO threads while the + // summary collector reads on the account scope. + @Volatile + private var loadSince = 0L + private val eventsThisLoad = AtomicInteger(0) + private val outOfWindowThisLoad = AtomicInteger(0) + + @Volatile + private var summaryJob: Job? = null + + private fun countEvent(createdAt: Long) { + eventsThisLoad.incrementAndGet() + if (createdAt < loadSince) outOfWindowThisLoad.incrementAndGet() + } + + private fun beginWindowLoad( + user: User, + scope: CoroutineScope, + ) { + loadSince = (slices[user.pubkeyHex]?.since ?: windowFor(user).since) - TimeUtils.twoDays() + eventsThisLoad.set(0) + outOfWindowThisLoad.set(0) + ensureSummaryLogger(scope) + windowLoad.startLoading(scope) + } + + private fun ensureSummaryLogger(scope: CoroutineScope) { + if (summaryJob?.isActive == true) return + summaryJob = + scope.launch { + var wasLoading = false + windowLoad.loading.collect { loading -> + if (!loading && wasLoading) { + val total = eventsThisLoad.get() + val outOfWindow = outOfWindowThisLoad.get() + Log.d(TAG) { + "[giftwrap.history] load summary: $total event(s), $outOfWindow before floor " + + "(since=$loadSince, ${daysAgo(loadSince)}d back)" + } + } + wasLoading = loading + } + } + } + + private val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: AccountQueryState): Subscription { + val user = user(key) + scope = key.account.scope + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.account.scope.launch(Dispatchers.IO) { + key.account.dmRelays.flow + .collectLatest { invalidateFilters() } + }, + ) + + return requestNewSubscription( + windowLoad.trackingListener( + onEachEvent = { event -> countEvent(event.createdAt) }, + ) { relay, filters -> newEose(key, relay, TimeUtils.now(), filters) }, + ) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } + + companion object { + private const val TAG = "DMPagination" + + // The slice doubles each widen so a sparse history (or confirming nothing older exists) + // reaches the 10-year backstop in ~10 requests instead of crawling a week at a time. + private const val WINDOW_GROWTH_FACTOR = 2L + } +} 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 5138c17d48..38a02225f9 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 @@ -155,10 +155,10 @@ private fun LoadOlderMessagesWhenScrolling( listState: LazyListState, accountViewModel: AccountViewModel, ) { - val giftWraps = remember(accountViewModel) { accountViewModel.dataSources().account.giftWraps } - val nip04 = remember(accountViewModel) { accountViewModel.dataSources().chatroom.nip04 } + val giftWrapsHistory = remember(accountViewModel) { accountViewModel.dataSources().account.giftWrapsHistory } + val nip04History = remember(accountViewModel) { accountViewModel.dataSources().chatroom.nip04History } - LaunchedEffect(listState, giftWraps, nip04) { + LaunchedEffect(listState, giftWrapsHistory, nip04History) { combine( snapshotFlow { val info = listState.layoutInfo @@ -167,17 +167,17 @@ private fun LoadOlderMessagesWhenScrolling( val overflowsScreen = info.visibleItemsInfo.size < total overflowsScreen && lastVisible >= total - PREFETCH_OLDER_MESSAGES }, - giftWraps.loadingMore, - nip04.loadingMore, - giftWraps.exhausted, + giftWrapsHistory.loadingMore, + nip04History.loadingMore, + giftWrapsHistory.exhausted, ) { wantMore, loadingGiftWraps, loadingNip04, exhausted -> wantMore && !loadingGiftWraps && !loadingNip04 && !exhausted }.distinctUntilChanged() .filter { it } .collect { Log.d("DMPagination") { "convo: widen (scrolled near oldest) → loadMore + reload" } - giftWraps.loadMore(accountViewModel.userProfile()) - nip04.reload() + giftWrapsHistory.loadMore(accountViewModel.userProfile()) + nip04History.reload() } } } @@ -198,11 +198,11 @@ fun ChatroomViewUI( onDispose { Log.d("DMPagination") { "convo: CLOSE room=${room.hashCode()}" } } } - val giftWraps = remember(accountViewModel) { accountViewModel.dataSources().account.giftWraps } - val nip04 = remember(accountViewModel) { accountViewModel.dataSources().chatroom.nip04 } - val loadingGiftWraps by giftWraps.loadingMore.collectAsStateWithLifecycle() - val loadingNip04 by nip04.loadingMore.collectAsStateWithLifecycle() - val historyExhausted by giftWraps.exhausted.collectAsStateWithLifecycle() + val giftWrapsHistory = remember(accountViewModel) { accountViewModel.dataSources().account.giftWrapsHistory } + val nip04History = remember(accountViewModel) { accountViewModel.dataSources().chatroom.nip04History } + val loadingGiftWraps by giftWrapsHistory.loadingMore.collectAsStateWithLifecycle() + val loadingNip04 by nip04History.loadingMore.collectAsStateWithLifecycle() + val historyExhausted by giftWrapsHistory.exhausted.collectAsStateWithLifecycle() Column(Modifier.fillMaxHeight()) { ObserveRelayListForDMsAndDisplayIfNotFound(accountViewModel, nav) @@ -235,8 +235,8 @@ fun ChatroomViewUI( ) { Log.d("DMPagination") { "convo: Load entire history tapped" } val user = accountViewModel.userProfile() - giftWraps.loadEverything(user) - nip04.reload() + giftWrapsHistory.loadEverything(user) + nip04History.reload() } } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt index 03c648dedf..4104bd7e21 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsHistoryEoseManager import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey @@ -36,13 +36,18 @@ class ChatroomQueryState( class ChatroomFilterAssembler( client: INostrClient, - giftWraps: AccountGiftWrapsEoseManager, + giftWrapsHistory: AccountGiftWrapsHistoryEoseManager, ) : ComposeSubscriptionManager() { - val nip04 = ChatroomNip04SubAssembler(client, ::allKeys, giftWraps) + // NIP-04 live tail: the recent week, always open at the top. + val nip04 = ChatroomNip04SubAssembler(client, ::allKeys) + + // NIP-04 history: older DMs, following the gift-wrap history's bounded slice. + val nip04History = ChatroomNip04HistorySubAssembler(client, ::allKeys, giftWrapsHistory) val group = listOf( nip04, + nip04History, ) override fun invalidateKeys() = invalidateFilters() 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 new file mode 100644 index 0000000000..6579aad610 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04HistorySubAssembler.kt @@ -0,0 +1,87 @@ +/* + * 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.amethyst.ui.screen.loggedIn.chats.privateDM.datasource + +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.AccountGiftWrapsHistoryEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow + +/** + * Loads older NIP-04 DMs (kind 4) for one conversation, following the gift-wrap history's current + * bounded slice ([AccountGiftWrapsHistoryEoseManager.currentSlice]) so a thread shows both DM + * protocols to the same depth. NIP-04 timestamps are exact, so the slice needs no margin. [reload] + * re-issues at the now-advanced slice; idle until the first slice is opened. + */ +class ChatroomNip04HistorySubAssembler( + client: INostrClient, + allKeys: () -> Set, + private val giftWrapsHistory: AccountGiftWrapsHistoryEoseManager, +) : PerUserAndFollowListEoseManager(client, allKeys) { + private val windowLoad = WindowLoadTracker("convo.nip04.history") + val loadingMore: StateFlow = windowLoad.loading + + // Account scope for the watchdog. Volatile: written on IO (newSub), read on UI (reload). + @Volatile + private var scope: CoroutineScope? = null + + override fun updateFilter( + key: ChatroomQueryState, + since: SincePerRelayMap?, + ): List? { + val slice = giftWrapsHistory.currentSlice(user(key)) + if (!key.account.isWriteable() || slice == null) { + windowLoad.setExpectedRelays(emptySet()) + return emptyList() + } + val (sliceSince, sliceUntil) = slice + val filters = filterNip04DMs(key.room.users, key.account, sliceSince, sliceUntil) + windowLoad.setExpectedRelays(filters?.mapTo(mutableSetOf()) { it.relay } ?: emptySet()) + Log.d("DMPagination") { "[convo.nip04.history] REQ slice since=$sliceSince until=$sliceUntil on ${filters?.size ?: 0} relay-filter(s)" } + return filters + } + + /** Re-issues at the gift-wrap history's now-advanced slice and tracks the load. */ + fun reload() { + Log.d("DMPagination") { "[convo.nip04.history] reload" } + scope?.let { windowLoad.startLoading(it) } + invalidateFilters() + } + + override fun user(key: ChatroomQueryState) = key.account.userProfile() + + override fun list(key: ChatroomQueryState) = key.listId + + override fun newSub(key: ChatroomQueryState): Subscription { + scope = key.account.scope + return requestNewSubscription( + windowLoad.trackingListener { relay, filters -> newEose(key, relay, TimeUtils.now(), filters) }, + ) + } +} 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 3193e11f9b..b0cf1d5a69 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 @@ -30,57 +30,40 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow /** - * Loads one conversation's NIP-04 DMs (kind 4). Like the rooms-list loader, it follows the account - * gift-wrap window's [AccountGiftWrapsEoseManager.windowSince] floor so a thread shows both DM - * protocols to the same depth. [reload] re-issues at the current floor; [loadingMore] reports when - * this protocol has covered it, which the conversation screen joins with the gift-wrap loader's flag - * to decide how deep the thread is safe to reveal. + * Always-on **live tail** for one conversation's NIP-04 DMs (kind 4). A fixed one-week floor, no + * upper bound, never widens — older history is loaded by [ChatroomNip04HistorySubAssembler] in + * bounded slices that follow the gift-wrap history window. */ class ChatroomNip04SubAssembler( client: INostrClient, allKeys: () -> Set, - private val giftWraps: AccountGiftWrapsEoseManager, ) : PerUserAndFollowListEoseManager(client, allKeys) { - private val windowLoad = WindowLoadTracker("convo.nip04") + private val windowLoad = WindowLoadTracker("convo.nip04.live") val loadingMore: StateFlow = windowLoad.loading - // Account scope for the watchdog. Volatile: written on IO (newSub), read on UI (reload). - @Volatile - private var scope: CoroutineScope? = null - override fun updateFilter( key: ChatroomQueryState, since: SincePerRelayMap?, ): List? = if (key.account.isWriteable()) { - val windowSince = giftWraps.windowSince(user(key)) - val filters = filterNip04DMs(key.room.users, key.account, windowSince) + val sinceTime = TimeUtils.now() - AccountGiftWrapsEoseManager.LIVE_TAIL_SECONDS + val filters = filterNip04DMs(key.room.users, key.account, sinceTime) windowLoad.setExpectedRelays(filters?.mapTo(mutableSetOf()) { it.relay } ?: emptySet()) - val daysAgo = (TimeUtils.now() - windowSince) / TimeUtils.ONE_DAY - Log.d("DMPagination") { "[convo.nip04] REQ since=$windowSince (${daysAgo}d) on ${filters?.size ?: 0} relay-filter(s)" } + Log.d("DMPagination") { "[convo.nip04.live] REQ since=$sinceTime (7d, no until) on ${filters?.size ?: 0} relay-filter(s)" } filters } else { windowLoad.setExpectedRelays(emptySet()) emptyList() } - /** Re-issues at the (now-wider) shared gift-wrap floor and tracks the load. */ - fun reload() { - Log.d("DMPagination") { "[convo.nip04] reload" } - scope?.let { windowLoad.startLoading(it) } - invalidateFilters() - } - override fun user(key: ChatroomQueryState) = key.account.userProfile() override fun list(key: ChatroomQueryState) = key.listId override fun newSub(key: ChatroomQueryState): Subscription { - scope = key.account.scope windowLoad.startLoading(key.account.scope) return requestNewSubscription( windowLoad.trackingListener { relay, filters -> newEose(key, relay, TimeUtils.now(), filters) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt index e0c9a159a0..df04273eba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt @@ -33,6 +33,7 @@ fun filterNip04DMs( group: Set?, account: Account?, windowStart: Long, + windowEnd: Long? = null, ): List? { if (group.isNullOrEmpty() || account == null) return null @@ -75,6 +76,7 @@ fun filterNip04DMs( authors = group.toList(), tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), since = windowStart, + until = windowEnd, ), ) } + @@ -87,6 +89,7 @@ fun filterNip04DMs( authors = listOf(account.userProfile().pubkeyHex), tags = mapOf("p" to group.toList()), since = windowStart, + until = windowEnd, ), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt index 0f1823b71f..123bb0f9e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsHistoryEoseManager import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient // This allows multiple screen to be listening to tags, even the same tag @@ -35,14 +35,18 @@ class ChatroomListState( @Stable class ChatroomListFilterAssembler( client: INostrClient, - giftWraps: AccountGiftWrapsEoseManager, + giftWrapsHistory: AccountGiftWrapsHistoryEoseManager, ) : ComposeSubscriptionManager() { - // NIP-04 DMs follow the account gift-wrap window's floor (the single source of truth). - val nip04 = ChatroomListNip04SubAssembler(client, ::allKeys, giftWraps) + // NIP-04 live tail: the recent week, always open at the top. + val nip04 = ChatroomListNip04SubAssembler(client, ::allKeys) + + // NIP-04 history: older DMs, following the gift-wrap history's bounded slice. + val nip04History = ChatroomListNip04HistorySubAssembler(client, ::allKeys, giftWrapsHistory) val group = listOf( nip04, + nip04History, FollowingPublicChatSubAssembler(client, ::allKeys), FollowingEphemeralChatSubAssembler(client, ::allKeys), ) 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 new file mode 100644 index 0000000000..347898f429 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt @@ -0,0 +1,118 @@ +/* + * 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.amethyst.ui.screen.loggedIn.chats.rooms.datasource + +import com.vitorpamplona.amethyst.model.User +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.AccountGiftWrapsHistoryEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +/** + * Loads older NIP-04 DMs (kind 4) for the rooms list, following the gift-wrap history's current + * bounded slice ([AccountGiftWrapsHistoryEoseManager.currentSlice]) so both DM protocols page the + * past to the same depth. NIP-04 timestamps are exact, so the slice needs no 2-day margin. [reload] + * re-issues at the (now-advanced) slice and tracks the load; idle until the first slice is opened. + */ +class ChatroomListNip04HistorySubAssembler( + client: INostrClient, + allKeys: () -> Set, + private val giftWrapsHistory: AccountGiftWrapsHistoryEoseManager, +) : PerUserEoseManager(client, allKeys) { + private val windowLoad = WindowLoadTracker("rooms.nip04.history") + val loadingMore: StateFlow = windowLoad.loading + + // Account scope for the watchdog. Volatile: written on IO (newSub), read on UI (reload). + @Volatile + private var scope: CoroutineScope? = null + + override fun updateFilter( + key: ChatroomListState, + since: SincePerRelayMap?, + ): List? { + val slice = giftWrapsHistory.currentSlice(user(key)) + if (!key.account.isWriteable() || slice == null) { + windowLoad.setExpectedRelays(emptySet()) + return emptyList() + } + val (sliceSince, sliceUntil) = slice + val homeRelays = key.account.homeRelays.flow.value + val dmRelays = key.account.dmRelays.flow.value + windowLoad.setExpectedRelays((homeRelays + dmRelays).toSet()) + Log.d("DMPagination") { "[rooms.nip04.history] REQ slice since=$sliceSince until=$sliceUntil on ${homeRelays.size + dmRelays.size} relay(s)" } + return homeRelays.map { filterNip04DMsFromMe(key.account.userProfile(), it, sliceSince, sliceUntil) } + + dmRelays.map { filterNip04DMsToMe(key.account.userProfile(), it, sliceSince, sliceUntil) } + } + + /** Re-issues at the gift-wrap history's now-advanced slice and tracks the load. */ + fun reload() { + Log.d("DMPagination") { "[rooms.nip04.history] reload" } + scope?.let { windowLoad.startLoading(it) } + invalidateFilters() + } + + override fun user(key: ChatroomListState) = key.account.userProfile() + + private val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: ChatroomListState): Subscription { + val user = user(key) + scope = key.account.scope + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.account.scope.launch(Dispatchers.IO) { + key.account.homeRelays.flow + .collectLatest { invalidateFilters() } + }, + key.account.scope.launch(Dispatchers.IO) { + key.account.dmRelays.flow + .collectLatest { invalidateFilters() } + }, + ) + + return requestNewSubscription( + windowLoad.trackingListener { relay, filters -> newEose(key, relay, TimeUtils.now(), filters) }, + ) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} 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 70549e1cd5..031d1fe0c5 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 @@ -31,7 +31,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -40,23 +39,17 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch /** - * Loads the account's NIP-04 DMs (kind 4) for the rooms list. It does not own a time window: it - * follows the gift-wrap window's [AccountGiftWrapsEoseManager.windowSince] floor, so both DM - * protocols are requested to the same depth. [reload] re-issues at the current floor (called after - * the gift-wrap window widens); [loadingMore] reports when this protocol has covered that floor. + * Always-on **live tail** for the account's NIP-04 DMs (kind 4) in the rooms list. Mirrors the + * gift-wrap live tail: a fixed one-week floor, no upper bound, never widens. Older NIP-04 history is + * loaded by [ChatroomListNip04HistorySubAssembler] in bounded slices. */ class ChatroomListNip04SubAssembler( client: INostrClient, allKeys: () -> Set, - private val giftWraps: AccountGiftWrapsEoseManager, ) : PerUserEoseManager(client, allKeys) { - private val windowLoad = WindowLoadTracker("rooms.nip04") + private val windowLoad = WindowLoadTracker("rooms.nip04.live") val loadingMore: StateFlow = windowLoad.loading - // Account scope for the watchdog. Volatile: written on IO (newSub), read on UI (reload). - @Volatile - private var scope: CoroutineScope? = null - override fun updateFilter( key: ChatroomListState, since: SincePerRelayMap?, @@ -64,25 +57,16 @@ class ChatroomListNip04SubAssembler( if (key.account.isWriteable()) { val homeRelays = key.account.homeRelays.flow.value val dmRelays = key.account.dmRelays.flow.value - val relays = (homeRelays + dmRelays).toSet() - windowLoad.setExpectedRelays(relays) - val windowSince = giftWraps.windowSince(user(key)) - val daysAgo = (TimeUtils.now() - windowSince) / TimeUtils.ONE_DAY - Log.d("DMPagination") { "[rooms.nip04] REQ since=$windowSince (${daysAgo}d) on ${relays.size} relay(s)" } - homeRelays.map { filterNip04DMsFromMe(key.account.userProfile(), it, windowSince) } + - dmRelays.map { filterNip04DMsToMe(key.account.userProfile(), it, windowSince) } + windowLoad.setExpectedRelays((homeRelays + dmRelays).toSet()) + val sinceTime = TimeUtils.now() - AccountGiftWrapsEoseManager.LIVE_TAIL_SECONDS + Log.d("DMPagination") { "[rooms.nip04.live] REQ since=$sinceTime (7d, no until) on ${homeRelays.size + dmRelays.size} relay(s)" } + homeRelays.map { filterNip04DMsFromMe(key.account.userProfile(), it, sinceTime) } + + dmRelays.map { filterNip04DMsToMe(key.account.userProfile(), it, sinceTime) } } else { windowLoad.setExpectedRelays(emptySet()) emptyList() } - /** Re-issues at the (now-wider) shared gift-wrap floor and tracks the load. */ - fun reload() { - Log.d("DMPagination") { "[rooms.nip04] reload" } - scope?.let { windowLoad.startLoading(it) } - invalidateFilters() - } - override fun user(key: ChatroomListState) = key.account.userProfile() private val userJobMap = mutableMapOf>() @@ -90,7 +74,6 @@ class ChatroomListNip04SubAssembler( @OptIn(FlowPreview::class) override fun newSub(key: ChatroomListState): Subscription { val user = user(key) - scope = key.account.scope windowLoad.startLoading(key.account.scope) userJobMap[user]?.forEach { it.cancel() } userJobMap[user] = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt index de96b9f2aa..63aaa11f4a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt @@ -30,6 +30,7 @@ fun filterNip04DMsFromMe( user: User, relay: NormalizedRelayUrl, since: Long?, + until: Long? = null, ): RelayBasedFilter = RelayBasedFilter( relay = relay, @@ -38,5 +39,6 @@ fun filterNip04DMsFromMe( kinds = listOf(PrivateDmEvent.KIND), authors = listOf(user.pubkeyHex), since = since, + until = until, ), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt index dcfdab1602..87f1514d19 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt @@ -30,6 +30,7 @@ fun filterNip04DMsToMe( user: User, relay: NormalizedRelayUrl, since: Long?, + until: Long? = null, ): RelayBasedFilter = RelayBasedFilter( relay = relay, @@ -38,5 +39,6 @@ fun filterNip04DMsToMe( kinds = listOf(PrivateDmEvent.KIND), tags = mapOf("p" to listOf(user.pubkeyHex)), since = since, + until = until, ), ) 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 5dbcb8b6e8..f23dd78701 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 @@ -61,7 +61,6 @@ import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter import java.io.Serializable @Composable @@ -91,11 +90,11 @@ private fun CrossFadeState( ) { val feedState by feedContentState.feedContent.collectAsStateWithLifecycle() - // The gift-wrap window is the single DM window (NIP-04 follows it), so once it reaches the - // maximum lookback the rooms list has pulled everything there is. Until then an empty feed means - // "still filling", not "no conversations" — keep the spinner up rather than flash empty. - val giftWraps = remember(accountViewModel) { accountViewModel.dataSources().account.giftWraps } - val historyExhausted by giftWraps.exhausted.collectAsStateWithLifecycle() + // The gift-wrap history window is the DM history window (NIP-04 history follows it), so once it + // reaches the maximum lookback the rooms list has pulled everything there is. Until then an empty + // feed means "still filling", not "no conversations" — keep the spinner up rather than flash empty. + val giftWrapsHistory = remember(accountViewModel) { accountViewModel.dataSources().account.giftWrapsHistory } + val historyExhausted by giftWrapsHistory.exhausted.collectAsStateWithLifecycle() // While the whole list is empty there is no LazyColumn to scroll, so keep widening the private // DM window here until rooms appear or it is exhausted. (Public / ephemeral / group rooms are @@ -142,18 +141,24 @@ private fun FeedLoaded( val myPubKey = accountViewModel.userProfile().pubkeyHex - val giftWraps = remember(accountViewModel) { accountViewModel.dataSources().account.giftWraps } - val nip04 = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04 } - val loadingGiftWraps by giftWraps.loadingMore.collectAsStateWithLifecycle() - val loadingNip04 by nip04.loadingMore.collectAsStateWithLifecycle() + val giftWrapsHistory = remember(accountViewModel) { accountViewModel.dataSources().account.giftWrapsHistory } + val nip04History = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04History } + val loadingGiftWraps by giftWrapsHistory.loadingMore.collectAsStateWithLifecycle() + val loadingNip04 by nip04History.loadingMore.collectAsStateWithLifecycle() val loadingMore = loadingGiftWraps || loadingNip04 - val historyExhausted by giftWraps.exhausted.collectAsStateWithLifecycle() + val historyExhausted by giftWrapsHistory.exhausted.collectAsStateWithLifecycle() // Widen the private DM window only as the user approaches the oldest LOADED private chat — // ignoring public / group / ephemeral rooms below it. Those are membership-based and can be // arbitrarily old; counting them would either stall private paging or drag the window back - // years. The lambda reads the live items + scroll state inside the snapshotFlow. - WidenPrivateWindowWhen(accountViewModel, "scroll") { + // years. [privateRoomCount] feeds the stall-gate: widening keeps pulling older MESSAGES, which for + // a few busy correspondents floods events without adding a row — so paging stops once a step + // brings in no new private room. The lambda reads the live items + scroll state in the snapshotFlow. + WidenPrivateWindowWhen( + accountViewModel, + "scroll", + privateRoomCount = { items.list.count { it.event is ChatroomKeyable } }, + ) { val info = listState.layoutInfo val total = info.totalItemsCount val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1 @@ -188,8 +193,8 @@ private fun FeedLoaded( if (index == privateBoundaryIndex && (loadingMore || !historyExhausted)) { DmLoadMoreIndicator(loadingMore, showLoadAll = !historyExhausted) { val user = accountViewModel.userProfile() - giftWraps.loadEverything(user) - nip04.reload() + giftWrapsHistory.loadEverything(user) + nip04History.reload() } } } @@ -199,8 +204,8 @@ private fun FeedLoaded( item(key = "loadingMoreFooter") { DmLoadMoreIndicator(loadingMore, showLoadAll = !historyExhausted) { val user = accountViewModel.userProfile() - giftWraps.loadEverything(user) - nip04.reload() + giftWrapsHistory.loadEverything(user) + nip04History.reload() } } } @@ -230,29 +235,48 @@ private const val PREFETCH_PRIVATE_CHATS = 5 private fun WidenPrivateWindowWhen( accountViewModel: AccountViewModel, trigger: String, + // Number of distinct private rooms currently loaded, or null for callers that should keep widening + // regardless (the empty feed, still hunting for the first room). When provided, the loop stops + // advancing once a widen brings in no new private room — widening only adds older MESSAGES, so a + // few correspondents' history can flood thousands of events without adding a row, and "fill the + // screen" must stop at "no new people" instead of walking the window to the 10-year backstop. The + // mark lives on the history manager so it survives leaving/reopening the screen. + privateRoomCount: (() -> Int)? = null, wantMore: () -> Boolean, ) { - val giftWraps = remember(accountViewModel) { accountViewModel.dataSources().account.giftWraps } - val nip04 = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04 } + val giftWrapsHistory = remember(accountViewModel) { accountViewModel.dataSources().account.giftWrapsHistory } + val nip04History = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04History } - LaunchedEffect(giftWraps, nip04) { + LaunchedEffect(giftWrapsHistory, nip04History) { combine( - snapshotFlow { wantMore() }, - giftWraps.loadingMore, - nip04.loadingMore, - giftWraps.exhausted, - ) { want, loadingGiftWraps, loadingNip04, exhausted -> - want && !loadingGiftWraps && !loadingNip04 && !exhausted + // Carries the private-room count (>= 0) while a widen is wanted, or NOT_WANTED otherwise. + snapshotFlow { if (wantMore()) (privateRoomCount?.invoke() ?: STILL_SEARCHING) else NOT_WANTED }, + giftWrapsHistory.loadingMore, + nip04History.loadingMore, + giftWrapsHistory.exhausted, + ) { count, loadingGiftWraps, loadingNip04, exhausted -> + if (count != NOT_WANTED && !loadingGiftWraps && !loadingNip04 && !exhausted) count else NOT_WANTED }.distinctUntilChanged() - .filter { it } - .collect { - Log.d("DMPagination") { "rooms.list: widen ($trigger) → loadMore + reload" } - giftWraps.loadMore(accountViewModel.userProfile()) - nip04.reload() + .collect { count -> + if (count == NOT_WANTED) return@collect + // Stop once a widen adds no new private room (but keep hunting while none are loaded). + if (privateRoomCount != null && count > 0 && count <= giftWrapsHistory.autoFillPrivateRoomMark) { + Log.d("DMPagination") { "rooms.list: widen ($trigger) stop — no new private rooms (count=$count)" } + return@collect + } + if (privateRoomCount != null) giftWrapsHistory.autoFillPrivateRoomMark = count + Log.d("DMPagination") { "rooms.list: widen ($trigger) → loadMore + reload (privateRooms=$count)" } + giftWrapsHistory.loadMore(accountViewModel.userProfile()) + nip04History.reload() } } } +// Sentinels for the widen-trigger flow: NOT_WANTED suppresses widening; STILL_SEARCHING is the count a +// caller without a private-room measure reports while it wants to keep widening (e.g. the empty feed). +private const val NOT_WANTED = -1 +private const val STILL_SEARCHING = 0 + // Stable per-chatroom key — derived from chatroom identity, not the latest // message id, so reorders move the row instead of recreating it. Compose // stores LazyColumn item keys in a SaveableStateHolder, which on Android diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/FilterGiftWrapsToPubkey.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/FilterGiftWrapsToPubkey.kt index 191d4e82e6..09b43e335d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/FilterGiftWrapsToPubkey.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/FilterGiftWrapsToPubkey.kt @@ -32,6 +32,7 @@ fun filterGiftWrapsToPubkey( relay: NormalizedRelayUrl, pubkey: HexKey?, since: Long?, + until: Long? = null, ): List { if (pubkey.isNullOrEmpty()) return emptyList() @@ -42,7 +43,12 @@ fun filterGiftWrapsToPubkey( Filter( kinds = listOf(GiftWrapEvent.KIND, EphemeralGiftWrapEvent.KIND), tags = mapOf("p" to listOf(pubkey)), + // A gift wrap's outer created_at is randomized up to 2 days before the real + // message time, so widen the lower bound by 2 days to catch wraps for messages + // right at the floor. (The upper bound needs no margin: a slice's `until` is the + // previous slice's un-margined floor, so the 2-day overlap already covers the seam.) since = since?.minus(TimeUtils.twoDays()), + until = until, ), ), ) From bc86813cbc4affb1d1c0c06c9cf7c7d4c7dca111 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 01:18:53 +0000 Subject: [PATCH 027/103] fix: load older history at the start of a short conversation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conversation auto-fill only fired when the thread overflowed the screen, so a one-message room sat at its load-more boundary without ever advancing — you were at the start of the chat but it wouldn't reach for older messages. That overflow guard was added back when each widen re-downloaded the whole window (to stop a short thread auto-walking the gift-wrap firehose); now that history loads in bounded, non-re-downloading slices that reason is gone. Drop the overflow requirement: load the next slice whenever the oldest end is in view, including a thread too short to scroll. A one-message room now walks history back to its real beginning (or until the window is exhausted), one bounded slice at a time, gated on both loaders being idle. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../loggedIn/chats/privateDM/ChatroomView.kt | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) 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 38a02225f9..60eb3ffa3e 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 @@ -139,16 +139,17 @@ private const val PREFETCH_OLDER_MESSAGES = 3 /** * Scroll-driven history loader for a conversation. The thread is reverse-laid-out (newest at the - * bottom, index 0), so older messages live at higher indices. It loads the next, older window only - * when the thread already overflows the screen AND the user has scrolled near the oldest loaded - * message — so a short thread is never auto-walked to the start of history (that would load the whole - * account's gift-wrap history). For more than what scrolling reaches, the oldest-end boundary offers - * an explicit "Load entire history". + * bottom, index 0), so older messages (and the load-more boundary) live at the highest indices. It + * loads the next, older slice whenever the oldest end is in view — including a thread too short to + * scroll, so sitting at the start of a one-message chat keeps walking history back to its real + * beginning (or until the window is exhausted). Each step is a bounded, one-shot slice that never + * re-downloads, so walking a short thread is cheap per step — gift wraps can't be filtered per room, + * so this advances the shared account-wide history window and the conversation's messages surface as + * its slices are decrypted. * - * The account-wide gift-wrap window is the single source of truth for the floor: NIP-17 advances via - * [AccountGiftWrapsEoseManager.loadMore], and the NIP-04 loader [ChatroomNip04SubAssembler.reload] - * re-requests kind:4 from that same floor. The step is gated on BOTH loaders being idle, so it never - * outruns the slower protocol. + * NIP-17 advances via [AccountGiftWrapsHistoryEoseManager.loadMore]; the NIP-04 follower + * [ChatroomNip04HistorySubAssembler.reload] re-requests kind:4 at that same slice. The step is gated + * on BOTH loaders being idle, so it never outruns the slower protocol, and stops once exhausted. */ @Composable private fun LoadOlderMessagesWhenScrolling( @@ -164,8 +165,9 @@ private fun LoadOlderMessagesWhenScrolling( val info = listState.layoutInfo val total = info.totalItemsCount val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1 - val overflowsScreen = info.visibleItemsInfo.size < total - overflowsScreen && lastVisible >= total - PREFETCH_OLDER_MESSAGES + // The oldest end is in view (no overflow requirement, so a one-message thread that + // can't scroll still qualifies and walks history to its start). + total > 0 && lastVisible >= total - PREFETCH_OLDER_MESSAGES }, giftWrapsHistory.loadingMore, nip04History.loadingMore, @@ -175,7 +177,7 @@ private fun LoadOlderMessagesWhenScrolling( }.distinctUntilChanged() .filter { it } .collect { - Log.d("DMPagination") { "convo: widen (scrolled near oldest) → loadMore + reload" } + Log.d("DMPagination") { "convo: widen (oldest in view) → loadMore + reload" } giftWrapsHistory.loadMore(accountViewModel.userProfile()) nip04History.reload() } From 77d8657b6269aab9a7aeaee193915441dcfea7e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 03:54:09 +0000 Subject: [PATCH 028/103] feat: page DM history by until+limit per relay (gap-proof stop signal) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The time-slice history bounded re-downloads but couldn't tell "this relay is empty" from "this is a gap" — an empty time slice can sit above older messages, so the only stop was the 10-year maxLookback, and a wide late slice could pull a 20k-event firehose in one request. History now pages backward by until+limit, per relay (UntilLimitPager). Each round asks every not-yet-empty relay for up to 10000 events older than its own cursor, no since, so gaps are skipped: an empty page + EOSE is a gap-proof "nothing older on this relay" signal. A relay returning fewer than the limit is treated as its own cap, not exhaustion — only an empty page ends it. A relay answering CLOSED isn't "empty" (it may answer after the auth handshake), so the global exhausted flag flips only when a whole round advances no relay at all, which also stops the loop on a relay that keeps CLOSing. limit caps per-request volume too. Both NIP-04 history managers now paginate themselves (per relay, scoped) instead of following the gift-wrap slice; loadEverything pages to the end by auto-issuing the next round until exhausted. The live tail and the rooms-list stall-gate are unchanged. Filter builders gained an optional limit; the conversation NIP-04 helper exposes its outbox relay set + a per-relay until builder. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- ...6-06-01-dm-live-tail-and-history-slices.md | 19 ++ .../eoseManagers/UntilLimitPager.kt | 130 +++++++++ .../RelaySubscriptionsCoordinator.kt | 4 +- .../AccountGiftWrapsHistoryEoseManager.kt | 259 ++++++++++-------- .../loggedIn/chats/privateDM/ChatroomView.kt | 16 +- .../datasource/ChatroomFilterAssembler.kt | 6 +- .../ChatroomNip04HistorySubAssembler.kt | 177 +++++++++--- .../privateDM/datasource/FilterNip04DMs.kt | 117 +++++--- .../datasource/ChatroomListFilterAssembler.kt | 6 +- .../ChatroomListNip04HistorySubAssembler.kt | 186 +++++++++---- .../rooms/datasource/FilterNip04DMsFromMe.kt | 2 + .../rooms/datasource/FilterNip04DMsToMe.kt | 2 + .../chats/rooms/feed/ChatroomListFeedView.kt | 29 +- .../nip17Dm/FilterGiftWrapsToPubkey.kt | 2 + 14 files changed, 696 insertions(+), 259 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt diff --git a/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md b/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md index d79bd1a2c9..fe259a14cc 100644 --- a/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md +++ b/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md @@ -73,3 +73,22 @@ screen). "Fill until full **or nothing new found**", instead of walking to the managers. - `ChatroomListFeedView`, `ChatroomView` — point "load older" at the history managers; combine live+history `loadingMore` for spinners; add the stall-gate. + +## Update: time-slices → `until`+`limit` paging + +The time-slice history (above) bounded re-downloads but still couldn't tell +"this relay is empty" from "this is a gap" — an empty slice might sit above +older messages, so the only stop was the 10-year `maxLookback`, and a wide late +slice could pull a 20k-event firehose. + +The history managers now page **backward by `until`+`limit`, per relay** +(`UntilLimitPager`). A relay returns up to `limit` (10000) events older than its +cursor, **skipping gaps**, so an empty page + EOSE is a gap-proof "nothing older" +signal. A relay returning fewer is treated as its own cap, not exhaustion (only +empty ends it). A relay answering CLOSED isn't "empty" (it may answer post-auth), +so the **global** exhausted flag flips only when a whole round advances no relay +at all. `limit` also caps per-request volume. + +Both NIP-04 history managers now paginate themselves (per relay) instead of +following the gift-wrap slice; `loadEverything` pages to the end by auto-issuing +the next round until exhausted. The live tail and stall-gate are unchanged. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt new file mode 100644 index 0000000000..0dffc22f52 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt @@ -0,0 +1,130 @@ +/* + * 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.amethyst.service.relayClient.eoseManagers + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import java.util.concurrent.ConcurrentHashMap + +/** + * Backward `until`+`limit` pagination cursor, tracked **independently per relay** (and per [K], e.g. + * per account or per conversation). + * + * The time-window model can't tell "this relay is empty" from "this is a gap" — a `since`/`until` + * slice that returns nothing might just be a quiet stretch with older messages beneath it. Paging by + * `until`+`limit` removes that ambiguity: a relay returns its N newest events older than `until`, + * **skipping gaps**, so an empty page can only mean there is nothing older. + * + * Stop signal (per relay): an **empty page followed by EOSE** ([onEose] with no events) marks that + * relay [done][isDone]. A relay that returns anything — even fewer than the requested limit, since a + * relay may cap results below what we asked — is *not* done; its cursor advances to one second below + * the oldest event it sent and it is asked again. Globally, the owner decides "exhausted" from + * [roundEventCount]: a whole round that advanced no relay (every relay empty-EOSE'd or only answered + * CLOSED) means nothing more is reachable. + * + * Not internally synchronized: per-relay counters are touched on the relay IO threads (one relay's + * callbacks are serialized) and read on the owning scope after the load settles; fields are volatile. + */ +class UntilLimitPager { + private class RelayCursor { + // The `until` for this relay's next page; null until the first page (caller supplies the start). + @Volatile var until: Long? = null + + // Set once the relay answered an empty page with EOSE: there is nothing older on it. + @Volatile var done: Boolean = false + + // Per-round tallies, reset by [beginRound]: how many events arrived and the oldest among them. + @Volatile var roundCount: Int = 0 + + @Volatile var roundOldest: Long = Long.MAX_VALUE + } + + private val perKey = ConcurrentHashMap>() + + private fun cursorsFor(key: K) = perKey.getOrPut(key) { ConcurrentHashMap() } + + private fun cursor( + key: K, + relay: NormalizedRelayUrl, + ) = cursorsFor(key).getOrPut(relay) { RelayCursor() } + + /** The `until` to request next from [relay], or [start] if it has not been paged yet. */ + fun untilFor( + key: K, + relay: NormalizedRelayUrl, + start: Long, + ): Long = cursor(key, relay).until ?: start + + /** True once [relay] answered an empty page with EOSE — nothing older to ask it for. */ + fun isDone( + key: K, + relay: NormalizedRelayUrl, + ): Boolean = cursor(key, relay).done + + /** Resets the per-round tallies for the relays a fresh round is about to request. */ + fun beginRound( + key: K, + relays: Collection, + ) = relays.forEach { + val c = cursor(key, it) + c.roundCount = 0 + c.roundOldest = Long.MAX_VALUE + } + + /** Records one event for [relay] in the current round. */ + fun onEvent( + key: K, + relay: NormalizedRelayUrl, + createdAt: Long, + ) { + val c = cursor(key, relay) + c.roundCount++ + if (createdAt < c.roundOldest) c.roundOldest = createdAt + } + + /** + * Finalizes [relay] for the round on its EOSE: an empty page marks it [done]; otherwise its cursor + * advances to just below the oldest event it returned (exclusive, so the next page makes progress + * and the relay can eventually reach an empty page). + */ + fun onEose( + key: K, + relay: NormalizedRelayUrl, + ) { + val c = cursor(key, relay) + if (c.roundCount == 0) { + c.done = true + } else { + c.until = c.roundOldest - 1 + } + } + + /** Total events received across [relays] in the round just finished. Zero ⇒ nothing more is reachable. */ + fun roundEventCount( + key: K, + relays: Collection, + ): Int = relays.sumOf { cursor(key, it).roundCount } + + /** Relays from [all] that still have older history to ask for (not yet empty-EOSE'd). */ + fun activeRelays( + key: K, + all: Collection, + ): List = all.filterNot { cursor(key, it).done } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 40a4db2d60..9357f8fee5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -90,7 +90,7 @@ class RelaySubscriptionsCoordinator( // always running, feed assemblers. val home = HomeFilterAssembler(client) - val chatroomList = ChatroomListFilterAssembler(client, account.giftWrapsHistory) + val chatroomList = ChatroomListFilterAssembler(client) val video = VideoFilterAssembler(client) val discovery = DiscoveryFilterAssembler(client) @@ -105,7 +105,7 @@ class RelaySubscriptionsCoordinator( // active depending on the screen. val channel = ChannelFilterAssembler(client) - val chatroom = ChatroomFilterAssembler(client, account.giftWrapsHistory) + val chatroom = ChatroomFilterAssembler(client) val community = CommunityFilterAssembler(client) val gitRepository = RepositoryFilterAssembler(client) val thread = ThreadFilterAssembler(client) 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 fcedc2dc92..3ec331753f 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 @@ -21,40 +21,42 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey -import com.vitorpamplona.amethyst.commons.relayClient.pagination.TimeWindowPagination +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.UntilLimitPager 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.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient 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 +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +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.Dispatchers -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicInteger /** * Loads the account's NIP-17 gift-wrap **history** — everything older than the one-week live tail - * ([AccountGiftWrapsEoseManager]) — in bounded, one-shot `[since, until]` slices. + * ([AccountGiftWrapsEoseManager]) — by **`until`+`limit` paging, per relay**. * - * It is idle until the screens call [loadMore]: each call advances the floor one geometric step and - * requests **only the new band** `[newFloor, previousFloor]`, never the whole window. Consecutive - * slices are disjoint, so re-issuing the (advanced) filter on a reconnect does not re-stream older - * slices — those already live in the cache. The 2-day NIP-17 margin (applied to the slice `since` by - * [filterGiftWrapsToPubkey]) overlaps adjacent slices so a randomized outer timestamp can't open a gap. + * Idle until a screen calls [loadMore]. Each round asks every not-yet-empty relay for [PAGE_LIMIT] + * gift wraps older than its own cursor (no `since`, so gaps are skipped). When a relay answers an + * empty page with EOSE it is done; otherwise its cursor advances below the oldest wrap it sent. The + * limit is **not** trusted as a stop signal (a relay may cap results on its own) — only an empty page + * is. The whole history is [exhausted] once a full round advances no relay at all (every relay + * empty-EOSE'd or only answered CLOSED), which is the gap-proof "nothing more is reachable" signal the + * old time-slice model couldn't produce. */ class AccountGiftWrapsHistoryEoseManager( client: INostrClient, @@ -62,130 +64,126 @@ class AccountGiftWrapsHistoryEoseManager( ) : PerUserEoseManager(client, allKeys) { override fun user(key: AccountQueryState) = key.account.userProfile() - // The window's [TimeWindowPagination.since] is the oldest floor reached so far; it starts at the - // live-tail floor (now − 1 week) and moves back one geometric step per loadMore. - private val windows = ConcurrentHashMap() + private val pager = UntilLimitPager() - // The current slice to request per user, or absent until the first loadMore (manager stays idle). - private val slices = ConcurrentHashMap() + // Users that have requested history at least once (else the manager stays idle, issuing no REQ). + private val started = ConcurrentHashMap.newKeySet() - private data class Slice( - val since: Long, - val until: Long, - ) + // The relays the in-flight round asked, per user — used to tally the round on completion. + private val askedRelays = ConcurrentHashMap>() - private fun windowFor(user: User) = windows.computeIfAbsent(user.pubkeyHex) { TimeWindowPagination(growthFactor = WINDOW_GROWTH_FACTOR) } - - /** The current history slice for [user] (logical, un-margined bounds), or null while idle. */ - fun currentSlice(user: User): Pair? = slices[user.pubkeyHex]?.let { it.since to it.until } + // The account behind each user pubkey, captured on subscribe so [loadMore] (UI thread) can read + // the DM relay list without the key. + private val accounts = ConcurrentHashMap() private val windowLoad = WindowLoadTracker("giftwrap.history") val loadingMore: StateFlow = windowLoad.loading - // True once the floor reached the maximum lookback: nothing older to fetch. + // True once a full round advanced no relay — nothing older is reachable. private val _exhausted = MutableStateFlow(false) val exhausted: StateFlow = _exhausted.asStateFlow() // Rooms-list auto-fill stall mark: the number of distinct private rooms shown the last time the // list auto-widened. The list stops widening once a step adds no new room (widening only pulls // older MESSAGES, which for a few busy correspondents can be thousands of events without a single - // new room). Kept here, beside the window it guards, so the stall survives leaving and reopening - // the Messages screen — a fresh UI-local counter would re-widen on every open. + // new room). Kept here so the stall survives leaving and reopening the Messages screen. @Volatile var autoFillPrivateRoomMark: Int = Int.MIN_VALUE - // Account scope for the window-load watchdog. Volatile: written on IO (newSub), read on UI (loadMore). + // Account scope for the watchdog / round collector. Volatile: written on IO (newSub), read on UI. @Volatile private var scope: CoroutineScope? = null + @Volatile + private var roundJob: Job? = null + + // The user whose round is in flight, read by the round collector on completion. + @Volatile + private var lastRoundUser: User? = null + + // "Load entire history" mode: keep paging to the end without waiting for more scrolling. + @Volatile + private var autoLoadAll = false + + // History starts just below the live tail's one-week floor and pages backward from there. + private fun startUntil() = TimeUtils.now() - AccountGiftWrapsEoseManager.LIVE_TAIL_SECONDS + + private fun daysAgo(epochSeconds: Long) = (TimeUtils.now() - epochSeconds) / TimeUtils.ONE_DAY + override fun updateFilter( key: AccountQueryState, since: SincePerRelayMap?, ): List { - val slice = slices[user(key).pubkeyHex] - if (!key.account.isWriteable() || slice == null) { + val user = user(key) + if (!key.account.isWriteable() || user.pubkeyHex !in started) { windowLoad.setExpectedRelays(emptySet()) return emptyList() } val relays = key.account.dmRelays.flow.value - windowLoad.setExpectedRelays(relays.toSet()) - Log.d(TAG) { "[giftwrap.history] REQ slice [${daysAgo(slice.since)}d, ${daysAgo(slice.until)}d] on ${relays.size} relay(s)" } - return relays.flatMap { relay -> - filterGiftWrapsToPubkey(relay = relay, pubkey = user(key).pubkeyHex, since = slice.since, until = slice.until) + val active = pager.activeRelays(user.pubkeyHex, relays).toSet() + askedRelays[user.pubkeyHex] = active + windowLoad.setExpectedRelays(active) + if (active.isEmpty()) return emptyList() + Log.d(TAG) { "[giftwrap.history] REQ ${active.size} relay(s), limit=$PAGE_LIMIT (until ${daysAgo(pager.untilFor(user.pubkeyHex, active.first(), startUntil()))}d…)" } + return active.flatMap { relay -> + filterGiftWrapsToPubkey( + relay = relay, + pubkey = user.pubkeyHex, + since = null, + until = pager.untilFor(user.pubkeyHex, relay, startUntil()), + limit = PAGE_LIMIT, + ) } } - private fun daysAgo(epochSeconds: Long) = (TimeUtils.now() - epochSeconds) / TimeUtils.ONE_DAY - - /** Widens the floor one geometric step back and requests only the new slice. No-op if exhausted. */ + /** Requests the next backward page from every relay that still has older history. No-op if exhausted. */ fun loadMore(user: User) { - val window = windowFor(user) - if (window.isExhausted()) { - Log.d(TAG) { "[giftwrap.history] loadMore ignored — already exhausted" } + if (_exhausted.value) { + Log.d(TAG) { "[giftwrap.history] loadMore ignored — exhausted" } return } - val until = window.since - window.loadMore() - slices[user.pubkeyHex] = Slice(since = window.since, until = until) - _exhausted.value = window.isExhausted() - Log.d(TAG) { "[giftwrap.history] loadMore slice [${daysAgo(window.since)}d, ${daysAgo(until)}d] (exhausted=${_exhausted.value})" } - scope?.let { beginWindowLoad(user, it) } + val account = accounts[user.pubkeyHex] ?: return + started.add(user.pubkeyHex) + val active = pager.activeRelays(user.pubkeyHex, account.dmRelays.flow.value) + if (active.isEmpty()) { + _exhausted.value = true + return + } + pager.beginRound(user.pubkeyHex, active) + lastRoundUser = user + Log.d(TAG) { "[giftwrap.history] loadMore → ${active.size} active relay(s)" } + scope?.let { + ensureRoundCollector(it) + windowLoad.startLoading(it) + } invalidateFilters() } - /** Requests the entire remaining past in one slice `[maxLookback, currentFloor]`. */ + /** Pages to the very end: each completed round auto-issues the next until the history is exhausted. */ fun loadEverything(user: User) { - val window = windowFor(user) - if (window.isExhausted()) return - val until = window.since - window.loadAll() - slices[user.pubkeyHex] = Slice(since = window.since, until = until) - _exhausted.value = true - Log.d(TAG) { "[giftwrap.history] loadEverything — slice [${daysAgo(window.since)}d, ${daysAgo(until)}d]" } - scope?.let { beginWindowLoad(user, it) } - invalidateFilters() + if (_exhausted.value) return + autoLoadAll = true + Log.d(TAG) { "[giftwrap.history] loadEverything — paging to the end" } + loadMore(user) } - // Per-load instrumentation: how many gift wraps a slice pulled, and how many fell outside the band - // the REQ actually asked for ([loadSince], the margined floor) — a non-zero out-of-band share means - // a relay ignored the bounds. Volatile/atomic: the event hook runs on relay IO threads while the - // summary collector reads on the account scope. - @Volatile - private var loadSince = 0L - private val eventsThisLoad = AtomicInteger(0) - private val outOfWindowThisLoad = AtomicInteger(0) - - @Volatile - private var summaryJob: Job? = null - - private fun countEvent(createdAt: Long) { - eventsThisLoad.incrementAndGet() - if (createdAt < loadSince) outOfWindowThisLoad.incrementAndGet() - } - - private fun beginWindowLoad( - user: User, - scope: CoroutineScope, - ) { - loadSince = (slices[user.pubkeyHex]?.since ?: windowFor(user).since) - TimeUtils.twoDays() - eventsThisLoad.set(0) - outOfWindowThisLoad.set(0) - ensureSummaryLogger(scope) - windowLoad.startLoading(scope) - } - - private fun ensureSummaryLogger(scope: CoroutineScope) { - if (summaryJob?.isActive == true) return - summaryJob = + // Emits the round tally and the exhausted decision when the in-flight load settles. A round that + // received no events means no relay had anything older → exhausted; otherwise (and in load-all + // mode) keep paging. + private fun ensureRoundCollector(scope: CoroutineScope) { + if (roundJob?.isActive == true) return + roundJob = scope.launch { var wasLoading = false windowLoad.loading.collect { loading -> if (!loading && wasLoading) { - val total = eventsThisLoad.get() - val outOfWindow = outOfWindowThisLoad.get() - Log.d(TAG) { - "[giftwrap.history] load summary: $total event(s), $outOfWindow before floor " + - "(since=$loadSince, ${daysAgo(loadSince)}d back)" + val user = lastRoundUser + if (user != null) { + val asked = askedRelays[user.pubkeyHex] ?: emptySet() + val count = pager.roundEventCount(user.pubkeyHex, asked) + _exhausted.value = count == 0 + Log.d(TAG) { "[giftwrap.history] round done: $count event(s), exhausted=${count == 0}" } + if (autoLoadAll && count > 0) loadMore(user) } } wasLoading = loading @@ -193,41 +191,62 @@ class AccountGiftWrapsHistoryEoseManager( } } - private val userJobMap = mutableMapOf>() - - @OptIn(FlowPreview::class) override fun newSub(key: AccountQueryState): Subscription { val user = user(key) scope = key.account.scope - userJobMap[user]?.forEach { it.cancel() } - userJobMap[user] = - listOf( - key.account.scope.launch(Dispatchers.IO) { - key.account.dmRelays.flow - .collectLatest { invalidateFilters() } - }, - ) - - return requestNewSubscription( - windowLoad.trackingListener( - onEachEvent = { event -> countEvent(event.createdAt) }, - ) { relay, filters -> newEose(key, relay, TimeUtils.now(), filters) }, - ) + accounts[user.pubkeyHex] = key.account + return requestNewSubscription(historyListener(user, key)) } - override fun endSub( - key: User, - subId: String, - ) { - super.endSub(key, subId) - userJobMap[key]?.forEach { it.cancel() } - } + private fun historyListener( + user: User, + key: AccountQueryState, + ): SubscriptionListener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + windowLoad.onRelayEvent(relay) + pager.onEvent(user.pubkeyHex, relay, event.createdAt) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + pager.onEose(user.pubkeyHex, relay) + windowLoad.onRelaySettled(relay) + newEose(key, relay, TimeUtils.now(), forFilters) + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + // CLOSED (e.g. auth-required) is not "empty": don't mark the relay done — it may answer + // after the auth handshake. It just settles the load so the spinner can clear. + windowLoad.onRelaySettled(relay) + } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + windowLoad.onRelaySettled(relay) + } + } companion object { private const val TAG = "DMPagination" - // The slice doubles each widen so a sparse history (or confirming nothing older exists) - // reaches the 10-year backstop in ~10 requests instead of crawling a week at a time. - private const val WINDOW_GROWTH_FACTOR = 2L + // Asked of every relay per page. Large on purpose: we want a whole band in one round where the + // relay allows it. A relay returning fewer is treated as its own cap, NOT as "nothing more" — + // only an empty page + EOSE ends a relay. + private const val PAGE_LIMIT = 10000 } } 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 60eb3ffa3e..d01c90aba4 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 @@ -172,14 +172,16 @@ private fun LoadOlderMessagesWhenScrolling( giftWrapsHistory.loadingMore, nip04History.loadingMore, giftWrapsHistory.exhausted, - ) { wantMore, loadingGiftWraps, loadingNip04, exhausted -> - wantMore && !loadingGiftWraps && !loadingNip04 && !exhausted + nip04History.exhausted, + ) { wantMore, loadingGiftWraps, loadingNip04, giftWrapsExhausted, nip04Exhausted -> + // Keep paging while either protocol still has older history to reach. + wantMore && !loadingGiftWraps && !loadingNip04 && !(giftWrapsExhausted && nip04Exhausted) }.distinctUntilChanged() .filter { it } .collect { - Log.d("DMPagination") { "convo: widen (oldest in view) → loadMore + reload" } + Log.d("DMPagination") { "convo: widen (oldest in view) → loadMore" } giftWrapsHistory.loadMore(accountViewModel.userProfile()) - nip04History.reload() + nip04History.loadMore() } } } @@ -204,7 +206,9 @@ fun ChatroomViewUI( val nip04History = remember(accountViewModel) { accountViewModel.dataSources().chatroom.nip04History } val loadingGiftWraps by giftWrapsHistory.loadingMore.collectAsStateWithLifecycle() val loadingNip04 by nip04History.loadingMore.collectAsStateWithLifecycle() - val historyExhausted by giftWrapsHistory.exhausted.collectAsStateWithLifecycle() + val giftWrapsExhausted by giftWrapsHistory.exhausted.collectAsStateWithLifecycle() + val nip04Exhausted by nip04History.exhausted.collectAsStateWithLifecycle() + val historyExhausted = giftWrapsExhausted && nip04Exhausted Column(Modifier.fillMaxHeight()) { ObserveRelayListForDMsAndDisplayIfNotFound(accountViewModel, nav) @@ -238,7 +242,7 @@ fun ChatroomViewUI( Log.d("DMPagination") { "convo: Load entire history tapped" } val user = accountViewModel.userProfile() giftWrapsHistory.loadEverything(user) - nip04History.reload() + nip04History.loadEverything() } } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt index 4104bd7e21..9366082074 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomFilterAssembler.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsHistoryEoseManager import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey @@ -36,13 +35,12 @@ class ChatroomQueryState( class ChatroomFilterAssembler( client: INostrClient, - giftWrapsHistory: AccountGiftWrapsHistoryEoseManager, ) : ComposeSubscriptionManager() { // NIP-04 live tail: the recent week, always open at the top. val nip04 = ChatroomNip04SubAssembler(client, ::allKeys) - // NIP-04 history: older DMs, following the gift-wrap history's bounded slice. - val nip04History = ChatroomNip04HistorySubAssembler(client, ::allKeys, giftWrapsHistory) + // NIP-04 history: older DMs, paged backward by until+limit, independently of gift wraps. + val nip04History = ChatroomNip04HistorySubAssembler(client, ::allKeys) val group = listOf( 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 6579aad610..a4f15a3165 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,67 +21,180 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.UntilLimitPager 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.AccountGiftWrapsHistoryEoseManager +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.relay.client.INostrClient 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 +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +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.Job +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap /** - * Loads older NIP-04 DMs (kind 4) for one conversation, following the gift-wrap history's current - * bounded slice ([AccountGiftWrapsHistoryEoseManager.currentSlice]) so a thread shows both DM - * protocols to the same depth. NIP-04 timestamps are exact, so the slice needs no margin. [reload] - * re-issues at the now-advanced slice; idle until the first slice is opened. + * Loads older NIP-04 DMs (kind 4) for one conversation by `until`+`limit` paging, per relay, scoped to + * the two participants. Same gap-proof model as the gift-wrap history (a relay is done on an empty + * page + EOSE; [exhausted] once a round advances no relay), keyed per conversation. Idle until + * [loadMore]. */ class ChatroomNip04HistorySubAssembler( client: INostrClient, allKeys: () -> Set, - private val giftWrapsHistory: AccountGiftWrapsHistoryEoseManager, ) : PerUserAndFollowListEoseManager(client, allKeys) { + // Keyed per conversation (listId): each thread paginates independently. + private val pager = UntilLimitPager() + private val started = ConcurrentHashMap.newKeySet() + private val askedRelays = ConcurrentHashMap>() + private val windowLoad = WindowLoadTracker("convo.nip04.history") val loadingMore: StateFlow = windowLoad.loading - // Account scope for the watchdog. Volatile: written on IO (newSub), read on UI (reload). + private val _exhausted = MutableStateFlow(false) + val exhausted: StateFlow = _exhausted.asStateFlow() + @Volatile private var scope: CoroutineScope? = null - override fun updateFilter( - key: ChatroomQueryState, - since: SincePerRelayMap?, - ): List? { - val slice = giftWrapsHistory.currentSlice(user(key)) - if (!key.account.isWriteable() || slice == null) { - windowLoad.setExpectedRelays(emptySet()) - return emptyList() - } - val (sliceSince, sliceUntil) = slice - val filters = filterNip04DMs(key.room.users, key.account, sliceSince, sliceUntil) - windowLoad.setExpectedRelays(filters?.mapTo(mutableSetOf()) { it.relay } ?: emptySet()) - Log.d("DMPagination") { "[convo.nip04.history] REQ slice since=$sliceSince until=$sliceUntil on ${filters?.size ?: 0} relay-filter(s)" } - return filters - } + @Volatile + private var roundJob: Job? = null - /** Re-issues at the gift-wrap history's now-advanced slice and tracks the load. */ - fun reload() { - Log.d("DMPagination") { "[convo.nip04.history] reload" } - scope?.let { windowLoad.startLoading(it) } - invalidateFilters() - } + @Volatile + private var autoLoadAll = false + + private fun startUntil() = TimeUtils.now() - AccountGiftWrapsEoseManager.LIVE_TAIL_SECONDS override fun user(key: ChatroomQueryState) = key.account.userProfile() override fun list(key: ChatroomQueryState) = key.listId + override fun updateFilter( + key: ChatroomQueryState, + since: SincePerRelayMap?, + ): List? { + val relays = nip04DMRelays(key.room.users, key.account) + if (!key.account.isWriteable() || key.listId !in started || relays == null) { + windowLoad.setExpectedRelays(emptySet()) + return emptyList() + } + val active = pager.activeRelays(key.listId, relays.all).toSet() + askedRelays[key.listId] = active + windowLoad.setExpectedRelays(active) + if (active.isEmpty()) return emptyList() + Log.d("DMPagination") { "[convo.nip04.history] REQ ${active.size} relay(s), limit=$PAGE_LIMIT" } + val activeRelays = + Nip04DmRelays( + toMeRelays = relays.toMeRelays.intersect(active), + fromMeRelays = relays.fromMeRelays.intersect(active), + ) + return filterNip04DMsHistory(key.room.users, key.account, activeRelays, PAGE_LIMIT) { relay -> + pager.untilFor(key.listId, relay, startUntil()) + } + } + + /** Requests the next backward page for every open conversation that still has older history. */ + fun loadMore() { + if (_exhausted.value) return + var anyActive = false + allKeys().forEach { key -> + val relays = nip04DMRelays(key.room.users, key.account) ?: return@forEach + started.add(key.listId) + val active = pager.activeRelays(key.listId, relays.all) + if (active.isNotEmpty()) { + pager.beginRound(key.listId, active) + anyActive = true + } + } + if (!anyActive) { + _exhausted.value = true + return + } + Log.d("DMPagination") { "[convo.nip04.history] loadMore" } + scope?.let { + ensureRoundCollector(it) + windowLoad.startLoading(it) + } + invalidateFilters() + } + + /** Pages to the end: each completed round auto-issues the next until exhausted. */ + fun loadEverything() { + if (_exhausted.value) return + autoLoadAll = true + loadMore() + } + + private fun ensureRoundCollector(scope: CoroutineScope) { + if (roundJob?.isActive == true) return + roundJob = + scope.launch { + var wasLoading = false + windowLoad.loading.collect { loading -> + if (!loading && wasLoading) { + val count = started.sumOf { listId -> pager.roundEventCount(listId, askedRelays[listId] ?: emptySet()) } + _exhausted.value = count == 0 + Log.d("DMPagination") { "[convo.nip04.history] round done: $count event(s), exhausted=${count == 0}" } + if (autoLoadAll && count > 0) loadMore() + } + wasLoading = loading + } + } + } + override fun newSub(key: ChatroomQueryState): Subscription { scope = key.account.scope - return requestNewSubscription( - windowLoad.trackingListener { relay, filters -> newEose(key, relay, TimeUtils.now(), filters) }, - ) + return requestNewSubscription(historyListener(key)) + } + + private fun historyListener(key: ChatroomQueryState): SubscriptionListener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + windowLoad.onRelayEvent(relay) + pager.onEvent(key.listId, relay, event.createdAt) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + pager.onEose(key.listId, relay) + windowLoad.onRelaySettled(relay) + newEose(key, relay, TimeUtils.now(), forFilters) + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + windowLoad.onRelaySettled(relay) + } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + windowLoad.onRelaySettled(relay) + } + } + + companion object { + private const val PAGE_LIMIT = 10000 } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt index df04273eba..977f1bcf0d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt @@ -29,12 +29,22 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -fun filterNip04DMs( +/** The two relay sets a conversation's NIP-04 DMs flow over, resolved via the outbox model. */ +class Nip04DmRelays( + val toMeRelays: Set, + val fromMeRelays: Set, +) { + val all: Set get() = toMeRelays + fromMeRelays +} + +/** + * Resolves where a conversation's NIP-04 DMs flow: messages **to me** arrive on my inbox + the + * group's outbox relays; messages **from me** arrive on my outbox + the group's inbox relays. + */ +fun nip04DMRelays( group: Set?, account: Account?, - windowStart: Long, - windowEnd: Long? = null, -): List? { +): Nip04DmRelays? { if (group.isNullOrEmpty() || account == null) return null val userOutboxRelays = account.homeRelays.flow.value @@ -64,33 +74,74 @@ fun filterNip04DMs( groupInboxRelays.addAll(inbox) } - val toMeRelays = (userInboxRelays + groupOutboxRelays) - val fromMeRelays = (userOutboxRelays + groupInboxRelays) - - return toMeRelays.map { - RelayBasedFilter( - relay = it, - filter = - Filter( - kinds = listOf(PrivateDmEvent.KIND), - authors = group.toList(), - tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), - since = windowStart, - until = windowEnd, - ), - ) - } + - fromMeRelays.map { - RelayBasedFilter( - relay = it, - filter = - Filter( - kinds = listOf(PrivateDmEvent.KIND), - authors = listOf(account.userProfile().pubkeyHex), - tags = mapOf("p" to group.toList()), - since = windowStart, - until = windowEnd, - ), - ) - } + return Nip04DmRelays( + toMeRelays = userInboxRelays + groupOutboxRelays, + fromMeRelays = userOutboxRelays + groupInboxRelays, + ) } + +private fun toMeFilter( + relay: NormalizedRelayUrl, + group: Set, + account: Account, + since: Long?, + until: Long?, + limit: Int?, +) = RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(PrivateDmEvent.KIND), + authors = group.toList(), + tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), + since = since, + until = until, + limit = limit, + ), +) + +private fun fromMeFilter( + relay: NormalizedRelayUrl, + group: Set, + account: Account, + since: Long?, + until: Long?, + limit: Int?, +) = RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(PrivateDmEvent.KIND), + authors = listOf(account.userProfile().pubkeyHex), + tags = mapOf("p" to group.toList()), + since = since, + until = until, + limit = limit, + ), +) + +/** Live-tail filters: everything since [windowStart], open-ended at the top (new messages keep arriving). */ +fun filterNip04DMs( + group: Set?, + account: Account?, + windowStart: Long, +): List? { + if (group.isNullOrEmpty() || account == null) return null + val relays = nip04DMRelays(group, account) ?: return null + return relays.toMeRelays.map { toMeFilter(it, group, account, since = windowStart, until = null, limit = null) } + + relays.fromMeRelays.map { fromMeFilter(it, group, account, since = windowStart, until = null, limit = null) } +} + +/** + * History filters: a bounded backward page per relay. Each relay is asked for [limit] events older + * than [untilFor]`(relay)` (no `since`), so it can be paged down to empty independently. + */ +fun filterNip04DMsHistory( + group: Set, + account: Account, + relays: Nip04DmRelays, + limit: Int, + untilFor: (NormalizedRelayUrl) -> Long?, +): List = + relays.toMeRelays.map { toMeFilter(it, group, account, since = null, until = untilFor(it), limit = limit) } + + relays.fromMeRelays.map { fromMeFilter(it, group, account, since = null, until = untilFor(it), limit = limit) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt index 123bb0f9e4..489b241971 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsHistoryEoseManager import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient // This allows multiple screen to be listening to tags, even the same tag @@ -35,13 +34,12 @@ class ChatroomListState( @Stable class ChatroomListFilterAssembler( client: INostrClient, - giftWrapsHistory: AccountGiftWrapsHistoryEoseManager, ) : ComposeSubscriptionManager() { // NIP-04 live tail: the recent week, always open at the top. val nip04 = ChatroomListNip04SubAssembler(client, ::allKeys) - // NIP-04 history: older DMs, following the gift-wrap history's bounded slice. - val nip04History = ChatroomListNip04HistorySubAssembler(client, ::allKeys, giftWrapsHistory) + // NIP-04 history: older DMs, paged backward by until+limit, independently of gift wraps. + val nip04History = ChatroomListNip04HistorySubAssembler(client, ::allKeys) val group = listOf( 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 347898f429..40199ef1b3 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 @@ -20,99 +20,191 @@ */ 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.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.UntilLimitPager 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.AccountGiftWrapsHistoryEoseManager +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.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +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.Dispatchers -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap /** - * Loads older NIP-04 DMs (kind 4) for the rooms list, following the gift-wrap history's current - * bounded slice ([AccountGiftWrapsHistoryEoseManager.currentSlice]) so both DM protocols page the - * past to the same depth. NIP-04 timestamps are exact, so the slice needs no 2-day margin. [reload] - * re-issues at the (now-advanced) slice and tracks the load; idle until the first slice is opened. + * Loads older NIP-04 DMs (kind 4) for the rooms list by `until`+`limit` paging, per relay — the same + * gap-proof model as [com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsHistoryEoseManager], + * but for kind 4 (exact timestamps, no margin) across the account's home + DM relays. Idle until + * [loadMore]; a relay is done on an empty page + EOSE; the whole history is [exhausted] once a round + * advances no relay. */ class ChatroomListNip04HistorySubAssembler( client: INostrClient, allKeys: () -> Set, - private val giftWrapsHistory: AccountGiftWrapsHistoryEoseManager, ) : PerUserEoseManager(client, allKeys) { + private val pager = UntilLimitPager() + private val started = ConcurrentHashMap.newKeySet() + private val askedRelays = ConcurrentHashMap>() + private val accounts = ConcurrentHashMap() + private val windowLoad = WindowLoadTracker("rooms.nip04.history") val loadingMore: StateFlow = windowLoad.loading - // Account scope for the watchdog. Volatile: written on IO (newSub), read on UI (reload). + private val _exhausted = MutableStateFlow(false) + val exhausted: StateFlow = _exhausted.asStateFlow() + @Volatile private var scope: CoroutineScope? = null + @Volatile + private var roundJob: Job? = null + + @Volatile + private var lastRoundUser: User? = null + + @Volatile + private var autoLoadAll = false + + private fun startUntil() = TimeUtils.now() - AccountGiftWrapsEoseManager.LIVE_TAIL_SECONDS + + override fun user(key: ChatroomListState) = key.account.userProfile() + override fun updateFilter( key: ChatroomListState, since: SincePerRelayMap?, ): List? { - val slice = giftWrapsHistory.currentSlice(user(key)) - if (!key.account.isWriteable() || slice == null) { + val user = user(key) + if (!key.account.isWriteable() || user.pubkeyHex !in started) { windowLoad.setExpectedRelays(emptySet()) return emptyList() } - val (sliceSince, sliceUntil) = slice val homeRelays = key.account.homeRelays.flow.value val dmRelays = key.account.dmRelays.flow.value - windowLoad.setExpectedRelays((homeRelays + dmRelays).toSet()) - Log.d("DMPagination") { "[rooms.nip04.history] REQ slice since=$sliceSince until=$sliceUntil on ${homeRelays.size + dmRelays.size} relay(s)" } - return homeRelays.map { filterNip04DMsFromMe(key.account.userProfile(), it, sliceSince, sliceUntil) } + - dmRelays.map { filterNip04DMsToMe(key.account.userProfile(), it, sliceSince, sliceUntil) } + val active = pager.activeRelays(user.pubkeyHex, (homeRelays + dmRelays).toSet()).toSet() + askedRelays[user.pubkeyHex] = active + windowLoad.setExpectedRelays(active) + if (active.isEmpty()) return emptyList() + Log.d("DMPagination") { "[rooms.nip04.history] REQ ${active.size} relay(s), limit=$PAGE_LIMIT" } + return homeRelays.filter { it in active }.map { + filterNip04DMsFromMe(user, it, since = null, until = pager.untilFor(user.pubkeyHex, it, startUntil()), limit = PAGE_LIMIT) + } + + dmRelays.filter { it in active }.map { + filterNip04DMsToMe(user, it, since = null, until = pager.untilFor(user.pubkeyHex, it, startUntil()), limit = PAGE_LIMIT) + } } - /** Re-issues at the gift-wrap history's now-advanced slice and tracks the load. */ - fun reload() { - Log.d("DMPagination") { "[rooms.nip04.history] reload" } - scope?.let { windowLoad.startLoading(it) } + /** Requests the next backward page from every relay that still has older NIP-04 history. */ + fun loadMore(user: User) { + if (_exhausted.value) return + val account = accounts[user.pubkeyHex] ?: return + started.add(user.pubkeyHex) + val all = (account.homeRelays.flow.value + account.dmRelays.flow.value).toSet() + val active = pager.activeRelays(user.pubkeyHex, all) + if (active.isEmpty()) { + _exhausted.value = true + return + } + pager.beginRound(user.pubkeyHex, active) + lastRoundUser = user + Log.d("DMPagination") { "[rooms.nip04.history] loadMore → ${active.size} active relay(s)" } + scope?.let { + ensureRoundCollector(it) + windowLoad.startLoading(it) + } invalidateFilters() } - override fun user(key: ChatroomListState) = key.account.userProfile() + /** Pages to the end: each completed round auto-issues the next until exhausted. */ + fun loadEverything(user: User) { + if (_exhausted.value) return + autoLoadAll = true + loadMore(user) + } - private val userJobMap = mutableMapOf>() + private fun ensureRoundCollector(scope: CoroutineScope) { + if (roundJob?.isActive == true) return + roundJob = + scope.launch { + var wasLoading = false + windowLoad.loading.collect { loading -> + if (!loading && wasLoading) { + val user = lastRoundUser + if (user != null) { + val asked = askedRelays[user.pubkeyHex] ?: emptySet() + val count = pager.roundEventCount(user.pubkeyHex, asked) + _exhausted.value = count == 0 + Log.d("DMPagination") { "[rooms.nip04.history] round done: $count event(s), exhausted=${count == 0}" } + if (autoLoadAll && count > 0) loadMore(user) + } + } + wasLoading = loading + } + } + } - @OptIn(FlowPreview::class) override fun newSub(key: ChatroomListState): Subscription { val user = user(key) scope = key.account.scope - userJobMap[user]?.forEach { it.cancel() } - userJobMap[user] = - listOf( - key.account.scope.launch(Dispatchers.IO) { - key.account.homeRelays.flow - .collectLatest { invalidateFilters() } - }, - key.account.scope.launch(Dispatchers.IO) { - key.account.dmRelays.flow - .collectLatest { invalidateFilters() } - }, - ) - - return requestNewSubscription( - windowLoad.trackingListener { relay, filters -> newEose(key, relay, TimeUtils.now(), filters) }, - ) + accounts[user.pubkeyHex] = key.account + return requestNewSubscription(historyListener(user, key)) } - override fun endSub( - key: User, - subId: String, - ) { - super.endSub(key, subId) - userJobMap[key]?.forEach { it.cancel() } + private fun historyListener( + user: User, + key: ChatroomListState, + ): SubscriptionListener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + windowLoad.onRelayEvent(relay) + pager.onEvent(user.pubkeyHex, relay, event.createdAt) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + pager.onEose(user.pubkeyHex, relay) + windowLoad.onRelaySettled(relay) + newEose(key, relay, TimeUtils.now(), forFilters) + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + windowLoad.onRelaySettled(relay) + } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + windowLoad.onRelaySettled(relay) + } + } + + companion object { + private const val PAGE_LIMIT = 10000 } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt index 63aaa11f4a..c000c246d1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt @@ -31,6 +31,7 @@ fun filterNip04DMsFromMe( relay: NormalizedRelayUrl, since: Long?, until: Long? = null, + limit: Int? = null, ): RelayBasedFilter = RelayBasedFilter( relay = relay, @@ -40,5 +41,6 @@ fun filterNip04DMsFromMe( authors = listOf(user.pubkeyHex), since = since, until = until, + limit = limit, ), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt index 87f1514d19..4b1148e066 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt @@ -31,6 +31,7 @@ fun filterNip04DMsToMe( relay: NormalizedRelayUrl, since: Long?, until: Long? = null, + limit: Int? = null, ): RelayBasedFilter = RelayBasedFilter( relay = relay, @@ -40,5 +41,6 @@ fun filterNip04DMsToMe( tags = mapOf("p" to listOf(user.pubkeyHex)), since = since, until = until, + limit = limit, ), ) 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 f23dd78701..52538eb448 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 @@ -90,11 +90,14 @@ private fun CrossFadeState( ) { val feedState by feedContentState.feedContent.collectAsStateWithLifecycle() - // The gift-wrap history window is the DM history window (NIP-04 history follows it), so once it - // reaches the maximum lookback the rooms list has pulled everything there is. Until then an empty - // feed means "still filling", not "no conversations" — keep the spinner up rather than flash empty. + // History is exhausted only once BOTH DM protocols have paged to their end (each stops when a + // round of until+limit pages brings nothing back). Until then an empty feed means "still filling", + // not "no conversations" — keep the spinner up rather than flash empty. val giftWrapsHistory = remember(accountViewModel) { accountViewModel.dataSources().account.giftWrapsHistory } - val historyExhausted by giftWrapsHistory.exhausted.collectAsStateWithLifecycle() + val nip04History = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04History } + val giftWrapsExhausted by giftWrapsHistory.exhausted.collectAsStateWithLifecycle() + val nip04Exhausted by nip04History.exhausted.collectAsStateWithLifecycle() + val historyExhausted = giftWrapsExhausted && nip04Exhausted // While the whole list is empty there is no LazyColumn to scroll, so keep widening the private // DM window here until rooms appear or it is exhausted. (Public / ephemeral / group rooms are @@ -146,7 +149,9 @@ private fun FeedLoaded( val loadingGiftWraps by giftWrapsHistory.loadingMore.collectAsStateWithLifecycle() val loadingNip04 by nip04History.loadingMore.collectAsStateWithLifecycle() val loadingMore = loadingGiftWraps || loadingNip04 - val historyExhausted by giftWrapsHistory.exhausted.collectAsStateWithLifecycle() + val giftWrapsExhausted by giftWrapsHistory.exhausted.collectAsStateWithLifecycle() + val nip04Exhausted by nip04History.exhausted.collectAsStateWithLifecycle() + val historyExhausted = giftWrapsExhausted && nip04Exhausted // Widen the private DM window only as the user approaches the oldest LOADED private chat — // ignoring public / group / ephemeral rooms below it. Those are membership-based and can be @@ -194,7 +199,7 @@ private fun FeedLoaded( DmLoadMoreIndicator(loadingMore, showLoadAll = !historyExhausted) { val user = accountViewModel.userProfile() giftWrapsHistory.loadEverything(user) - nip04History.reload() + nip04History.loadEverything(user) } } } @@ -205,7 +210,7 @@ private fun FeedLoaded( DmLoadMoreIndicator(loadingMore, showLoadAll = !historyExhausted) { val user = accountViewModel.userProfile() giftWrapsHistory.loadEverything(user) - nip04History.reload() + nip04History.loadEverything(user) } } } @@ -254,8 +259,10 @@ private fun WidenPrivateWindowWhen( giftWrapsHistory.loadingMore, nip04History.loadingMore, giftWrapsHistory.exhausted, - ) { count, loadingGiftWraps, loadingNip04, exhausted -> - if (count != NOT_WANTED && !loadingGiftWraps && !loadingNip04 && !exhausted) count else NOT_WANTED + nip04History.exhausted, + ) { count, loadingGiftWraps, loadingNip04, giftWrapsExhausted, nip04Exhausted -> + // Keep paging while either protocol still has older history to reach. + if (count != NOT_WANTED && !loadingGiftWraps && !loadingNip04 && !(giftWrapsExhausted && nip04Exhausted)) count else NOT_WANTED }.distinctUntilChanged() .collect { count -> if (count == NOT_WANTED) return@collect @@ -265,9 +272,9 @@ private fun WidenPrivateWindowWhen( return@collect } if (privateRoomCount != null) giftWrapsHistory.autoFillPrivateRoomMark = count - Log.d("DMPagination") { "rooms.list: widen ($trigger) → loadMore + reload (privateRooms=$count)" } + Log.d("DMPagination") { "rooms.list: widen ($trigger) → loadMore (privateRooms=$count)" } giftWrapsHistory.loadMore(accountViewModel.userProfile()) - nip04History.reload() + nip04History.loadMore(accountViewModel.userProfile()) } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/FilterGiftWrapsToPubkey.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/FilterGiftWrapsToPubkey.kt index 09b43e335d..9502c087ad 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/FilterGiftWrapsToPubkey.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/FilterGiftWrapsToPubkey.kt @@ -33,6 +33,7 @@ fun filterGiftWrapsToPubkey( pubkey: HexKey?, since: Long?, until: Long? = null, + limit: Int? = null, ): List { if (pubkey.isNullOrEmpty()) return emptyList() @@ -49,6 +50,7 @@ fun filterGiftWrapsToPubkey( // previous slice's un-margined floor, so the 2-day overlap already covers the seam.) since = since?.minus(TimeUtils.twoDays()), until = until, + limit = limit, ), ), ) From 5e67fe913fb45afc925fd8998ef20d67b47ae9b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 17:57:56 +0000 Subject: [PATCH 029/103] fix: page NIP-17 and NIP-04 history independently in the rooms list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rooms list had a single auto-fill trigger and a single loading boundary, both pinned to the oldest private room of EITHER protocol. When NIP-04 history ran far deeper than NIP-17 (e.g. NIP-04 back to 2023, NIP-17 shallow), that oldest room was a 2023 NIP-04 row at the very bottom, so gift-wrap loadMore only fired when the user scrolled all the way down to it — NIP-17 never paged on the way, and the loading indicator was only visible at the bottom. Split the trigger and the boundary per protocol. Each protocol now widens on its OWN oldest-loaded room (gated only on its own loader and its own room-count stall-gate) and shows its OWN loading indicator at its own depth, so NIP-17 and NIP-04 page independently as the user scrolls — matching their very different histories. WidenPrivateWindowWhen is generalized to a per-protocol WidenHistoryWhen called once per protocol (and once per protocol for the empty-feed hunt). Each history manager carries its own auto-fill stall mark (autoFillRoomMark). https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../AccountGiftWrapsHistoryEoseManager.kt | 11 +- .../ChatroomListNip04HistorySubAssembler.kt | 4 + .../chats/rooms/feed/ChatroomListFeedView.kt | 176 +++++++++++------- 3 files changed, 116 insertions(+), 75 deletions(-) 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 3ec331753f..16ebe37005 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 @@ -83,12 +83,13 @@ class AccountGiftWrapsHistoryEoseManager( private val _exhausted = MutableStateFlow(false) val exhausted: StateFlow = _exhausted.asStateFlow() - // Rooms-list auto-fill stall mark: the number of distinct private rooms shown the last time the - // list auto-widened. The list stops widening once a step adds no new room (widening only pulls - // older MESSAGES, which for a few busy correspondents can be thousands of events without a single - // new room). Kept here so the stall survives leaving and reopening the Messages screen. + // Rooms-list auto-fill stall mark: the number of THIS protocol's distinct rooms shown the last + // time the list auto-widened it. The list stops widening once a step adds no new room of this + // protocol (widening only pulls older MESSAGES, which for a few busy correspondents can be + // thousands of events without a single new room). Kept here so the stall survives leaving and + // reopening the Messages screen. @Volatile - var autoFillPrivateRoomMark: Int = Int.MIN_VALUE + var autoFillRoomMark: Int = Int.MIN_VALUE // Account scope for the watchdog / round collector. Volatile: written on IO (newSub), read on UI. @Volatile 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 40199ef1b3..4a33868724 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 @@ -79,6 +79,10 @@ class ChatroomListNip04HistorySubAssembler( @Volatile private var autoLoadAll = false + // Rooms-list auto-fill stall mark for NIP-04 rooms (see the gift-wrap history manager's twin). + @Volatile + var autoFillRoomMark: Int = Int.MIN_VALUE + private fun startUntil() = TimeUtils.now() - AccountGiftWrapsEoseManager.LIVE_TAIL_SECONDS 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 52538eb448..5973d632df 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 @@ -54,11 +54,13 @@ 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.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import java.io.Serializable @@ -99,10 +101,28 @@ private fun CrossFadeState( val nip04Exhausted by nip04History.exhausted.collectAsStateWithLifecycle() val historyExhausted = giftWrapsExhausted && nip04Exhausted - // While the whole list is empty there is no LazyColumn to scroll, so keep widening the private - // DM window here until rooms appear or it is exhausted. (Public / ephemeral / group rooms are - // membership-based and load on their own — they are not part of the window.) - WidenPrivateWindowWhen(accountViewModel, "empty") { feedState is FeedState.Empty } + // While the whole list is empty there is no LazyColumn to scroll, so hunt BOTH protocols for the + // first rooms (no stall-gate while searching) until they appear or each is exhausted. The two run + // independently — NIP-04 and NIP-17 have very different histories and depths. + val user = accountViewModel.userProfile() + WidenHistoryWhen( + "empty.nip17", + giftWrapsHistory.loadingMore, + giftWrapsHistory.exhausted, + roomCount = null, + getMark = { 0 }, + setMark = {}, + loadMore = { giftWrapsHistory.loadMore(user) }, + ) { feedState is FeedState.Empty } + WidenHistoryWhen( + "empty.nip04", + nip04History.loadingMore, + nip04History.exhausted, + roomCount = null, + getMark = { 0 }, + setMark = {}, + loadMore = { nip04History.loadMore(user) }, + ) { feedState is FeedState.Empty } CrossfadeIfEnabled( targetState = feedState, @@ -148,32 +168,50 @@ private fun FeedLoaded( val nip04History = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04History } val loadingGiftWraps by giftWrapsHistory.loadingMore.collectAsStateWithLifecycle() val loadingNip04 by nip04History.loadingMore.collectAsStateWithLifecycle() - val loadingMore = loadingGiftWraps || loadingNip04 val giftWrapsExhausted by giftWrapsHistory.exhausted.collectAsStateWithLifecycle() val nip04Exhausted by nip04History.exhausted.collectAsStateWithLifecycle() - val historyExhausted = giftWrapsExhausted && nip04Exhausted + val user = accountViewModel.userProfile() - // Widen the private DM window only as the user approaches the oldest LOADED private chat — - // ignoring public / group / ephemeral rooms below it. Those are membership-based and can be - // arbitrarily old; counting them would either stall private paging or drag the window back - // years. [privateRoomCount] feeds the stall-gate: widening keeps pulling older MESSAGES, which for - // a few busy correspondents floods events without adding a row — so paging stops once a step - // brings in no new private room. The lambda reads the live items + scroll state in the snapshotFlow. - WidenPrivateWindowWhen( - accountViewModel, - "scroll", - privateRoomCount = { items.list.count { it.event is ChatroomKeyable } }, + // NIP-17 and NIP-04 have very different histories and depths (e.g. NIP-04 reaching back to 2023 + // while NIP-17 is shallow), so each protocol gets its OWN trigger keyed to its OWN oldest loaded + // room — otherwise the deeper protocol's tail pins the boundary to the bottom and the shallower + // one never loads until the user scrolls all the way past it. Each is gated only on its own loader + // and its own room count (stall-gate), so they advance independently as the user scrolls. Public / + // group / ephemeral rooms are membership-based and excluded. + WidenHistoryWhen( + "scroll.nip17", + giftWrapsHistory.loadingMore, + giftWrapsHistory.exhausted, + roomCount = { items.list.count { it.event is ChatroomKeyable && it.event !is PrivateDmEvent } }, + getMark = { giftWrapsHistory.autoFillRoomMark }, + setMark = { giftWrapsHistory.autoFillRoomMark = it }, + loadMore = { giftWrapsHistory.loadMore(user) }, ) { val info = listState.layoutInfo - val total = info.totalItemsCount val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1 - val oldestPrivate = items.list.indexOfLast { it.event is ChatroomKeyable } - total > 0 && (oldestPrivate < 0 || lastVisible >= oldestPrivate - PREFETCH_PRIVATE_CHATS) + val oldest = items.list.indexOfLast { it.event is ChatroomKeyable && it.event !is PrivateDmEvent } + info.totalItemsCount > 0 && (oldest < 0 || lastVisible >= oldest - PREFETCH_PRIVATE_CHATS) + } + WidenHistoryWhen( + "scroll.nip04", + nip04History.loadingMore, + nip04History.exhausted, + roomCount = { items.list.count { it.event is PrivateDmEvent } }, + getMark = { nip04History.autoFillRoomMark }, + setMark = { nip04History.autoFillRoomMark = it }, + loadMore = { nip04History.loadMore(user) }, + ) { + val info = listState.layoutInfo + val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1 + val oldest = items.list.indexOfLast { it.event is PrivateDmEvent } + info.totalItemsCount > 0 && (oldest < 0 || lastVisible >= oldest - PREFETCH_PRIVATE_CHATS) } - // The private-DM loading boundary sits right after the last loaded private chat: that's where - // older private history streams in, while public / group rooms below are shown regardless. - val privateBoundaryIndex = items.list.indexOfLast { it.event is ChatroomKeyable } + // One loading boundary PER protocol, at that protocol's oldest loaded room: a spinner while it + // pages and a "load entire history" button until it is exhausted. They sit at different depths + // (NIP-04's typically deeper), so the user sees each protocol load where its history actually ends. + val oldestNip17Index = items.list.indexOfLast { it.event is ChatroomKeyable && it.event !is PrivateDmEvent } + val oldestNip04Index = items.list.indexOfLast { it.event is PrivateDmEvent } LazyColumn( contentPadding = rememberFeedContentPadding(FeedPadding), @@ -195,21 +233,29 @@ private fun FeedLoaded( thickness = DividerThickness, ) - if (index == privateBoundaryIndex && (loadingMore || !historyExhausted)) { - DmLoadMoreIndicator(loadingMore, showLoadAll = !historyExhausted) { - val user = accountViewModel.userProfile() + if (index == oldestNip17Index && (loadingGiftWraps || !giftWrapsExhausted)) { + DmLoadMoreIndicator(loadingGiftWraps, showLoadAll = !giftWrapsExhausted) { giftWrapsHistory.loadEverything(user) + } + } + if (index == oldestNip04Index && (loadingNip04 || !nip04Exhausted)) { + DmLoadMoreIndicator(loadingNip04, showLoadAll = !nip04Exhausted) { nip04History.loadEverything(user) } } } - // No private chat is loaded yet (e.g. only public rooms so far): show the boundary at the end. - if (privateBoundaryIndex < 0 && (loadingMore || !historyExhausted)) { - item(key = "loadingMoreFooter") { - DmLoadMoreIndicator(loadingMore, showLoadAll = !historyExhausted) { - val user = accountViewModel.userProfile() + // Protocols with no room loaded yet (e.g. only public rooms so far): show their boundary at the end. + if (oldestNip17Index < 0 && (loadingGiftWraps || !giftWrapsExhausted)) { + item(key = "nip17Footer") { + DmLoadMoreIndicator(loadingGiftWraps, showLoadAll = !giftWrapsExhausted) { giftWrapsHistory.loadEverything(user) + } + } + } + if (oldestNip04Index < 0 && (loadingNip04 || !nip04Exhausted)) { + item(key = "nip04Footer") { + DmLoadMoreIndicator(loadingNip04, showLoadAll = !nip04Exhausted) { nip04History.loadEverything(user) } } @@ -222,59 +268,49 @@ private fun FeedLoaded( private const val PREFETCH_PRIVATE_CHATS = 5 /** - * Widens the DM window whenever [wantMore] becomes true and the previous step isn't still loading, - * stopping once the window is exhausted. Advances the single gift-wrap window ([loadMore]) and tells - * the NIP-04 follower to re-request at the new floor ([reload]). + * Drives ONE protocol's history paging from a scroll/empty trigger. When [wantMore] becomes true and + * that protocol isn't already loading or [exhausted], it calls [loadMore]. * - * [wantMore] is evaluated inside a snapshotFlow, so it may read live Compose state (scroll position, - * the feed list). Callers decide the policy: the empty feed widens to discover the first rooms; the - * loaded feed widens as the user approaches the oldest loaded PRIVATE chat — public, group and - * ephemeral rooms are membership-based (shown regardless of age) and deliberately excluded, so an - * old public chat at the bottom never drags the window back with it. + * [wantMore] and [roomCount] are read inside a snapshotFlow, so they may observe live Compose state + * (scroll position, the feed list). [roomCount] (when non-null) feeds the stall-gate: widening only + * pulls older MESSAGES, so a few busy correspondents can flood events without adding a single room — + * paging therefore stops once a step brings in no new room of this protocol (tracked via [getMark] / + * [setMark], which live on the history manager so the stall survives leaving/reopening the screen). + * Pass `roomCount = null` to widen regardless of progress (the empty feed, hunting for the first room). * - * The guard waits on BOTH loaders, gated on all of each one's relays answering (or a timeout) rather - * than the first EOSE, so a fast near-empty relay can't let the loop outrun the slow relay that - * holds the conversations. + * Each protocol gets its own instance, gated only on its own loader, so NIP-04 and NIP-17 — which have + * very different histories — page independently as the user scrolls. */ @Composable -private fun WidenPrivateWindowWhen( - accountViewModel: AccountViewModel, +private fun WidenHistoryWhen( trigger: String, - // Number of distinct private rooms currently loaded, or null for callers that should keep widening - // regardless (the empty feed, still hunting for the first room). When provided, the loop stops - // advancing once a widen brings in no new private room — widening only adds older MESSAGES, so a - // few correspondents' history can flood thousands of events without adding a row, and "fill the - // screen" must stop at "no new people" instead of walking the window to the 10-year backstop. The - // mark lives on the history manager so it survives leaving/reopening the screen. - privateRoomCount: (() -> Int)? = null, + loadingMore: StateFlow, + exhausted: StateFlow, + roomCount: (() -> Int)?, + getMark: () -> Int, + setMark: (Int) -> Unit, + loadMore: () -> Unit, wantMore: () -> Boolean, ) { - val giftWrapsHistory = remember(accountViewModel) { accountViewModel.dataSources().account.giftWrapsHistory } - val nip04History = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04History } - - LaunchedEffect(giftWrapsHistory, nip04History) { + LaunchedEffect(loadingMore, exhausted) { combine( - // Carries the private-room count (>= 0) while a widen is wanted, or NOT_WANTED otherwise. - snapshotFlow { if (wantMore()) (privateRoomCount?.invoke() ?: STILL_SEARCHING) else NOT_WANTED }, - giftWrapsHistory.loadingMore, - nip04History.loadingMore, - giftWrapsHistory.exhausted, - nip04History.exhausted, - ) { count, loadingGiftWraps, loadingNip04, giftWrapsExhausted, nip04Exhausted -> - // Keep paging while either protocol still has older history to reach. - if (count != NOT_WANTED && !loadingGiftWraps && !loadingNip04 && !(giftWrapsExhausted && nip04Exhausted)) count else NOT_WANTED + // Carries this protocol's room count (>= 0) while a widen is wanted, or NOT_WANTED otherwise. + snapshotFlow { if (wantMore()) (roomCount?.invoke() ?: STILL_SEARCHING) else NOT_WANTED }, + loadingMore, + exhausted, + ) { count, loading, exhaustedNow -> + if (count != NOT_WANTED && !loading && !exhaustedNow) count else NOT_WANTED }.distinctUntilChanged() .collect { count -> if (count == NOT_WANTED) return@collect - // Stop once a widen adds no new private room (but keep hunting while none are loaded). - if (privateRoomCount != null && count > 0 && count <= giftWrapsHistory.autoFillPrivateRoomMark) { - Log.d("DMPagination") { "rooms.list: widen ($trigger) stop — no new private rooms (count=$count)" } + // Stop once a widen adds no new room of this protocol (but keep hunting while none loaded). + if (roomCount != null && count > 0 && count <= getMark()) { + Log.d("DMPagination") { "rooms.list: widen ($trigger) stop — no new rooms (count=$count)" } return@collect } - if (privateRoomCount != null) giftWrapsHistory.autoFillPrivateRoomMark = count - Log.d("DMPagination") { "rooms.list: widen ($trigger) → loadMore (privateRooms=$count)" } - giftWrapsHistory.loadMore(accountViewModel.userProfile()) - nip04History.loadMore(accountViewModel.userProfile()) + if (roomCount != null) setMark(count) + Log.d("DMPagination") { "rooms.list: widen ($trigger) → loadMore (rooms=$count)" } + loadMore() } } } From 0d0857c2e9659375138c180bf890dee7e3ed3a7f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 18:38:49 +0000 Subject: [PATCH 030/103] feat: modern DM history status card with "all caught up" finish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the bare spinner + "Load entire history" link with a status card that tells the user what the app is actually reaching for: per protocol, it shows "Older messages" with a subtitle of " · N relays · back to " while it pages. When that protocol runs dry the card doesn't just vanish — it crossfades to "All caught up · Reached the start of your <…> messages", holds for a beat, then collapses away. The history managers now surface the live status the card needs: relayCount (relays the current page is asking) and reachedBack (oldest point paged to, from the deepest per-relay cursor), added to all three history managers and computed via UntilLimitPager.deepestUntil. The "load entire history" action is dropped — scroll-driven paging already walks to exhaustion, so the link was redundant. Each protocol's card sits at its own oldest-loaded boundary (rooms list) or both stack at the conversation's oldest end, so the two protocols' loading is shown independently at their real depths. New strings use a for the relay count. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../eoseManagers/UntilLimitPager.kt | 10 ++ .../AccountGiftWrapsHistoryEoseManager.kt | 11 ++ .../chats/feed/DmLoadMoreIndicator.kt | 151 +++++++++++++++--- .../loggedIn/chats/privateDM/ChatroomView.kt | 37 ++--- .../ChatroomNip04HistorySubAssembler.kt | 15 ++ .../ChatroomListNip04HistorySubAssembler.kt | 9 ++ .../chats/rooms/feed/ChatroomListFeedView.kt | 41 ++--- amethyst/src/main/res/values/strings.xml | 11 ++ 8 files changed, 227 insertions(+), 58 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt index 0dffc22f52..a7042bc348 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt @@ -127,4 +127,14 @@ class UntilLimitPager { key: K, all: Collection, ): List = all.filterNot { cursor(key, it).done } + + /** + * The oldest point reached across [relays] — the minimum cursor (how far back paging has gone). + * Relays not yet paged count as [start]. Null when [relays] is empty. + */ + fun deepestUntil( + key: K, + relays: Collection, + start: Long, + ): Long? = relays.takeIf { it.isNotEmpty() }?.minOf { cursor(key, it).until ?: start } } 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 16ebe37005..b3cfe11135 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 @@ -83,6 +83,14 @@ class AccountGiftWrapsHistoryEoseManager( private val _exhausted = MutableStateFlow(false) val exhausted: StateFlow = _exhausted.asStateFlow() + // Status surfaced to the loading card: how many relays the current page is asking, and the oldest + // point paging has reached (epoch seconds, the deepest cursor). + private val _relayCount = MutableStateFlow(0) + val relayCount: StateFlow = _relayCount.asStateFlow() + + private val _reachedBack = MutableStateFlow(null) + val reachedBack: StateFlow = _reachedBack.asStateFlow() + // Rooms-list auto-fill stall mark: the number of THIS protocol's distinct rooms shown the last // time the list auto-widened it. The list stops widening once a step adds no new room of this // protocol (widening only pulls older MESSAGES, which for a few busy correspondents can be @@ -152,6 +160,8 @@ class AccountGiftWrapsHistoryEoseManager( } pager.beginRound(user.pubkeyHex, active) lastRoundUser = user + _relayCount.value = active.size + _reachedBack.value = pager.deepestUntil(user.pubkeyHex, active, startUntil()) Log.d(TAG) { "[giftwrap.history] loadMore → ${active.size} active relay(s)" } scope?.let { ensureRoundCollector(it) @@ -183,6 +193,7 @@ class AccountGiftWrapsHistoryEoseManager( val asked = askedRelays[user.pubkeyHex] ?: emptySet() val count = pager.roundEventCount(user.pubkeyHex, asked) _exhausted.value = count == 0 + _reachedBack.value = pager.deepestUntil(user.pubkeyHex, asked, startUntil()) Log.d(TAG) { "[giftwrap.history] round done: $count event(s), exhausted=${count == 0}" } if (autoLoadAll && count > 0) loadMore(user) } 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 d06363d30c..08c2f7e5fe 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 @@ -20,43 +20,156 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.theme.Size10dp -import com.vitorpamplona.amethyst.ui.theme.Size25dp +import kotlinx.coroutines.delay +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +// How long the "All caught up" state lingers before the card collapses away. +private const val ALL_DONE_VISIBLE_MS = 2200L /** - * The DM "older history" boundary, shared by the rooms list and the conversation screen: a spinner - * while a window load is in flight, and — while there is still older history to reach — a button to - * skip the windowed paging and pull the entire history at once. + * The DM "older history" status card, shown at one protocol's oldest-loaded boundary (rooms list and + * conversation). It tells the user exactly what the app is reaching for: which protocol, how many + * relays it is asking, and how far back it has paged. When that protocol runs dry it does NOT just + * vanish — it crossfades to an "All caught up" state, holds for a beat, then collapses away. + * + * @param protocolName human label woven into sentences, e.g. "encrypted" / "legacy". + * @param protocolTag short technical tag for the subtitle, e.g. "NIP-17" / "NIP-04". + * @param reachedBack epoch seconds of the oldest point reached so far (the deepest `until` cursor). */ @Composable -fun DmLoadMoreIndicator( - loadingMore: Boolean, - showLoadAll: Boolean, - onLoadEntireHistory: () -> Unit, +fun DmHistoryLoadingCard( + protocolName: String, + protocolTag: String, + loading: Boolean, + exhausted: Boolean, + relayCount: Int, + reachedBack: Long?, + modifier: Modifier = Modifier, ) { - Column( - Modifier.fillMaxWidth().padding(vertical = Size10dp), - horizontalAlignment = Alignment.CenterHorizontally, + // Once exhausted, show "All caught up" for a beat, then collapse. Reset if it un-exhausts. + var collapsed by remember { mutableStateOf(false) } + LaunchedEffect(exhausted) { + collapsed = + if (exhausted) { + delay(ALL_DONE_VISIBLE_MS) + true + } else { + false + } + } + + AnimatedVisibility( + visible = !collapsed, + modifier = modifier, + enter = fadeIn(), + exit = shrinkVertically(tween(400)) + fadeOut(tween(250)), ) { - if (loadingMore) { - CircularProgressIndicator(Modifier.size(Size25dp)) - } - if (showLoadAll) { - TextButton(onClick = onLoadEntireHistory) { - Text(stringResource(R.string.chats_load_entire_history)) + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + tonalElevation = 2.dp, + ) { + Crossfade(targetState = exhausted, animationSpec = tween(500), label = "dmHistoryState") { done -> + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(22.dp), contentAlignment = Alignment.Center) { + if (done) { + Text( + "✓", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } else if (loading) { + CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + } + } + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { + Text( + text = + if (done) { + stringResource(R.string.chats_history_all_caught_up) + } else { + stringResource(R.string.chats_history_older, protocolName) + }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = + if (done) { + stringResource(R.string.chats_history_reached_start, protocolName) + } else { + historySubtitle(protocolTag, relayCount, reachedBack) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } } } } + +@Composable +private fun historySubtitle( + protocolTag: String, + relayCount: Int, + reachedBack: Long?, +): String { + val backLabel = + remember(reachedBack) { + reachedBack?.let { SimpleDateFormat("MMM yyyy", Locale.getDefault()).format(Date(it * 1000)) } + } + val relays = pluralStringResource(R.plurals.chats_history_relays, relayCount, relayCount) + return if (backLabel != null) { + stringResource(R.string.chats_history_subtitle, protocolTag, relays, backLabel) + } else { + stringResource(R.string.chats_history_subtitle_no_date, protocolTag, relays) + } +} 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 e3186e1657..dfb8871cae 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 @@ -36,9 +36,11 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel @@ -46,7 +48,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMsAndDisplayIfNotFound import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.DmLoadMoreIndicator +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.DmHistoryLoadingCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal.ChatroomFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssemblerSubscription @@ -219,7 +221,12 @@ fun ChatroomViewUI( val loadingNip04 by nip04History.loadingMore.collectAsStateWithLifecycle() val giftWrapsExhausted by giftWrapsHistory.exhausted.collectAsStateWithLifecycle() val nip04Exhausted by nip04History.exhausted.collectAsStateWithLifecycle() - val historyExhausted = giftWrapsExhausted && nip04Exhausted + val giftWrapsRelays by giftWrapsHistory.relayCount.collectAsStateWithLifecycle() + val giftWrapsReached by giftWrapsHistory.reachedBack.collectAsStateWithLifecycle() + val nip04Relays by nip04History.relayCount.collectAsStateWithLifecycle() + val nip04Reached by nip04History.reachedBack.collectAsStateWithLifecycle() + val nip17Name = stringResource(R.string.chats_history_proto_nip17) + val nip04Name = stringResource(R.string.chats_history_proto_nip04) Column(Modifier.fillMaxHeight()) { ObserveRelayListForDMsAndDisplayIfNotFound(accountViewModel, nav) @@ -239,24 +246,14 @@ fun ChatroomViewUI( avoidDraft = newPostModel.draftTag, onWantsToReply = newPostModel::reply, onWantsToEditDraft = newPostModel::editFromDraft, - // While there is older history to reach, show the same spinner / "load all" boundary - // as the rooms list at the oldest end (spinner only while actually loading). - olderBoundary = - if (historyExhausted) { - null - } else { - { - DmLoadMoreIndicator( - loadingMore = loadingGiftWraps || loadingNip04, - showLoadAll = true, - ) { - Log.d("DMPagination") { "convo: Load entire history tapped" } - val user = accountViewModel.userProfile() - giftWrapsHistory.loadEverything(user) - nip04History.loadEverything() - } - } - }, + // One status card per protocol at the oldest end: each shows what it's reaching for + // while it pages and crossfades to "All caught up" when that protocol runs dry. + olderBoundary = { + Column { + DmHistoryLoadingCard(nip17Name, "NIP-17", loadingGiftWraps, giftWrapsExhausted, giftWrapsRelays, giftWrapsReached) + DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Exhausted, nip04Relays, nip04Reached) + } + }, listStateObserver = { listState -> LoadOlderMessagesWhenScrolling(listState, accountViewModel) }, 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 a4f15a3165..d80a6bf1d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04HistorySubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04HistorySubAssembler.kt @@ -63,6 +63,12 @@ class ChatroomNip04HistorySubAssembler( private val _exhausted = MutableStateFlow(false) val exhausted: StateFlow = _exhausted.asStateFlow() + private val _relayCount = MutableStateFlow(0) + val relayCount: StateFlow = _relayCount.asStateFlow() + + private val _reachedBack = MutableStateFlow(null) + val reachedBack: StateFlow = _reachedBack.asStateFlow() + @Volatile private var scope: CoroutineScope? = null @@ -106,6 +112,8 @@ class ChatroomNip04HistorySubAssembler( fun loadMore() { if (_exhausted.value) return var anyActive = false + var totalRelays = 0 + var deepest: Long? = null allKeys().forEach { key -> val relays = nip04DMRelays(key.room.users, key.account) ?: return@forEach started.add(key.listId) @@ -113,12 +121,18 @@ class ChatroomNip04HistorySubAssembler( if (active.isNotEmpty()) { pager.beginRound(key.listId, active) anyActive = true + totalRelays += active.size + pager.deepestUntil(key.listId, active, startUntil())?.let { d -> + deepest = deepest?.let { minOf(it, d) } ?: d + } } } if (!anyActive) { _exhausted.value = true return } + _relayCount.value = totalRelays + _reachedBack.value = deepest Log.d("DMPagination") { "[convo.nip04.history] loadMore" } scope?.let { ensureRoundCollector(it) @@ -143,6 +157,7 @@ class ChatroomNip04HistorySubAssembler( if (!loading && wasLoading) { val count = started.sumOf { listId -> pager.roundEventCount(listId, askedRelays[listId] ?: emptySet()) } _exhausted.value = count == 0 + _reachedBack.value = started.mapNotNull { listId -> pager.deepestUntil(listId, askedRelays[listId] ?: emptySet(), startUntil()) }.minOrNull() Log.d("DMPagination") { "[convo.nip04.history] round done: $count event(s), exhausted=${count == 0}" } if (autoLoadAll && count > 0) loadMore() } 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 4a33868724..3827570ec9 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 @@ -67,6 +67,12 @@ class ChatroomListNip04HistorySubAssembler( private val _exhausted = MutableStateFlow(false) val exhausted: StateFlow = _exhausted.asStateFlow() + private val _relayCount = MutableStateFlow(0) + val relayCount: StateFlow = _relayCount.asStateFlow() + + private val _reachedBack = MutableStateFlow(null) + val reachedBack: StateFlow = _reachedBack.asStateFlow() + @Volatile private var scope: CoroutineScope? = null @@ -124,6 +130,8 @@ class ChatroomListNip04HistorySubAssembler( } pager.beginRound(user.pubkeyHex, active) lastRoundUser = user + _relayCount.value = active.size + _reachedBack.value = pager.deepestUntil(user.pubkeyHex, active, startUntil()) Log.d("DMPagination") { "[rooms.nip04.history] loadMore → ${active.size} active relay(s)" } scope?.let { ensureRoundCollector(it) @@ -151,6 +159,7 @@ class ChatroomListNip04HistorySubAssembler( val asked = askedRelays[user.pubkeyHex] ?: emptySet() val count = pager.roundEventCount(user.pubkeyHex, asked) _exhausted.value = count == 0 + _reachedBack.value = pager.deepestUntil(user.pubkeyHex, asked, startUntil()) Log.d("DMPagination") { "[rooms.nip04.history] round done: $count event(s), exhausted=${count == 0}" } if (autoLoadAll && count > 0) loadMore(user) } 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 5973d632df..065b48fcc7 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 @@ -34,7 +34,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier +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.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState @@ -48,7 +50,7 @@ import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.DmLoadMoreIndicator +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.DmHistoryLoadingCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChatroomHeaderCompose import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding @@ -207,9 +209,16 @@ private fun FeedLoaded( info.totalItemsCount > 0 && (oldest < 0 || lastVisible >= oldest - PREFETCH_PRIVATE_CHATS) } - // One loading boundary PER protocol, at that protocol's oldest loaded room: a spinner while it - // pages and a "load entire history" button until it is exhausted. They sit at different depths - // (NIP-04's typically deeper), so the user sees each protocol load where its history actually ends. + // 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. The two sit at different depths (NIP-04's typically deeper), + // so the user sees each protocol load where its own history actually ends. + val giftWrapsRelays by giftWrapsHistory.relayCount.collectAsStateWithLifecycle() + val giftWrapsReached by giftWrapsHistory.reachedBack.collectAsStateWithLifecycle() + val nip04Relays by nip04History.relayCount.collectAsStateWithLifecycle() + val nip04Reached by nip04History.reachedBack.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 } val oldestNip04Index = items.list.indexOfLast { it.event is PrivateDmEvent } @@ -233,31 +242,25 @@ private fun FeedLoaded( thickness = DividerThickness, ) - if (index == oldestNip17Index && (loadingGiftWraps || !giftWrapsExhausted)) { - DmLoadMoreIndicator(loadingGiftWraps, showLoadAll = !giftWrapsExhausted) { - giftWrapsHistory.loadEverything(user) - } + // 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, giftWrapsReached) } - if (index == oldestNip04Index && (loadingNip04 || !nip04Exhausted)) { - DmLoadMoreIndicator(loadingNip04, showLoadAll = !nip04Exhausted) { - nip04History.loadEverything(user) - } + if (index == oldestNip04Index) { + DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Exhausted, nip04Relays, nip04Reached) } } - // Protocols with no room loaded yet (e.g. only public rooms so far): show their boundary at the end. + // Protocols with no room loaded yet (e.g. only public rooms so far): show their card at the end. if (oldestNip17Index < 0 && (loadingGiftWraps || !giftWrapsExhausted)) { item(key = "nip17Footer") { - DmLoadMoreIndicator(loadingGiftWraps, showLoadAll = !giftWrapsExhausted) { - giftWrapsHistory.loadEverything(user) - } + DmHistoryLoadingCard(nip17Name, "NIP-17", loadingGiftWraps, giftWrapsExhausted, giftWrapsRelays, giftWrapsReached) } } if (oldestNip04Index < 0 && (loadingNip04 || !nip04Exhausted)) { item(key = "nip04Footer") { - DmLoadMoreIndicator(loadingNip04, showLoadAll = !nip04Exhausted) { - nip04History.loadEverything(user) - } + DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Exhausted, nip04Relays, nip04Reached) } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 853a9ed4cb..058c6a8427 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -275,6 +275,17 @@ Loading feed Loading account Load entire history + Older %1$s messages + All caught up + Reached the start of your %1$s messages + encrypted + legacy + %1$s · %2$s · back to %3$s + %1$s · %2$s + + %1$d relay + %1$d relays + "Error loading replies: " Try again No notifications yet. From 0f0644200d2cc726c57538c78abd440129bd215d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 19:11:40 +0000 Subject: [PATCH 031/103] fix: don't leak DM history state across logged-in accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DM managers live in a single shared coordinator (Amethyst.instance.sources), so every logged-in account uses the same instances. The per-account paging state (pager cursors, started set, accounts map) was keyed by pubkey and fine, but the single display flows — exhausted, relayCount, reachedBack, autoFillRoomMark — were not. Switching from an account that had exhausted its history to another left exhausted=true, so the second account's auto-fill was gated shut and its chats never paged in. Each history manager now tracks the active account/conversation and repoints its display flows on switch: exhausted is restored per-account (kept in a small exhaustedByUser/exhaustedByList map so an already-finished account shows "all caught up" rather than re-paging), and the cosmetic flows + stall mark reset. Paging cursors stay in the per-account pager, so progress is preserved. Also scopes the conversation history pager key by account pubkey: a ChatroomKey (hence listId) is identical for the same correspondent across accounts, so two logged-in users viewing the same person would otherwise share one cursor. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../AccountGiftWrapsHistoryEoseManager.kt | 18 +++++++ .../ChatroomNip04HistorySubAssembler.kt | 53 ++++++++++++++----- .../ChatroomListNip04HistorySubAssembler.kt | 15 ++++++ 3 files changed, 72 insertions(+), 14 deletions(-) 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 b3cfe11135..63621e7a6b 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 @@ -76,6 +76,14 @@ class AccountGiftWrapsHistoryEoseManager( // the DM relay list without the key. private val accounts = ConcurrentHashMap() + // This manager is shared across logged-in accounts (one singleton coordinator), so the single + // display flows below must follow whichever account is currently active. Per-account paging + // cursors live in [pager], so switching away and back preserves progress; [exhaustedByUser] lets + // the display flow repoint accurately on switch instead of leaking the previous account's state. + @Volatile + private var activeUser: HexKey? = null + private val exhaustedByUser = ConcurrentHashMap() + private val windowLoad = WindowLoadTracker("giftwrap.history") val loadingMore: StateFlow = windowLoad.loading @@ -155,6 +163,7 @@ class AccountGiftWrapsHistoryEoseManager( started.add(user.pubkeyHex) val active = pager.activeRelays(user.pubkeyHex, account.dmRelays.flow.value) if (active.isEmpty()) { + exhaustedByUser[user.pubkeyHex] = true _exhausted.value = true return } @@ -192,6 +201,7 @@ class AccountGiftWrapsHistoryEoseManager( if (user != null) { val asked = askedRelays[user.pubkeyHex] ?: emptySet() val count = pager.roundEventCount(user.pubkeyHex, asked) + exhaustedByUser[user.pubkeyHex] = count == 0 _exhausted.value = count == 0 _reachedBack.value = pager.deepestUntil(user.pubkeyHex, asked, startUntil()) Log.d(TAG) { "[giftwrap.history] round done: $count event(s), exhausted=${count == 0}" } @@ -207,6 +217,14 @@ class AccountGiftWrapsHistoryEoseManager( val user = user(key) scope = key.account.scope accounts[user.pubkeyHex] = key.account + if (activeUser != user.pubkeyHex) { + activeUser = user.pubkeyHex + // Account switched: repoint the shared display flows to this account's own state. + _exhausted.value = exhaustedByUser[user.pubkeyHex] ?: false + _relayCount.value = 0 + _reachedBack.value = null + autoFillRoomMark = Int.MIN_VALUE + } return requestNewSubscription(historyListener(user, key)) } 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 d80a6bf1d4..38f250c737 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 @@ -69,6 +69,12 @@ class ChatroomNip04HistorySubAssembler( private val _reachedBack = MutableStateFlow(null) val reachedBack: StateFlow = _reachedBack.asStateFlow() + // Shared across accounts/conversations (singleton coordinator): repoint the display flows to the + // conversation now on screen instead of leaking the previous one's state. Cursors live in [pager]. + @Volatile + private var activeList: String? = null + private val exhaustedByList = ConcurrentHashMap() + @Volatile private var scope: CoroutineScope? = null @@ -84,17 +90,23 @@ class ChatroomNip04HistorySubAssembler( override fun list(key: ChatroomQueryState) = key.listId + // The pager/state key is account-scoped: a ChatroomKey (and thus listId) is the same for the same + // correspondent across accounts, so without the account pubkey two logged-in users viewing the + // same person would share one cursor. + private fun pagerKey(key: ChatroomQueryState) = user(key).pubkeyHex + "/" + key.listId + override fun updateFilter( key: ChatroomQueryState, since: SincePerRelayMap?, ): List? { + val pk = pagerKey(key) val relays = nip04DMRelays(key.room.users, key.account) - if (!key.account.isWriteable() || key.listId !in started || relays == null) { + if (!key.account.isWriteable() || pk !in started || relays == null) { windowLoad.setExpectedRelays(emptySet()) return emptyList() } - val active = pager.activeRelays(key.listId, relays.all).toSet() - askedRelays[key.listId] = active + val active = pager.activeRelays(pk, relays.all).toSet() + askedRelays[pk] = active windowLoad.setExpectedRelays(active) if (active.isEmpty()) return emptyList() Log.d("DMPagination") { "[convo.nip04.history] REQ ${active.size} relay(s), limit=$PAGE_LIMIT" } @@ -104,7 +116,7 @@ class ChatroomNip04HistorySubAssembler( fromMeRelays = relays.fromMeRelays.intersect(active), ) return filterNip04DMsHistory(key.room.users, key.account, activeRelays, PAGE_LIMIT) { relay -> - pager.untilFor(key.listId, relay, startUntil()) + pager.untilFor(pk, relay, startUntil()) } } @@ -116,18 +128,20 @@ class ChatroomNip04HistorySubAssembler( var deepest: Long? = null allKeys().forEach { key -> val relays = nip04DMRelays(key.room.users, key.account) ?: return@forEach - started.add(key.listId) - val active = pager.activeRelays(key.listId, relays.all) + val pk = pagerKey(key) + started.add(pk) + val active = pager.activeRelays(pk, relays.all) if (active.isNotEmpty()) { - pager.beginRound(key.listId, active) + pager.beginRound(pk, active) anyActive = true totalRelays += active.size - pager.deepestUntil(key.listId, active, startUntil())?.let { d -> + pager.deepestUntil(pk, active, startUntil())?.let { d -> deepest = deepest?.let { minOf(it, d) } ?: d } } } if (!anyActive) { + activeList?.let { exhaustedByList[it] = true } _exhausted.value = true return } @@ -155,9 +169,10 @@ class ChatroomNip04HistorySubAssembler( var wasLoading = false windowLoad.loading.collect { loading -> if (!loading && wasLoading) { - val count = started.sumOf { listId -> pager.roundEventCount(listId, askedRelays[listId] ?: emptySet()) } + val count = started.sumOf { pk -> pager.roundEventCount(pk, askedRelays[pk] ?: emptySet()) } + activeList?.let { exhaustedByList[it] = count == 0 } _exhausted.value = count == 0 - _reachedBack.value = started.mapNotNull { listId -> pager.deepestUntil(listId, askedRelays[listId] ?: emptySet(), startUntil()) }.minOrNull() + _reachedBack.value = started.mapNotNull { pk -> pager.deepestUntil(pk, askedRelays[pk] ?: emptySet(), startUntil()) }.minOrNull() Log.d("DMPagination") { "[convo.nip04.history] round done: $count event(s), exhausted=${count == 0}" } if (autoLoadAll && count > 0) loadMore() } @@ -168,11 +183,20 @@ class ChatroomNip04HistorySubAssembler( override fun newSub(key: ChatroomQueryState): Subscription { scope = key.account.scope + val pk = pagerKey(key) + if (activeList != pk) { + activeList = pk + // A different conversation (or account) is on screen: repoint the display flows to it. + _exhausted.value = exhaustedByList[pk] ?: false + _relayCount.value = 0 + _reachedBack.value = null + } return requestNewSubscription(historyListener(key)) } - private fun historyListener(key: ChatroomQueryState): SubscriptionListener = - object : SubscriptionListener { + private fun historyListener(key: ChatroomQueryState): SubscriptionListener { + val pk = pagerKey(key) + return object : SubscriptionListener { override fun onEvent( event: Event, isLive: Boolean, @@ -180,14 +204,14 @@ class ChatroomNip04HistorySubAssembler( forFilters: List?, ) { windowLoad.onRelayEvent(relay) - pager.onEvent(key.listId, relay, event.createdAt) + pager.onEvent(pk, relay, event.createdAt) } override fun onEose( relay: NormalizedRelayUrl, forFilters: List?, ) { - pager.onEose(key.listId, relay) + pager.onEose(pk, relay) windowLoad.onRelaySettled(relay) newEose(key, relay, TimeUtils.now(), forFilters) } @@ -208,6 +232,7 @@ class ChatroomNip04HistorySubAssembler( windowLoad.onRelaySettled(relay) } } + } companion object { private const val PAGE_LIMIT = 10000 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 3827570ec9..4bf8088501 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 @@ -61,6 +61,12 @@ class ChatroomListNip04HistorySubAssembler( private val askedRelays = ConcurrentHashMap>() private val accounts = ConcurrentHashMap() + // Shared across accounts (singleton coordinator): repoint the display flows to the active account + // on switch instead of leaking the previous one's exhausted/mark state. Cursors live in [pager]. + @Volatile + private var activeUser: HexKey? = null + private val exhaustedByUser = ConcurrentHashMap() + private val windowLoad = WindowLoadTracker("rooms.nip04.history") val loadingMore: StateFlow = windowLoad.loading @@ -125,6 +131,7 @@ class ChatroomListNip04HistorySubAssembler( val all = (account.homeRelays.flow.value + account.dmRelays.flow.value).toSet() val active = pager.activeRelays(user.pubkeyHex, all) if (active.isEmpty()) { + exhaustedByUser[user.pubkeyHex] = true _exhausted.value = true return } @@ -158,6 +165,7 @@ class ChatroomListNip04HistorySubAssembler( if (user != null) { val asked = askedRelays[user.pubkeyHex] ?: emptySet() val count = pager.roundEventCount(user.pubkeyHex, asked) + exhaustedByUser[user.pubkeyHex] = count == 0 _exhausted.value = count == 0 _reachedBack.value = pager.deepestUntil(user.pubkeyHex, asked, startUntil()) Log.d("DMPagination") { "[rooms.nip04.history] round done: $count event(s), exhausted=${count == 0}" } @@ -173,6 +181,13 @@ class ChatroomListNip04HistorySubAssembler( val user = user(key) scope = key.account.scope accounts[user.pubkeyHex] = key.account + if (activeUser != user.pubkeyHex) { + activeUser = user.pubkeyHex + _exhausted.value = exhaustedByUser[user.pubkeyHex] ?: false + _relayCount.value = 0 + _reachedBack.value = null + autoFillRoomMark = Int.MIN_VALUE + } return requestNewSubscription(historyListener(user, key)) } From baad77a966a699da193de49aa651c217cc87b026 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 19:18:38 +0000 Subject: [PATCH 032/103] refactor: key the conversation history pager by a (account, ChatroomKey) type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the "pubkey/listId" string key with a small ConvoKey data class holding the account pubkey and the ChatroomKey. ChatroomKey is a data class over the participant set, so it's a collision-free key — unlike listId, which is its 32-bit hashCode as a string and can collide. Including the account keeps the two accounts' views of the same correspondent on separate cursors (the manager is a singleton shared across logged-in accounts). Also lighter on allocation than the string it replaces: the per-relay-event hot path captures the key once per subscription (no per-event construction), and the remaining call sites build one small object instead of concatenating + hashing a string. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../ChatroomNip04HistorySubAssembler.kt | 46 +++++++++++-------- 1 file changed, 26 insertions(+), 20 deletions(-) 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 38f250c737..96d2631c8c 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 @@ -26,12 +26,14 @@ import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.WindowLoadTra 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.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope @@ -52,10 +54,19 @@ class ChatroomNip04HistorySubAssembler( client: INostrClient, allKeys: () -> Set, ) : PerUserAndFollowListEoseManager(client, allKeys) { - // Keyed per conversation (listId): each thread paginates independently. - private val pager = UntilLimitPager() - private val started = ConcurrentHashMap.newKeySet() - private val askedRelays = ConcurrentHashMap>() + // Keyed by (account, conversation) so each thread paginates independently — and so the same + // correspondent opened from two logged-in accounts doesn't share a cursor. ChatroomKey is a data + // class over the participant set, so it's a collision-free key (unlike its 32-bit hashCode/listId). + private data class ConvoKey( + val account: HexKey, + val room: ChatroomKey, + ) + + private fun convoKey(key: ChatroomQueryState) = ConvoKey(user(key).pubkeyHex, key.room) + + private val pager = UntilLimitPager() + private val started = ConcurrentHashMap.newKeySet() + private val askedRelays = ConcurrentHashMap>() private val windowLoad = WindowLoadTracker("convo.nip04.history") val loadingMore: StateFlow = windowLoad.loading @@ -72,8 +83,8 @@ class ChatroomNip04HistorySubAssembler( // Shared across accounts/conversations (singleton coordinator): repoint the display flows to the // conversation now on screen instead of leaking the previous one's state. Cursors live in [pager]. @Volatile - private var activeList: String? = null - private val exhaustedByList = ConcurrentHashMap() + private var activeConvo: ConvoKey? = null + private val exhaustedByConvo = ConcurrentHashMap() @Volatile private var scope: CoroutineScope? = null @@ -90,16 +101,11 @@ class ChatroomNip04HistorySubAssembler( override fun list(key: ChatroomQueryState) = key.listId - // The pager/state key is account-scoped: a ChatroomKey (and thus listId) is the same for the same - // correspondent across accounts, so without the account pubkey two logged-in users viewing the - // same person would share one cursor. - private fun pagerKey(key: ChatroomQueryState) = user(key).pubkeyHex + "/" + key.listId - override fun updateFilter( key: ChatroomQueryState, since: SincePerRelayMap?, ): List? { - val pk = pagerKey(key) + val pk = convoKey(key) val relays = nip04DMRelays(key.room.users, key.account) if (!key.account.isWriteable() || pk !in started || relays == null) { windowLoad.setExpectedRelays(emptySet()) @@ -128,7 +134,7 @@ class ChatroomNip04HistorySubAssembler( var deepest: Long? = null allKeys().forEach { key -> val relays = nip04DMRelays(key.room.users, key.account) ?: return@forEach - val pk = pagerKey(key) + val pk = convoKey(key) started.add(pk) val active = pager.activeRelays(pk, relays.all) if (active.isNotEmpty()) { @@ -141,7 +147,7 @@ class ChatroomNip04HistorySubAssembler( } } if (!anyActive) { - activeList?.let { exhaustedByList[it] = true } + activeConvo?.let { exhaustedByConvo[it] = true } _exhausted.value = true return } @@ -170,7 +176,7 @@ class ChatroomNip04HistorySubAssembler( windowLoad.loading.collect { loading -> if (!loading && wasLoading) { val count = started.sumOf { pk -> pager.roundEventCount(pk, askedRelays[pk] ?: emptySet()) } - activeList?.let { exhaustedByList[it] = count == 0 } + activeConvo?.let { exhaustedByConvo[it] = count == 0 } _exhausted.value = count == 0 _reachedBack.value = started.mapNotNull { pk -> pager.deepestUntil(pk, askedRelays[pk] ?: emptySet(), startUntil()) }.minOrNull() Log.d("DMPagination") { "[convo.nip04.history] round done: $count event(s), exhausted=${count == 0}" } @@ -183,11 +189,11 @@ class ChatroomNip04HistorySubAssembler( override fun newSub(key: ChatroomQueryState): Subscription { scope = key.account.scope - val pk = pagerKey(key) - if (activeList != pk) { - activeList = pk + val pk = convoKey(key) + if (activeConvo != pk) { + activeConvo = pk // A different conversation (or account) is on screen: repoint the display flows to it. - _exhausted.value = exhaustedByList[pk] ?: false + _exhausted.value = exhaustedByConvo[pk] ?: false _relayCount.value = 0 _reachedBack.value = null } @@ -195,7 +201,7 @@ class ChatroomNip04HistorySubAssembler( } private fun historyListener(key: ChatroomQueryState): SubscriptionListener { - val pk = pagerKey(key) + val pk = convoKey(key) return object : SubscriptionListener { override fun onEvent( event: Event, From ae62968160e04b4053f1050ed293e2be55c808e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 20:00:31 +0000 Subject: [PATCH 033/103] fix: mark DM history exhausted only when every relay empty-EOSEs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exhaustion was decided by "the round returned zero events," but a round can return zero because relays CLOSED (auth) or never answered — not because they reached the end. So "All caught up" appeared before slow/auth relays had actually finished. A relay is finished only when it returns an empty page followed by EOSE — the pager already records exactly that in its per-relay `done` flag (CLOSED / cannot-connect deliberately don't set it). So the chat is now exhausted only when every relay is done (activeRelays is empty), per the rule "all relays must return that EOSE." A post-auth empty EOSE that lands after the round already settled on the earlier CLOSED now flips exhausted immediately too (markExhaustedIfAllDone in onEose), not just at the next round boundary. Because a chat is no longer "done" while a relay keeps CLOSING, the conversation auto-fill (no stall-gate) would otherwise re-issue identical rounds and hammer that relay. A no-progress guard skips re-issuing a round whose relay set and zero-event result are unchanged; the pool re-auths on the open subscription and its EOSE clears the guard and finishes the relay. All the new single-valued state resets on account/conversation switch alongside the existing display flows. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../AccountGiftWrapsHistoryEoseManager.kt | 55 +++++++++++-- .../ChatroomNip04HistorySubAssembler.kt | 77 +++++++++++++++---- .../ChatroomListNip04HistorySubAssembler.kt | 42 +++++++++- 3 files changed, 149 insertions(+), 25 deletions(-) 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 63621e7a6b..2788776f3d 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 @@ -84,6 +84,16 @@ class AccountGiftWrapsHistoryEoseManager( private var activeUser: HexKey? = null private val exhaustedByUser = ConcurrentHashMap() + // No-progress guard: the relay set the last round asked and how many events it returned. If a fresh + // loadMore would ask the same relays and the last round brought nothing (all CLOSED / unanswered), + // we skip it rather than busy-retry — the pool re-auths on the open subscription and its EOSE will + // advance/finish them. Cleared by onEose (any EOSE means something changed). + @Volatile + private var lastAskedActive: Set = emptySet() + + @Volatile + private var lastRoundEventCount = -1 + private val windowLoad = WindowLoadTracker("giftwrap.history") val loadingMore: StateFlow = windowLoad.loading @@ -167,6 +177,15 @@ class AccountGiftWrapsHistoryEoseManager( _exhausted.value = true return } + val activeSet = active.toSet() + if (activeSet == lastAskedActive && lastRoundEventCount == 0) { + // The same relays just returned nothing (e.g. all CLOSED, auth pending). The pool retries + // auth on the open subscription and its EOSE finishes them (see markExhaustedIfAllDone), so + // don't hammer with an identical round. onEose clears this gate when anything changes. + Log.d(TAG) { "[giftwrap.history] loadMore skipped — no progress on the same relays" } + return + } + lastAskedActive = activeSet pager.beginRound(user.pubkeyHex, active) lastRoundUser = user _relayCount.value = active.size @@ -187,9 +206,10 @@ class AccountGiftWrapsHistoryEoseManager( loadMore(user) } - // Emits the round tally and the exhausted decision when the in-flight load settles. A round that - // received no events means no relay had anything older → exhausted; otherwise (and in load-all - // mode) keep paging. + // Emits the round tally and the exhausted decision when the in-flight load settles. Exhausted ONLY + // when every relay has returned an empty page + EOSE (pager.done): a relay that merely CLOSED (e.g. + // auth-required, before its post-auth retry) or never answered is NOT finished, so we don't call it + // "all caught up" — we keep loading it. In load-all mode, keep paging until that's true. private fun ensureRoundCollector(scope: CoroutineScope) { if (roundJob?.isActive == true) return roundJob = @@ -201,11 +221,14 @@ class AccountGiftWrapsHistoryEoseManager( if (user != null) { val asked = askedRelays[user.pubkeyHex] ?: emptySet() val count = pager.roundEventCount(user.pubkeyHex, asked) - exhaustedByUser[user.pubkeyHex] = count == 0 - _exhausted.value = count == 0 + lastRoundEventCount = count + val allRelays = accounts[user.pubkeyHex]?.dmRelays?.flow?.value ?: emptySet() + val exhaustedNow = allRelays.isNotEmpty() && pager.activeRelays(user.pubkeyHex, allRelays).isEmpty() + exhaustedByUser[user.pubkeyHex] = exhaustedNow + _exhausted.value = exhaustedNow _reachedBack.value = pager.deepestUntil(user.pubkeyHex, asked, startUntil()) - Log.d(TAG) { "[giftwrap.history] round done: $count event(s), exhausted=${count == 0}" } - if (autoLoadAll && count > 0) loadMore(user) + Log.d(TAG) { "[giftwrap.history] round done: $count event(s), exhausted=$exhaustedNow" } + if (autoLoadAll && !exhaustedNow) loadMore(user) } } wasLoading = loading @@ -213,6 +236,16 @@ class AccountGiftWrapsHistoryEoseManager( } } + // Flips to exhausted only once every relay has returned an empty page + EOSE (all done). Sets true + // only — the false transitions belong to loadMore / the round collector. Safe off the round path. + private fun markExhaustedIfAllDone(user: User) { + val allRelays = accounts[user.pubkeyHex]?.dmRelays?.flow?.value ?: return + if (allRelays.isNotEmpty() && pager.activeRelays(user.pubkeyHex, allRelays).isEmpty()) { + exhaustedByUser[user.pubkeyHex] = true + if (activeUser == user.pubkeyHex) _exhausted.value = true + } + } + override fun newSub(key: AccountQueryState): Subscription { val user = user(key) scope = key.account.scope @@ -224,6 +257,8 @@ class AccountGiftWrapsHistoryEoseManager( _relayCount.value = 0 _reachedBack.value = null autoFillRoomMark = Int.MIN_VALUE + lastAskedActive = emptySet() + lastRoundEventCount = -1 } return requestNewSubscription(historyListener(user, key)) } @@ -250,6 +285,12 @@ class AccountGiftWrapsHistoryEoseManager( pager.onEose(user.pubkeyHex, relay) windowLoad.onRelaySettled(relay) newEose(key, relay, TimeUtils.now(), forFilters) + // An EOSE means this relay changed (finished, or delivered a page) — clear the + // no-progress gate so the next loadMore can continue, even off the round path. + lastRoundEventCount = -1 + // A post-auth empty EOSE can land after the round already settled on the earlier CLOSED; + // flip to exhausted the moment this finishes the last relay, not only at round end. + markExhaustedIfAllDone(user) } override fun onClosed( 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 96d2631c8c..3959faf6b4 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 @@ -86,6 +86,14 @@ class ChatroomNip04HistorySubAssembler( private var activeConvo: ConvoKey? = null private val exhaustedByConvo = ConcurrentHashMap() + // No-progress guard (see the gift-wrap history manager's twin): skip re-issuing an identical round + // that brought nothing; cleared by onEose. + @Volatile + private var lastAskedActive: Set = emptySet() + + @Volatile + private var lastRoundEventCount = -1 + @Volatile private var scope: CoroutineScope? = null @@ -129,28 +137,39 @@ class ChatroomNip04HistorySubAssembler( /** Requests the next backward page for every open conversation that still has older history. */ fun loadMore() { if (_exhausted.value) return - var anyActive = false - var totalRelays = 0 - var deepest: Long? = null + // Gather the active (not-finished) relays per open conversation first, so the no-progress guard + // is checked before any beginRound (which would otherwise reset round tallies prematurely). + val perKeyActive = mutableListOf>>() + val activeUnion = mutableSetOf() allKeys().forEach { key -> val relays = nip04DMRelays(key.room.users, key.account) ?: return@forEach val pk = convoKey(key) started.add(pk) val active = pager.activeRelays(pk, relays.all) if (active.isNotEmpty()) { - pager.beginRound(pk, active) - anyActive = true - totalRelays += active.size - pager.deepestUntil(pk, active, startUntil())?.let { d -> - deepest = deepest?.let { minOf(it, d) } ?: d - } + perKeyActive.add(pk to active) + activeUnion.addAll(active) } } - if (!anyActive) { + if (perKeyActive.isEmpty()) { activeConvo?.let { exhaustedByConvo[it] = true } _exhausted.value = true return } + if (activeUnion == lastAskedActive && lastRoundEventCount == 0) { + Log.d("DMPagination") { "[convo.nip04.history] loadMore skipped — no progress on the same relays" } + return + } + lastAskedActive = activeUnion + var totalRelays = 0 + var deepest: Long? = null + perKeyActive.forEach { (pk, active) -> + pager.beginRound(pk, active) + totalRelays += active.size + pager.deepestUntil(pk, active, startUntil())?.let { d -> + deepest = deepest?.let { minOf(it, d) } ?: d + } + } _relayCount.value = totalRelays _reachedBack.value = deepest Log.d("DMPagination") { "[convo.nip04.history] loadMore" } @@ -176,11 +195,21 @@ class ChatroomNip04HistorySubAssembler( windowLoad.loading.collect { loading -> if (!loading && wasLoading) { val count = started.sumOf { pk -> pager.roundEventCount(pk, askedRelays[pk] ?: emptySet()) } - activeConvo?.let { exhaustedByConvo[it] = count == 0 } - _exhausted.value = count == 0 + lastRoundEventCount = count + // Exhausted ONLY when every open conversation's relays have all returned an + // empty page + EOSE; a CLOSED / unanswered relay isn't finished, so keep loading. + val keys = allKeys() + val exhaustedNow = + keys.isNotEmpty() && + keys.none { key -> + val relays = nip04DMRelays(key.room.users, key.account) + relays != null && pager.activeRelays(convoKey(key), relays.all).isNotEmpty() + } + activeConvo?.let { exhaustedByConvo[it] = exhaustedNow } + _exhausted.value = exhaustedNow _reachedBack.value = started.mapNotNull { pk -> pager.deepestUntil(pk, askedRelays[pk] ?: emptySet(), startUntil()) }.minOrNull() - Log.d("DMPagination") { "[convo.nip04.history] round done: $count event(s), exhausted=${count == 0}" } - if (autoLoadAll && count > 0) loadMore() + Log.d("DMPagination") { "[convo.nip04.history] round done: $count event(s), exhausted=$exhaustedNow" } + if (autoLoadAll && !exhaustedNow) loadMore() } wasLoading = loading } @@ -196,10 +225,28 @@ class ChatroomNip04HistorySubAssembler( _exhausted.value = exhaustedByConvo[pk] ?: false _relayCount.value = 0 _reachedBack.value = null + lastAskedActive = emptySet() + lastRoundEventCount = -1 } return requestNewSubscription(historyListener(key)) } + // Flips to exhausted only once every open conversation's relays have all returned an empty page + + // EOSE. Sets true only — false transitions belong to loadMore / the round collector. + private fun markExhaustedIfAllDone() { + val keys = allKeys() + val allDone = + keys.isNotEmpty() && + keys.none { key -> + val relays = nip04DMRelays(key.room.users, key.account) + relays != null && pager.activeRelays(convoKey(key), relays.all).isNotEmpty() + } + if (allDone) { + activeConvo?.let { exhaustedByConvo[it] = true } + _exhausted.value = true + } + } + private fun historyListener(key: ChatroomQueryState): SubscriptionListener { val pk = convoKey(key) return object : SubscriptionListener { @@ -220,6 +267,8 @@ class ChatroomNip04HistorySubAssembler( pager.onEose(pk, relay) windowLoad.onRelaySettled(relay) newEose(key, relay, TimeUtils.now(), forFilters) + lastRoundEventCount = -1 + markExhaustedIfAllDone() } override fun onClosed( 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 4bf8088501..e84b1838a1 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 @@ -67,6 +67,14 @@ class ChatroomListNip04HistorySubAssembler( private var activeUser: HexKey? = null private val exhaustedByUser = ConcurrentHashMap() + // No-progress guard: skip re-issuing an identical round that brought nothing (see the gift-wrap + // history manager's twin); cleared by onEose. + @Volatile + private var lastAskedActive: Set = emptySet() + + @Volatile + private var lastRoundEventCount = -1 + private val windowLoad = WindowLoadTracker("rooms.nip04.history") val loadingMore: StateFlow = windowLoad.loading @@ -135,6 +143,12 @@ class ChatroomListNip04HistorySubAssembler( _exhausted.value = true return } + val activeSet = active.toSet() + if (activeSet == lastAskedActive && lastRoundEventCount == 0) { + Log.d("DMPagination") { "[rooms.nip04.history] loadMore skipped — no progress on the same relays" } + return + } + lastAskedActive = activeSet pager.beginRound(user.pubkeyHex, active) lastRoundUser = user _relayCount.value = active.size @@ -165,11 +179,17 @@ class ChatroomListNip04HistorySubAssembler( if (user != null) { val asked = askedRelays[user.pubkeyHex] ?: emptySet() val count = pager.roundEventCount(user.pubkeyHex, asked) - exhaustedByUser[user.pubkeyHex] = count == 0 - _exhausted.value = count == 0 + lastRoundEventCount = count + val account = accounts[user.pubkeyHex] + val allRelays = account?.let { (it.homeRelays.flow.value + it.dmRelays.flow.value).toSet() } ?: emptySet() + // Exhausted ONLY when every relay returned an empty page + EOSE; CLOSED / + // unanswered relays are not finished, so keep loading them. + val exhaustedNow = allRelays.isNotEmpty() && pager.activeRelays(user.pubkeyHex, allRelays).isEmpty() + exhaustedByUser[user.pubkeyHex] = exhaustedNow + _exhausted.value = exhaustedNow _reachedBack.value = pager.deepestUntil(user.pubkeyHex, asked, startUntil()) - Log.d("DMPagination") { "[rooms.nip04.history] round done: $count event(s), exhausted=${count == 0}" } - if (autoLoadAll && count > 0) loadMore(user) + Log.d("DMPagination") { "[rooms.nip04.history] round done: $count event(s), exhausted=$exhaustedNow" } + if (autoLoadAll && !exhaustedNow) loadMore(user) } } wasLoading = loading @@ -187,10 +207,22 @@ class ChatroomListNip04HistorySubAssembler( _relayCount.value = 0 _reachedBack.value = null autoFillRoomMark = Int.MIN_VALUE + lastAskedActive = emptySet() + lastRoundEventCount = -1 } return requestNewSubscription(historyListener(user, key)) } + // Flips to exhausted only once every relay has returned an empty page + EOSE. Sets true only. + private fun markExhaustedIfAllDone(user: User) { + val account = accounts[user.pubkeyHex] ?: return + val allRelays = (account.homeRelays.flow.value + account.dmRelays.flow.value).toSet() + if (allRelays.isNotEmpty() && pager.activeRelays(user.pubkeyHex, allRelays).isEmpty()) { + exhaustedByUser[user.pubkeyHex] = true + if (activeUser == user.pubkeyHex) _exhausted.value = true + } + } + private fun historyListener( user: User, key: ChatroomListState, @@ -213,6 +245,8 @@ class ChatroomListNip04HistorySubAssembler( pager.onEose(user.pubkeyHex, relay) windowLoad.onRelaySettled(relay) newEose(key, relay, TimeUtils.now(), forFilters) + lastRoundEventCount = -1 + markExhaustedIfAllDone(user) } override fun onClosed( From 5080ab08c56102f7f0fa7a2ec85654287bf4443e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 20:24:23 +0000 Subject: [PATCH 034/103] fix: give up on relays that keep rejecting us so a chat can finish loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With "exhausted only when every relay empty-EOSEs", a relay that demands auth we can't satisfy — e.g. relay.ditto.pub answering "auth-required: all authors must be authenticated" for a correspondent's pubkey we can't authenticate as — CLOSEs every round, never finishes, and the conversation loads forever. UntilLimitPager now tracks a per-relay CLOSED streak; after GIVE_UP_AFTER_CLOSES (3) consecutive CLOSEDs with no answer in between (so the pool's auth handshake + a retry have already failed), the relay is marked "given up" and excluded from activeRelays. It is NOT counted as done (it didn't empty-EOSE — we just can't read it), but it no longer blocks exhaustion. The streak resets on any event or EOSE, so a relay whose auth succeeds is never abandoned. onClosed wires into the pager and re-checks exhaustion when a relay tips into given-up. NIP-17 only queries the user's own DM relays (auth = self), so it rarely hits this; it's the NIP-04 conversation fan-out to the correspondent's relays that trips author-auth-required relays. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../eoseManagers/UntilLimitPager.kt | 46 ++++++++++++++++++- .../AccountGiftWrapsHistoryEoseManager.kt | 5 +- .../ChatroomNip04HistorySubAssembler.kt | 3 ++ .../ChatroomListNip04HistorySubAssembler.kt | 1 + 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt index a7042bc348..9dce43f344 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt @@ -50,6 +50,14 @@ class UntilLimitPager { // Set once the relay answered an empty page with EOSE: there is nothing older on it. @Volatile var done: Boolean = false + // Set once the relay has rejected us [GIVE_UP_AFTER_CLOSES] times in a row without ever + // answering (CLOSED, e.g. "auth-required" for authors we can't authenticate). It is NOT done — + // we just can't read its window — but it must be excluded so it doesn't block exhaustion forever. + @Volatile var givenUp: Boolean = false + + // Consecutive CLOSEDs since this relay last actually answered (event or EOSE). Reset on contact. + @Volatile var closedStreak: Int = 0 + // Per-round tallies, reset by [beginRound]: how many events arrived and the oldest among them. @Volatile var roundCount: Int = 0 @@ -95,6 +103,7 @@ class UntilLimitPager { createdAt: Long, ) { val c = cursor(key, relay) + c.closedStreak = 0 c.roundCount++ if (createdAt < c.roundOldest) c.roundOldest = createdAt } @@ -109,6 +118,7 @@ class UntilLimitPager { relay: NormalizedRelayUrl, ) { val c = cursor(key, relay) + c.closedStreak = 0 if (c.roundCount == 0) { c.done = true } else { @@ -116,17 +126,43 @@ class UntilLimitPager { } } + /** + * Records a CLOSED (rejection) from [relay]. After [GIVE_UP_AFTER_CLOSES] in a row with no answer in + * between — i.e. the relay keeps rejecting us and auth can't fix it — the relay is [given up][givenUp] + * so it stops blocking exhaustion. Returns true if this CLOSED tipped it into given-up. + */ + fun onClosed( + key: K, + relay: NormalizedRelayUrl, + ): Boolean { + val c = cursor(key, relay) + if (c.givenUp || c.done) return false + c.closedStreak++ + if (c.closedStreak >= GIVE_UP_AFTER_CLOSES) { + c.givenUp = true + return true + } + return false + } + /** Total events received across [relays] in the round just finished. Zero ⇒ nothing more is reachable. */ fun roundEventCount( key: K, relays: Collection, ): Int = relays.sumOf { cursor(key, it).roundCount } - /** Relays from [all] that still have older history to ask for (not yet empty-EOSE'd). */ + /** + * Relays from [all] that still have older history to ask for: not yet empty-EOSE'd ([done]) and not + * abandoned as unreadable ([givenUp]). + */ fun activeRelays( key: K, all: Collection, - ): List = all.filterNot { cursor(key, it).done } + ): List = + all.filterNot { + val c = cursor(key, it) + c.done || c.givenUp + } /** * The oldest point reached across [relays] — the minimum cursor (how far back paging has gone). @@ -137,4 +173,10 @@ class UntilLimitPager { relays: Collection, start: Long, ): Long? = relays.takeIf { it.isNotEmpty() }?.minOf { cursor(key, it).until ?: start } + + companion object { + // Consecutive CLOSEDs (with no answer in between) before a relay is abandoned as unreadable. + // Allows for the pool's auth handshake + a retry or two before concluding auth can't succeed. + private const val GIVE_UP_AFTER_CLOSES = 3 + } } 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 2788776f3d..42c5edf2b0 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 @@ -299,8 +299,11 @@ class AccountGiftWrapsHistoryEoseManager( forFilters: List?, ) { // CLOSED (e.g. auth-required) is not "empty": don't mark the relay done — it may answer - // after the auth handshake. It just settles the load so the spinner can clear. + // after the auth handshake. It just settles the load so the spinner can clear. But if it + // keeps rejecting us (auth we can't satisfy), the pager eventually gives up on it; once + // that's the last blocker, exhaustion can complete. windowLoad.onRelaySettled(relay) + if (pager.onClosed(user.pubkeyHex, relay)) markExhaustedIfAllDone(user) } override fun onCannotConnect( 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 3959faf6b4..0bf83fcb7d 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 @@ -277,6 +277,9 @@ class ChatroomNip04HistorySubAssembler( forFilters: List?, ) { windowLoad.onRelaySettled(relay) + // A relay (e.g. the correspondent's) may demand auth we can't satisfy and CLOSE every + // round; once the pager gives up on it, it stops blocking this thread's exhaustion. + if (pager.onClosed(pk, relay)) markExhaustedIfAllDone() } override fun onCannotConnect( 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 e84b1838a1..7beb7bf8ae 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 @@ -255,6 +255,7 @@ class ChatroomListNip04HistorySubAssembler( forFilters: List?, ) { windowLoad.onRelaySettled(relay) + if (pager.onClosed(user.pubkeyHex, relay)) markExhaustedIfAllDone(user) } override fun onCannotConnect( From 5bd7b765d6d6ab0153ac88cd127e474ae748c4db Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 20:57:34 +0000 Subject: [PATCH 035/103] chore: log which account contributes which relays to the DM filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds DmRelayLog, a diagnostic that prints — per DM subscription — the account whose relays are being used and breaks the relay set down by the source list each relay comes from (NIP-65 inbox/outbox, DM-relay-list, private-storage outbox, local relays). Wired into all six DM assemblers (NIP-17 live/history, NIP-04 rooms live/history, NIP-04 convo live/history). The existing REQ lines now also print the resolved relay URLs (split into fromMe/outbox and toMe/inbox for the NIP-04 paths), so an unexpected relay — e.g. a write-only NIP-65 relay that only the NIP-04 home+dm path queries — can be traced back to the list it leaks in from. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../relayClient/eoseManagers/DmRelayLog.kt | 63 +++++++++++++++++++ .../AccountGiftWrapsEoseManager.kt | 4 +- .../AccountGiftWrapsHistoryEoseManager.kt | 4 +- .../ChatroomNip04HistorySubAssembler.kt | 4 +- .../datasource/ChatroomNip04SubAssembler.kt | 4 +- .../ChatroomListNip04HistorySubAssembler.kt | 4 +- .../ChatroomListNip04SubAssembler.kt | 4 +- 7 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/DmRelayLog.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/DmRelayLog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/DmRelayLog.kt new file mode 100644 index 0000000000..39d1370a16 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/DmRelayLog.kt @@ -0,0 +1,63 @@ +/* + * 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.amethyst.service.relayClient.eoseManagers + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log + +/** + * Diagnostic for "which account is bringing which relays into the DM filters". For each DM + * subscription it prints the account whose relays are used and breaks the relay set down by the + * source list each relay comes from (NIP-65 inbox/outbox, the DM-relay-list, the private-storage + * outbox, local relays). Use it to trace an unexpected relay — e.g. a write-only NIP-65 relay that + * only the NIP-04 (home+dm) path queries — back to the list it leaks in from. + */ +object DmRelayLog { + private const val TAG = "DMPagination" + + fun log( + label: String, + account: Account, + ) = Log.d(TAG) { + val pk = account.userProfile().pubkeyHex.take(8) + val inbox = account.nip65RelayList.inboxFlow.value + val outbox = account.nip65RelayList.outboxFlow.value + val dmList = account.dmRelayList.flow.value + val priv = account.privateStorageRelayList.flow.value + val local = account.localRelayList.flow.value + buildString { + append("[$label] account=$pk relays by source:") + appendSource("nip65In", inbox) + appendSource("nip65Out", outbox) + appendSource("dmList", dmList) + appendSource("private", priv) + appendSource("local", local) + } + } + + private fun StringBuilder.appendSource( + name: String, + relays: Collection, + ) { + if (relays.isNotEmpty()) append(" $name=${relays.map { it.url }}") + } +} 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 3a7339d59a..caede915f3 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 @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59G import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey 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 @@ -68,7 +69,8 @@ class AccountGiftWrapsEoseManager( val relays = key.account.dmRelays.flow.value windowLoad.setExpectedRelays(relays.toSet()) val sinceTime = TimeUtils.now() - LIVE_TAIL_SECONDS - Log.d(TAG) { "[giftwrap.live] REQ since=$sinceTime (7d, no until) on ${relays.size} relay(s)" } + DmRelayLog.log("giftwrap.live", key.account) + Log.d(TAG) { "[giftwrap.live] REQ since=$sinceTime (7d, no until) on ${relays.size} relay(s): ${relays.map { it.url }}" } return relays.flatMap { relay -> filterGiftWrapsToPubkey(relay = relay, pubkey = user(key).pubkeyHex, since = sinceTime) } 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 42c5edf2b0..ecc58ff8f5 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 @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59G import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey 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.PerUserEoseManager import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.UntilLimitPager import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.WindowLoadTracker @@ -151,7 +152,8 @@ class AccountGiftWrapsHistoryEoseManager( askedRelays[user.pubkeyHex] = active windowLoad.setExpectedRelays(active) if (active.isEmpty()) return emptyList() - Log.d(TAG) { "[giftwrap.history] REQ ${active.size} relay(s), limit=$PAGE_LIMIT (until ${daysAgo(pager.untilFor(user.pubkeyHex, active.first(), startUntil()))}d…)" } + DmRelayLog.log("giftwrap.history", key.account) + Log.d(TAG) { "[giftwrap.history] REQ ${active.size} relay(s) ${active.map { it.url }}, limit=$PAGE_LIMIT (until ${daysAgo(pager.untilFor(user.pubkeyHex, active.first(), startUntil()))}d…)" } return active.flatMap { relay -> filterGiftWrapsToPubkey( relay = relay, 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 0bf83fcb7d..d9f9bae2cd 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 @@ -20,6 +20,7 @@ */ 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.UntilLimitPager import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.WindowLoadTracker @@ -123,7 +124,8 @@ class ChatroomNip04HistorySubAssembler( askedRelays[pk] = active windowLoad.setExpectedRelays(active) if (active.isEmpty()) return emptyList() - Log.d("DMPagination") { "[convo.nip04.history] REQ ${active.size} relay(s), limit=$PAGE_LIMIT" } + DmRelayLog.log("convo.nip04.history", key.account) + Log.d("DMPagination") { "[convo.nip04.history] REQ ${active.size} relay(s), limit=$PAGE_LIMIT fromMe(outbox)=${relays.fromMeRelays.intersect(active).map { it.url }} toMe(inbox)=${relays.toMeRelays.intersect(active).map { it.url }}" } val activeRelays = Nip04DmRelays( toMeRelays = relays.toMeRelays.intersect(active), 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 b0cf1d5a69..d58db5032c 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 @@ -20,6 +20,7 @@ */ 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 @@ -52,7 +53,8 @@ class ChatroomNip04SubAssembler( val sinceTime = TimeUtils.now() - AccountGiftWrapsEoseManager.LIVE_TAIL_SECONDS val filters = filterNip04DMs(key.room.users, key.account, sinceTime) windowLoad.setExpectedRelays(filters?.mapTo(mutableSetOf()) { it.relay } ?: emptySet()) - Log.d("DMPagination") { "[convo.nip04.live] REQ since=$sinceTime (7d, no until) on ${filters?.size ?: 0} relay-filter(s)" } + DmRelayLog.log("convo.nip04.live", key.account) + Log.d("DMPagination") { "[convo.nip04.live] REQ since=$sinceTime (7d, no until) on ${filters?.size ?: 0} relay-filter(s): ${filters?.map { it.relay.url }?.distinct()}" } filters } else { windowLoad.setExpectedRelays(emptySet()) 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 7beb7bf8ae..5ad5fc6634 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 @@ -22,6 +22,7 @@ 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.PerUserEoseManager import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.UntilLimitPager import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.WindowLoadTracker @@ -122,7 +123,8 @@ class ChatroomListNip04HistorySubAssembler( askedRelays[user.pubkeyHex] = active windowLoad.setExpectedRelays(active) if (active.isEmpty()) return emptyList() - Log.d("DMPagination") { "[rooms.nip04.history] REQ ${active.size} relay(s), limit=$PAGE_LIMIT" } + DmRelayLog.log("rooms.nip04.history", key.account) + Log.d("DMPagination") { "[rooms.nip04.history] REQ ${active.size} relay(s), limit=$PAGE_LIMIT fromMe(outbox)=${homeRelays.filter { it in active }.map { it.url }} toMe(inbox)=${dmRelays.filter { it in active }.map { it.url }}" } return homeRelays.filter { it in active }.map { filterNip04DMsFromMe(user, it, since = null, until = pager.untilFor(user.pubkeyHex, it, startUntil()), limit = PAGE_LIMIT) } + 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 031d1fe0c5..747af475a4 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 @@ -21,6 +21,7 @@ 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 @@ -59,7 +60,8 @@ class ChatroomListNip04SubAssembler( val dmRelays = key.account.dmRelays.flow.value windowLoad.setExpectedRelays((homeRelays + dmRelays).toSet()) val sinceTime = TimeUtils.now() - AccountGiftWrapsEoseManager.LIVE_TAIL_SECONDS - Log.d("DMPagination") { "[rooms.nip04.live] REQ since=$sinceTime (7d, no until) on ${homeRelays.size + dmRelays.size} relay(s)" } + DmRelayLog.log("rooms.nip04.live", key.account) + Log.d("DMPagination") { "[rooms.nip04.live] REQ since=$sinceTime (7d, no until) fromMe(outbox)=${homeRelays.map { it.url }} toMe(inbox)=${dmRelays.map { it.url }}" } homeRelays.map { filterNip04DMsFromMe(key.account.userProfile(), it, sinceTime) } + dmRelays.map { filterNip04DMsToMe(key.account.userProfile(), it, sinceTime) } } else { From 772355d2fcaecd48aea1a32468cde657e68860f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 21:28:30 +0000 Subject: [PATCH 036/103] fix: don't ask a correspondent's relays for my own NIP-04 messages A conversation's NIP-04 relay set folded the correspondent's inbox (read) relays into the from-me filter set, so we sent {authors:[me]} to relays that belong to the other party (e.g. ditto). Those relays have no reason to hold my authored messages and auth-walled ones reject the filter outright ("all authors must be authenticated"), stalling the load. Scope filters to relay owners: my outbox carries my messages; the correspondent's outbox (plus my own inbox as a legacy safety net) carries theirs. Drops groupInbox from the from-me set. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../privateDM/datasource/FilterNip04DMs.kt | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt index 977f1bcf0d..bdf4d0570e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt @@ -38,8 +38,15 @@ class Nip04DmRelays( } /** - * Resolves where a conversation's NIP-04 DMs flow: messages **to me** arrive on my inbox + the - * group's outbox relays; messages **from me** arrive on my outbox + the group's inbox relays. + * Resolves where a conversation's NIP-04 DMs flow, scoped to relay owners so a filter naming a user + * is only sent to relays that user actually lists: + * + * - **to me** (`authors=[group]`) → the group's **outbox** (where they publish) plus **my inbox** + * (my own DM relays, as a safety net for legacy senders that delivered straight to my inbox). + * - **from me** (`authors=[me]`) → **my outbox** only. We deliberately do *not* ask the group's + * inbox relays for my-authored messages: those relays belong to the correspondent, not me, and + * querying them for `authors=[me]` is both redundant (my outbox already has them) and a trigger + * for auth-walled relays like ditto that reject authors they can't authenticate. */ fun nip04DMRelays( group: Set?, @@ -51,7 +58,6 @@ fun nip04DMRelays( val userInboxRelays = account.dmRelays.flow.value val groupOutboxRelays = mutableSetOf() - val groupInboxRelays = mutableSetOf() group.forEach { val authorHomeRelayEventAddress = AdvertisedRelayListEvent.createAddressTag(it) @@ -64,19 +70,11 @@ fun nip04DMRelays( ?: emptyList() groupOutboxRelays.addAll(outbox) - - val inbox = - authorHomeRelayEvent?.readRelaysNorm()?.ifEmpty { null } - ?: LocalCache.getUserIfExists(it)?.allUsedRelaysOrNull() - ?: LocalCache.relayHints.hintsForKey(it).ifEmpty { null } - ?: emptyList() - - groupInboxRelays.addAll(inbox) } return Nip04DmRelays( toMeRelays = userInboxRelays + groupOutboxRelays, - fromMeRelays = userOutboxRelays + groupInboxRelays, + fromMeRelays = userOutboxRelays, ) } From 0430fd176c52dd06965aabcef6ac9680ec7b0169 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 21:48:06 +0000 Subject: [PATCH 037/103] refactor: scope NIP-04 DM filters per relay to the keys that own each relay Nip04DmRelays held two flat relay sets and every filter named the whole conversation group, so a relay that belongs to only one counterpart was still asked about all of them (e.g. {authors:[bob,charlie]} sent to a relay that is only charlie's). Restructure it into per-relay key maps so each relay sees exactly the keys it owns: fromMe: my outbox -> {authors:[me], #p:[whole group]} each counterpart inbox -> {authors:[me], #p:[keys reading there]} toMe: my inbox -> {authors:[whole group], #p:[me]} each counterpart outbox-> {authors:[keys publishing there], #p:[me]} Relays shared across roles union their key sets, so my own relays still carry the full group while a counterpart's relay only ever names that counterpart. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../ChatroomNip04HistorySubAssembler.kt | 8 +- .../privateDM/datasource/FilterNip04DMs.kt | 85 ++++++++++++------- 2 files changed, 58 insertions(+), 35 deletions(-) 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 d9f9bae2cd..646fbfbad6 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 @@ -125,13 +125,13 @@ class ChatroomNip04HistorySubAssembler( windowLoad.setExpectedRelays(active) if (active.isEmpty()) return emptyList() DmRelayLog.log("convo.nip04.history", key.account) - Log.d("DMPagination") { "[convo.nip04.history] REQ ${active.size} relay(s), limit=$PAGE_LIMIT fromMe(outbox)=${relays.fromMeRelays.intersect(active).map { it.url }} toMe(inbox)=${relays.toMeRelays.intersect(active).map { it.url }}" } + Log.d("DMPagination") { "[convo.nip04.history] REQ ${active.size} relay(s), limit=$PAGE_LIMIT fromMe(outbox)=${relays.fromMeRelays.keys.intersect(active).map { it.url }} toMe(inbox)=${relays.toMeRelays.keys.intersect(active).map { it.url }}" } val activeRelays = Nip04DmRelays( - toMeRelays = relays.toMeRelays.intersect(active), - fromMeRelays = relays.fromMeRelays.intersect(active), + toMeRelays = relays.toMeRelays.filterKeys { it in active }, + fromMeRelays = relays.fromMeRelays.filterKeys { it in active }, ) - return filterNip04DMsHistory(key.room.users, key.account, activeRelays, PAGE_LIMIT) { relay -> + return filterNip04DMsHistory(key.account, activeRelays, PAGE_LIMIT) { relay -> pager.untilFor(pk, relay, startUntil()) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt index bdf4d0570e..a1217546c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt @@ -29,25 +29,34 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -/** The two relay sets a conversation's NIP-04 DMs flow over, resolved via the outbox model. */ +/** + * Where a conversation's NIP-04 DMs flow, resolved via the outbox model and **scoped per relay** so + * every filter only names the keys that actually own the relay it is sent to. + * + * Each map is `relay -> the counterpart keys to name in that relay's filter`: + * - [toMeRelays] queries messages **to me** (`authors=[those keys], #p=[me]`). A relay appears here + * when it is my inbox (then the key set is the whole group) and/or a counterpart's outbox (then + * the key set is just the counterparts who publish there). + * - [fromMeRelays] queries messages **from me** (`authors=[me], #p=[those keys]`). A relay appears + * here when it is my outbox (then the key set is the whole group) and/or a counterpart's inbox + * (then the key set is just the counterparts who read there). + * + * Scoping the key set per relay is what keeps us from sending, e.g., `authors=[bob]` to a relay that + * is only charlie's — a filter that relay has no reason to serve. + */ class Nip04DmRelays( - val toMeRelays: Set, - val fromMeRelays: Set, + val toMeRelays: Map>, + val fromMeRelays: Map>, ) { - val all: Set get() = toMeRelays + fromMeRelays + val all: Set get() = toMeRelays.keys + fromMeRelays.keys } -/** - * Resolves where a conversation's NIP-04 DMs flow, scoped to relay owners so a filter naming a user - * is only sent to relays that user actually lists: - * - * - **to me** (`authors=[group]`) → the group's **outbox** (where they publish) plus **my inbox** - * (my own DM relays, as a safety net for legacy senders that delivered straight to my inbox). - * - **from me** (`authors=[me]`) → **my outbox** only. We deliberately do *not* ask the group's - * inbox relays for my-authored messages: those relays belong to the correspondent, not me, and - * querying them for `authors=[me]` is both redundant (my outbox already has them) and a trigger - * for auth-walled relays like ditto that reject authors they can't authenticate. - */ +private fun addAll( + map: MutableMap>, + relays: Collection, + keys: Collection, +) = relays.forEach { map.getOrPut(it) { mutableSetOf() }.addAll(keys) } + fun nip04DMRelays( group: Set?, account: Account?, @@ -57,8 +66,18 @@ fun nip04DMRelays( val userOutboxRelays = account.homeRelays.flow.value val userInboxRelays = account.dmRelays.flow.value - val groupOutboxRelays = mutableSetOf() + // relay -> counterpart keys whose messages-to-me we ask that relay for (authors set, #p=[me]). + val toMe = mutableMapOf>() + // relay -> counterpart keys whose copy of my messages we ask that relay for (#p set, authors=[me]). + val fromMe = mutableMapOf>() + // My own relays carry the whole conversation: my inbox holds everyone's messages to me, my outbox + // holds all of mine. Both filters name the full group on these relays. + addAll(toMe, userInboxRelays, group) + addAll(fromMe, userOutboxRelays, group) + + // Each counterpart's own relays only get a filter naming that counterpart: their outbox (where + // they publish their messages to me) and their inbox (where they keep my messages to them). group.forEach { val authorHomeRelayEventAddress = AdvertisedRelayListEvent.createAddressTag(it) val authorHomeRelayEvent = (LocalCache.getAddressableNoteIfExists(authorHomeRelayEventAddress)?.event as? AdvertisedRelayListEvent) @@ -69,18 +88,22 @@ fun nip04DMRelays( ?: LocalCache.relayHints.hintsForKey(it).ifEmpty { null } ?: emptyList() - groupOutboxRelays.addAll(outbox) + val inbox = + authorHomeRelayEvent?.readRelaysNorm()?.ifEmpty { null } + ?: LocalCache.getUserIfExists(it)?.allUsedRelaysOrNull() + ?: LocalCache.relayHints.hintsForKey(it).ifEmpty { null } + ?: emptyList() + + addAll(toMe, outbox, listOf(it)) + addAll(fromMe, inbox, listOf(it)) } - return Nip04DmRelays( - toMeRelays = userInboxRelays + groupOutboxRelays, - fromMeRelays = userOutboxRelays, - ) + return Nip04DmRelays(toMe, fromMe) } private fun toMeFilter( relay: NormalizedRelayUrl, - group: Set, + authors: Set, account: Account, since: Long?, until: Long?, @@ -90,7 +113,7 @@ private fun toMeFilter( filter = Filter( kinds = listOf(PrivateDmEvent.KIND), - authors = group.toList(), + authors = authors.toList(), tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), since = since, until = until, @@ -100,7 +123,7 @@ private fun toMeFilter( private fun fromMeFilter( relay: NormalizedRelayUrl, - group: Set, + pTags: Set, account: Account, since: Long?, until: Long?, @@ -111,7 +134,7 @@ private fun fromMeFilter( Filter( kinds = listOf(PrivateDmEvent.KIND), authors = listOf(account.userProfile().pubkeyHex), - tags = mapOf("p" to group.toList()), + tags = mapOf("p" to pTags.toList()), since = since, until = until, limit = limit, @@ -126,20 +149,20 @@ fun filterNip04DMs( ): List? { if (group.isNullOrEmpty() || account == null) return null val relays = nip04DMRelays(group, account) ?: return null - return relays.toMeRelays.map { toMeFilter(it, group, account, since = windowStart, until = null, limit = null) } + - relays.fromMeRelays.map { fromMeFilter(it, group, account, since = windowStart, until = null, limit = null) } + return relays.toMeRelays.map { (relay, authors) -> toMeFilter(relay, authors, account, since = windowStart, until = null, limit = null) } + + relays.fromMeRelays.map { (relay, pTags) -> fromMeFilter(relay, pTags, account, since = windowStart, until = null, limit = null) } } /** * History filters: a bounded backward page per relay. Each relay is asked for [limit] events older - * than [untilFor]`(relay)` (no `since`), so it can be paged down to empty independently. + * than [untilFor]`(relay)` (no `since`), so it can be paged down to empty independently. The author / + * `#p` key set per relay comes straight from [relays], so each relay only sees the keys it owns. */ fun filterNip04DMsHistory( - group: Set, account: Account, relays: Nip04DmRelays, limit: Int, untilFor: (NormalizedRelayUrl) -> Long?, ): List = - relays.toMeRelays.map { toMeFilter(it, group, account, since = null, until = untilFor(it), limit = limit) } + - relays.fromMeRelays.map { fromMeFilter(it, group, account, since = null, until = untilFor(it), limit = limit) } + relays.toMeRelays.map { (relay, authors) -> toMeFilter(relay, authors, account, since = null, until = untilFor(relay), limit = limit) } + + relays.fromMeRelays.map { (relay, pTags) -> fromMeFilter(relay, pTags, account, since = null, until = untilFor(relay), limit = limit) } From 3cb6613cd76919149013686e32afbfd0a6a04ca7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 22:10:16 +0000 Subject: [PATCH 038/103] fix: give up on DM relays that accept a REQ but never answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The convo NIP-04 history spinner stayed up for minutes against 9 relays even when the conversation was fully loaded. Auth-walled relays (ditto, nostr.wine, …) accept the REQ (success=true) and then send nothing — no event, no EOSE, no CLOSED. WindowLoadTracker only completed once every relay reached a terminal signal, an idle gate that required hearing from *all* relays, or the 5-minute cap; a silent relay armed none of those, so only the cap freed the spinner. The pager likewise kept the silent relay 'active' every round, so the count never dropped and exhaustion never completed. Add a silence backstop keyed off onSubscriptionStarted (REQ delivered, post-connect — so a slow connect isn't mistaken for a dead relay): a relay that received its REQ but stays silent past silenceTimeout (10s) no longer blocks completion and is reported via onAbandoned, which the convo assembler uses to giveUp() the relay in its pager so it leaves the active set and lets exhaustion finish. finish() applies the give-up before flipping loading, so the round collector recomputes exhaustion after the silent relays are dropped. Tests cover the pager give-up/exhaustion and the tracker's silence + connection-gap behavior. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../eoseManagers/UntilLimitPager.kt | 16 ++++ .../eoseManagers/WindowLoadTracker.kt | 68 +++++++++++--- .../ChatroomNip04HistorySubAssembler.kt | 21 ++++- .../eoseManagers/UntilLimitPagerGiveUpTest.kt | 69 ++++++++++++++ .../WindowLoadTrackerSilenceTest.kt | 94 +++++++++++++++++++ 5 files changed, 256 insertions(+), 12 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPagerGiveUpTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTrackerSilenceTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt index 9dce43f344..db33a87959 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt @@ -145,6 +145,22 @@ class UntilLimitPager { return false } + /** + * Abandons [relay] for [key] when it accepted our REQ but never answered (no event, EOSE, or CLOSED + * within the silence window). Like [givenUp] via [onClosed], it is excluded from [activeRelays] so a + * silent relay can't block exhaustion forever — but a relay that already finished cleanly ([done]) + * is left alone. Returns true if this abandoned a relay that wasn't already done/given-up. + */ + fun giveUp( + key: K, + relay: NormalizedRelayUrl, + ): Boolean { + val c = cursor(key, relay) + if (c.done || c.givenUp) return false + c.givenUp = true + return true + } + /** Total events received across [relays] in the round just finished. Zero ⇒ nothing more is reachable. */ fun roundEventCount( key: K, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt index 97483a755b..378e44fd57 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt @@ -58,16 +58,25 @@ import kotlin.time.Duration.Companion.seconds * mistakes a half-loaded window for a finished one, which is exactly how a load reports "1 event" * when a hundred are still on the way. * - * Two backstops cover misbehaving relays. If every relay has at least been *heard from* (any event, - * EOSE, CLOSED, or cannot-connect) but one streamed events without ever sending EOSE, an [idleTimeout] - * of quiet completes the load — the "heard from all" gate is what keeps this from firing in a - * connection gap. And an [absoluteCap] bounds a relay that connects and then dribbles or hangs forever. + * Three backstops cover misbehaving relays. If every relay we're still waiting on has at least been + * *heard from* (any event, EOSE, CLOSED, or cannot-connect) but one streamed events without ever + * sending EOSE, an [idleTimeout] of quiet completes the load — the "heard from" gate is what keeps + * this from firing in a connection gap. A relay that *received our REQ* ([onReqSent]) but then went + * completely silent — no event, no EOSE, no CLOSED — for [silenceTimeout] is given up on: an + * auth-walled relay (ditto, paid relays) commonly accepts the REQ and answers nothing, and measuring + * from REQ-delivery (not window start) means a slow connect doesn't count against it. Such relays are + * reported to [onAbandoned] so the owner can drop them from its pager too. And an [absoluteCap] bounds + * a relay that hangs in the connect itself, before any REQ is even sent. */ class WindowLoadTracker( // Short label for the DMPagination logs (e.g. "giftwrap", "rooms.nip04", "convo.nip04"). private val name: String = "dm", private val idleTimeout: Duration = 3.seconds, + private val silenceTimeout: Duration = 10.seconds, private val absoluteCap: Duration = 5.minutes, + // Invoked with the relays that received a REQ but stayed silent past [silenceTimeout] when a load + // finishes — the owner gives up on them in its pager so they stop blocking future rounds. + private val onAbandoned: (Set) -> Unit = {}, ) { private val _loading = MutableStateFlow(true) val loading: StateFlow = _loading.asStateFlow() @@ -85,6 +94,10 @@ class WindowLoadTracker( // [expected] the stored backfill is complete on every relay and the load is done. private val settled = ConcurrentHashMap.newKeySet() + // When the REQ was actually delivered to each relay (post-connect). The silence backstop measures + // from here, not window start, so a slow connect isn't mistaken for a dead relay. + private val reqSentAt = ConcurrentHashMap() + private var watchdog: Job? = null // Incremented on every (re)start so a stale watchdog that wakes right as a new load begins @@ -103,6 +116,7 @@ class WindowLoadTracker( expected = emptySet() heardFrom.clear() settled.clear() + reqSentAt.clear() lastActivityMs = System.currentTimeMillis() val wasLoading = _loading.value _loading.value = true @@ -128,11 +142,21 @@ class WindowLoadTracker( deadline: Long, ): Boolean { if (gen != generation || !_loading.value) return false - // Every relay has spoken and the stream has gone quiet: a relay that streamed without ever - // EOSE'ing is done. The "heard from all" gate keeps this from firing in a connection gap. - if (expected.isNotEmpty() && heardFrom.containsAll(expected) && now - lastActivityMs >= idleTimeout.inWholeMilliseconds) { - finish("idle") - return false + if (expected.isNotEmpty()) { + // A relay is accounted for once it reached a terminal signal, or it received our REQ and + // then stayed completely silent past [silenceTimeout] (an auth-walled / dead relay). Once + // every relay is accounted for, nothing more is coming. + if (expected.all { settled.contains(it) || silencedOut(it, now) }) { + finish("settled/silent") + return false + } + // Idle backstop: every relay we're still waiting on has at least streamed something (so this + // isn't a connection gap) and the stream has gone quiet. Silenced/settled relays don't count. + val stillWaiting = expected.filterNot { settled.contains(it) || silencedOut(it, now) } + if (stillWaiting.all { heardFrom.contains(it) } && now - lastActivityMs >= idleTimeout.inWholeMilliseconds) { + finish("idle") + return false + } } if (now >= deadline) { finish("cap") @@ -141,6 +165,14 @@ class WindowLoadTracker( return true } + // A relay that received its REQ but produced no signal at all for [silenceTimeout]. Measured from + // REQ-delivery so a slow connect (or a still-connecting relay, which has no [reqSentAt]) is never + // counted as silent. + private fun silencedOut( + relay: NormalizedRelayUrl, + now: Long, + ): Boolean = relay !in heardFrom && (reqSentAt[relay]?.let { now - it >= silenceTimeout.inWholeMilliseconds } ?: false) + /** Records which relays the current REQ was sent to. Completes immediately if there are none. */ @Synchronized fun setExpectedRelays(relays: Set) { @@ -152,6 +184,16 @@ class WindowLoadTracker( } } + /** + * Records that the REQ was delivered to [relayUrl] (post-connect). Starts that relay's silence clock. + * Ignored for relays outside the current [expected] set (or before it is known). + */ + @Synchronized + fun onReqSent(relayUrl: String) { + val relay = expected.firstOrNull { it.url == relayUrl } ?: return + reqSentAt.putIfAbsent(relay, System.currentTimeMillis()) + } + /** A non-terminal sign of life from [relay] (a stored or live event). Keeps the idle timer alive. */ fun onRelayEvent(relay: NormalizedRelayUrl) { heardFrom.add(relay) @@ -174,10 +216,14 @@ class WindowLoadTracker( @Synchronized private fun finish(reason: String) { if (!_loading.value) return - _loading.value = false watchdog?.cancel() watchdog = null - Log.d(TAG) { "[$name] load done: $reason" } + // Give up the silent relays BEFORE flipping [loading]: the owner's round collector reacts to + // loading=false by recomputing exhaustion from its pager, so the give-up has to land first. + val abandoned = expected.filterTo(mutableSetOf()) { silencedOut(it, System.currentTimeMillis()) } + Log.d(TAG) { "[$name] load done: $reason" + if (abandoned.isEmpty()) "" else " (gave up on silent ${abandoned.map { it.url }})" } + if (abandoned.isNotEmpty()) onAbandoned(abandoned) + _loading.value = false } companion object { 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 646fbfbad6..981a5b7e03 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 @@ -69,7 +69,7 @@ class ChatroomNip04HistorySubAssembler( private val started = ConcurrentHashMap.newKeySet() private val askedRelays = ConcurrentHashMap>() - private val windowLoad = WindowLoadTracker("convo.nip04.history") + private val windowLoad = WindowLoadTracker("convo.nip04.history", onAbandoned = ::onRelaysAbandoned) val loadingMore: StateFlow = windowLoad.loading private val _exhausted = MutableStateFlow(false) @@ -233,6 +233,18 @@ class ChatroomNip04HistorySubAssembler( return requestNewSubscription(historyListener(key)) } + // A relay accepted the REQ but never answered (auth-walled / dead): drop it from every open + // conversation's pager so it stops blocking the relay count and exhaustion on the next round. May + // complete exhaustion right away if it was the last relay still holding a thread open. + private fun onRelaysAbandoned(relays: Set) { + var gaveUp = false + started.forEach { pk -> + val asked = askedRelays[pk] ?: return@forEach + relays.forEach { if (it in asked && pager.giveUp(pk, it)) gaveUp = true } + } + if (gaveUp) markExhaustedIfAllDone() + } + // Flips to exhausted only once every open conversation's relays have all returned an empty page + // EOSE. Sets true only — false transitions belong to loadMore / the round collector. private fun markExhaustedIfAllDone() { @@ -252,6 +264,13 @@ class ChatroomNip04HistorySubAssembler( private fun historyListener(key: ChatroomQueryState): SubscriptionListener { val pk = convoKey(key) return object : SubscriptionListener { + override fun onSubscriptionStarted( + relay: String, + forFilters: List, + ) { + windowLoad.onReqSent(relay) + } + override fun onEvent( event: Event, isLive: Boolean, diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPagerGiveUpTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPagerGiveUpTest.kt new file mode 100644 index 0000000000..595bc4c2fb --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPagerGiveUpTest.kt @@ -0,0 +1,69 @@ +/* + * 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.amethyst.service.relayClient.eoseManagers + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class UntilLimitPagerGiveUpTest { + private val mine = NormalizedRelayUrl("wss://vitor.nostr1.com/") + private val silent = NormalizedRelayUrl("wss://relay.ditto.pub/") + private val all = listOf(mine, silent) + + @Test + fun givenUpRelayLeavesTheActiveSet() { + val pager = UntilLimitPager() + + assertEquals(all, pager.activeRelays("k", all)) + + assertTrue("first give-up takes effect", pager.giveUp("k", silent)) + assertEquals(listOf(mine), pager.activeRelays("k", all)) + + assertFalse("giving up twice is a no-op", pager.giveUp("k", silent)) + } + + @Test + fun givingUpEveryRelayExhaustsTheKey() { + val pager = UntilLimitPager() + + // mine pages to empty cleanly; the silent relay never answers and is given up. + pager.beginRound("k", all) + pager.onEose("k", mine) // empty page + EOSE => done + pager.giveUp("k", silent) + + assertTrue("no relay left to ask", pager.activeRelays("k", all).isEmpty()) + } + + @Test + fun aRelayThatAlreadyFinishedIsNotMarkedGivenUp() { + val pager = UntilLimitPager() + + pager.beginRound("k", listOf(mine)) + pager.onEose("k", mine) // done + + // A late silence sweep must not "give up" a relay that already finished cleanly. + assertFalse(pager.giveUp("k", mine)) + assertTrue(pager.isDone("k", mine)) + } +} 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 new file mode 100644 index 0000000000..b6a699b4f5 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTrackerSilenceTest.kt @@ -0,0 +1,94 @@ +/* + * 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.amethyst.service.relayClient.eoseManagers + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.atomic.AtomicReference +import kotlin.time.Duration.Companion.milliseconds + +/** + * Real-time (not virtual-time) tests: the tracker's watchdog reads the wall clock, so a short + * [silenceTimeout] with real delays is the honest way to exercise the silence backstop. + */ +class WindowLoadTrackerSilenceTest { + private val good = NormalizedRelayUrl("wss://vitor.nostr1.com/") + private val silent = NormalizedRelayUrl("wss://relay.ditto.pub/") + + @Test + fun silentRelayDoesNotBlockTheLoadAndIsReportedAsAbandoned() = + runBlocking { + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val abandoned = AtomicReference>(emptySet()) + val tracker = + WindowLoadTracker( + name = "test", + silenceTimeout = 50.milliseconds, + onAbandoned = { abandoned.set(it) }, + ) + + tracker.startLoading(scope) + tracker.setExpectedRelays(setOf(good, silent)) + // Both received the REQ; only the good relay answers (an EOSE settles it). + tracker.onReqSent(good.url) + tracker.onReqSent(silent.url) + tracker.onRelaySettled(good) + + // The good relay is settled and the silent one trips the silence backstop, so the load + // completes without ever hearing from the silent relay. + withTimeout(3000) { tracker.loading.first { !it } } + + assertEquals(setOf(silent), abandoned.get()) + scope.cancel() + } + + @Test + fun aSilentRelayThatNeverGotAReqStillBlocksUntilItSettles() = + runBlocking { + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val tracker = WindowLoadTracker(name = "test", silenceTimeout = 50.milliseconds) + + tracker.startLoading(scope) + tracker.setExpectedRelays(setOf(good, silent)) + tracker.onReqSent(good.url) + tracker.onRelaySettled(good) + // `silent` is still connecting: no onReqSent, so the silence clock never starts and the + // load must stay open (a connection gap must not be mistaken for a dead relay). + + Thread.sleep(400) // well past silenceTimeout + assertTrue("still loading while a relay has not even been sent its REQ", tracker.loading.value) + + // Once it connects, gets its REQ, and stays silent, the backstop then completes the load. + tracker.onReqSent(silent.url) + withTimeout(3000) { tracker.loading.first { !it } } + + scope.cancel() + } +} From d658ae24148143e0531c5adc4c0ce59a19c386ba Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 22:31:42 +0000 Subject: [PATCH 039/103] fix: don't let a relay stuck before its REQ hold the DM load for 5 min MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The convo history spinner could sit for the full 5-minute absoluteCap ('load done: cap' in the logs). A correspondent relay (ditto) dropped after its auth-required CLOSEs and got stuck reconnecting on a flaky network, so its round-2 REQ was never delivered. It therefore reached neither a terminal signal (no CLOSE without a REQ) nor the silence backstop (which measures from REQ-delivery), and blocked the round until the cap. Add a connect-grace backstop: a relay that has been expected past connectGrace (15s) without even receiving its REQ — i.e. stuck connecting — stops blocking the round. Unlike the silence backstop it does NOT give the relay up (it may be a genuinely slow connect), so the owner keeps it and retries it next round; only relays that accepted a REQ and then went silent are abandoned. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../eoseManagers/WindowLoadTracker.kt | 46 ++++++++++++++----- .../WindowLoadTrackerSilenceTest.kt | 25 ++++++++++ 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt index 378e44fd57..0f5e3dff10 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt @@ -65,14 +65,18 @@ import kotlin.time.Duration.Companion.seconds * completely silent — no event, no EOSE, no CLOSED — for [silenceTimeout] is given up on: an * auth-walled relay (ditto, paid relays) commonly accepts the REQ and answers nothing, and measuring * from REQ-delivery (not window start) means a slow connect doesn't count against it. Such relays are - * reported to [onAbandoned] so the owner can drop them from its pager too. And an [absoluteCap] bounds - * a relay that hangs in the connect itself, before any REQ is even sent. + * reported to [onAbandoned] so the owner can drop them from its pager too. A relay that never even + * *receives* its REQ (stuck connecting / reconnecting, so it can neither settle nor go "silent") stops + * blocking the round after [connectGrace] from the load start — but it is NOT given up (it may be a + * genuinely slow connect), so the owner keeps it and retries it next round. And an [absoluteCap] is + * the final ceiling on a window that somehow defeats all of the above. */ class WindowLoadTracker( // Short label for the DMPagination logs (e.g. "giftwrap", "rooms.nip04", "convo.nip04"). private val name: String = "dm", private val idleTimeout: Duration = 3.seconds, private val silenceTimeout: Duration = 10.seconds, + private val connectGrace: Duration = 15.seconds, private val absoluteCap: Duration = 5.minutes, // Invoked with the relays that received a REQ but stayed silent past [silenceTimeout] when a load // finishes — the owner gives up on them in its pager so they stop blocking future rounds. @@ -109,6 +113,10 @@ class WindowLoadTracker( @Volatile private var lastActivityMs = 0L + // Wall-clock the current window began; the connect-grace backstop measures from here. + @Volatile + private var loadStartMs = 0L + /** Begins a fresh window load: clears the per-relay sets, raises [loading], and arms the watchdog. */ @Synchronized fun startLoading(scope: CoroutineScope) { @@ -117,7 +125,9 @@ class WindowLoadTracker( heardFrom.clear() settled.clear() reqSentAt.clear() - lastActivityMs = System.currentTimeMillis() + val nowMs = System.currentTimeMillis() + lastActivityMs = nowMs + loadStartMs = nowMs val wasLoading = _loading.value _loading.value = true Log.d(TAG) { "[$name] load start" + if (!wasLoading) "" else " (restart)" } @@ -143,16 +153,15 @@ class WindowLoadTracker( ): Boolean { if (gen != generation || !_loading.value) return false if (expected.isNotEmpty()) { - // A relay is accounted for once it reached a terminal signal, or it received our REQ and - // then stayed completely silent past [silenceTimeout] (an auth-walled / dead relay). Once - // every relay is accounted for, nothing more is coming. - if (expected.all { settled.contains(it) || silencedOut(it, now) }) { + // Once every relay is accounted for — settled, gone silent after its REQ, or stuck before + // its REQ even went out — nothing more is coming for this round. + if (expected.all { accountedFor(it, now) }) { finish("settled/silent") return false } // Idle backstop: every relay we're still waiting on has at least streamed something (so this - // isn't a connection gap) and the stream has gone quiet. Silenced/settled relays don't count. - val stillWaiting = expected.filterNot { settled.contains(it) || silencedOut(it, now) } + // isn't a connection gap) and the stream has gone quiet. Accounted-for relays don't count. + val stillWaiting = expected.filterNot { accountedFor(it, now) } if (stillWaiting.all { heardFrom.contains(it) } && now - lastActivityMs >= idleTimeout.inWholeMilliseconds) { finish("idle") return false @@ -165,14 +174,29 @@ class WindowLoadTracker( return true } + // A relay no longer worth waiting on this round: it reached a terminal signal, went silent after + // its REQ, or never even received its REQ within the connect grace. + private fun accountedFor( + relay: NormalizedRelayUrl, + now: Long, + ): Boolean = settled.contains(relay) || silencedOut(relay, now) || connectStalled(relay, now) + // A relay that received its REQ but produced no signal at all for [silenceTimeout]. Measured from - // REQ-delivery so a slow connect (or a still-connecting relay, which has no [reqSentAt]) is never - // counted as silent. + // REQ-delivery so a slow connect (which has no [reqSentAt] yet) is never counted as silent. These + // relays ARE given up on — accepting a REQ and then answering nothing is an auth-walled / dead relay. private fun silencedOut( relay: NormalizedRelayUrl, now: Long, ): Boolean = relay !in heardFrom && (reqSentAt[relay]?.let { now - it >= silenceTimeout.inWholeMilliseconds } ?: false) + // A relay that is still expected but has neither been heard from nor even received its REQ within + // [connectGrace] of the load start — i.e. stuck connecting / reconnecting. It stops blocking the + // round, but is NOT given up (it may simply be a slow connect): the owner retries it next round. + private fun connectStalled( + relay: NormalizedRelayUrl, + now: Long, + ): Boolean = relay !in heardFrom && !reqSentAt.containsKey(relay) && now - loadStartMs >= connectGrace.inWholeMilliseconds + /** Records which relays the current REQ was sent to. Completes immediately if there are none. */ @Synchronized fun setExpectedRelays(relays: Set) { 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 b6a699b4f5..a4f1d42ac1 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 @@ -69,6 +69,31 @@ class WindowLoadTrackerSilenceTest { scope.cancel() } + @Test + fun aRelayStuckBeforeItsReqStopsBlockingButIsNotGivenUp() = + runBlocking { + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val abandoned = AtomicReference>(emptySet()) + val tracker = + WindowLoadTracker( + name = "test", + connectGrace = 50.milliseconds, + onAbandoned = { abandoned.set(it) }, + ) + + tracker.startLoading(scope) + tracker.setExpectedRelays(setOf(good, silent)) + // `good` answers; `silent` is stuck connecting — it never even receives its REQ. + tracker.onReqSent(good.url) + tracker.onRelaySettled(good) + + // The connect-grace backstop completes the load without the stuck relay... + withTimeout(3000) { tracker.loading.first { !it } } + // ...but it must NOT be given up: a slow connect deserves a retry next round. + assertEquals(emptySet(), abandoned.get()) + scope.cancel() + } + @Test fun aSilentRelayThatNeverGotAReqStillBlocksUntilItSettles() = runBlocking { From 0d27477c4d043a2516074ada57bc29095b2cd8b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 23:43:12 +0000 Subject: [PATCH 040/103] fix: mark a DM conversation done when its last relays stop making progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the connect-grace fix the convo history no longer hangs, but it could linger forever showing 'N relays' with no progress bar and exhausted=false: a correspondent's auth-walled relay (ditto, which only ever CLOSEs 'all authors must be authenticated') never reaches the pager's done/given-up state, and the no-progress guard merely *skipped* re-issuing the round — stopping the loop without ever reflecting that we're finished. When the guard trips (same active relays, zero events two rounds running) give up on those relays and recompute exhaustion, so the conversation reports as fully loaded and the relay count clears. My own reachable relays empty-EOSE to 'done' and never reach this branch, so only genuinely stuck correspondent relays are dropped. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../datasource/ChatroomNip04HistorySubAssembler.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 981a5b7e03..1bb665f986 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 @@ -159,7 +159,14 @@ class ChatroomNip04HistorySubAssembler( return } if (activeUnion == lastAskedActive && lastRoundEventCount == 0) { - Log.d("DMPagination") { "[convo.nip04.history] loadMore skipped — no progress on the same relays" } + // The same relays just returned nothing two rounds running — they're unreadable: a + // correspondent's auth-walled relay that only ever CLOSEs (ditto: "all authors must be + // authenticated"), or one perpetually stuck reconnecting. Give up on them so the + // conversation reports as finished instead of lingering forever on a "N relays" count + // with no progress bar. (My own reachable relays empty-EOSE to `done` and never land here.) + Log.d("DMPagination") { "[convo.nip04.history] giving up — no progress on the same relays ${activeUnion.map { it.url }}" } + perKeyActive.forEach { (pk, active) -> active.forEach { pager.giveUp(pk, it) } } + markExhaustedIfAllDone() return } lastAskedActive = activeUnion From 07b0c87c38e1a51d22ffcbcd6a4cb26ee52a7d48 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 00:04:09 +0000 Subject: [PATCH 041/103] fix: stop giftwrap/rooms loads completing before their REQs are sent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The silence + connect-grace backstops are only meaningful when the owner feeds onReqSent, which only the convo NIP-04 path does. But connectStalled keyed off the ABSENCE of a recorded REQ, so for giftwrap/rooms (which never call onReqSent) every relay looked connect-stalled after connectGrace — the window completed at ~15s before its REQs had even gone out during a slow connect storm ('giftwrap.live load done: settled/silent' 45s before the REQ), prematurely declaring an empty round done and tripping the no-progress guard, so giftwraps stopped loading. Gate both REQ-aware backstops behind a tracksReqSends flag that only the convo manager sets; everyone else keeps the plain settle / idle / cap behavior. Adds a regression test that a non-tracking tracker keeps blocking a never-heard-from relay until it actually settles. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../eoseManagers/WindowLoadTracker.kt | 13 +++++-- .../ChatroomNip04HistorySubAssembler.kt | 2 +- .../WindowLoadTrackerSilenceTest.kt | 34 ++++++++++++++++++- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt index 0f5e3dff10..3387ce2c49 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt @@ -70,10 +70,19 @@ import kotlin.time.Duration.Companion.seconds * blocking the round after [connectGrace] from the load start — but it is NOT given up (it may be a * genuinely slow connect), so the owner keeps it and retries it next round. And an [absoluteCap] is * the final ceiling on a window that somehow defeats all of the above. + * + * The two REQ-aware backstops (silence + connect-grace) only make sense when the owner actually feeds + * [onReqSent], so they are gated behind [tracksReqSends]. A tracker that does NOT track REQ sends keeps + * the plain settle / idle / cap behavior — otherwise, with an always-empty [reqSentAt], EVERY relay + * would look "connect-stalled" after [connectGrace] and the window would complete before its REQs even + * went out (e.g. during a slow connect storm), prematurely declaring an empty round done. */ class WindowLoadTracker( // Short label for the DMPagination logs (e.g. "giftwrap", "rooms.nip04", "convo.nip04"). private val name: String = "dm", + // Whether the owner feeds [onReqSent]; enables the silence + connect-grace backstops. Off by + // default so trackers that don't track REQ sends are unaffected by them. + private val tracksReqSends: Boolean = false, private val idleTimeout: Duration = 3.seconds, private val silenceTimeout: Duration = 10.seconds, private val connectGrace: Duration = 15.seconds, @@ -187,7 +196,7 @@ class WindowLoadTracker( private fun silencedOut( relay: NormalizedRelayUrl, now: Long, - ): Boolean = relay !in heardFrom && (reqSentAt[relay]?.let { now - it >= silenceTimeout.inWholeMilliseconds } ?: false) + ): Boolean = tracksReqSends && relay !in heardFrom && (reqSentAt[relay]?.let { now - it >= silenceTimeout.inWholeMilliseconds } ?: false) // A relay that is still expected but has neither been heard from nor even received its REQ within // [connectGrace] of the load start — i.e. stuck connecting / reconnecting. It stops blocking the @@ -195,7 +204,7 @@ class WindowLoadTracker( private fun connectStalled( relay: NormalizedRelayUrl, now: Long, - ): Boolean = relay !in heardFrom && !reqSentAt.containsKey(relay) && now - loadStartMs >= connectGrace.inWholeMilliseconds + ): Boolean = tracksReqSends && relay !in heardFrom && !reqSentAt.containsKey(relay) && now - loadStartMs >= connectGrace.inWholeMilliseconds /** Records which relays the current REQ was sent to. Completes immediately if there are none. */ @Synchronized 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 1bb665f986..1b8a456eac 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 @@ -69,7 +69,7 @@ class ChatroomNip04HistorySubAssembler( private val started = ConcurrentHashMap.newKeySet() private val askedRelays = ConcurrentHashMap>() - private val windowLoad = WindowLoadTracker("convo.nip04.history", onAbandoned = ::onRelaysAbandoned) + private val windowLoad = WindowLoadTracker("convo.nip04.history", tracksReqSends = true, onAbandoned = ::onRelaysAbandoned) val loadingMore: StateFlow = windowLoad.loading private val _exhausted = MutableStateFlow(false) 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 a4f1d42ac1..c7a31f1e8d 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 @@ -50,6 +50,7 @@ class WindowLoadTrackerSilenceTest { val tracker = WindowLoadTracker( name = "test", + tracksReqSends = true, silenceTimeout = 50.milliseconds, onAbandoned = { abandoned.set(it) }, ) @@ -77,6 +78,7 @@ class WindowLoadTrackerSilenceTest { val tracker = WindowLoadTracker( name = "test", + tracksReqSends = true, connectGrace = 50.milliseconds, onAbandoned = { abandoned.set(it) }, ) @@ -94,11 +96,41 @@ class WindowLoadTrackerSilenceTest { scope.cancel() } + @Test + fun withoutReqTrackingAStalledRelayKeepsBlockingUntilItSettles() = + runBlocking { + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + // A tracker that does NOT feed onReqSent (giftwrap / rooms): the REQ-aware backstops must + // stay off, or every never-heard-from relay would look stalled and the window would finish + // before its REQs even went out (the connect-storm regression). + val tracker = + WindowLoadTracker( + name = "test", + tracksReqSends = false, + silenceTimeout = 50.milliseconds, + connectGrace = 50.milliseconds, + ) + + tracker.startLoading(scope) + tracker.setExpectedRelays(setOf(good, silent)) + tracker.onRelaySettled(good) + + // `silent` was never heard from and never got a REQ; well past both short backstops it must + // STILL block, because this tracker doesn't track REQ sends. + Thread.sleep(400) + assertTrue("non-req-tracking tracker must not abandon a stalled relay", tracker.loading.value) + + // Only an actual terminal signal completes it. + tracker.onRelaySettled(silent) + withTimeout(3000) { tracker.loading.first { !it } } + scope.cancel() + } + @Test fun aSilentRelayThatNeverGotAReqStillBlocksUntilItSettles() = runBlocking { val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val tracker = WindowLoadTracker(name = "test", silenceTimeout = 50.milliseconds) + val tracker = WindowLoadTracker(name = "test", tracksReqSends = true, silenceTimeout = 50.milliseconds) tracker.startLoading(scope) tracker.setExpectedRelays(setOf(good, silent)) From cbee966bb783e8918702aa9742a3ddabf9de20a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 00:29:53 +0000 Subject: [PATCH 042/103] feat: page each NIP-04 DM relay independently to converge on one window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the lock-step round model (every relay advanced one page per global round, gated by the slowest) with per-relay continuous paging: each relay continues to its next page the instant it EOSEs, off its own cursor. The subscription layer diffs per relay, so re-issuing only re-REQs the relay whose cursor moved — others' in-flight REQs are untouched. Fast relays race to the bottom in back-to-back pages while slow / auth-walled relays catch up at their own pace; none are abandoned (this reverts the give-up behaviour — slow relays keep their subscription open and keep trying), so every relay converges on the same window. A relay is done on an empty page; one that won't answer (auth CLOSE, unreachable, silent) is marked stalled but keeps trying. loadingMore clears once every relay is done or stalled. Exposes per-relay RelayPagingProgress (reached-back / done / stalled) for the upcoming in-stream progress markers. Splits the convo widen loop so each protocol pages on its own loader state — NIP-04's continuous loading no longer starves gift-wrap paging. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../loggedIn/chats/privateDM/ChatroomView.kt | 35 +-- .../ChatroomNip04HistorySubAssembler.kt | 245 +++++++++--------- 2 files changed, 136 insertions(+), 144 deletions(-) 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 dfb8871cae..e4059b7cac 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 @@ -161,8 +161,10 @@ private const val PREFETCH_OLDER_MESSAGES = 3 * room, so this advances the shared account-wide history window and the conversation's messages * surface as its pages are decrypted. * - * Both protocols advance via their history managers' `loadMore`; the step is gated on BOTH loaders - * being idle, so it never outruns the slower one, and stops once both report exhausted. + * Each protocol advances via its own history manager's `loadMore`, gated only on ITS OWN loader/ + * exhausted state — so a slow protocol (e.g. NIP-04 waiting on a sluggish correspondent relay) never + * holds back the other. NIP-04 then pages every relay independently to completion on its own; gift + * wraps stay round-driven and re-step here as the oldest end stays in view. */ @Composable private fun LoadOlderMessagesWhenScrolling( @@ -173,7 +175,7 @@ private fun LoadOlderMessagesWhenScrolling( val nip04History = remember(accountViewModel) { accountViewModel.dataSources().chatroom.nip04History } LaunchedEffect(listState, giftWrapsHistory, nip04History) { - combine( + val wantMore = snapshotFlow { val info = listState.layoutInfo val total = info.totalItemsCount @@ -181,21 +183,24 @@ private fun LoadOlderMessagesWhenScrolling( // The oldest end is in view (no overflow requirement, so a one-message thread that // can't scroll still qualifies and walks history to its start). total > 0 && lastVisible >= total - PREFETCH_OLDER_MESSAGES - }, - giftWrapsHistory.loadingMore, - nip04History.loadingMore, - giftWrapsHistory.exhausted, - nip04History.exhausted, - ) { wantMore, loadingGiftWraps, loadingNip04, giftWrapsExhausted, nip04Exhausted -> - // Keep paging while either protocol still has older history to reach. - wantMore && !loadingGiftWraps && !loadingNip04 && !(giftWrapsExhausted && nip04Exhausted) - }.distinctUntilChanged() - .filter { it } - .collect { - Log.d("DMPagination") { "convo: widen (oldest in view) → loadMore" } + }.distinctUntilChanged() + + launch { + combine(wantMore, giftWrapsHistory.loadingMore, giftWrapsHistory.exhausted) { want, loading, exhausted -> + want && !loading && !exhausted + }.distinctUntilChanged().filter { it }.collect { + Log.d("DMPagination") { "convo: widen (oldest in view) → giftwrap loadMore" } giftWrapsHistory.loadMore(accountViewModel.userProfile()) + } + } + launch { + combine(wantMore, nip04History.loadingMore, nip04History.exhausted) { want, loading, exhausted -> + want && !loading && !exhausted + }.distinctUntilChanged().filter { it }.collect { + Log.d("DMPagination") { "convo: widen (oldest in view) → nip04 loadMore" } nip04History.loadMore() } + } } } 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 1b8a456eac..bc8b9f99f6 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 @@ -20,7 +20,6 @@ */ 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.UntilLimitPager import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.WindowLoadTracker @@ -45,11 +44,30 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap +/** How far back one relay has paged a conversation, for the per-relay progress markers. */ +data class RelayPagingProgress( + // The oldest createdAt this relay has loaded down to (its `until` cursor). The marker sits here and + // slides down (older) as the relay pages further back. + val reachedUntil: Long, + // The relay answered an empty page: it has nothing older, it has reached the bottom of its window. + val done: Boolean, + // The relay isn't answering right now (auth-walled CLOSE / unreachable / slow). It is NOT abandoned + // — its subscription stays open and it keeps trying to catch up — but it isn't currently advancing. + val stalled: Boolean, +) + /** - * Loads older NIP-04 DMs (kind 4) for one conversation by `until`+`limit` paging, per relay, scoped to - * the two participants. Same gap-proof model as the gift-wrap history (a relay is done on an empty - * page + EOSE; [exhausted] once a round advances no relay), keyed per conversation. Idle until - * [loadMore]. + * Loads older NIP-04 DMs (kind 4) for one conversation by `until`+`limit` paging — **per relay, + * independently**. There are no lock-step rounds: every relay drives its own pages off its own cursor, + * continuing the instant it EOSEs (the subscription layer diffs per relay, so re-issuing only re-REQs + * the relay whose cursor moved; the others' in-flight REQs are untouched). Fast relays race to the + * bottom of the conversation in a few back-to-back pages while slow / auth-walled relays catch up at + * their own pace in the background — none are abandoned, so they all converge on the same window. + * + * A relay is *done* once it answers an empty page (nothing older). A relay that won't answer (auth + * CLOSE, unreachable, silent) is marked *stalled* for the markers but keeps its subscription open and + * keeps trying. The [loadingMore] spinner reflects whether anything is still actively advancing; it + * clears once every relay is either done or stalled, without waiting on the slow ones beyond that. */ class ChatroomNip04HistorySubAssembler( client: INostrClient, @@ -67,9 +85,12 @@ class ChatroomNip04HistorySubAssembler( private val pager = UntilLimitPager() private val started = ConcurrentHashMap.newKeySet() - private val askedRelays = ConcurrentHashMap>() - private val windowLoad = WindowLoadTracker("convo.nip04.history", tracksReqSends = true, onAbandoned = ::onRelaysAbandoned) + // Relays currently not advancing for a conversation (auth CLOSE / unreachable / silent). Tracked for + // the progress markers; these relays are NOT given up — they keep their subscription and keep trying. + private val stalledRelays = ConcurrentHashMap>() + + private val windowLoad = WindowLoadTracker("convo.nip04.history", tracksReqSends = true, onAbandoned = ::onRelaysStalled) val loadingMore: StateFlow = windowLoad.loading private val _exhausted = MutableStateFlow(false) @@ -81,28 +102,21 @@ class ChatroomNip04HistorySubAssembler( private val _reachedBack = MutableStateFlow(null) val reachedBack: StateFlow = _reachedBack.asStateFlow() + // Per-relay paging progress for the conversation on screen — the data the in-stream markers render. + private val _relayProgress = MutableStateFlow>(emptyMap()) + val relayProgress: StateFlow> = _relayProgress.asStateFlow() + // Shared across accounts/conversations (singleton coordinator): repoint the display flows to the // conversation now on screen instead of leaking the previous one's state. Cursors live in [pager]. @Volatile private var activeConvo: ConvoKey? = null private val exhaustedByConvo = ConcurrentHashMap() - // No-progress guard (see the gift-wrap history manager's twin): skip re-issuing an identical round - // that brought nothing; cleared by onEose. - @Volatile - private var lastAskedActive: Set = emptySet() - - @Volatile - private var lastRoundEventCount = -1 - @Volatile private var scope: CoroutineScope? = null @Volatile - private var roundJob: Job? = null - - @Volatile - private var autoLoadAll = false + private var doneJob: Job? = null private fun startUntil() = TimeUtils.now() - AccountGiftWrapsEoseManager.LIVE_TAIL_SECONDS @@ -116,115 +130,104 @@ class ChatroomNip04HistorySubAssembler( ): List? { val pk = convoKey(key) val relays = nip04DMRelays(key.room.users, key.account) - if (!key.account.isWriteable() || pk !in started || relays == null) { - windowLoad.setExpectedRelays(emptySet()) - return emptyList() - } + if (!key.account.isWriteable() || pk !in started || relays == null) return emptyList() + + // Every relay that still has older history to ask for, each at its own cursor. A relay whose + // cursor advanced since the last assembly re-REQs its next page; one still mid-page keeps its + // open REQ; a done relay drops out (its REQ closes). This is what lets relays run independently. val active = pager.activeRelays(pk, relays.all).toSet() - askedRelays[pk] = active - windowLoad.setExpectedRelays(active) if (active.isEmpty()) return emptyList() - DmRelayLog.log("convo.nip04.history", key.account) - Log.d("DMPagination") { "[convo.nip04.history] REQ ${active.size} relay(s), limit=$PAGE_LIMIT fromMe(outbox)=${relays.fromMeRelays.keys.intersect(active).map { it.url }} toMe(inbox)=${relays.toMeRelays.keys.intersect(active).map { it.url }}" } - val activeRelays = + + val scoped = Nip04DmRelays( toMeRelays = relays.toMeRelays.filterKeys { it in active }, fromMeRelays = relays.fromMeRelays.filterKeys { it in active }, ) - return filterNip04DMsHistory(key.account, activeRelays, PAGE_LIMIT) { relay -> + return filterNip04DMsHistory(key.account, scoped, PAGE_LIMIT) { relay -> pager.untilFor(pk, relay, startUntil()) } } - /** Requests the next backward page for every open conversation that still has older history. */ + /** Starts (or resumes) per-relay paging for every open conversation. Idempotent: safe to call again. */ fun loadMore() { - if (_exhausted.value) return - // Gather the active (not-finished) relays per open conversation first, so the no-progress guard - // is checked before any beginRound (which would otherwise reset round tallies prematurely). - val perKeyActive = mutableListOf>>() - val activeUnion = mutableSetOf() - allKeys().forEach { key -> + val keys = allKeys() + val fullRelays = mutableSetOf() + var anyActive = false + keys.forEach { key -> val relays = nip04DMRelays(key.room.users, key.account) ?: return@forEach val pk = convoKey(key) started.add(pk) - val active = pager.activeRelays(pk, relays.all) - if (active.isNotEmpty()) { - perKeyActive.add(pk to active) - activeUnion.addAll(active) - } + fullRelays.addAll(relays.all) + if (pager.activeRelays(pk, relays.all).isNotEmpty()) anyActive = true } - if (perKeyActive.isEmpty()) { + if (fullRelays.isEmpty()) return + if (!anyActive) { + // Everything already paged to the bottom. activeConvo?.let { exhaustedByConvo[it] = true } _exhausted.value = true return } - if (activeUnion == lastAskedActive && lastRoundEventCount == 0) { - // The same relays just returned nothing two rounds running — they're unreadable: a - // correspondent's auth-walled relay that only ever CLOSEs (ditto: "all authors must be - // authenticated"), or one perpetually stuck reconnecting. Give up on them so the - // conversation reports as finished instead of lingering forever on a "N relays" count - // with no progress bar. (My own reachable relays empty-EOSE to `done` and never land here.) - Log.d("DMPagination") { "[convo.nip04.history] giving up — no progress on the same relays ${activeUnion.map { it.url }}" } - perKeyActive.forEach { (pk, active) -> active.forEach { pager.giveUp(pk, it) } } - markExhaustedIfAllDone() - return - } - lastAskedActive = activeUnion - var totalRelays = 0 - var deepest: Long? = null - perKeyActive.forEach { (pk, active) -> - pager.beginRound(pk, active) - totalRelays += active.size - pager.deepestUntil(pk, active, startUntil())?.let { d -> - deepest = deepest?.let { minOf(it, d) } ?: d - } - } - _relayCount.value = totalRelays - _reachedBack.value = deepest - Log.d("DMPagination") { "[convo.nip04.history] loadMore" } + _exhausted.value = false + _relayCount.value = fullRelays.size scope?.let { - ensureRoundCollector(it) - windowLoad.startLoading(it) + ensureDoneCollector(it) + // One window spanning the whole per-relay pagination: it settles a relay only on that relay's + // empty-EOSE (done) or when it goes silent/stalled, never on a mid-history page, so the + // spinner tracks "is anything still advancing" rather than any single round. + if (!windowLoad.loading.value) windowLoad.startLoading(it) + windowLoad.setExpectedRelays(fullRelays) } + publishProgress() + Log.d("DMPagination") { "[convo.nip04.history] paging ${fullRelays.size} relay(s) independently" } invalidateFilters() } - /** Pages to the end: each completed round auto-issues the next until exhausted. */ - fun loadEverything() { - if (_exhausted.value) return - autoLoadAll = true - loadMore() - } + /** Per-relay paging already runs to completion on its own, so loading everything is just [loadMore]. */ + fun loadEverything() = loadMore() - private fun ensureRoundCollector(scope: CoroutineScope) { - if (roundJob?.isActive == true) return - roundJob = + // Flips [exhausted] when the window settles (every relay done or stalled) and back to false when a + // fresh page starts. Tied to the spinner so "nothing is advancing" and "caught up" stay consistent. + private fun ensureDoneCollector(scope: CoroutineScope) { + if (doneJob?.isActive == true) return + doneJob = scope.launch { var wasLoading = false windowLoad.loading.collect { loading -> if (!loading && wasLoading) { - val count = started.sumOf { pk -> pager.roundEventCount(pk, askedRelays[pk] ?: emptySet()) } - lastRoundEventCount = count - // Exhausted ONLY when every open conversation's relays have all returned an - // empty page + EOSE; a CLOSED / unanswered relay isn't finished, so keep loading. - val keys = allKeys() - val exhaustedNow = - keys.isNotEmpty() && - keys.none { key -> - val relays = nip04DMRelays(key.room.users, key.account) - relays != null && pager.activeRelays(convoKey(key), relays.all).isNotEmpty() - } - activeConvo?.let { exhaustedByConvo[it] = exhaustedNow } - _exhausted.value = exhaustedNow - _reachedBack.value = started.mapNotNull { pk -> pager.deepestUntil(pk, askedRelays[pk] ?: emptySet(), startUntil()) }.minOrNull() - Log.d("DMPagination") { "[convo.nip04.history] round done: $count event(s), exhausted=$exhaustedNow" } - if (autoLoadAll && !exhaustedNow) loadMore() + activeConvo?.let { exhaustedByConvo[it] = true } + _exhausted.value = true + publishProgress() + Log.d("DMPagination") { "[convo.nip04.history] all relays settled (done or stalled)" } } wasLoading = loading } } } + // WindowLoadTracker reports relays that accepted a REQ then went silent, or never got their REQ out. + // We do NOT give up on them (they may simply be slow and need to catch up) — we just record them as + // stalled for the markers and let them keep their open subscription. + private fun onRelaysStalled(relays: Set) { + started.forEach { pk -> stalledRelays.getOrPut(pk) { ConcurrentHashMap.newKeySet() }.addAll(relays) } + publishProgress() + } + + private fun publishProgress() { + val pk = activeConvo ?: return + val relays = allKeys().firstOrNull { convoKey(it) == pk }?.let { nip04DMRelays(it.room.users, it.account) } ?: return + val stalled = stalledRelays[pk] ?: emptySet() + val start = startUntil() + _relayProgress.value = + relays.all.associateWith { relay -> + RelayPagingProgress( + reachedUntil = pager.untilFor(pk, relay, start), + done = pager.isDone(pk, relay), + stalled = relay in stalled && !pager.isDone(pk, relay), + ) + } + _reachedBack.value = pager.deepestUntil(pk, relays.all, start) + } + override fun newSub(key: ChatroomQueryState): Subscription { scope = key.account.scope val pk = convoKey(key) @@ -234,40 +237,11 @@ class ChatroomNip04HistorySubAssembler( _exhausted.value = exhaustedByConvo[pk] ?: false _relayCount.value = 0 _reachedBack.value = null - lastAskedActive = emptySet() - lastRoundEventCount = -1 + _relayProgress.value = emptyMap() } return requestNewSubscription(historyListener(key)) } - // A relay accepted the REQ but never answered (auth-walled / dead): drop it from every open - // conversation's pager so it stops blocking the relay count and exhaustion on the next round. May - // complete exhaustion right away if it was the last relay still holding a thread open. - private fun onRelaysAbandoned(relays: Set) { - var gaveUp = false - started.forEach { pk -> - val asked = askedRelays[pk] ?: return@forEach - relays.forEach { if (it in asked && pager.giveUp(pk, it)) gaveUp = true } - } - if (gaveUp) markExhaustedIfAllDone() - } - - // Flips to exhausted only once every open conversation's relays have all returned an empty page + - // EOSE. Sets true only — false transitions belong to loadMore / the round collector. - private fun markExhaustedIfAllDone() { - val keys = allKeys() - val allDone = - keys.isNotEmpty() && - keys.none { key -> - val relays = nip04DMRelays(key.room.users, key.account) - relays != null && pager.activeRelays(convoKey(key), relays.all).isNotEmpty() - } - if (allDone) { - activeConvo?.let { exhaustedByConvo[it] = true } - _exhausted.value = true - } - } - private fun historyListener(key: ChatroomQueryState): SubscriptionListener { val pk = convoKey(key) return object : SubscriptionListener { @@ -286,17 +260,26 @@ class ChatroomNip04HistorySubAssembler( ) { windowLoad.onRelayEvent(relay) pager.onEvent(pk, relay, event.createdAt) + stalledRelays[pk]?.remove(relay) } override fun onEose( relay: NormalizedRelayUrl, forFilters: List?, ) { + stalledRelays[pk]?.remove(relay) pager.onEose(pk, relay) - windowLoad.onRelaySettled(relay) + if (pager.isDone(pk, relay)) { + // Reached the bottom on this relay: settle it for the spinner, nothing more to ask. + windowLoad.onRelaySettled(relay) + } else { + // This page had events: reset only this relay's tally and let it continue to its + // next page immediately, independent of every other relay. + pager.beginRound(pk, listOf(relay)) + } newEose(key, relay, TimeUtils.now(), forFilters) - lastRoundEventCount = -1 - markExhaustedIfAllDone() + publishProgress() + invalidateFilters() } override fun onClosed( @@ -304,10 +287,12 @@ class ChatroomNip04HistorySubAssembler( relay: NormalizedRelayUrl, forFilters: List?, ) { + // A relay (e.g. the correspondent's) may demand auth we can't satisfy and CLOSE. It's + // stalled, not done — keep its subscription so the pool can re-auth and it can catch up — + // but don't let it hold the spinner. windowLoad.onRelaySettled(relay) - // A relay (e.g. the correspondent's) may demand auth we can't satisfy and CLOSE every - // round; once the pager gives up on it, it stops blocking this thread's exhaustion. - if (pager.onClosed(pk, relay)) markExhaustedIfAllDone() + stalledRelays.getOrPut(pk) { ConcurrentHashMap.newKeySet() }.add(relay) + publishProgress() } override fun onCannotConnect( @@ -316,6 +301,8 @@ class ChatroomNip04HistorySubAssembler( forFilters: List?, ) { windowLoad.onRelaySettled(relay) + stalledRelays.getOrPut(pk) { ConcurrentHashMap.newKeySet() }.add(relay) + publishProgress() } } } From afa02df3a097a25fd0ce196cf5973c8bbb200acf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 00:39:00 +0000 Subject: [PATCH 043/103] feat: in-stream per-relay paging markers for NIP-04 conversations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visualises the per-relay-independent engine: each relay gets a thin marker in the message stream at the depth (createdAt) it has paged down to, sitting just below the oldest message it has loaded. As a relay pages older its reached-back cursor drops and the marker slides down; a relay that races ahead leaves its marker deep while slower relays' trail higher and converge as they catch up — ✓ done (empty-EOSE), … stalled (auth CLOSE / unreachable, still trying), ↓ reaching. Hidden once the conversation is fully converged (every relay done or stalled). Adds an optional markersInGap slot to the shared chat feed view (no-op for public-chat callers) invoked per message gap with its createdAt bounds; ChatroomView renders the markers from the relayProgress flow. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../loggedIn/chats/feed/ChatFeedView.kt | 19 ++++ .../chats/feed/layouts/RelayReachMarker.kt | 101 ++++++++++++++++++ .../loggedIn/chats/privateDM/ChatroomView.kt | 56 ++++++++++ 3 files changed, 176 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt index ab351e4692..d4a1cc2737 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt @@ -64,6 +64,10 @@ fun RefreshingChatroomFeedView( // Optional footer rendered at the oldest end of the thread (a "load more" / spinner affordance). // Null for callers that load their whole history at once (public chats / channels). olderBoundary: (@Composable () -> Unit)? = null, + // Optional per-gap hook: invoked between each message and its next-older neighbour with their + // createdAt bounds, so a caller (private DMs) can draw per-relay paging markers at the depth each + // relay has reached. No-op for callers without per-relay progress. + markersInGap: (@Composable (newerCreatedAt: Long?, olderCreatedAt: Long?) -> Unit)? = null, ) { SaveableFeedState(feedContentState, scrollStateKey) { listState -> listStateObserver(listState) @@ -77,6 +81,7 @@ fun RefreshingChatroomFeedView( onWantsToEditDraft, avoidDraft, olderBoundary, + markersInGap, ) } } @@ -92,6 +97,7 @@ fun RenderChatFeedView( onWantsToEditDraft: (Note) -> Unit, avoidDraft: DraftTagState? = null, olderBoundary: (@Composable () -> Unit)? = null, + markersInGap: (@Composable (newerCreatedAt: Long?, olderCreatedAt: Long?) -> Unit)? = null, ) { val feedState by feed.feedContent.collectAsStateWithLifecycle() @@ -120,6 +126,7 @@ fun RenderChatFeedView( onWantsToEditDraft, avoidDraft, olderBoundary, + markersInGap, ) } } @@ -137,6 +144,7 @@ fun ChatFeedLoaded( onWantsToEditDraft: (Note) -> Unit, avoidDraft: DraftTagState? = null, olderBoundary: (@Composable () -> Unit)? = null, + markersInGap: (@Composable (newerCreatedAt: Long?, olderCreatedAt: Long?) -> Unit)? = null, ) { val items by loaded.feed.collectAsStateWithLifecycle() @@ -180,6 +188,17 @@ fun ChatFeedLoaded( ) NewDateOrSubjectDivisor(items.list.getOrNull(index + 1), item) + + // Per-relay paging markers belonging in the gap toward the next-older message. With the + // reverse layout this draws just above the message (the older side), so a relay's marker + // appears right below the oldest message it has reached and slides down as it pages. + markersInGap?.invoke( + item.event?.createdAt, + items.list + .getOrNull(index + 1) + ?.event + ?.createdAt, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt new file mode 100644 index 0000000000..35fceb8470 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt @@ -0,0 +1,101 @@ +/* + * 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.amethyst.ui.screen.loggedIn.chats.feed.layouts + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.HalfPadding + +/** How far one relay has paged into the conversation, for an in-stream progress marker. */ +enum class RelayReachState { + // Still paging older — its marker slides down (older) as it advances. + REACHING, + + // Accepted but not answering right now (auth CLOSE / unreachable / slow); kept open, still trying. + STALLED, + + // Hit an empty page: nothing older on this relay, it has reached the bottom of its window. + DONE, +} + +/** One relay's marker entry within a gap. */ +data class RelayReach( + val name: String, + val state: RelayReachState, +) + +/** + * A thin divider drawn between two messages marking the point one or more relays have paged down to. + * As a relay loads older history its [RelayReach.reachedUntil][reached cursor] drops, so the caller + * places this marker further down (older) in the stream — relays that race ahead leave their marker + * deep while slower relays' markers trail higher up, converging as they catch up. + */ +@Composable +fun RelayReachMarker(entries: List) { + if (entries.isEmpty()) return + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = HalfPadding, + ) { + HorizontalDivider(modifier = Modifier.weight(1f), thickness = DividerThickness) + // Group by state so a gap shared by several relays reads as e.g. "✓ vitor, nos.lol ↓ wine". + entries + .groupBy { it.state } + .toSortedMap(compareBy { it.ordinal }) + .forEach { (state, list) -> + Text( + text = glyph(state) + " " + list.joinToString(", ") { it.name }, + color = color(state), + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + ) + } + HorizontalDivider(modifier = Modifier.weight(1f), thickness = DividerThickness) + } +} + +private fun glyph(state: RelayReachState) = + when (state) { + RelayReachState.REACHING -> "↓" + RelayReachState.STALLED -> "…" + RelayReachState.DONE -> "✓" + } + +@Composable +private fun color(state: RelayReachState): Color = + when (state) { + RelayReachState.REACHING -> MaterialTheme.colorScheme.onSurfaceVariant + RelayReachState.STALLED -> MaterialTheme.colorScheme.error + RelayReachState.DONE -> MaterialTheme.colorScheme.primary + } 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 e4059b7cac..8878f4e4d6 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 @@ -50,12 +50,17 @@ import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMsAndDisp import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.DmHistoryLoadingCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.RelayReach +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.RelayReachMarker +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.RelayReachState import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal.ChatroomFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.RelayPagingProgress import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.ChatNewMessageViewModel 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.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.utils.Log @@ -230,6 +235,7 @@ fun ChatroomViewUI( val giftWrapsReached by giftWrapsHistory.reachedBack.collectAsStateWithLifecycle() val nip04Relays by nip04History.relayCount.collectAsStateWithLifecycle() val nip04Reached by nip04History.reachedBack.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) @@ -259,6 +265,15 @@ fun ChatroomViewUI( DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Exhausted, nip04Relays, nip04Reached) } }, + // While NIP-04 is still converging, drop a marker into each gap for every relay whose + // reached-back cursor falls there: it sits below the oldest message that relay has loaded + // and slides down as the relay pages older. Hidden once every relay is done or stalled. + markersInGap = + if (nip04Exhausted) { + null + } else { + { newer, older -> RelayReachMarkersInGap(nip04Progress, newer, older) } + }, listStateObserver = { listState -> LoadOlderMessagesWhenScrolling(listState, accountViewModel) }, @@ -282,3 +297,44 @@ fun ChatroomViewUI( ) } } + +/** + * Renders the NIP-04 paging markers that belong between a message (at [newerCreatedAt]) and its + * next-older neighbour (at [olderCreatedAt], null at the oldest end): every relay whose reached-back + * cursor falls in `(olderCreatedAt, newerCreatedAt]`. A relay sits below the oldest message it has + * loaded, so as it pages older its cursor drops and the marker moves down the stream toward the others. + */ +@Composable +private fun RelayReachMarkersInGap( + progress: Map, + newerCreatedAt: Long?, + olderCreatedAt: Long?, +) { + val here = + remember(progress, newerCreatedAt, olderCreatedAt) { + progress.mapNotNull { (relay, p) -> + val reached = p.reachedUntil + val belongsHere = newerCreatedAt != null && newerCreatedAt > reached && (olderCreatedAt == null || olderCreatedAt <= reached) + if (!belongsHere) { + null + } else { + RelayReach( + name = relayShortName(relay), + state = + when { + p.done -> RelayReachState.DONE + p.stalled -> RelayReachState.STALLED + else -> RelayReachState.REACHING + }, + ) + } + } + } + RelayReachMarker(here) +} + +private fun relayShortName(relay: NormalizedRelayUrl): String = + relay.url + .substringAfter("://") + .trimEnd('/') + .substringBefore('/') From af193e03ab26dd483666e3e8f6620f37784135e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 02:26:46 +0000 Subject: [PATCH 044/103] refactor: tidy DM history assembler + restore per-relay diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit follow-up on the per-relay paging work: - Restore DmRelayLog in the convo history loadMore (every other nip04 / giftwrap assembler logs it) and add per-relay milestone logs: which relay reached the bottom, which stalled and why, plus a one-line done/still-trying breakdown when the window settles — the snapshot to reach for when a chat doesn't load. - Extract markStalled() (dedupes onClosed/onCannotConnect, logs once per relay) and relaysFor() (dedupes the active-convo relay lookup). - relayCount now counts the relays still being paged (done ones drop out) instead of staying frozen at the total. - Drop the unused loadEverything(). - Fix WindowLoadTracker docs/log that claimed it 'gives up' on silent relays: it only stops waiting and reports them; the owner decides (the convo keeps them open and retries). Fix a dangling KDoc link in RelayReachMarker. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../eoseManagers/WindowLoadTracker.kt | 19 ++++--- .../chats/feed/layouts/RelayReachMarker.kt | 6 +-- .../ChatroomNip04HistorySubAssembler.kt | 52 +++++++++++++------ 3 files changed, 51 insertions(+), 26 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt index 3387ce2c49..9c69818324 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTracker.kt @@ -62,10 +62,11 @@ import kotlin.time.Duration.Companion.seconds * *heard from* (any event, EOSE, CLOSED, or cannot-connect) but one streamed events without ever * sending EOSE, an [idleTimeout] of quiet completes the load — the "heard from" gate is what keeps * this from firing in a connection gap. A relay that *received our REQ* ([onReqSent]) but then went - * completely silent — no event, no EOSE, no CLOSED — for [silenceTimeout] is given up on: an + * completely silent — no event, no EOSE, no CLOSED — for [silenceTimeout] stops blocking the load: an * auth-walled relay (ditto, paid relays) commonly accepts the REQ and answers nothing, and measuring * from REQ-delivery (not window start) means a slow connect doesn't count against it. Such relays are - * reported to [onAbandoned] so the owner can drop them from its pager too. A relay that never even + * reported to [onAbandoned] so the owner can react — drop them from its pager, or keep them and flag + * them stalled (the convo history keeps trying). A relay that never even * *receives* its REQ (stuck connecting / reconnecting, so it can neither settle nor go "silent") stops * blocking the round after [connectGrace] from the load start — but it is NOT given up (it may be a * genuinely slow connect), so the owner keeps it and retries it next round. And an [absoluteCap] is @@ -87,8 +88,9 @@ class WindowLoadTracker( private val silenceTimeout: Duration = 10.seconds, private val connectGrace: Duration = 15.seconds, private val absoluteCap: Duration = 5.minutes, - // Invoked with the relays that received a REQ but stayed silent past [silenceTimeout] when a load - // finishes — the owner gives up on them in its pager so they stop blocking future rounds. + // Invoked when a load finishes with the relays that received a REQ but stayed silent past + // [silenceTimeout]. The owner decides what to do — drop them from its pager, or keep them open and + // flag them stalled. The tracker itself only stops waiting on them; it does not give them up. private val onAbandoned: (Set) -> Unit = {}, ) { private val _loading = MutableStateFlow(true) @@ -192,7 +194,8 @@ class WindowLoadTracker( // A relay that received its REQ but produced no signal at all for [silenceTimeout]. Measured from // REQ-delivery so a slow connect (which has no [reqSentAt] yet) is never counted as silent. These - // relays ARE given up on — accepting a REQ and then answering nothing is an auth-walled / dead relay. + // are reported to [onAbandoned] on finish (accepting a REQ then answering nothing usually means an + // auth-walled / dead relay) — but whether to give them up is the owner's call, not the tracker's. private fun silencedOut( relay: NormalizedRelayUrl, now: Long, @@ -251,10 +254,10 @@ class WindowLoadTracker( if (!_loading.value) return watchdog?.cancel() watchdog = null - // Give up the silent relays BEFORE flipping [loading]: the owner's round collector reacts to - // loading=false by recomputing exhaustion from its pager, so the give-up has to land first. + // Report the silent relays BEFORE flipping [loading]: the owner reacts to loading=false by + // recomputing state from its pager, so its reaction to these has to land first. val abandoned = expected.filterTo(mutableSetOf()) { silencedOut(it, System.currentTimeMillis()) } - Log.d(TAG) { "[$name] load done: $reason" + if (abandoned.isEmpty()) "" else " (gave up on silent ${abandoned.map { it.url }})" } + Log.d(TAG) { "[$name] load done: $reason" + if (abandoned.isEmpty()) "" else " (silent: ${abandoned.map { it.url }})" } if (abandoned.isNotEmpty()) onAbandoned(abandoned) _loading.value = false } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt index 35fceb8470..ae1bd607ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt @@ -55,9 +55,9 @@ data class RelayReach( /** * A thin divider drawn between two messages marking the point one or more relays have paged down to. - * As a relay loads older history its [RelayReach.reachedUntil][reached cursor] drops, so the caller - * places this marker further down (older) in the stream — relays that race ahead leave their marker - * deep while slower relays' markers trail higher up, converging as they catch up. + * As a relay loads older history its reached cursor drops, so the caller places this marker further + * down (older) in the stream — relays that race ahead leave their marker deep while slower relays' + * markers trail higher up, converging as they catch up. */ @Composable fun RelayReachMarker(entries: List) { 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 bc8b9f99f6..61fddfe7cd 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 @@ -20,6 +20,7 @@ */ 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.UntilLimitPager import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.WindowLoadTracker @@ -150,15 +151,14 @@ class ChatroomNip04HistorySubAssembler( /** Starts (or resumes) per-relay paging for every open conversation. Idempotent: safe to call again. */ fun loadMore() { - val keys = allKeys() val fullRelays = mutableSetOf() var anyActive = false - keys.forEach { key -> + allKeys().forEach { key -> val relays = nip04DMRelays(key.room.users, key.account) ?: return@forEach - val pk = convoKey(key) - started.add(pk) + started.add(convoKey(key)) fullRelays.addAll(relays.all) - if (pager.activeRelays(pk, relays.all).isNotEmpty()) anyActive = true + if (pager.activeRelays(convoKey(key), relays.all).isNotEmpty()) anyActive = true + DmRelayLog.log("convo.nip04.history", key.account) } if (fullRelays.isEmpty()) return if (!anyActive) { @@ -168,7 +168,6 @@ class ChatroomNip04HistorySubAssembler( return } _exhausted.value = false - _relayCount.value = fullRelays.size scope?.let { ensureDoneCollector(it) // One window spanning the whole per-relay pagination: it settles a relay only on that relay's @@ -178,13 +177,10 @@ class ChatroomNip04HistorySubAssembler( windowLoad.setExpectedRelays(fullRelays) } publishProgress() - Log.d("DMPagination") { "[convo.nip04.history] paging ${fullRelays.size} relay(s) independently" } + Log.d("DMPagination") { "[convo.nip04.history] paging ${fullRelays.size} relay(s) independently: ${fullRelays.map { it.url }}" } invalidateFilters() } - /** Per-relay paging already runs to completion on its own, so loading everything is just [loadMore]. */ - fun loadEverything() = loadMore() - // Flips [exhausted] when the window settles (every relay done or stalled) and back to false when a // fresh page starts. Tied to the spinner so "nothing is advancing" and "caught up" stay consistent. private fun ensureDoneCollector(scope: CoroutineScope) { @@ -197,7 +193,7 @@ class ChatroomNip04HistorySubAssembler( activeConvo?.let { exhaustedByConvo[it] = true } _exhausted.value = true publishProgress() - Log.d("DMPagination") { "[convo.nip04.history] all relays settled (done or stalled)" } + logSettleSummary() } wasLoading = loading } @@ -208,13 +204,26 @@ class ChatroomNip04HistorySubAssembler( // We do NOT give up on them (they may simply be slow and need to catch up) — we just record them as // stalled for the markers and let them keep their open subscription. private fun onRelaysStalled(relays: Set) { - started.forEach { pk -> stalledRelays.getOrPut(pk) { ConcurrentHashMap.newKeySet() }.addAll(relays) } + started.forEach { pk -> relays.forEach { markStalled(pk, it, "no response (silence/connect timeout)") } } publishProgress() } + // Records [relay] as not currently advancing for [pk] and logs it once (the first time it stalls in + // this window). The relay is kept — it kept its subscription and keeps trying to catch up. + private fun markStalled( + pk: ConvoKey, + relay: NormalizedRelayUrl, + reason: String, + ) { + val firstTime = stalledRelays.getOrPut(pk) { ConcurrentHashMap.newKeySet() }.add(relay) + if (firstTime) Log.d("DMPagination") { "[convo.nip04.history] ${relay.url} stalled — $reason (kept open, still trying)" } + } + + private fun relaysFor(pk: ConvoKey): Nip04DmRelays? = allKeys().firstOrNull { convoKey(it) == pk }?.let { nip04DMRelays(it.room.users, it.account) } + private fun publishProgress() { val pk = activeConvo ?: return - val relays = allKeys().firstOrNull { convoKey(it) == pk }?.let { nip04DMRelays(it.room.users, it.account) } ?: return + val relays = relaysFor(pk) ?: return val stalled = stalledRelays[pk] ?: emptySet() val start = startUntil() _relayProgress.value = @@ -225,9 +234,21 @@ class ChatroomNip04HistorySubAssembler( stalled = relay in stalled && !pager.isDone(pk, relay), ) } + // "Asking N relays" on the status card: the ones still being paged (done relays have dropped out). + _relayCount.value = pager.activeRelays(pk, relays.all).size _reachedBack.value = pager.deepestUntil(pk, relays.all, start) } + // A one-line breakdown of where each relay landed when the window settles — the snapshot to reach for + // when a conversation didn't load tomorrow: who reached the bottom vs. who is still being retried. + private fun logSettleSummary() { + val pk = activeConvo ?: return + val relays = relaysFor(pk) ?: return + val done = relays.all.filter { pager.isDone(pk, it) }.map { it.url } + val stillTrying = relays.all.filterNot { pager.isDone(pk, it) }.map { it.url } + Log.d("DMPagination") { "[convo.nip04.history] settled — done=$done still-trying=$stillTrying" } + } + override fun newSub(key: ChatroomQueryState): Subscription { scope = key.account.scope val pk = convoKey(key) @@ -272,6 +293,7 @@ class ChatroomNip04HistorySubAssembler( if (pager.isDone(pk, relay)) { // Reached the bottom on this relay: settle it for the spinner, nothing more to ask. windowLoad.onRelaySettled(relay) + Log.d("DMPagination") { "[convo.nip04.history] ${relay.url} reached the bottom (done)" } } else { // This page had events: reset only this relay's tally and let it continue to its // next page immediately, independent of every other relay. @@ -291,7 +313,7 @@ class ChatroomNip04HistorySubAssembler( // stalled, not done — keep its subscription so the pool can re-auth and it can catch up — // but don't let it hold the spinner. windowLoad.onRelaySettled(relay) - stalledRelays.getOrPut(pk) { ConcurrentHashMap.newKeySet() }.add(relay) + markStalled(pk, relay, "CLOSED: $message") publishProgress() } @@ -301,7 +323,7 @@ class ChatroomNip04HistorySubAssembler( forFilters: List?, ) { windowLoad.onRelaySettled(relay) - stalledRelays.getOrPut(pk) { ConcurrentHashMap.newKeySet() }.add(relay) + markStalled(pk, relay, "cannot connect: $message") publishProgress() } } From 5b0f2051db4ea62abe3ce25e0216f4f012878c51 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 16:48:18 +0000 Subject: [PATCH 045/103] fix: convo NIP-04 history stuck 'loading' with 0 relays on first open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadingMore was wired straight to windowLoad.loading, which starts true (the tracker assumes a load is in flight from construction). On the first conversation open — before any paging window has run — that true wedged the scroll-driven loader: its gate is '!loading', so loadMore never fired, and even if it had, the 'if (!windowLoad.loading.value) startLoading' guard would have skipped startLoading (value was the construction-time true), leaving no watchdog to ever settle it. Result: permanent spinner, 0 relays. Earlier opens only worked because a prior conversation had left the shared tracker at false. Expose a _loadingMore that starts false and is mirrored from the window by the done collector, and track windowActive ourselves so the first loadMore actually starts the window (and a re-entrant loadMore mid-page doesn't reset it and forget finished relays). https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../ChatroomNip04HistorySubAssembler.kt | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) 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 61fddfe7cd..8970c46dbb 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 @@ -92,7 +92,13 @@ class ChatroomNip04HistorySubAssembler( private val stalledRelays = ConcurrentHashMap>() private val windowLoad = WindowLoadTracker("convo.nip04.history", tracksReqSends = true, onAbandoned = ::onRelaysStalled) - val loadingMore: StateFlow = windowLoad.loading + + // Exposed instead of windowLoad.loading directly: that flow starts `true` (it assumes a load is in + // flight from construction). Wired straight through, its `true` would wedge the scroll-driven + // loader — whose gate is `!loading` — so the first loadMore could never fire. This starts false and + // only goes true once paging actually begins (mirrored from windowLoad by the done collector). + private val _loadingMore = MutableStateFlow(false) + val loadingMore: StateFlow = _loadingMore.asStateFlow() private val _exhausted = MutableStateFlow(false) val exhausted: StateFlow = _exhausted.asStateFlow() @@ -119,6 +125,12 @@ class ChatroomNip04HistorySubAssembler( @Volatile private var doneJob: Job? = null + // Whether a paging window is currently running. We track it ourselves rather than reading + // windowLoad.loading (which starts `true` before any window exists), so the first loadMore actually + // starts the window instead of mistaking the construction-time `true` for an in-flight one. + @Volatile + private var windowActive = false + private fun startUntil() = TimeUtils.now() - AccountGiftWrapsEoseManager.LIVE_TAIL_SECONDS override fun user(key: ChatroomQueryState) = key.account.userProfile() @@ -172,8 +184,14 @@ class ChatroomNip04HistorySubAssembler( ensureDoneCollector(it) // One window spanning the whole per-relay pagination: it settles a relay only on that relay's // empty-EOSE (done) or when it goes silent/stalled, never on a mid-history page, so the - // spinner tracks "is anything still advancing" rather than any single round. - if (!windowLoad.loading.value) windowLoad.startLoading(it) + // spinner tracks "is anything still advancing" rather than any single round. Start it only if + // none is running — a re-entrant loadMore (the scroll loader re-firing mid-pagination) must + // not reset the window and forget the relays that already finished. + if (!windowActive) { + windowActive = true + _loadingMore.value = true + windowLoad.startLoading(it) + } windowLoad.setExpectedRelays(fullRelays) } publishProgress() @@ -181,15 +199,18 @@ class ChatroomNip04HistorySubAssembler( invalidateFilters() } - // Flips [exhausted] when the window settles (every relay done or stalled) and back to false when a - // fresh page starts. Tied to the spinner so "nothing is advancing" and "caught up" stay consistent. + // Mirrors the window's loading state into [_loadingMore] and, when it settles (every relay done or + // stalled), flips [exhausted] and clears [windowActive] so the next loadMore can start a fresh window. private fun ensureDoneCollector(scope: CoroutineScope) { if (doneJob?.isActive == true) return doneJob = scope.launch { var wasLoading = false windowLoad.loading.collect { loading -> + _loadingMore.value = loading && windowActive if (!loading && wasLoading) { + windowActive = false + _loadingMore.value = false activeConvo?.let { exhaustedByConvo[it] = true } _exhausted.value = true publishProgress() From e1cdd40bb5fb8d60f690471e2a1c416b65b60cb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 17:16:21 +0000 Subject: [PATCH 046/103] chore: remove superseded TimeWindowPagination, refresh DM design doc Review prep for the DM pagination branch: - Delete commons TimeWindowPagination + its test: the early since-based time-window approach, referenced only by its own test and fully superseded by UntilLimitPager (until+limit, gap-proof). 212 lines a reviewer would otherwise study for nothing. - Bring the design doc up to the final architecture: NIP-04 per-relay filter scoping, per-relay independent paging (no rounds) + in-stream markers for the convo, the round model still used by rooms/gift-wrap, the WindowLoadTracker backstops and tracksReqSends gating, the loadingMore-starts-false fix, and the DMPagination diagnostics map. Marks the obsolete time-slice section as superseded. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- ...6-06-01-dm-live-tail-and-history-slices.md | 80 +++++++++++ .../pagination/TimeWindowPagination.kt | 87 ------------ .../pagination/TimeWindowPaginationTest.kt | 125 ------------------ 3 files changed, 80 insertions(+), 212 deletions(-) delete mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/pagination/TimeWindowPagination.kt delete mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/pagination/TimeWindowPaginationTest.kt diff --git a/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md b/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md index fe259a14cc..4be0d089c0 100644 --- a/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md +++ b/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md @@ -41,6 +41,10 @@ timestamps, so its slices need no margin. ### Slice math (gift-wrap history window) +> Superseded by the two updates below — kept for the history of the design. The +> `TimeWindowPagination` class this described has been removed; the history +> managers now page by `until`+`limit` per relay (`UntilLimitPager`). + `TimeWindowPagination.since` starts at `now − 1week` (= the live-tail floor). - `loadMore`: `until = window.since` (current floor); `window.loadMore()` moves @@ -92,3 +96,79 @@ at all. `limit` also caps per-request volume. Both NIP-04 history managers now paginate themselves (per relay) instead of following the gift-wrap slice; `loadEverything` pages to the end by auto-issuing the next round until exhausted. The live tail and stall-gate are unchanged. + +## Update 2: NIP-04 filters scoped per relay + +A conversation's NIP-04 filters named the whole participant set on every relay, +so a relay that belongs to one correspondent was still asked about all of them +(`{authors:[bob,charlie]}` sent to a relay that is only charlie's), and the +`from-me` leg (`authors:[me]`) was sent to the correspondents' inbox relays — +which auth-walled relays (ditto: "all authors must be authenticated") reject +outright, stalling the load. + +`Nip04DmRelays` is now two **per-relay key maps** (`relay → which keys to name +there`), built from the outbox model: + +- **to me** (`#p:[me]`) — my inbox carries the whole group; each correspondent's + outbox carries only that correspondent. +- **from me** (`authors:[me]`) — my outbox carries the whole group; each + correspondent's inbox carries only that correspondent. + +Relays shared across roles union their key sets, so a relay only ever sees the +keys that actually own it. + +## Update 3: per-relay independent paging + in-stream markers (convo only) + +The round model paced every relay at the slowest one: each `loadMore` issued one +page to all active relays and waited for the slowest to settle before the next. +Fast own-relays that hold the whole conversation were stuck behind a +correspondent's 15 s timeout. + +`ChatroomNip04HistorySubAssembler` was rewritten to page **each relay +independently, no rounds**. A relay continues to its next page the instant *it* +EOSEs (the subscription layer diffs per relay, so re-issuing only re-REQs the +relay whose cursor moved; the others' in-flight REQs are untouched). Fast relays +race to the bottom in back-to-back pages; slow / auth-walled relays catch up at +their own pace — **none are abandoned** (they keep their subscription open and +keep trying), so every relay converges on the same window. + +- A relay is **done** on an empty page; one that won't answer (auth CLOSE, + unreachable, silent) is flagged **stalled** but kept open. +- `loadingMore` reflects "is anything still advancing"; it clears once every + relay is done or stalled. It is exposed as a flow that **starts `false`** (not + `windowLoad.loading`, which starts `true` and would wedge the scroll loader's + `!loading` gate on first open), and the assembler tracks `windowActive` itself + so the first `loadMore` actually starts the window. +- `relayProgress` (`relay → reached-back / done / stalled`) feeds **in-stream + markers** (`RelayReachMarker`, wired through `ChatFeedView.markersInGap`): a + thin divider per relay at the depth it has reached, sliding down as it pages + and converging — `↓` reaching, `…` stalled, `✓` done. + +The **rooms-list and gift-wrap** history managers still use the round model +(`AccountGiftWrapsHistoryEoseManager`, +`ChatroomListNip04HistorySubAssembler`) — they query only the account's own +(fast, reachable) relays, so the lock-step never bites there. Only the +conversation screen, which fans out to correspondents' relays, needed the +per-relay rewrite. + +### Window completion backstops (`WindowLoadTracker`) + +The shared window tracker finishes when every relay reaches a terminal signal +(EOSE / CLOSED / cannot-connect), with three backstops for misbehaving relays: +**idle** (every still-waited relay was heard from and the stream went quiet), +**silence** (a relay that got its REQ but answered nothing for 10 s), and +**connect-grace** (a relay that never even received its REQ within 15 s, stuck +connecting). The two REQ-aware backstops are gated behind `tracksReqSends`, set +only by the convo manager — without it an always-empty `reqSentAt` would make +every relay look connect-stalled and complete the window before its REQs even +went out. Silent relays are reported via `onAbandoned`; the tracker only stops +waiting, the owner decides what to do (the convo keeps them and flags stalled). + +## Diagnostics + +The whole path logs under one tag, **`DMPagination`** (debug builds): +`DmRelayDiagnosticsLogger` folds the per-relay connection timeline (REQ sent, +connect/disconnect, CLOSED/NOTICE/OK-fail) into it; `DmRelayLog` prints the +"relays by source" breakdown per subscription; and each assembler logs its +milestones (paging start, a relay reaching the bottom / stalling with the +reason, the settle summary of done-vs-still-trying). diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/pagination/TimeWindowPagination.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/pagination/TimeWindowPagination.kt deleted file mode 100644 index 6bbabd8cf5..0000000000 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/pagination/TimeWindowPagination.kt +++ /dev/null @@ -1,87 +0,0 @@ -/* - * 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.amethyst.commons.relayClient.pagination - -import com.vitorpamplona.quartz.utils.TimeUtils - -/** - * Tracks how far back in time a relay subscription should reach. - * - * Boot opens a small window (recent-first) so a screen becomes usable before the - * whole history is fetched and decrypted. Each [loadMore] widens the floor backward - * so scrolling pulls older history on demand. - * - * Only the lower bound ([since]) moves: the subscription stays open so new events - * keep streaming live regardless of the window. The floor is requested in full on - * every assembly (the value is small and bounded), which keeps the window robust - * even if the in-memory note store evicts previously-loaded events under memory - * pressure. - * - * The step can grow geometrically ([growthFactor] > 1) so an auto-fill loop that keeps - * widening to fill a screen (or to confirm there is no older history) converges in a - * handful of requests instead of crawling back a fixed slice at a time. [maxLookback] - * is a hard floor: once [since] reaches it, [isExhausted] is true and there is nothing - * older to ask for. - */ -class TimeWindowPagination( - private val initialWindow: Long = ONE_WEEK_IN_SECONDS, - private val step: Long = ONE_WEEK_IN_SECONDS, - private val growthFactor: Long = 1L, - private val maxLookback: Long = TEN_YEARS_IN_SECONDS, -) { - /** Epoch seconds; events older than this are not requested from relays. */ - @Volatile - var since: Long = TimeUtils.now() - initialWindow - private set - - @Volatile - private var currentStep: Long = step - - private fun floor() = TimeUtils.now() - maxLookback - - /** Widens the window backward by the current step, clamped at [maxLookback], then grows the step. */ - fun loadMore() { - since = maxOf(floor(), since - currentStep) - if (growthFactor > 1L) currentStep *= growthFactor - } - - /** Jumps straight to [maxLookback] so a single request pulls the entire history. */ - fun loadAll() { - since = floor() - } - - /** True once the window has reached [maxLookback] — there is no older history to request. */ - fun isExhausted(): Boolean = since <= floor() - - /** Resets the window back to the initial boot size, anchored at the current time. */ - fun reset() { - since = TimeUtils.now() - initialWindow - currentStep = step - } - - companion object { - const val ONE_WEEK_IN_SECONDS = TimeUtils.ONE_WEEK.toLong() - - // Covers the entire history of Nostr (which began ~2021) with margin, so reaching it - // genuinely means "nothing older exists" rather than an arbitrary cutoff. - const val TEN_YEARS_IN_SECONDS = TimeUtils.ONE_YEAR.toLong() * 10 - } -} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/pagination/TimeWindowPaginationTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/pagination/TimeWindowPaginationTest.kt deleted file mode 100644 index cb79f93940..0000000000 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/pagination/TimeWindowPaginationTest.kt +++ /dev/null @@ -1,125 +0,0 @@ -/* - * 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.amethyst.commons.relayClient.pagination - -import com.vitorpamplona.quartz.utils.TimeUtils -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class TimeWindowPaginationTest { - @Test - fun bootOpensAWindowThatStartsRecent() { - val window = 1000L - val pagination = TimeWindowPagination(initialWindow = window, step = 500L) - - // floor is roughly `now - initialWindow`, never unbounded - val expected = TimeUtils.now() - window - assertTrue("floor should be near now - window", kotlin.math.abs(pagination.since - expected) <= 2) - } - - @Test - fun loadMoreWidensTheFloorBackwardByOneStep() { - val pagination = TimeWindowPagination(initialWindow = 1000L, step = 500L) - val before = pagination.since - - pagination.loadMore() - assertEquals(before - 500L, pagination.since) - - pagination.loadMore() - assertEquals(before - 1000L, pagination.since) - } - - @Test - fun resetReturnsToTheInitialBootWindow() { - val window = 1000L - val pagination = TimeWindowPagination(initialWindow = window, step = 500L) - pagination.loadMore() - pagination.loadMore() - - pagination.reset() - - val expected = TimeUtils.now() - window - assertTrue("reset floor should be near now - window", kotlin.math.abs(pagination.since - expected) <= 2) - } - - @Test - fun growingStepDoublesTheReachEachLoadMore() { - val pagination = TimeWindowPagination(initialWindow = 10L, step = 100L, growthFactor = 2L, maxLookback = Long.MAX_VALUE / 2) - val before = pagination.since - - pagination.loadMore() - assertEquals("first step is the base step", before - 100L, pagination.since) - - pagination.loadMore() - assertEquals("second step is doubled", before - 300L, pagination.since) - - pagination.loadMore() - assertEquals("third step is doubled again", before - 700L, pagination.since) - } - - @Test - fun windowIsNotExhaustedWhileWithinLookback() { - val pagination = TimeWindowPagination(initialWindow = 10L, step = 10L, growthFactor = 2L, maxLookback = 100L) - assertTrue("a fresh window is not exhausted", !pagination.isExhausted()) - - pagination.loadMore() // -> now-20 - assertTrue("still within the 100s lookback", !pagination.isExhausted()) - } - - @Test - fun windowBecomesExhaustedAndClampsAtMaxLookback() { - val maxLookback = 100L - val pagination = TimeWindowPagination(initialWindow = 10L, step = 10L, growthFactor = 2L, maxLookback = maxLookback) - - // Geometric reach 10,20,40,80 crosses the 100s floor within a handful of steps. - repeat(6) { pagination.loadMore() } - - assertTrue("window should report exhausted at the floor", pagination.isExhausted()) - val floor = TimeUtils.now() - maxLookback - assertTrue("since must not go past the floor", pagination.since >= floor - 2 && pagination.since <= floor + 2) - } - - @Test - fun loadAllJumpsStraightToExhaustion() { - val maxLookback = 100L - val pagination = TimeWindowPagination(initialWindow = 10L, step = 10L, growthFactor = 2L, maxLookback = maxLookback) - assertTrue("not exhausted before loadAll", !pagination.isExhausted()) - - pagination.loadAll() - - assertTrue("loadAll exhausts the window in one step", pagination.isExhausted()) - val floor = TimeUtils.now() - maxLookback - assertTrue("since lands at the floor", kotlin.math.abs(pagination.since - floor) <= 2) - } - - @Test - fun resetClearsStepGrowth() { - val pagination = TimeWindowPagination(initialWindow = 10L, step = 100L, growthFactor = 2L, maxLookback = Long.MAX_VALUE / 2) - pagination.loadMore() // step grows to 200 - pagination.loadMore() // step grows to 400 - - pagination.reset() - val before = pagination.since - pagination.loadMore() - assertEquals("after reset the step is back to the base", before - 100L, pagination.since) - } -} From ab7aa44d210e3f6afb2cad11de77d294dad398d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 17:41:45 +0000 Subject: [PATCH 047/103] fix: pin the convo history floor per window to stop re-REQ churn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startUntil() is 'now - 1week' and was recomputed on every updateFilter, so it drifted forward in real time. A relay that hadn't advanced its cursor (first page in flight, or empty) has until = that floor, so its filter changed every time ANY other relay's EOSE triggered invalidateFilters — the subscription saw a 'new' filter and re-REQed it. The trace showed nostr.oxtr.dev asked twice (until 1779903206 then ...207, +1s) and reaching the bottom (done) twice, and it fed extra ditto REQ->CLOSE churn and rate-limiting on the busy relays. Pin the floor once when the window starts and reuse it for the window's life (which ends in ~2s once every relay is done or stalled), so an un-advanced relay's filter stays stable and only a relay whose cursor genuinely advanced is re-REQed. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../ChatroomNip04HistorySubAssembler.kt | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) 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 8970c46dbb..f35bd3bbf6 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 @@ -131,8 +131,18 @@ class ChatroomNip04HistorySubAssembler( @Volatile private var windowActive = false + // The history floor (live-tail boundary) pinned for the current window. startUntil() is `now − 1w`, + // which drifts forward in real time — if it were recomputed per assembly, an un-advanced relay's + // filter (until = floor) would change every time ANY relay's EOSE triggers invalidateFilters, + // re-REQing relays that haven't moved. Pinning it per window keeps those filters stable so only a + // relay whose cursor genuinely advanced is re-REQed. + @Volatile + private var windowFloor = 0L + private fun startUntil() = TimeUtils.now() - AccountGiftWrapsEoseManager.LIVE_TAIL_SECONDS + private fun floor() = windowFloor.takeIf { it != 0L } ?: startUntil() + override fun user(key: ChatroomQueryState) = key.account.userProfile() override fun list(key: ChatroomQueryState) = key.listId @@ -157,7 +167,7 @@ class ChatroomNip04HistorySubAssembler( fromMeRelays = relays.fromMeRelays.filterKeys { it in active }, ) return filterNip04DMsHistory(key.account, scoped, PAGE_LIMIT) { relay -> - pager.untilFor(pk, relay, startUntil()) + pager.untilFor(pk, relay, floor()) } } @@ -189,6 +199,7 @@ class ChatroomNip04HistorySubAssembler( // not reset the window and forget the relays that already finished. if (!windowActive) { windowActive = true + windowFloor = startUntil() _loadingMore.value = true windowLoad.startLoading(it) } @@ -246,7 +257,7 @@ class ChatroomNip04HistorySubAssembler( val pk = activeConvo ?: return val relays = relaysFor(pk) ?: return val stalled = stalledRelays[pk] ?: emptySet() - val start = startUntil() + val start = floor() _relayProgress.value = relays.all.associateWith { relay -> RelayPagingProgress( From 40b60e7bcc5f19e17299ad972390b98f7660ea21 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 18:50:13 +0000 Subject: [PATCH 048/103] fix: compact relay markers + no '0 relays' flash on the DM loading card Two UI papercuts in the per-relay DM history surface: - RelayReachMarker listed every relay name comma-joined per state, so a gap shared by many relays (e.g. all nine clustered at the live-tail floor on first open) overflowed into an unreadable line. Now each state shows the relay's host name only when it is the sole one of its state at that depth (the usual converged case); otherwise just a count, with maxLines/ellipsis as a backstop. - The history status card briefly read 'loading from 0 relays': the count populated a beat after loadingMore flipped true (and again as the last relay settled). Set the relay count before raising the spinner in loadMore, and defensively drop the relay clause from the card subtitle when the count is 0. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../loggedIn/chats/feed/DmLoadMoreIndicator.kt | 3 +++ .../loggedIn/chats/feed/layouts/RelayReachMarker.kt | 13 ++++++++++--- .../datasource/ChatroomNip04HistorySubAssembler.kt | 3 +++ 3 files changed, 16 insertions(+), 3 deletions(-) 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 08c2f7e5fe..e408d4188d 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 @@ -162,6 +162,9 @@ private fun historySubtitle( relayCount: Int, reachedBack: Long?, ): String { + // A transient frame can carry loading=true with relayCount=0 (the count updates a beat after the + // spinner flips, and again as the last relay settles); don't render a nonsensical "0 relays". + if (relayCount <= 0) return protocolTag val backLabel = remember(reachedBack) { reachedBack?.let { SimpleDateFormat("MMM yyyy", Locale.getDefault()).format(Date(it * 1000)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt index ae1bd607ea..5152955297 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -58,6 +59,11 @@ data class RelayReach( * As a relay loads older history its reached cursor drops, so the caller places this marker further * down (older) in the stream — relays that race ahead leave their marker deep while slower relays' * markers trail higher up, converging as they catch up. + * + * Each state in the gap renders one compact label: a relay's host name when it is the only one of its + * state there (the usual converged case, where each relay sits at its own depth), or just a count when + * several pile up at the same depth (e.g. all nine clustered at the live-tail floor on first open) so + * the line can't grow into an unreadable comma list. */ @Composable fun RelayReachMarker(entries: List) { @@ -65,20 +71,21 @@ fun RelayReachMarker(entries: List) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = HalfPadding, ) { HorizontalDivider(modifier = Modifier.weight(1f), thickness = DividerThickness) - // Group by state so a gap shared by several relays reads as e.g. "✓ vitor, nos.lol ↓ wine". entries .groupBy { it.state } .toSortedMap(compareBy { it.ordinal }) .forEach { (state, list) -> Text( - text = glyph(state) + " " + list.joinToString(", ") { it.name }, + text = glyph(state) + " " + if (list.size == 1) list.first().name else list.size.toString(), color = color(state), fontSize = 11.sp, fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } HorizontalDivider(modifier = Modifier.weight(1f), thickness = DividerThickness) 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 f35bd3bbf6..f29d7f88ac 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 @@ -200,6 +200,9 @@ class ChatroomNip04HistorySubAssembler( if (!windowActive) { windowActive = true windowFloor = startUntil() + // Populate the relay count BEFORE raising the spinner, so the status card never renders + // a "loading from 0 relays" frame between loadingMore flipping true and the first progress. + publishProgress() _loadingMore.value = true windowLoad.startLoading(it) } From 81dd2585dc8840f271678118b4cb3064a661e559 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 20:19:44 +0000 Subject: [PATCH 049/103] fix: DM history card showed text but a blank icon when paused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status card's icon slot only handled two states — caught-up (✓) and loading (spinner) — leaving it blank in the third: not exhausted but not actively loading (the rooms-list auto-fill stops short of exhaustion via the no-new-rooms stall-gate, or between round-model pages). The card then read 'Older … messages · N relays' with nothing on the left. Fill that paused state with a static '⋯' glyph so the slot is never empty. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../screen/loggedIn/chats/feed/DmLoadMoreIndicator.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 e408d4188d..7c6bf4d863 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 @@ -124,6 +124,16 @@ fun DmHistoryLoadingCard( ) } else if (loading) { CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + // Paused: not caught up, but not actively loading (the rooms-list auto-fill + // stopped short of exhaustion, or we're between round-model pages). Show a + // static "more" glyph so the icon slot is never blank — loading resumes on scroll. + Text( + "⋯", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } Spacer(Modifier.width(14.dp)) From 432a19f7c000433841f2773dc038f99390b8ed51 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 20:25:57 +0000 Subject: [PATCH 050/103] feat: label the in-stream relay markers 'Relay sync:' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bare glyph markers (✓ 8 · ↓ 1) had no context. Prefix each with a translatable 'Relay sync:' label and add '·' separators between states, so a marker reads e.g. 'Relay sync: ✓ 8 · ↓ 1' or 'Relay sync: ↓ nostr.wine'. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../chats/feed/layouts/RelayReachMarker.kt | 26 ++++++++++++++----- amethyst/src/main/res/values/strings.xml | 1 + 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt index 5152955297..0556ab521d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt @@ -29,10 +29,12 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.HalfPadding @@ -60,10 +62,11 @@ data class RelayReach( * down (older) in the stream — relays that race ahead leave their marker deep while slower relays' * markers trail higher up, converging as they catch up. * - * Each state in the gap renders one compact label: a relay's host name when it is the only one of its - * state there (the usual converged case, where each relay sits at its own depth), or just a count when - * several pile up at the same depth (e.g. all nine clustered at the live-tail floor on first open) so - * the line can't grow into an unreadable comma list. + * A leading "Relay sync:" label gives the glyphs context; then each state renders one compact label: + * a relay's host name when it is the only one of its state there (the usual converged case, where each + * relay sits at its own depth), or just a count when several pile up at the same depth (e.g. all nine + * clustered at the live-tail floor on first open) so the line can't grow into an unreadable comma list. + * Reads e.g. "Relay sync: ✓ 8 · ↓ 1" or "Relay sync: ↓ nostr.wine". */ @Composable fun RelayReachMarker(entries: List) { @@ -71,14 +74,25 @@ fun RelayReachMarker(entries: List) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = HalfPadding, ) { HorizontalDivider(modifier = Modifier.weight(1f), thickness = DividerThickness) + Text( + text = stringResource(R.string.chats_history_relay_sync), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + ) entries .groupBy { it.state } .toSortedMap(compareBy { it.ordinal }) - .forEach { (state, list) -> + .entries + .forEachIndexed { index, (state, list) -> + if (index > 0) { + Text("·", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 11.sp) + } Text( text = glyph(state) + " " + if (list.size == 1) list.first().name else list.size.toString(), color = color(state), diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 423fe79308..db206e3863 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -280,6 +280,7 @@ Reached the start of your %1$s messages encrypted legacy + Relay sync: %1$s · %2$s · back to %3$s %1$s · %2$s From 98fb87200596cbb81772c1fd0b9cf8f2531fad77 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 20:57:27 +0000 Subject: [PATCH 051/103] =?UTF-8?q?feat:=20drop=20the=20rooms-list=20stall?= =?UTF-8?q?-gate=20=E2=80=94=20page=20to=20exhaustion=20while=20in=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stall-gate stopped widening once a load surfaced older messages but no new conversation row, leaving the boundary card in a confusing paused state (not loading, not caught-up). It existed to brake the OLD pagination model, where every widen re-downloaded the whole window; the per-relay until+limit paging doesn't re-download, so the brake is obsolete — and a visible boundary card means the user is waiting for more, so pausing there made no sense. Now while the boundary is in view each protocol pages round after round until genuinely exhausted (empty page), then shows 'all caught up'. The card is only ever loading or caught-up, never paused. Removes the autoFillRoomMark mark plumbing and the getMark/setMark gate. Tradeoff: a user with few conversations but deep message history pages that history to the end on reaching the bottom — bounded and efficient now (no re-download), terminated by exhaustion + the no-progress guard. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../AccountGiftWrapsHistoryEoseManager.kt | 9 ----- .../ChatroomListNip04HistorySubAssembler.kt | 5 --- .../chats/rooms/feed/ChatroomListFeedView.kt | 34 ++++++------------- 3 files changed, 10 insertions(+), 38 deletions(-) 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 ecc58ff8f5..ff3f2180b9 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 @@ -110,14 +110,6 @@ class AccountGiftWrapsHistoryEoseManager( private val _reachedBack = MutableStateFlow(null) val reachedBack: StateFlow = _reachedBack.asStateFlow() - // Rooms-list auto-fill stall mark: the number of THIS protocol's distinct rooms shown the last - // time the list auto-widened it. The list stops widening once a step adds no new room of this - // protocol (widening only pulls older MESSAGES, which for a few busy correspondents can be - // thousands of events without a single new room). Kept here so the stall survives leaving and - // reopening the Messages screen. - @Volatile - var autoFillRoomMark: Int = Int.MIN_VALUE - // Account scope for the watchdog / round collector. Volatile: written on IO (newSub), read on UI. @Volatile private var scope: CoroutineScope? = null @@ -258,7 +250,6 @@ class AccountGiftWrapsHistoryEoseManager( _exhausted.value = exhaustedByUser[user.pubkeyHex] ?: false _relayCount.value = 0 _reachedBack.value = null - autoFillRoomMark = Int.MIN_VALUE lastAskedActive = emptySet() lastRoundEventCount = -1 } 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 5ad5fc6634..b94820782e 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 @@ -100,10 +100,6 @@ class ChatroomListNip04HistorySubAssembler( @Volatile private var autoLoadAll = false - // Rooms-list auto-fill stall mark for NIP-04 rooms (see the gift-wrap history manager's twin). - @Volatile - var autoFillRoomMark: Int = Int.MIN_VALUE - private fun startUntil() = TimeUtils.now() - AccountGiftWrapsEoseManager.LIVE_TAIL_SECONDS override fun user(key: ChatroomListState) = key.account.userProfile() @@ -208,7 +204,6 @@ class ChatroomListNip04HistorySubAssembler( _exhausted.value = exhaustedByUser[user.pubkeyHex] ?: false _relayCount.value = 0 _reachedBack.value = null - autoFillRoomMark = Int.MIN_VALUE lastAskedActive = emptySet() lastRoundEventCount = -1 } 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 065b48fcc7..092bd87f5c 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 @@ -112,8 +112,6 @@ private fun CrossFadeState( giftWrapsHistory.loadingMore, giftWrapsHistory.exhausted, roomCount = null, - getMark = { 0 }, - setMark = {}, loadMore = { giftWrapsHistory.loadMore(user) }, ) { feedState is FeedState.Empty } WidenHistoryWhen( @@ -121,8 +119,6 @@ private fun CrossFadeState( nip04History.loadingMore, nip04History.exhausted, roomCount = null, - getMark = { 0 }, - setMark = {}, loadMore = { nip04History.loadMore(user) }, ) { feedState is FeedState.Empty } @@ -178,15 +174,14 @@ private fun FeedLoaded( // while NIP-17 is shallow), so each protocol gets its OWN trigger keyed to its OWN oldest loaded // room — otherwise the deeper protocol's tail pins the boundary to the bottom and the shallower // one never loads until the user scrolls all the way past it. Each is gated only on its own loader - // and its own room count (stall-gate), so they advance independently as the user scrolls. Public / + // and "is my oldest room near the bottom of the viewport", so while the boundary is in view it keeps + // paging to exhaustion (no stall-gate — a visible card means the user is waiting for more). Public / // group / ephemeral rooms are membership-based and excluded. WidenHistoryWhen( "scroll.nip17", giftWrapsHistory.loadingMore, giftWrapsHistory.exhausted, roomCount = { items.list.count { it.event is ChatroomKeyable && it.event !is PrivateDmEvent } }, - getMark = { giftWrapsHistory.autoFillRoomMark }, - setMark = { giftWrapsHistory.autoFillRoomMark = it }, loadMore = { giftWrapsHistory.loadMore(user) }, ) { val info = listState.layoutInfo @@ -199,8 +194,6 @@ private fun FeedLoaded( nip04History.loadingMore, nip04History.exhausted, roomCount = { items.list.count { it.event is PrivateDmEvent } }, - getMark = { nip04History.autoFillRoomMark }, - setMark = { nip04History.autoFillRoomMark = it }, loadMore = { nip04History.loadMore(user) }, ) { val info = listState.layoutInfo @@ -271,15 +264,16 @@ private fun FeedLoaded( private const val PREFETCH_PRIVATE_CHATS = 5 /** - * Drives ONE protocol's history paging from a scroll/empty trigger. When [wantMore] becomes true and - * that protocol isn't already loading or [exhausted], it calls [loadMore]. + * Drives ONE protocol's history paging from a scroll/empty trigger. While [wantMore] is true and that + * protocol isn't already loading or [exhausted], it keeps calling [loadMore] round after round until + * the history is genuinely exhausted (an empty `until`+`limit` page) — there is no stall-gate: if the + * boundary card is in view the user is waiting for more, so we don't stop just because a band of older + * messages surfaced no new conversation row. Paging naturally stops when [wantMore] goes false (the + * boundary scrolls out of view) or the protocol exhausts. * * [wantMore] and [roomCount] are read inside a snapshotFlow, so they may observe live Compose state - * (scroll position, the feed list). [roomCount] (when non-null) feeds the stall-gate: widening only - * pulls older MESSAGES, so a few busy correspondents can flood events without adding a single room — - * paging therefore stops once a step brings in no new room of this protocol (tracked via [getMark] / - * [setMark], which live on the history manager so the stall survives leaving/reopening the screen). - * Pass `roomCount = null` to widen regardless of progress (the empty feed, hunting for the first room). + * (scroll position, the feed list). [roomCount] is only carried for the log line; pass `null` when the + * caller has no room measure (the empty feed, hunting for the first room). * * Each protocol gets its own instance, gated only on its own loader, so NIP-04 and NIP-17 — which have * very different histories — page independently as the user scrolls. @@ -290,8 +284,6 @@ private fun WidenHistoryWhen( loadingMore: StateFlow, exhausted: StateFlow, roomCount: (() -> Int)?, - getMark: () -> Int, - setMark: (Int) -> Unit, loadMore: () -> Unit, wantMore: () -> Boolean, ) { @@ -306,12 +298,6 @@ private fun WidenHistoryWhen( }.distinctUntilChanged() .collect { count -> if (count == NOT_WANTED) return@collect - // Stop once a widen adds no new room of this protocol (but keep hunting while none loaded). - if (roomCount != null && count > 0 && count <= getMark()) { - Log.d("DMPagination") { "rooms.list: widen ($trigger) stop — no new rooms (count=$count)" } - return@collect - } - if (roomCount != null) setMark(count) Log.d("DMPagination") { "rooms.list: widen ($trigger) → loadMore (rooms=$count)" } loadMore() } From a3b4df8e6e319883a537e452498a608e17d5e6a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 21:34:50 +0000 Subject: [PATCH 052/103] fix: rooms-list history stuck on 'Loading feed' after a no-progress round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a cold start with no cached rooms and no recent (<7d) DMs, the rooms list relies entirely on history paging. If a round settles via cannot-connect / CLOSE (relays dropped during a connect storm) instead of a clean empty-EOSE, no relay is marked done, exhausted stays false, and the no-progress guard then refuses to retry — recovery would only come if the relays happened to reconnect AND re-EOSE the open subscription on their own. Meanwhile the empty feed shows LoadingFeed() forever (it needs BOTH protocols exhausted to show 'no conversations'). When a round makes no progress but isn't exhausted, actively retry after a 5s backoff (clearing the no-progress gate) instead of waiting passively. Paced so a fast-CLOSE / rate-limited relay isn't hammered, and it stops once the protocol exhausts or a round makes progress. Applied to both round-model history managers (giftwrap + rooms NIP-04). Not caused by the stall-gate drop — the empty-feed search path is unchanged by that commit; this is a pre-existing fragility the severe 76s connect storm in this cold start exposed. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../AccountGiftWrapsHistoryEoseManager.kt | 29 ++++++++++++++++++- .../ChatroomListNip04HistorySubAssembler.kt | 27 ++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) 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 ff3f2180b9..7e3986b7b3 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 @@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -117,6 +118,12 @@ class AccountGiftWrapsHistoryEoseManager( @Volatile private var roundJob: Job? = null + // Backoff retry after a round that made no progress but isn't exhausted (relays failed to answer + // cleanly — cannot-connect / CLOSE during a connect storm — rather than empty-EOSE'ing). Without it + // the no-progress guard would never re-fire and a cold, empty feed would stay on the spinner forever. + @Volatile + private var retryJob: Job? = null + // The user whose round is in flight, read by the round collector on completion. @Volatile private var lastRoundUser: User? = null @@ -222,7 +229,24 @@ class AccountGiftWrapsHistoryEoseManager( _exhausted.value = exhaustedNow _reachedBack.value = pager.deepestUntil(user.pubkeyHex, asked, startUntil()) Log.d(TAG) { "[giftwrap.history] round done: $count event(s), exhausted=$exhaustedNow" } - if (autoLoadAll && !exhaustedNow) loadMore(user) + if (autoLoadAll && !exhaustedNow) { + loadMore(user) + } else if (!exhaustedNow && count == 0) { + // No progress and not exhausted: the relays failed to answer cleanly + // (cannot-connect / CLOSE) rather than empty-EOSE'ing. Retry after a + // backoff so a transient failure recovers — paced so a fast-CLOSE + // (rate-limited) relay isn't hammered. Stops once exhausted or loading. + retryJob?.cancel() + retryJob = + scope.launch { + delay(NO_PROGRESS_RETRY_MS) + if (!_exhausted.value && !windowLoad.loading.value) { + lastAskedActive = emptySet() + Log.d(TAG) { "[giftwrap.history] retry after no-progress round" } + loadMore(user) + } + } + } } } wasLoading = loading @@ -315,5 +339,8 @@ class AccountGiftWrapsHistoryEoseManager( // relay allows it. A relay returning fewer is treated as its own cap, NOT as "nothing more" — // only an empty page + EOSE ends a relay. private const val PAGE_LIMIT = 10000 + + // Backoff before retrying a no-progress, not-exhausted round (transient relay failure). + private const val NO_PROGRESS_RETRY_MS = 5_000L } } 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 b94820782e..ff75a86c90 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 @@ -40,6 +40,7 @@ import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -94,6 +95,11 @@ class ChatroomListNip04HistorySubAssembler( @Volatile private var roundJob: Job? = null + // Backoff retry after a no-progress, not-exhausted round (relays failed to answer cleanly rather + // than empty-EOSE'ing), so a transient connect-storm failure recovers instead of stalling forever. + @Volatile + private var retryJob: Job? = null + @Volatile private var lastRoundUser: User? = null @@ -187,7 +193,23 @@ class ChatroomListNip04HistorySubAssembler( _exhausted.value = exhaustedNow _reachedBack.value = pager.deepestUntil(user.pubkeyHex, asked, startUntil()) Log.d("DMPagination") { "[rooms.nip04.history] round done: $count event(s), exhausted=$exhaustedNow" } - if (autoLoadAll && !exhaustedNow) loadMore(user) + if (autoLoadAll && !exhaustedNow) { + loadMore(user) + } else if (!exhaustedNow && count == 0) { + // No progress and not exhausted: relays failed to answer cleanly rather + // than empty-EOSE'ing. Retry after a backoff so a transient failure + // recovers, paced so a rate-limited relay isn't hammered. + retryJob?.cancel() + retryJob = + scope.launch { + delay(NO_PROGRESS_RETRY_MS) + if (!_exhausted.value && !windowLoad.loading.value) { + lastAskedActive = emptySet() + Log.d("DMPagination") { "[rooms.nip04.history] retry after no-progress round" } + loadMore(user) + } + } + } } } wasLoading = loading @@ -266,5 +288,8 @@ class ChatroomListNip04HistorySubAssembler( companion object { private const val PAGE_LIMIT = 10000 + + // Backoff before retrying a no-progress, not-exhausted round (transient relay failure). + private const val NO_PROGRESS_RETRY_MS = 5_000L } } From edbdbdcc8eccf97b3f5cf91f664ab172713d3d74 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 21:52:38 +0000 Subject: [PATCH 053/103] fix: give up on unreachable relays so the rooms list resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the no-progress retry: in this trace vitor/mom/nos.lol never connect at all (only damus gets a REQ — the other three cannot-connect for both live and history). The retry correctly re-attempts, but it can't reach relays that won't connect, so every round is 0 events, no relay is ever 'done', exhausted never completes, and it retries forever — a cold, empty feed stays on the spinner. The round-model history had no give-up for repeated cannot-connect (only for CLOSE), unlike the convo path. Route onCannotConnect through the same pager give-up as onClosed, so a relay that's unreachable for a few rounds is abandoned, exhaustion completes, and the screen resolves (to the loaded rooms, or empty + retry) instead of spinning forever. The streak resets on any contact, so a merely slow relay that connects within a few attempts isn't dropped. Applied to both round-model managers. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt | 5 +++++ .../rooms/datasource/ChatroomListNip04HistorySubAssembler.kt | 4 ++++ 2 files changed, 9 insertions(+) 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 7e3986b7b3..8734fe3d20 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 @@ -328,7 +328,12 @@ class AccountGiftWrapsHistoryEoseManager( message: String, forFilters: List?, ) { + // Cannot-connect is also "no answer": count it toward give-up like a CLOSE, so a relay + // that's unreachable (down / blocked) doesn't keep exhaustion false forever — otherwise a + // cold, empty feed would retry it endlessly and stay on the spinner. Once it's given up, + // exhaustion can complete and the screen resolves (to the loaded rooms, or empty + retry). windowLoad.onRelaySettled(relay) + if (pager.onClosed(user.pubkeyHex, relay)) markExhaustedIfAllDone(user) } } 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 ff75a86c90..adf9cae42b 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 @@ -282,7 +282,11 @@ class ChatroomListNip04HistorySubAssembler( message: String, forFilters: List?, ) { + // Count cannot-connect toward give-up like a CLOSE, so an unreachable relay doesn't keep + // exhaustion false forever (otherwise a cold, empty feed retries it endlessly and stays on + // the spinner). Once given up, exhaustion completes and the screen resolves. windowLoad.onRelaySettled(relay) + if (pager.onClosed(user.pubkeyHex, relay)) markExhaustedIfAllDone(user) } } From d92c4be096b56eda4a6369115cd67d70a4027f86 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 3 Jun 2026 18:23:54 -0400 Subject: [PATCH 054/103] fix: recover Tor from a wedged Arti guard sample on startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a flaky network Arti records circuit failures past the first hop as "indeterminate" and, once a guard's indeterminate ratio crosses 0.7, permanently disables it. Disabled guards are never re-enabled nor removed from the sample (60-day lifetime), and the sample is capped at 60. Arti normally refills usable guards when they fall below 20, but a full sample of unusable guards leaves no room — replenishment wedges and every circuit returns AllGuardsDown. The state persists in guards.json and bootstrap still "succeeds", so no existing self-heal path fires: Tor stays broken across restarts. This is the long-standing, hard-to-reproduce production "can't connect to Tor" bug. On init, scan guards.json and, if any non-empty guard selection has zero usable guards (disabled or unlisted_since set), wipe Arti state so the next bootstrap rebuilds a fresh sample. A single usable guard is enough to build circuits, so recovery only triggers at the last resort to preserve guard-set stability (anonymity) and avoid pointless churn on bad networks. Verified on emulator: poisoned 60/60 -> wipe -> fresh 20/20 usable sample -> .onion OnOpen, zero AllGuardsDown. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../amethyst/ui/tor/TorService.kt | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt index 6b27c59acc..b5233c6199 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.tor import android.content.Context +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -70,6 +71,67 @@ class TorService( } } + /** + * The on-disk guard sample Arti persists between runs. + * Path: `/arti/state/state/guards.json`. + */ + private fun guardsFile() = File(File(File(artiDataDir(), "state"), "state"), "guards.json") + + /** + * Detects the wedged-guard-sample state behind the long-standing "can't + * connect to Tor" bug. + * + * On a flaky network, Arti records circuit failures past the first hop as + * "indeterminate" (it can't tell whether the guard or a later hop was at + * fault). Once a guard's indeterminate ratio crosses 0.7, Arti + * *permanently* disables it (`TooManyIndeterminateFailures`). Disabled + * guards are never re-enabled and never removed from the sample (kept for + * the 60-day confirmed lifetime), and the sample is capped at + * `max_sample_size` (60). Arti normally refills usable guards from the + * network when they drop below `min_filtered_sample_size` (20), but once + * the sample is full of unusable guards there is no room to add more — so + * replenishment is permanently wedged and every circuit returns + * `AllGuardsDown`. The state persists in `guards.json`, and bootstrap still + * "succeeds" (it reads cached directory data), so none of the init-failure + * self-heal paths ever fire and Tor is stuck across restarts. + * + * A single usable guard is enough to keep building circuits, so we only + * recover at the last resort: when a non-empty guard set has *zero* usable + * guards. A guard is unusable on disk if it has been permanently + * `disabled` or dropped from the consensus (`unlisted_since` set); + * reachability is in-memory only and not persisted, so it can't be checked + * here. Returns true when at least one non-empty selection has no usable + * guard left. + */ + private fun noUsableGuards(): Boolean { + val file = guardsFile() + if (!file.exists()) return false + + return try { + val root = jacksonObjectMapper().readTree(file) + var wedged = false + // Each top-level field is a guard-set selection (e.g. "default"). + root.forEach { selection -> + val guards = selection.get("guards") ?: return@forEach + if (guards.isArray && guards.size() > 0) { + val usable = + guards.count { guard -> + val disabled = guard.get("disabled") + val unlisted = guard.get("unlisted_since") + val isDisabled = disabled != null && !disabled.isNull + val isUnlisted = unlisted != null && !unlisted.isNull + !isDisabled && !isUnlisted + } + if (usable == 0) wedged = true + } + } + wedged + } catch (e: Exception) { + Log.w("TorService") { "Could not inspect guards.json: ${e.message}" } + false + } + } + /** * Clears all Arti persistent data (state + cache). Used as a last resort * when initialization fails, to recover from corrupted state. @@ -122,6 +184,16 @@ class TorService( // fresh network data, preventing stale guards/circuits. clearArtiCache() + // Self-heal the wedged guard sample (see [noUsableGuards]): if + // the persisted sample has no usable guard left, Arti can + // neither build circuits nor replenish, and would return + // AllGuardsDown forever. Wipe the on-disk state so the next + // bootstrap rebuilds a fresh guard sample. + if (noUsableGuards()) { + Log.w("TorService") { "No usable Arti guards left on disk — wiping state to rebuild the guard sample" } + clearAllArtiData() + } + val dataDir = artiDataDir().absolutePath Log.d("TorService") { "Initializing Arti with data dir: $dataDir" } From 0077c136a536ab83911ec08a51442fff1fdf1992 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 23:24:59 +0000 Subject: [PATCH 055/103] fix: keep the DM history 'reached back' date monotonic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-model history cards computed reachedBack = deepestUntil over the ACTIVE relays only. When the deepest relay finished paging and dropped out of the active set, the min jumped to the next-active (newer) relay's cursor, so 'back to X' lurched FORWARD to a more recent date — un-reaching history it had already loaded. Visible in the giftwrap trace: the deepest relays reach ~2023, then once they finish and only the shallow inbox.nostr.wine (~70d) remains, deepestUntil(active) snaps back to ~70d. Compute it over ALL relays (including finished ones, which keep their deep cursor), matching the convo path. Now it only ever moves older. Applied to both round-model managers (giftwrap + rooms NIP-04). Note: a large *forward* jump (e.g. ~3 years in one step) is still expected and correct — a dense relay can return a full 10k-event page spanning years, so the oldest-loaded date legitimately leaps. No messages are skipped; each relay pages contiguously. https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW --- .../nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt | 8 ++++++-- .../datasource/ChatroomListNip04HistorySubAssembler.kt | 7 +++++-- 2 files changed, 11 insertions(+), 4 deletions(-) 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 8734fe3d20..1cc766027a 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 @@ -190,7 +190,10 @@ class AccountGiftWrapsHistoryEoseManager( pager.beginRound(user.pubkeyHex, active) lastRoundUser = user _relayCount.value = active.size - _reachedBack.value = pager.deepestUntil(user.pubkeyHex, active, startUntil()) + // Over ALL relays, not just the still-active ones: a relay that finished keeps its deep cursor, + // so "reached back to X" stays monotonic instead of jumping back to a newer date when the + // deepest relay drops out of the active set. + _reachedBack.value = pager.deepestUntil(user.pubkeyHex, account.dmRelays.flow.value, startUntil()) Log.d(TAG) { "[giftwrap.history] loadMore → ${active.size} active relay(s)" } scope?.let { ensureRoundCollector(it) @@ -227,7 +230,8 @@ class AccountGiftWrapsHistoryEoseManager( val exhaustedNow = allRelays.isNotEmpty() && pager.activeRelays(user.pubkeyHex, allRelays).isEmpty() exhaustedByUser[user.pubkeyHex] = exhaustedNow _exhausted.value = exhaustedNow - _reachedBack.value = pager.deepestUntil(user.pubkeyHex, asked, startUntil()) + // Over ALL relays (incl. finished ones) so the "reached back" date is monotonic. + _reachedBack.value = pager.deepestUntil(user.pubkeyHex, allRelays, startUntil()) Log.d(TAG) { "[giftwrap.history] round done: $count event(s), exhausted=$exhaustedNow" } if (autoLoadAll && !exhaustedNow) { loadMore(user) 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 adf9cae42b..248860f18e 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 @@ -156,7 +156,9 @@ class ChatroomListNip04HistorySubAssembler( pager.beginRound(user.pubkeyHex, active) lastRoundUser = user _relayCount.value = active.size - _reachedBack.value = pager.deepestUntil(user.pubkeyHex, active, startUntil()) + // Over ALL relays (incl. finished ones) so "reached back to X" stays monotonic and doesn't jump + // to a newer date when the deepest relay drops out of the active set. + _reachedBack.value = pager.deepestUntil(user.pubkeyHex, all, startUntil()) Log.d("DMPagination") { "[rooms.nip04.history] loadMore → ${active.size} active relay(s)" } scope?.let { ensureRoundCollector(it) @@ -191,7 +193,8 @@ class ChatroomListNip04HistorySubAssembler( val exhaustedNow = allRelays.isNotEmpty() && pager.activeRelays(user.pubkeyHex, allRelays).isEmpty() exhaustedByUser[user.pubkeyHex] = exhaustedNow _exhausted.value = exhaustedNow - _reachedBack.value = pager.deepestUntil(user.pubkeyHex, asked, startUntil()) + // Over ALL relays (incl. finished ones) so the "reached back" date is monotonic. + _reachedBack.value = pager.deepestUntil(user.pubkeyHex, allRelays, startUntil()) Log.d("DMPagination") { "[rooms.nip04.history] round done: $count event(s), exhausted=$exhaustedNow" } if (autoLoadAll && !exhaustedNow) { loadMore(user) From bd77e9381883f46b6f919d7bc9c888177ca3c2ff Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 15:05:18 +0000 Subject: [PATCH 056/103] fix(chats): surface streamed strangers in New Requests The additive predicate in ChatroomListNewFeedFilter required room.senderIntersects(followingKeySet) to be true, the exact inverse of the full feed() rebuild, which includes a room only when the sender is NOT followed. So new gift wraps / NIP-04 DMs from strangers streaming in through the additive update path were all rejected, and the New Requests list never grew past whatever feed() had computed at screen-open time. On a heavy account this showed as the list freezing at a handful of rooms while tens of thousands of events decrypted and history exhausted - 'loading but not changing the screen'. Negate the senderIntersects term so the additive path matches feed(). --- .../loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt index 7cf22d65e9..5feb61404b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt @@ -122,7 +122,7 @@ class ChatroomListNewFeedFilter( if (room != null && ( newNote.author?.pubkeyHex != me.pubkeyHex && - room.senderIntersects(followingKeySet) && + !room.senderIntersects(followingKeySet) && !account.chatroomList.hasSentMessagesTo(roomKey) ) && !account.isAllHidden(roomKey.users) From 81701ea751459ac0de4677c969d92b1281bea1fa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 15:47:32 +0000 Subject: [PATCH 057/103] feat(chats): walk DM history to load a reply's unloaded target Reply quotes inside a conversation rendered the same 'post not found' BlankNote as the main feeds when the target message had not been paged in yet. But a reply target in a DM is not missing - it is simply older than the loaded window, and for NIP-17 the inner rumor id is not even queryable on relays (only the outer gift-wrap id is), so the only way to surface it is to keep paging gift-wrap history until the wrap carrying it decrypts. Add LoadingReplyNote: a custom inner-quote placeholder that shows a small spinner + 'looking for the original message' and drives the matching history pager's loadMore in a loop (gated on its own loadingMore/exhausted) until the target decrypts - at which point WatchNoteEvent crossfades the real message in and disposes the loader - or the protocol's history runs dry, settling into the terminal 'not found' text. It runs regardless of scroll position so opening a thread pulls an off-screen reply target in on its own; the loop is idempotent so multiple loaders and the scroll loader coalesce onto one paging window. Wire it in via a new optional onBlank slot on ChatroomMessageCompose, chosen by the parent message's protocol (gift-wraps vs NIP-04). Public chats and marmot groups keep the default blank. --- .../loggedIn/chats/feed/ChatMessageCompose.kt | 32 +++- .../loggedIn/chats/feed/LoadingReplyNote.kt | 137 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 1 + 3 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/LoadingReplyNote.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index d0ac5a38e1..0d8d9dede0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -84,6 +84,7 @@ import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip13Pow.strongPoWOrNull import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent @@ -106,8 +107,12 @@ fun ChatroomMessageCompose( onScrollToNote: ((Note) -> Unit)? = null, shouldHighlight: Boolean = false, onHighlightFinished: (() -> Unit)? = null, + // Replaces the generic "post not found" blank while baseNote's event hasn't loaded. Used for + // reply quotes inside a DM, where the target is simply older than the loaded window (see + // LoadingReplyNote). Null keeps the default blank for every other caller. + onBlank: (@Composable () -> Unit)? = null, ) { - WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel, nav) { + val onFound: @Composable () -> Unit = { WatchBlockAndReport( note = baseNote, showHiddenWarning = false, @@ -140,6 +145,12 @@ fun ChatroomMessageCompose( } } } + + if (onBlank != null) { + WatchNoteEvent(baseNote = baseNote, onNoteEventFound = onFound, onBlank = onBlank, accountViewModel = accountViewModel) + } else { + WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel, nav = nav, onNoteEventFound = onFound) + } } @Composable @@ -396,9 +407,23 @@ private fun RenderReply( } } - replyTo.value?.let { note -> + replyTo.value?.let { replyNote -> + // For a DM, a reply target that hasn't arrived isn't lost — it's older than the loaded + // window (and for gift wraps can't be fetched by id). Swap the generic blank for one that + // walks history backward until it surfaces. Pick the pager by the PARENT's protocol; leave + // public chats / marmot groups (not a DM event here) on the default blank. + val replyBlank: (@Composable () -> Unit)? = + when (note.event) { + is ChatMessageEvent, is ChatMessageEncryptedFileHeaderEvent -> { + { LoadingReplyNote(DmReplyProtocol.NIP17, accountViewModel) } + } + is PrivateDmEvent -> { + { LoadingReplyNote(DmReplyProtocol.NIP04, accountViewModel) } + } + else -> null + } ChatroomMessageCompose( - baseNote = note, + baseNote = replyNote, routeForLastRead = null, innerQuote = true, parentBackgroundColor = bgColor, @@ -407,6 +432,7 @@ private fun RenderReply( onWantsToReply = onWantsToReply, onWantsToEditDraft = onWantsToEditDraft, onScrollToNote = onScrollToNote, + onBlank = replyBlank, ) } } 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 new file mode 100644 index 0000000000..c94acbc0e3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/LoadingReplyNote.kt @@ -0,0 +1,137 @@ +/* + * 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.amethyst.ui.screen.loggedIn.chats.feed + +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +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.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter + +/** Which DM history pager backs the conversation an unloaded reply belongs to. */ +enum class DmReplyProtocol { + // NIP-17 gift wraps. The rumor id of the reply target is NOT queryable on relays (only the outer + // 1059 wrap id is), so the only way to surface it is to keep paging gift-wrap history until the wrap + // carrying it is decrypted — hence we drive the account-wide gift-wrap history pager. + NIP17, + + // NIP-04 legacy DMs (kind 4). Paged per relay for the open conversation. + NIP04, +} + +/** + * The inner-quote placeholder for a reply whose target message has not been paged in yet — used in + * place of the generic [com.vitorpamplona.amethyst.ui.note.BlankNote] ("post not found") that the main + * feeds show. A reply target inside a conversation isn't *missing*, it's simply older than the window + * loaded so far; for gift wraps it can't even be fetched by id. So instead of declaring it lost, this + * card actively walks the conversation's history backward — kicking the protocol's `loadMore` each time + * the previous page settles — until either the target decrypts (the surrounding + * [com.vitorpamplona.amethyst.ui.note.WatchNoteEvent] crossfades the real message in and disposes this) + * or that protocol's history runs dry, at which point it settles into the terminal "not found" text. + * + * It runs regardless of scroll position (no oldest-end gate like the scroll-driven loader) precisely so + * that opening a thread and seeing a reply to something off-screen pulls that something in on its own. + * The drive loop is idempotent and gated on the pager's own `loadingMore`/`exhausted`, so several + * unloaded replies on screen — and the scroll loader — all coalesce onto the same paging window. + */ +@Composable +fun LoadingReplyNote( + protocol: DmReplyProtocol, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, +) { + val giftWrapsHistory = remember(accountViewModel) { accountViewModel.dataSources().account.giftWrapsHistory } + val nip04History = remember(accountViewModel) { accountViewModel.dataSources().chatroom.nip04History } + + val loadingFlow: StateFlow = + when (protocol) { + DmReplyProtocol.NIP17 -> giftWrapsHistory.loadingMore + DmReplyProtocol.NIP04 -> nip04History.loadingMore + } + val exhaustedFlow: StateFlow = + when (protocol) { + DmReplyProtocol.NIP17 -> giftWrapsHistory.exhausted + DmReplyProtocol.NIP04 -> nip04History.exhausted + } + + val exhausted by exhaustedFlow.collectAsStateWithLifecycle() + + LaunchedEffect(protocol, loadingFlow, exhaustedFlow) { + // 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 } + .distinctUntilChanged() + .filter { it } + .collect { + Log.d("DMPagination") { "reply blank: widen → $protocol loadMore (searching for unloaded reply)" } + when (protocol) { + DmReplyProtocol.NIP17 -> giftWrapsHistory.loadMore(accountViewModel.userProfile()) + DmReplyProtocol.NIP04 -> nip04History.loadMore() + } + } + } + + Crossfade(targetState = exhausted, label = "loadingReplyState") { isExhausted -> + Row( + modifier = modifier.padding(start = 20.dp, end = 20.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (!isExhausted) { + CircularProgressIndicator(Modifier.size(14.dp), strokeWidth = 2.dp, color = Color.Gray) + } + Text( + text = + if (isExhausted) { + stringRes(R.string.post_not_found_short) + } else { + stringRes(R.string.chats_reply_searching_history) + }, + color = Color.Gray, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index db206e3863..5bcb9d3f40 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -281,6 +281,7 @@ encrypted legacy Relay sync: + Looking for the original message… %1$s · %2$s · back to %3$s %1$s · %2$s From efecbc40ab961fdfea68ac636beaf07e44717489 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 16:01:32 +0000 Subject: [PATCH 058/103] style(chats): match unloaded-reply loader to the history loading card Give LoadingReplyNote the same chrome as DmHistoryLoadingCard - rounded translucent surface, spinner-in-a-box, status line - so an unloaded reply reads as the same 'reaching back into history' state, just inline in the quote instead of at the oldest end. --- .../loggedIn/chats/feed/LoadingReplyNote.kt | 68 +++++++++++++------ 1 file changed, 47 insertions(+), 21 deletions(-) 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 c94acbc0e3..dc96f379cf 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 @@ -21,12 +21,18 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed import androidx.compose.animation.Crossfade -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -34,7 +40,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -111,27 +117,47 @@ fun LoadingReplyNote( } } - Crossfade(targetState = exhausted, label = "loadingReplyState") { isExhausted -> - Row( - modifier = modifier.padding(start = 20.dp, end = 20.dp, top = 8.dp, bottom = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (!isExhausted) { - CircularProgressIndicator(Modifier.size(14.dp), strokeWidth = 2.dp, color = Color.Gray) - } - Text( - text = + // Same chrome as DmHistoryLoadingCard (the older-history status card at the oldest end) so an + // unloaded reply reads as the same kind of "reaching back into history" state, just inline in the + // quote: rounded translucent surface, a spinner-in-a-box, then the status line. + Surface( + modifier = modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + tonalElevation = 2.dp, + ) { + Crossfade(targetState = exhausted, animationSpec = tween(500), label = "loadingReplyState") { isExhausted -> + Row( + Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(22.dp), contentAlignment = Alignment.Center) { if (isExhausted) { - stringRes(R.string.post_not_found_short) + Text( + "⋯", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } else { - stringRes(R.string.chats_reply_searching_history) - }, - color = Color.Gray, - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + } + } + Spacer(Modifier.width(14.dp)) + Text( + text = + if (isExhausted) { + stringRes(R.string.post_not_found_short) + } else { + stringRes(R.string.chats_reply_searching_history) + }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } From 89f55a2509446b77c0056ea72c5b528252a53fd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 17:42:52 +0000 Subject: [PATCH 059/103] feat(chats): show relay count and reach-back on the reply loader The unloaded-reply loader matched the history card's chrome but dropped its status line. Surface the same detail: which protocol, how many relays it's still asking, and how far back it has paged - reusing the card's historySubtitle so the inline loader and the oldest-end card read identically. --- .../chats/feed/DmLoadMoreIndicator.kt | 2 +- .../loggedIn/chats/feed/LoadingReplyNote.kt | 57 ++++++++++++++----- 2 files changed, 45 insertions(+), 14 deletions(-) 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 7c6bf4d863..50208d4375 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 @@ -167,7 +167,7 @@ fun DmHistoryLoadingCard( } @Composable -private fun historySubtitle( +internal fun historySubtitle( protocolTag: String, relayCount: Int, reachedBack: Long?, 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 dc96f379cf..bce658db6e 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 @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -98,8 +99,25 @@ fun LoadingReplyNote( DmReplyProtocol.NIP17 -> giftWrapsHistory.exhausted DmReplyProtocol.NIP04 -> nip04History.exhausted } + val relayCountFlow: StateFlow = + when (protocol) { + DmReplyProtocol.NIP17 -> giftWrapsHistory.relayCount + DmReplyProtocol.NIP04 -> nip04History.relayCount + } + val reachedBackFlow: StateFlow = + when (protocol) { + DmReplyProtocol.NIP17 -> giftWrapsHistory.reachedBack + DmReplyProtocol.NIP04 -> nip04History.reachedBack + } + val protocolTag = + when (protocol) { + DmReplyProtocol.NIP17 -> "NIP-17" + DmReplyProtocol.NIP04 -> "NIP-04" + } val exhausted by exhaustedFlow.collectAsStateWithLifecycle() + val relayCount by relayCountFlow.collectAsStateWithLifecycle() + val reachedBack by reachedBackFlow.collectAsStateWithLifecycle() LaunchedEffect(protocol, loadingFlow, exhaustedFlow) { // Step the next, older page whenever the previous one has settled and history isn't exhausted. @@ -144,19 +162,32 @@ fun LoadingReplyNote( } } Spacer(Modifier.width(14.dp)) - Text( - text = - if (isExhausted) { - stringRes(R.string.post_not_found_short) - } else { - stringRes(R.string.chats_reply_searching_history) - }, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Column(Modifier.weight(1f)) { + Text( + text = + if (isExhausted) { + stringRes(R.string.post_not_found_short) + } else { + stringRes(R.string.chats_reply_searching_history) + }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + // Same status line as the oldest-end card: which protocol, how many relays it's + // still asking, and how far back it has paged. Hidden once history runs dry. + if (!isExhausted) { + Text( + text = historySubtitle(protocolTag, relayCount, reachedBack), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } } } } From 60b8629a27b07de0df894516c81dee69af94addf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 18:36:07 +0000 Subject: [PATCH 060/103] refactor(dm): page NIP-17 gift-wrap history per relay, like NIP-04 The gift-wrap history loader was round-based: loadMore asked every active relay together and the next page only went out after the whole round settled, so one slow or auth-walled relay throttled the cadence and fast relays idled until the laggards finished. The per-conversation NIP-04 loader already pages each relay independently; this brings NIP-17 to the same model. Now a single loadMore opens one window spanning the whole walk, and each relay continues itself the instant it EOSEs a non-empty page (pager.beginRound([relay]) + invalidateFilters, which the sub layer diffs so only the advanced relay re-REQs). Fast relays race to the bottom while slow ones catch up in the background. The WindowLoadTracker switches to tracksReqSends=true with an onAbandoned handler that marks silent/ unreachable relays stalled (kept open, still trying) rather than giving up on them - exhaustion comes from the window settling via the tracker's silence + connect-grace backstops, which also removes the need for the old no-progress retry loop. Drop the now-dead loadEverything/autoLoadAll (no callers; with independent paging one loadMore already walks each relay to its bottom). Pin the history floor per window to keep un-advanced relays' filters stable across the per-EOSE invalidateFilters, and keep reachedBack monotonic over all relays. --- .../AccountGiftWrapsHistoryEoseManager.kt | 307 +++++++++--------- 1 file changed, 157 insertions(+), 150 deletions(-) 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 1cc766027a..9dd5d66bb9 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 @@ -41,7 +41,6 @@ import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -50,15 +49,22 @@ import java.util.concurrent.ConcurrentHashMap /** * Loads the account's NIP-17 gift-wrap **history** — everything older than the one-week live tail - * ([AccountGiftWrapsEoseManager]) — by **`until`+`limit` paging, per relay**. + * ([AccountGiftWrapsEoseManager]) — by **`until`+`limit` paging, per relay, independently**. * - * Idle until a screen calls [loadMore]. Each round asks every not-yet-empty relay for [PAGE_LIMIT] - * gift wraps older than its own cursor (no `since`, so gaps are skipped). When a relay answers an - * empty page with EOSE it is done; otherwise its cursor advances below the oldest wrap it sent. The - * limit is **not** trusted as a stop signal (a relay may cap results on its own) — only an empty page - * is. The whole history is [exhausted] once a full round advances no relay at all (every relay - * empty-EOSE'd or only answered CLOSED), which is the gap-proof "nothing more is reachable" signal the - * old time-slice model couldn't produce. + * There are no lock-step rounds: a single [loadMore] kicks off every relay that still has older + * history, and from then on each relay drives its own pages off its own cursor, continuing the instant + * it EOSEs a non-empty page ([onEose] → `pager.beginRound([relay])` + `invalidateFilters`; the + * subscription layer diffs per relay, so re-issuing only re-REQs the relay whose cursor moved). Fast + * relays race to the bottom of the history in back-to-back pages while slow / auth-walled relays catch + * up at their own pace in the background — none holds the others back. This mirrors the per-conversation + * NIP-04 loader ([com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomNip04HistorySubAssembler]). + * + * A relay is *done* once it answers an empty page (nothing older). A relay that won't answer (auth + * CLOSE, unreachable, silent) is marked *stalled* for the logs but keeps its subscription open and keeps + * trying. The [loadingMore] spinner reflects whether anything is still actively advancing across one + * window spanning the whole walk; it clears — and [exhausted] flips — once every relay is either done or + * stalled (the [WindowLoadTracker] settles silent / unreachable relays via its silence + connect-grace + * backstops), without waiting on the slow ones beyond that. */ class AccountGiftWrapsHistoryEoseManager( client: INostrClient, @@ -66,75 +72,79 @@ class AccountGiftWrapsHistoryEoseManager( ) : PerUserEoseManager(client, allKeys) { override fun user(key: AccountQueryState) = key.account.userProfile() + // Per-relay cursors, keyed by account pubkey so switching accounts preserves each one's progress. private val pager = UntilLimitPager() // Users that have requested history at least once (else the manager stays idle, issuing no REQ). private val started = ConcurrentHashMap.newKeySet() - // The relays the in-flight round asked, per user — used to tally the round on completion. - private val askedRelays = ConcurrentHashMap>() - - // The account behind each user pubkey, captured on subscribe so [loadMore] (UI thread) can read - // the DM relay list without the key. + // The account behind each user pubkey, captured on subscribe so [loadMore] (UI thread) can read the + // DM relay list without the key. private val accounts = ConcurrentHashMap() + // Relays currently not advancing for a user (auth CLOSE / unreachable / silent). Tracked only for the + // logs; these relays are NOT given up — they keep their subscription and keep trying to catch up. + private val stalledRelays = ConcurrentHashMap>() + // This manager is shared across logged-in accounts (one singleton coordinator), so the single - // display flows below must follow whichever account is currently active. Per-account paging - // cursors live in [pager], so switching away and back preserves progress; [exhaustedByUser] lets - // the display flow repoint accurately on switch instead of leaking the previous account's state. + // display flows below must follow whichever account is currently active. Per-account paging cursors + // live in [pager], so switching away and back preserves progress; [exhaustedByUser] lets the display + // flow repoint accurately on switch instead of leaking the previous account's state. @Volatile private var activeUser: HexKey? = null private val exhaustedByUser = ConcurrentHashMap() - // No-progress guard: the relay set the last round asked and how many events it returned. If a fresh - // loadMore would ask the same relays and the last round brought nothing (all CLOSED / unanswered), - // we skip it rather than busy-retry — the pool re-auths on the open subscription and its EOSE will - // advance/finish them. Cleared by onEose (any EOSE means something changed). - @Volatile - private var lastAskedActive: Set = emptySet() + private val windowLoad = WindowLoadTracker("giftwrap.history", tracksReqSends = true, onAbandoned = ::onRelaysStalled) - @Volatile - private var lastRoundEventCount = -1 + // Exposed instead of windowLoad.loading directly: that flow starts `true` (it assumes a load is in + // flight from construction). Wired straight through, its `true` would wedge the scroll-driven loader + // — whose gate is `!loading` — so the first loadMore could never fire. This starts false and only + // goes true once paging actually begins (mirrored from windowLoad by the done collector). + private val _loadingMore = MutableStateFlow(false) + val loadingMore: StateFlow = _loadingMore.asStateFlow() - private val windowLoad = WindowLoadTracker("giftwrap.history") - val loadingMore: StateFlow = windowLoad.loading - - // True once a full round advanced no relay — nothing older is reachable. + // True once the window settles (every relay done or stalled) — nothing more is reachable right now. private val _exhausted = MutableStateFlow(false) val exhausted: StateFlow = _exhausted.asStateFlow() - // Status surfaced to the loading card: how many relays the current page is asking, and the oldest - // point paging has reached (epoch seconds, the deepest cursor). + // Status surfaced to the loading card: how many relays are still being paged, and the oldest point + // paging has reached (epoch seconds, the deepest cursor). private val _relayCount = MutableStateFlow(0) val relayCount: StateFlow = _relayCount.asStateFlow() private val _reachedBack = MutableStateFlow(null) val reachedBack: StateFlow = _reachedBack.asStateFlow() - // Account scope for the watchdog / round collector. Volatile: written on IO (newSub), read on UI. + // Account scope for the done collector. Volatile: written on IO (newSub), read on UI. @Volatile private var scope: CoroutineScope? = null @Volatile - private var roundJob: Job? = null + private var doneJob: Job? = null - // Backoff retry after a round that made no progress but isn't exhausted (relays failed to answer - // cleanly — cannot-connect / CLOSE during a connect storm — rather than empty-EOSE'ing). Without it - // the no-progress guard would never re-fire and a cold, empty feed would stay on the spinner forever. + // The user whose window is in flight, read by the done collector when it settles. @Volatile - private var retryJob: Job? = null + private var windowUser: User? = null - // The user whose round is in flight, read by the round collector on completion. + // Whether a paging window is currently running. Tracked ourselves rather than read from + // windowLoad.loading (which starts `true` before any window exists), so the first loadMore actually + // starts the window instead of mistaking the construction-time `true` for an in-flight one. @Volatile - private var lastRoundUser: User? = null + private var windowActive = false - // "Load entire history" mode: keep paging to the end without waiting for more scrolling. + // The history floor (live-tail boundary) pinned for the current window. startUntil() is `now − 1w`, + // which drifts forward in real time — if it were recomputed per assembly, an un-advanced relay's + // filter (until = floor) would change every time ANY relay's EOSE triggers invalidateFilters, + // re-REQing relays that haven't moved. Pinning it per window keeps those filters stable so only a + // relay whose cursor genuinely advanced is re-REQed. @Volatile - private var autoLoadAll = false + private var windowFloor = 0L // History starts just below the live tail's one-week floor and pages backward from there. private fun startUntil() = TimeUtils.now() - AccountGiftWrapsEoseManager.LIVE_TAIL_SECONDS + private fun floor() = windowFloor.takeIf { it != 0L } ?: startUntil() + private fun daysAgo(epochSeconds: Long) = (TimeUtils.now() - epochSeconds) / TimeUtils.ONE_DAY override fun updateFilter( @@ -142,115 +152,82 @@ class AccountGiftWrapsHistoryEoseManager( since: SincePerRelayMap?, ): List { val user = user(key) - if (!key.account.isWriteable() || user.pubkeyHex !in started) { - windowLoad.setExpectedRelays(emptySet()) - return emptyList() - } + if (!key.account.isWriteable() || user.pubkeyHex !in started) return emptyList() + + // Every relay that still has older history to ask for, each at its own cursor. A relay whose + // cursor advanced since the last assembly re-REQs its next page; one still mid-page keeps its open + // REQ; a done relay drops out (its REQ closes). This is what lets relays run independently. val relays = key.account.dmRelays.flow.value val active = pager.activeRelays(user.pubkeyHex, relays).toSet() - askedRelays[user.pubkeyHex] = active - windowLoad.setExpectedRelays(active) if (active.isEmpty()) return emptyList() DmRelayLog.log("giftwrap.history", key.account) - Log.d(TAG) { "[giftwrap.history] REQ ${active.size} relay(s) ${active.map { it.url }}, limit=$PAGE_LIMIT (until ${daysAgo(pager.untilFor(user.pubkeyHex, active.first(), startUntil()))}d…)" } + Log.d(TAG) { "[giftwrap.history] REQ ${active.size} relay(s) ${active.map { it.url }}, limit=$PAGE_LIMIT (until ${daysAgo(pager.untilFor(user.pubkeyHex, active.first(), floor()))}d…)" } return active.flatMap { relay -> filterGiftWrapsToPubkey( relay = relay, pubkey = user.pubkeyHex, since = null, - until = pager.untilFor(user.pubkeyHex, relay, startUntil()), + until = pager.untilFor(user.pubkeyHex, relay, floor()), limit = PAGE_LIMIT, ) } } - /** Requests the next backward page from every relay that still has older history. No-op if exhausted. */ + /** Starts (or resumes) per-relay paging of the gift-wrap history. Idempotent: safe to call again. */ fun loadMore(user: User) { - if (_exhausted.value) { - Log.d(TAG) { "[giftwrap.history] loadMore ignored — exhausted" } - return - } val account = accounts[user.pubkeyHex] ?: return started.add(user.pubkeyHex) - val active = pager.activeRelays(user.pubkeyHex, account.dmRelays.flow.value) - if (active.isEmpty()) { + val allRelays = account.dmRelays.flow.value + if (allRelays.isEmpty()) return + if (pager.activeRelays(user.pubkeyHex, allRelays).isEmpty()) { + // Everything already paged to the bottom. exhaustedByUser[user.pubkeyHex] = true _exhausted.value = true return } - val activeSet = active.toSet() - if (activeSet == lastAskedActive && lastRoundEventCount == 0) { - // The same relays just returned nothing (e.g. all CLOSED, auth pending). The pool retries - // auth on the open subscription and its EOSE finishes them (see markExhaustedIfAllDone), so - // don't hammer with an identical round. onEose clears this gate when anything changes. - Log.d(TAG) { "[giftwrap.history] loadMore skipped — no progress on the same relays" } - return - } - lastAskedActive = activeSet - pager.beginRound(user.pubkeyHex, active) - lastRoundUser = user - _relayCount.value = active.size - // Over ALL relays, not just the still-active ones: a relay that finished keeps its deep cursor, - // so "reached back to X" stays monotonic instead of jumping back to a newer date when the - // deepest relay drops out of the active set. - _reachedBack.value = pager.deepestUntil(user.pubkeyHex, account.dmRelays.flow.value, startUntil()) - Log.d(TAG) { "[giftwrap.history] loadMore → ${active.size} active relay(s)" } + _exhausted.value = false + DmRelayLog.log("giftwrap.history", account) + windowUser = user scope?.let { - ensureRoundCollector(it) - windowLoad.startLoading(it) + ensureDoneCollector(it) + // One window spanning the whole per-relay pagination: it settles a relay only on that relay's + // empty-EOSE (done) or when it goes silent/stalled, never on a mid-history page, so the + // spinner tracks "is anything still advancing" rather than any single page. Start it only if + // none is running — a re-entrant loadMore (the scroll loader re-firing mid-pagination) must + // not reset the window and forget the relays that already finished. + if (!windowActive) { + windowActive = true + windowFloor = startUntil() + // Populate the relay count BEFORE raising the spinner, so the status card never renders a + // "loading from 0 relays" frame between loadingMore flipping true and the first progress. + updateStatus(user) + _loadingMore.value = true + windowLoad.startLoading(it) + } + windowLoad.setExpectedRelays(allRelays.toSet()) } + updateStatus(user) + Log.d(TAG) { "[giftwrap.history] paging ${allRelays.size} relay(s) independently: ${allRelays.map { it.url }}" } invalidateFilters() } - /** Pages to the very end: each completed round auto-issues the next until the history is exhausted. */ - fun loadEverything(user: User) { - if (_exhausted.value) return - autoLoadAll = true - Log.d(TAG) { "[giftwrap.history] loadEverything — paging to the end" } - loadMore(user) - } - - // Emits the round tally and the exhausted decision when the in-flight load settles. Exhausted ONLY - // when every relay has returned an empty page + EOSE (pager.done): a relay that merely CLOSED (e.g. - // auth-required, before its post-auth retry) or never answered is NOT finished, so we don't call it - // "all caught up" — we keep loading it. In load-all mode, keep paging until that's true. - private fun ensureRoundCollector(scope: CoroutineScope) { - if (roundJob?.isActive == true) return - roundJob = + // Mirrors the window's loading state into [_loadingMore] and, when it settles (every relay done or + // stalled), flips [exhausted] and clears [windowActive] so the next loadMore can start a fresh window. + private fun ensureDoneCollector(scope: CoroutineScope) { + if (doneJob?.isActive == true) return + doneJob = scope.launch { var wasLoading = false windowLoad.loading.collect { loading -> + _loadingMore.value = loading && windowActive if (!loading && wasLoading) { - val user = lastRoundUser - if (user != null) { - val asked = askedRelays[user.pubkeyHex] ?: emptySet() - val count = pager.roundEventCount(user.pubkeyHex, asked) - lastRoundEventCount = count - val allRelays = accounts[user.pubkeyHex]?.dmRelays?.flow?.value ?: emptySet() - val exhaustedNow = allRelays.isNotEmpty() && pager.activeRelays(user.pubkeyHex, allRelays).isEmpty() - exhaustedByUser[user.pubkeyHex] = exhaustedNow - _exhausted.value = exhaustedNow - // Over ALL relays (incl. finished ones) so the "reached back" date is monotonic. - _reachedBack.value = pager.deepestUntil(user.pubkeyHex, allRelays, startUntil()) - Log.d(TAG) { "[giftwrap.history] round done: $count event(s), exhausted=$exhaustedNow" } - if (autoLoadAll && !exhaustedNow) { - loadMore(user) - } else if (!exhaustedNow && count == 0) { - // No progress and not exhausted: the relays failed to answer cleanly - // (cannot-connect / CLOSE) rather than empty-EOSE'ing. Retry after a - // backoff so a transient failure recovers — paced so a fast-CLOSE - // (rate-limited) relay isn't hammered. Stops once exhausted or loading. - retryJob?.cancel() - retryJob = - scope.launch { - delay(NO_PROGRESS_RETRY_MS) - if (!_exhausted.value && !windowLoad.loading.value) { - lastAskedActive = emptySet() - Log.d(TAG) { "[giftwrap.history] retry after no-progress round" } - loadMore(user) - } - } - } + windowActive = false + _loadingMore.value = false + windowUser?.let { user -> + exhaustedByUser[user.pubkeyHex] = true + if (activeUser == user.pubkeyHex) _exhausted.value = true + updateStatus(user) + logSettleSummary(user) } } wasLoading = loading @@ -258,14 +235,41 @@ class AccountGiftWrapsHistoryEoseManager( } } - // Flips to exhausted only once every relay has returned an empty page + EOSE (all done). Sets true - // only — the false transitions belong to loadMore / the round collector. Safe off the round path. - private fun markExhaustedIfAllDone(user: User) { - val allRelays = accounts[user.pubkeyHex]?.dmRelays?.flow?.value ?: return - if (allRelays.isNotEmpty() && pager.activeRelays(user.pubkeyHex, allRelays).isEmpty()) { - exhaustedByUser[user.pubkeyHex] = true - if (activeUser == user.pubkeyHex) _exhausted.value = true - } + // WindowLoadTracker reports relays that accepted a REQ then went silent, or never got their REQ out. + // We do NOT give up on them (they may simply be slow and need to catch up) — we just record them as + // stalled for the logs and let them keep their open subscription. + private fun onRelaysStalled(relays: Set) { + started.forEach { pk -> relays.forEach { markStalled(pk, it, "no response (silence/connect timeout)") } } + } + + // Records [relay] as not currently advancing for [pk] and logs it once (the first time it stalls in + // this window). The relay is kept — it kept its subscription and keeps trying to catch up. + private fun markStalled( + pk: HexKey, + relay: NormalizedRelayUrl, + reason: String, + ) { + val firstTime = stalledRelays.getOrPut(pk) { ConcurrentHashMap.newKeySet() }.add(relay) + if (firstTime) Log.d(TAG) { "[giftwrap.history] ${relay.url} stalled — $reason (kept open, still trying)" } + } + + private fun updateStatus(user: User) { + val relays = accounts[user.pubkeyHex]?.dmRelays?.flow?.value ?: emptySet() + // "Asking N relays" on the status card: the ones still being paged (done relays have dropped out). + _relayCount.value = pager.activeRelays(user.pubkeyHex, relays).size + // Over ALL relays, not just the still-active ones: a relay that finished keeps its deep cursor, so + // "reached back to X" stays monotonic instead of jumping back to a newer date when the deepest + // relay drops out of the active set. + _reachedBack.value = pager.deepestUntil(user.pubkeyHex, relays, floor()) + } + + // A one-line breakdown of where each relay landed when the window settles — the snapshot to reach for + // when history didn't load tomorrow: who reached the bottom vs. who is still being retried. + private fun logSettleSummary(user: User) { + val relays = accounts[user.pubkeyHex]?.dmRelays?.flow?.value ?: return + val done = relays.filter { pager.isDone(user.pubkeyHex, it) }.map { it.url } + val stillTrying = relays.filterNot { pager.isDone(user.pubkeyHex, it) }.map { it.url } + Log.d(TAG) { "[giftwrap.history] settled — done=$done still-trying=$stillTrying" } } override fun newSub(key: AccountQueryState): Subscription { @@ -278,8 +282,6 @@ class AccountGiftWrapsHistoryEoseManager( _exhausted.value = exhaustedByUser[user.pubkeyHex] ?: false _relayCount.value = 0 _reachedBack.value = null - lastAskedActive = emptySet() - lastRoundEventCount = -1 } return requestNewSubscription(historyListener(user, key)) } @@ -289,6 +291,13 @@ class AccountGiftWrapsHistoryEoseManager( key: AccountQueryState, ): SubscriptionListener = object : SubscriptionListener { + override fun onSubscriptionStarted( + relay: String, + forFilters: List, + ) { + windowLoad.onReqSent(relay) + } + override fun onEvent( event: Event, isLive: Boolean, @@ -297,21 +306,27 @@ class AccountGiftWrapsHistoryEoseManager( ) { windowLoad.onRelayEvent(relay) pager.onEvent(user.pubkeyHex, relay, event.createdAt) + stalledRelays[user.pubkeyHex]?.remove(relay) } override fun onEose( relay: NormalizedRelayUrl, forFilters: List?, ) { + stalledRelays[user.pubkeyHex]?.remove(relay) pager.onEose(user.pubkeyHex, relay) - windowLoad.onRelaySettled(relay) + if (pager.isDone(user.pubkeyHex, relay)) { + // Reached the bottom on this relay: settle it for the spinner, nothing more to ask. + windowLoad.onRelaySettled(relay) + Log.d(TAG) { "[giftwrap.history] ${relay.url} reached the bottom (done)" } + } else { + // This page had events: reset only this relay's tally and let it continue to its next + // page immediately, independent of every other relay. + pager.beginRound(user.pubkeyHex, listOf(relay)) + } newEose(key, relay, TimeUtils.now(), forFilters) - // An EOSE means this relay changed (finished, or delivered a page) — clear the - // no-progress gate so the next loadMore can continue, even off the round path. - lastRoundEventCount = -1 - // A post-auth empty EOSE can land after the round already settled on the earlier CLOSED; - // flip to exhausted the moment this finishes the last relay, not only at round end. - markExhaustedIfAllDone(user) + updateStatus(user) + invalidateFilters() } override fun onClosed( @@ -319,12 +334,11 @@ class AccountGiftWrapsHistoryEoseManager( relay: NormalizedRelayUrl, forFilters: List?, ) { - // CLOSED (e.g. auth-required) is not "empty": don't mark the relay done — it may answer - // after the auth handshake. It just settles the load so the spinner can clear. But if it - // keeps rejecting us (auth we can't satisfy), the pager eventually gives up on it; once - // that's the last blocker, exhaustion can complete. + // A relay (e.g. an author's) may demand auth we can't satisfy and CLOSE. It's stalled, not + // done — keep its subscription so the pool can re-auth and it can catch up — but don't let + // it hold the spinner. windowLoad.onRelaySettled(relay) - if (pager.onClosed(user.pubkeyHex, relay)) markExhaustedIfAllDone(user) + markStalled(user.pubkeyHex, relay, "CLOSED: $message") } override fun onCannotConnect( @@ -332,12 +346,8 @@ class AccountGiftWrapsHistoryEoseManager( message: String, forFilters: List?, ) { - // Cannot-connect is also "no answer": count it toward give-up like a CLOSE, so a relay - // that's unreachable (down / blocked) doesn't keep exhaustion false forever — otherwise a - // cold, empty feed would retry it endlessly and stay on the spinner. Once it's given up, - // exhaustion can complete and the screen resolves (to the loaded rooms, or empty + retry). windowLoad.onRelaySettled(relay) - if (pager.onClosed(user.pubkeyHex, relay)) markExhaustedIfAllDone(user) + markStalled(user.pubkeyHex, relay, "cannot connect: $message") } } @@ -348,8 +358,5 @@ class AccountGiftWrapsHistoryEoseManager( // relay allows it. A relay returning fewer is treated as its own cap, NOT as "nothing more" — // only an empty page + EOSE ends a relay. private const val PAGE_LIMIT = 10000 - - // Backoff before retrying a no-progress, not-exhausted round (transient relay failure). - private const val NO_PROGRESS_RETRY_MS = 5_000L } } From 9f0ecd549f2ea2b71dba7074daf896e161ef920f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 18:55:44 +0000 Subject: [PATCH 061/103] refactor(dm): page rooms-list NIP-04 history per relay; drop dead pager code Convert ChatroomListNip04HistorySubAssembler from the round-based model to the per-relay-independent one, matching the per-conversation NIP-04 loader and the gift-wrap history loader. A single loadMore opens one window spanning the whole walk; each relay continues itself on its own non-empty EOSE (beginRound([relay]) + invalidateFilters), and silent/unreachable relays are marked stalled (kept open) with exhaustion coming from the WindowLoadTracker's silence + connect-grace backstops (tracksReqSends=true). The rooms list now pages both protocols identically. This was the last user of the round-tally + give-up machinery, so remove it from UntilLimitPager: roundEventCount(), onClosed()/giveUp() and the givenUp/closedStreak cursor state + GIVE_UP_AFTER_CLOSES. activeRelays now excludes only done relays. Delete UntilLimitPagerGiveUpTest (tested the removed give-up path; abandonment is now the WindowLoadTracker's job, which its own test covers). Also drop the now-dead loadEverything/autoLoadAll and the no-progress retry (no callers; with independent paging one loadMore already walks each relay to its bottom, and the tracker's backstops cover cold starts). --- .../eoseManagers/UntilLimitPager.kt | 75 +---- .../ChatroomListNip04HistorySubAssembler.kt | 268 ++++++++++-------- .../eoseManagers/UntilLimitPagerGiveUpTest.kt | 69 ----- 3 files changed, 158 insertions(+), 254 deletions(-) delete mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPagerGiveUpTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt index db33a87959..3eb54055b1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt @@ -35,9 +35,10 @@ import java.util.concurrent.ConcurrentHashMap * Stop signal (per relay): an **empty page followed by EOSE** ([onEose] with no events) marks that * relay [done][isDone]. A relay that returns anything — even fewer than the requested limit, since a * relay may cap results below what we asked — is *not* done; its cursor advances to one second below - * the oldest event it sent and it is asked again. Globally, the owner decides "exhausted" from - * [roundEventCount]: a whole round that advanced no relay (every relay empty-EOSE'd or only answered - * CLOSED) means nothing more is reachable. + * the oldest event it sent and the owner asks it again (typically right away, per relay). Relays a + * relay can't be read from (CLOSED / unreachable / silent) are the owner's concern, not the cursor's: + * the owner flags them stalled and lets its [WindowLoadTracker] settle the load so they can't block + * exhaustion forever. * * Not internally synchronized: per-relay counters are touched on the relay IO threads (one relay's * callbacks are serialized) and read on the owning scope after the load settles; fields are volatile. @@ -50,14 +51,6 @@ class UntilLimitPager { // Set once the relay answered an empty page with EOSE: there is nothing older on it. @Volatile var done: Boolean = false - // Set once the relay has rejected us [GIVE_UP_AFTER_CLOSES] times in a row without ever - // answering (CLOSED, e.g. "auth-required" for authors we can't authenticate). It is NOT done — - // we just can't read its window — but it must be excluded so it doesn't block exhaustion forever. - @Volatile var givenUp: Boolean = false - - // Consecutive CLOSEDs since this relay last actually answered (event or EOSE). Reset on contact. - @Volatile var closedStreak: Int = 0 - // Per-round tallies, reset by [beginRound]: how many events arrived and the oldest among them. @Volatile var roundCount: Int = 0 @@ -103,7 +96,6 @@ class UntilLimitPager { createdAt: Long, ) { val c = cursor(key, relay) - c.closedStreak = 0 c.roundCount++ if (createdAt < c.roundOldest) c.roundOldest = createdAt } @@ -118,7 +110,6 @@ class UntilLimitPager { relay: NormalizedRelayUrl, ) { val c = cursor(key, relay) - c.closedStreak = 0 if (c.roundCount == 0) { c.done = true } else { @@ -126,59 +117,11 @@ class UntilLimitPager { } } - /** - * Records a CLOSED (rejection) from [relay]. After [GIVE_UP_AFTER_CLOSES] in a row with no answer in - * between — i.e. the relay keeps rejecting us and auth can't fix it — the relay is [given up][givenUp] - * so it stops blocking exhaustion. Returns true if this CLOSED tipped it into given-up. - */ - fun onClosed( - key: K, - relay: NormalizedRelayUrl, - ): Boolean { - val c = cursor(key, relay) - if (c.givenUp || c.done) return false - c.closedStreak++ - if (c.closedStreak >= GIVE_UP_AFTER_CLOSES) { - c.givenUp = true - return true - } - return false - } - - /** - * Abandons [relay] for [key] when it accepted our REQ but never answered (no event, EOSE, or CLOSED - * within the silence window). Like [givenUp] via [onClosed], it is excluded from [activeRelays] so a - * silent relay can't block exhaustion forever — but a relay that already finished cleanly ([done]) - * is left alone. Returns true if this abandoned a relay that wasn't already done/given-up. - */ - fun giveUp( - key: K, - relay: NormalizedRelayUrl, - ): Boolean { - val c = cursor(key, relay) - if (c.done || c.givenUp) return false - c.givenUp = true - return true - } - - /** Total events received across [relays] in the round just finished. Zero ⇒ nothing more is reachable. */ - fun roundEventCount( - key: K, - relays: Collection, - ): Int = relays.sumOf { cursor(key, it).roundCount } - - /** - * Relays from [all] that still have older history to ask for: not yet empty-EOSE'd ([done]) and not - * abandoned as unreadable ([givenUp]). - */ + /** Relays from [all] that still have older history to ask for: not yet empty-EOSE'd ([done]). */ fun activeRelays( key: K, all: Collection, - ): List = - all.filterNot { - val c = cursor(key, it) - c.done || c.givenUp - } + ): List = all.filterNot { cursor(key, it).done } /** * The oldest point reached across [relays] — the minimum cursor (how far back paging has gone). @@ -189,10 +132,4 @@ class UntilLimitPager { relays: Collection, start: Long, ): Long? = relays.takeIf { it.isNotEmpty() }?.minOf { cursor(key, it).until ?: start } - - companion object { - // Consecutive CLOSEDs (with no answer in between) before a relay is abandoned as unreadable. - // Allows for the pool's auth handshake + a retry or two before concluding auth can't succeed. - private const val GIVE_UP_AFTER_CLOSES = 3 - } } 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 248860f18e..608f90286b 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 @@ -40,7 +40,6 @@ import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -48,11 +47,20 @@ import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap /** - * Loads older NIP-04 DMs (kind 4) for the rooms list by `until`+`limit` paging, per relay — the same - * gap-proof model as [com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsHistoryEoseManager], - * but for kind 4 (exact timestamps, no margin) across the account's home + DM relays. Idle until - * [loadMore]; a relay is done on an empty page + EOSE; the whole history is [exhausted] once a round - * advances no relay. + * Loads older NIP-04 DMs (kind 4) for the rooms list by `until`+`limit` paging, **per relay, + * independently** — the same model as the per-conversation loader + * ([com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomNip04HistorySubAssembler]) + * and the gift-wrap history loader + * ([com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsHistoryEoseManager]), + * but account-wide across the home (outbox, *from me*) + DM (inbox, *to me*) relays. + * + * Idle until [loadMore]. A single [loadMore] kicks off every relay that still has older history, and + * from then on each relay drives its own pages off its own cursor, continuing the instant it EOSEs a + * non-empty page ([onEose] → `pager.beginRound([relay])` + `invalidateFilters`; the subscription layer + * diffs per relay, so re-issuing only re-REQs the relay whose cursor moved). A relay is *done* on an + * empty page + EOSE; one that won't answer (auth CLOSE, unreachable, silent) is marked *stalled* but + * kept open. The whole history is [exhausted] once the window settles — every relay done or stalled — + * via the [WindowLoadTracker]'s silence + connect-grace backstops. */ class ChatroomListNip04HistorySubAssembler( client: INostrClient, @@ -60,25 +68,26 @@ class ChatroomListNip04HistorySubAssembler( ) : PerUserEoseManager(client, allKeys) { private val pager = UntilLimitPager() private val started = ConcurrentHashMap.newKeySet() - private val askedRelays = ConcurrentHashMap>() private val accounts = ConcurrentHashMap() - // Shared across accounts (singleton coordinator): repoint the display flows to the active account - // on switch instead of leaking the previous one's exhausted/mark state. Cursors live in [pager]. + // Relays currently not advancing for a user (auth CLOSE / unreachable / silent). Tracked only for the + // logs; these relays are NOT given up — they keep their subscription and keep trying to catch up. + private val stalledRelays = ConcurrentHashMap>() + + // Shared across accounts (singleton coordinator): repoint the display flows to the active account on + // switch instead of leaking the previous one's state. Cursors live in [pager]. @Volatile private var activeUser: HexKey? = null private val exhaustedByUser = ConcurrentHashMap() - // No-progress guard: skip re-issuing an identical round that brought nothing (see the gift-wrap - // history manager's twin); cleared by onEose. - @Volatile - private var lastAskedActive: Set = emptySet() + private val windowLoad = WindowLoadTracker("rooms.nip04.history", tracksReqSends = true, onAbandoned = ::onRelaysStalled) - @Volatile - private var lastRoundEventCount = -1 - - private val windowLoad = WindowLoadTracker("rooms.nip04.history") - val loadingMore: StateFlow = windowLoad.loading + // Exposed instead of windowLoad.loading directly: that flow starts `true` (it assumes a load is in + // flight from construction). Wired straight through, its `true` would wedge the scroll-driven loader + // — whose gate is `!loading` — so the first loadMore could never fire. This starts false and only + // goes true once paging actually begins (mirrored from windowLoad by the done collector). + private val _loadingMore = MutableStateFlow(false) + val loadingMore: StateFlow = _loadingMore.asStateFlow() private val _exhausted = MutableStateFlow(false) val exhausted: StateFlow = _exhausted.asStateFlow() @@ -93,21 +102,30 @@ class ChatroomListNip04HistorySubAssembler( private var scope: CoroutineScope? = null @Volatile - private var roundJob: Job? = null + private var doneJob: Job? = null - // Backoff retry after a no-progress, not-exhausted round (relays failed to answer cleanly rather - // than empty-EOSE'ing), so a transient connect-storm failure recovers instead of stalling forever. + // The user whose window is in flight, read by the done collector when it settles. @Volatile - private var retryJob: Job? = null + private var windowUser: User? = null + // Whether a paging window is currently running. Tracked ourselves rather than read from + // windowLoad.loading (which starts `true` before any window exists), so the first loadMore actually + // starts the window instead of mistaking the construction-time `true` for an in-flight one. @Volatile - private var lastRoundUser: User? = null + private var windowActive = false + // The history floor (live-tail boundary) pinned for the current window. startUntil() is `now − 1w`, + // which drifts forward in real time — if it were recomputed per assembly, an un-advanced relay's + // filter (until = floor) would change every time ANY relay's EOSE triggers invalidateFilters, + // re-REQing relays that haven't moved. Pinning it per window keeps those filters stable so only a + // relay whose cursor genuinely advanced is re-REQed. @Volatile - private var autoLoadAll = false + private var windowFloor = 0L private fun startUntil() = TimeUtils.now() - AccountGiftWrapsEoseManager.LIVE_TAIL_SECONDS + private fun floor() = windowFloor.takeIf { it != 0L } ?: startUntil() + override fun user(key: ChatroomListState) = key.account.userProfile() override fun updateFilter( @@ -115,104 +133,80 @@ class ChatroomListNip04HistorySubAssembler( since: SincePerRelayMap?, ): List? { val user = user(key) - if (!key.account.isWriteable() || user.pubkeyHex !in started) { - windowLoad.setExpectedRelays(emptySet()) - return emptyList() - } + if (!key.account.isWriteable() || user.pubkeyHex !in started) return emptyList() + + // Every relay that still has older history to ask for, each at its own cursor. A relay whose + // cursor advanced since the last assembly re-REQs its next page; one still mid-page keeps its open + // REQ; a done relay drops out (its REQ closes). This is what lets relays run independently. val homeRelays = key.account.homeRelays.flow.value val dmRelays = key.account.dmRelays.flow.value val active = pager.activeRelays(user.pubkeyHex, (homeRelays + dmRelays).toSet()).toSet() - askedRelays[user.pubkeyHex] = active - windowLoad.setExpectedRelays(active) if (active.isEmpty()) return emptyList() DmRelayLog.log("rooms.nip04.history", key.account) Log.d("DMPagination") { "[rooms.nip04.history] REQ ${active.size} relay(s), limit=$PAGE_LIMIT fromMe(outbox)=${homeRelays.filter { it in active }.map { it.url }} toMe(inbox)=${dmRelays.filter { it in active }.map { it.url }}" } return homeRelays.filter { it in active }.map { - filterNip04DMsFromMe(user, it, since = null, until = pager.untilFor(user.pubkeyHex, it, startUntil()), limit = PAGE_LIMIT) + filterNip04DMsFromMe(user, it, since = null, until = pager.untilFor(user.pubkeyHex, it, floor()), limit = PAGE_LIMIT) } + dmRelays.filter { it in active }.map { - filterNip04DMsToMe(user, it, since = null, until = pager.untilFor(user.pubkeyHex, it, startUntil()), limit = PAGE_LIMIT) + filterNip04DMsToMe(user, it, since = null, until = pager.untilFor(user.pubkeyHex, it, floor()), limit = PAGE_LIMIT) } } - /** Requests the next backward page from every relay that still has older NIP-04 history. */ + /** Starts (or resumes) per-relay paging of the NIP-04 history. Idempotent: safe to call again. */ fun loadMore(user: User) { - if (_exhausted.value) return val account = accounts[user.pubkeyHex] ?: return started.add(user.pubkeyHex) val all = (account.homeRelays.flow.value + account.dmRelays.flow.value).toSet() - val active = pager.activeRelays(user.pubkeyHex, all) - if (active.isEmpty()) { + if (all.isEmpty()) return + if (pager.activeRelays(user.pubkeyHex, all).isEmpty()) { + // Everything already paged to the bottom. exhaustedByUser[user.pubkeyHex] = true _exhausted.value = true return } - val activeSet = active.toSet() - if (activeSet == lastAskedActive && lastRoundEventCount == 0) { - Log.d("DMPagination") { "[rooms.nip04.history] loadMore skipped — no progress on the same relays" } - return - } - lastAskedActive = activeSet - pager.beginRound(user.pubkeyHex, active) - lastRoundUser = user - _relayCount.value = active.size - // Over ALL relays (incl. finished ones) so "reached back to X" stays monotonic and doesn't jump - // to a newer date when the deepest relay drops out of the active set. - _reachedBack.value = pager.deepestUntil(user.pubkeyHex, all, startUntil()) - Log.d("DMPagination") { "[rooms.nip04.history] loadMore → ${active.size} active relay(s)" } + _exhausted.value = false + DmRelayLog.log("rooms.nip04.history", account) + windowUser = user scope?.let { - ensureRoundCollector(it) - windowLoad.startLoading(it) + ensureDoneCollector(it) + // One window spanning the whole per-relay pagination: it settles a relay only on that relay's + // empty-EOSE (done) or when it goes silent/stalled, never on a mid-history page, so the + // spinner tracks "is anything still advancing" rather than any single page. Start it only if + // none is running — a re-entrant loadMore (the scroll loader re-firing mid-pagination) must + // not reset the window and forget the relays that already finished. + if (!windowActive) { + windowActive = true + windowFloor = startUntil() + // Populate the relay count BEFORE raising the spinner, so the status card never renders a + // "loading from 0 relays" frame between loadingMore flipping true and the first progress. + updateStatus(user) + _loadingMore.value = true + windowLoad.startLoading(it) + } + windowLoad.setExpectedRelays(all) } + updateStatus(user) + Log.d("DMPagination") { "[rooms.nip04.history] paging ${all.size} relay(s) independently: ${all.map { it.url }}" } invalidateFilters() } - /** Pages to the end: each completed round auto-issues the next until exhausted. */ - fun loadEverything(user: User) { - if (_exhausted.value) return - autoLoadAll = true - loadMore(user) - } - - private fun ensureRoundCollector(scope: CoroutineScope) { - if (roundJob?.isActive == true) return - roundJob = + // Mirrors the window's loading state into [_loadingMore] and, when it settles (every relay done or + // stalled), flips [exhausted] and clears [windowActive] so the next loadMore can start a fresh window. + private fun ensureDoneCollector(scope: CoroutineScope) { + if (doneJob?.isActive == true) return + doneJob = scope.launch { var wasLoading = false windowLoad.loading.collect { loading -> + _loadingMore.value = loading && windowActive if (!loading && wasLoading) { - val user = lastRoundUser - if (user != null) { - val asked = askedRelays[user.pubkeyHex] ?: emptySet() - val count = pager.roundEventCount(user.pubkeyHex, asked) - lastRoundEventCount = count - val account = accounts[user.pubkeyHex] - val allRelays = account?.let { (it.homeRelays.flow.value + it.dmRelays.flow.value).toSet() } ?: emptySet() - // Exhausted ONLY when every relay returned an empty page + EOSE; CLOSED / - // unanswered relays are not finished, so keep loading them. - val exhaustedNow = allRelays.isNotEmpty() && pager.activeRelays(user.pubkeyHex, allRelays).isEmpty() - exhaustedByUser[user.pubkeyHex] = exhaustedNow - _exhausted.value = exhaustedNow - // Over ALL relays (incl. finished ones) so the "reached back" date is monotonic. - _reachedBack.value = pager.deepestUntil(user.pubkeyHex, allRelays, startUntil()) - Log.d("DMPagination") { "[rooms.nip04.history] round done: $count event(s), exhausted=$exhaustedNow" } - if (autoLoadAll && !exhaustedNow) { - loadMore(user) - } else if (!exhaustedNow && count == 0) { - // No progress and not exhausted: relays failed to answer cleanly rather - // than empty-EOSE'ing. Retry after a backoff so a transient failure - // recovers, paced so a rate-limited relay isn't hammered. - retryJob?.cancel() - retryJob = - scope.launch { - delay(NO_PROGRESS_RETRY_MS) - if (!_exhausted.value && !windowLoad.loading.value) { - lastAskedActive = emptySet() - Log.d("DMPagination") { "[rooms.nip04.history] retry after no-progress round" } - loadMore(user) - } - } - } + windowActive = false + _loadingMore.value = false + windowUser?.let { user -> + exhaustedByUser[user.pubkeyHex] = true + if (activeUser == user.pubkeyHex) _exhausted.value = true + updateStatus(user) + logSettleSummary(user) } } wasLoading = loading @@ -220,36 +214,71 @@ class ChatroomListNip04HistorySubAssembler( } } + // WindowLoadTracker reports relays that accepted a REQ then went silent, or never got their REQ out. + // We do NOT give up on them (they may simply be slow and need to catch up) — we just record them as + // stalled for the logs and let them keep their open subscription. + private fun onRelaysStalled(relays: Set) { + started.forEach { pk -> relays.forEach { markStalled(pk, it, "no response (silence/connect timeout)") } } + } + + // Records [relay] as not currently advancing for [pk] and logs it once (the first time it stalls in + // this window). The relay is kept — it kept its subscription and keeps trying to catch up. + private fun markStalled( + pk: HexKey, + relay: NormalizedRelayUrl, + reason: String, + ) { + val firstTime = stalledRelays.getOrPut(pk) { ConcurrentHashMap.newKeySet() }.add(relay) + if (firstTime) Log.d("DMPagination") { "[rooms.nip04.history] ${relay.url} stalled — $reason (kept open, still trying)" } + } + + private fun updateStatus(user: User) { + val account = accounts[user.pubkeyHex] + val all = account?.let { (it.homeRelays.flow.value + it.dmRelays.flow.value).toSet() } ?: emptySet() + // "Asking N relays" on the status card: the ones still being paged (done relays have dropped out). + _relayCount.value = pager.activeRelays(user.pubkeyHex, all).size + // Over ALL relays, not just the still-active ones: a relay that finished keeps its deep cursor, so + // "reached back to X" stays monotonic instead of jumping back to a newer date when the deepest + // relay drops out of the active set. + _reachedBack.value = pager.deepestUntil(user.pubkeyHex, all, floor()) + } + + // A one-line breakdown of where each relay landed when the window settles — the snapshot to reach for + // when history didn't load tomorrow: who reached the bottom vs. who is still being retried. + private fun logSettleSummary(user: User) { + val account = accounts[user.pubkeyHex] ?: return + val all = (account.homeRelays.flow.value + account.dmRelays.flow.value).toSet() + val done = all.filter { pager.isDone(user.pubkeyHex, it) }.map { it.url } + val stillTrying = all.filterNot { pager.isDone(user.pubkeyHex, it) }.map { it.url } + Log.d("DMPagination") { "[rooms.nip04.history] settled — done=$done still-trying=$stillTrying" } + } + override fun newSub(key: ChatroomListState): Subscription { val user = user(key) scope = key.account.scope accounts[user.pubkeyHex] = key.account if (activeUser != user.pubkeyHex) { activeUser = user.pubkeyHex + // Account switched: repoint the shared display flows to this account's own state. _exhausted.value = exhaustedByUser[user.pubkeyHex] ?: false _relayCount.value = 0 _reachedBack.value = null - lastAskedActive = emptySet() - lastRoundEventCount = -1 } return requestNewSubscription(historyListener(user, key)) } - // Flips to exhausted only once every relay has returned an empty page + EOSE. Sets true only. - private fun markExhaustedIfAllDone(user: User) { - val account = accounts[user.pubkeyHex] ?: return - val allRelays = (account.homeRelays.flow.value + account.dmRelays.flow.value).toSet() - if (allRelays.isNotEmpty() && pager.activeRelays(user.pubkeyHex, allRelays).isEmpty()) { - exhaustedByUser[user.pubkeyHex] = true - if (activeUser == user.pubkeyHex) _exhausted.value = true - } - } - private fun historyListener( user: User, key: ChatroomListState, ): SubscriptionListener = object : SubscriptionListener { + override fun onSubscriptionStarted( + relay: String, + forFilters: List, + ) { + windowLoad.onReqSent(relay) + } + override fun onEvent( event: Event, isLive: Boolean, @@ -258,17 +287,27 @@ class ChatroomListNip04HistorySubAssembler( ) { windowLoad.onRelayEvent(relay) pager.onEvent(user.pubkeyHex, relay, event.createdAt) + stalledRelays[user.pubkeyHex]?.remove(relay) } override fun onEose( relay: NormalizedRelayUrl, forFilters: List?, ) { + stalledRelays[user.pubkeyHex]?.remove(relay) pager.onEose(user.pubkeyHex, relay) - windowLoad.onRelaySettled(relay) + if (pager.isDone(user.pubkeyHex, relay)) { + // Reached the bottom on this relay: settle it for the spinner, nothing more to ask. + windowLoad.onRelaySettled(relay) + Log.d("DMPagination") { "[rooms.nip04.history] ${relay.url} reached the bottom (done)" } + } else { + // This page had events: reset only this relay's tally and let it continue to its next + // page immediately, independent of every other relay. + pager.beginRound(user.pubkeyHex, listOf(relay)) + } newEose(key, relay, TimeUtils.now(), forFilters) - lastRoundEventCount = -1 - markExhaustedIfAllDone(user) + updateStatus(user) + invalidateFilters() } override fun onClosed( @@ -276,8 +315,11 @@ class ChatroomListNip04HistorySubAssembler( relay: NormalizedRelayUrl, forFilters: List?, ) { + // A relay may demand auth we can't satisfy and CLOSE. It's stalled, not done — keep its + // subscription so the pool can re-auth and it can catch up — but don't let it hold the + // spinner. windowLoad.onRelaySettled(relay) - if (pager.onClosed(user.pubkeyHex, relay)) markExhaustedIfAllDone(user) + markStalled(user.pubkeyHex, relay, "CLOSED: $message") } override fun onCannotConnect( @@ -285,18 +327,12 @@ class ChatroomListNip04HistorySubAssembler( message: String, forFilters: List?, ) { - // Count cannot-connect toward give-up like a CLOSE, so an unreachable relay doesn't keep - // exhaustion false forever (otherwise a cold, empty feed retries it endlessly and stays on - // the spinner). Once given up, exhaustion completes and the screen resolves. windowLoad.onRelaySettled(relay) - if (pager.onClosed(user.pubkeyHex, relay)) markExhaustedIfAllDone(user) + markStalled(user.pubkeyHex, relay, "cannot connect: $message") } } companion object { private const val PAGE_LIMIT = 10000 - - // Backoff before retrying a no-progress, not-exhausted round (transient relay failure). - private const val NO_PROGRESS_RETRY_MS = 5_000L } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPagerGiveUpTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPagerGiveUpTest.kt deleted file mode 100644 index 595bc4c2fb..0000000000 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPagerGiveUpTest.kt +++ /dev/null @@ -1,69 +0,0 @@ -/* - * 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.amethyst.service.relayClient.eoseManagers - -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class UntilLimitPagerGiveUpTest { - private val mine = NormalizedRelayUrl("wss://vitor.nostr1.com/") - private val silent = NormalizedRelayUrl("wss://relay.ditto.pub/") - private val all = listOf(mine, silent) - - @Test - fun givenUpRelayLeavesTheActiveSet() { - val pager = UntilLimitPager() - - assertEquals(all, pager.activeRelays("k", all)) - - assertTrue("first give-up takes effect", pager.giveUp("k", silent)) - assertEquals(listOf(mine), pager.activeRelays("k", all)) - - assertFalse("giving up twice is a no-op", pager.giveUp("k", silent)) - } - - @Test - fun givingUpEveryRelayExhaustsTheKey() { - val pager = UntilLimitPager() - - // mine pages to empty cleanly; the silent relay never answers and is given up. - pager.beginRound("k", all) - pager.onEose("k", mine) // empty page + EOSE => done - pager.giveUp("k", silent) - - assertTrue("no relay left to ask", pager.activeRelays("k", all).isEmpty()) - } - - @Test - fun aRelayThatAlreadyFinishedIsNotMarkedGivenUp() { - val pager = UntilLimitPager() - - pager.beginRound("k", listOf(mine)) - pager.onEose("k", mine) // done - - // A late silence sweep must not "give up" a relay that already finished cleanly. - assertFalse(pager.giveUp("k", mine)) - assertTrue(pager.isDone("k", mine)) - } -} From 4ce5f6b1a314f79776d1008d95505e0159aa384d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 18:59:03 +0000 Subject: [PATCH 062/103] feat(dm): per-relay reach markers for NIP-17, like NIP-04 The gift-wrap history loader exposed only relayCount + reachedBack, so the conversation's in-stream marker trail showed NIP-04 relays paging back but never NIP-17 ones. Give AccountGiftWrapsHistoryEoseManager the same relayProgress map (per-relay reachedUntil / done / stalled) the per-conversation NIP-04 loader publishes, refreshed on every page, stall, CLOSE, and cannot-connect. Move the shared RelayPagingProgress data class out of the NIP-04 UI-package assembler into service/relayClient/eoseManagers so the gift-wrap manager can produce it without the service layer depending on a UI package. ChatroomView now merges both protocols' progress into the gap markers, each contributing only while it is still paging; a relay that serves both (the DM inbox relays) collapses to one marker. Markers hide once both protocols are exhausted. --- .../eoseManagers/RelayPagingProgress.kt | 33 +++++++++++++++++++ .../AccountGiftWrapsHistoryEoseManager.kt | 24 +++++++++++++- .../loggedIn/chats/privateDM/ChatroomView.kt | 25 ++++++++++---- .../ChatroomNip04HistorySubAssembler.kt | 13 +------- 4 files changed, 76 insertions(+), 19 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/RelayPagingProgress.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/RelayPagingProgress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/RelayPagingProgress.kt new file mode 100644 index 0000000000..ed69b5cf46 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/RelayPagingProgress.kt @@ -0,0 +1,33 @@ +/* + * 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.amethyst.service.relayClient.eoseManagers + +/** How far back one relay has paged a DM history, for the per-relay progress markers. */ +data class RelayPagingProgress( + // The oldest createdAt this relay has loaded down to (its `until` cursor). The marker sits here and + // slides down (older) as the relay pages further back. + val reachedUntil: Long, + // The relay answered an empty page: it has nothing older, it has reached the bottom of its window. + val done: Boolean, + // The relay isn't answering right now (auth-walled CLOSE / unreachable / slow). It is NOT abandoned + // — its subscription stays open and it keeps trying to catch up — but it isn't currently advancing. + val stalled: Boolean, +) 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 9dd5d66bb9..527028c85c 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 @@ -25,6 +25,7 @@ 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.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.RelayPagingProgress import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.UntilLimitPager import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.WindowLoadTracker import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState @@ -115,6 +116,12 @@ class AccountGiftWrapsHistoryEoseManager( private val _reachedBack = MutableStateFlow(null) val reachedBack: StateFlow = _reachedBack.asStateFlow() + // Per-relay paging progress for the active account — the data the in-stream markers render, same as + // the per-conversation NIP-04 loader. Account-wide (gift wraps can't be filtered per room), so a + // marker shows how far back THIS relay has paged the account's gift-wrap history. + private val _relayProgress = MutableStateFlow>(emptyMap()) + val relayProgress: StateFlow> = _relayProgress.asStateFlow() + // Account scope for the done collector. Volatile: written on IO (newSub), read on UI. @Volatile private var scope: CoroutineScope? = null @@ -240,6 +247,7 @@ class AccountGiftWrapsHistoryEoseManager( // stalled for the logs and let them keep their open subscription. private fun onRelaysStalled(relays: Set) { started.forEach { pk -> relays.forEach { markStalled(pk, it, "no response (silence/connect timeout)") } } + windowUser?.let { updateStatus(it) } } // Records [relay] as not currently advancing for [pk] and logs it once (the first time it stalls in @@ -260,7 +268,18 @@ class AccountGiftWrapsHistoryEoseManager( // Over ALL relays, not just the still-active ones: a relay that finished keeps its deep cursor, so // "reached back to X" stays monotonic instead of jumping back to a newer date when the deepest // relay drops out of the active set. - _reachedBack.value = pager.deepestUntil(user.pubkeyHex, relays, floor()) + val start = floor() + _reachedBack.value = pager.deepestUntil(user.pubkeyHex, relays, start) + // Per-relay markers: where each relay's cursor sits and whether it's done / stalled. + val stalled = stalledRelays[user.pubkeyHex] ?: emptySet() + _relayProgress.value = + relays.associateWith { relay -> + RelayPagingProgress( + reachedUntil = pager.untilFor(user.pubkeyHex, relay, start), + done = pager.isDone(user.pubkeyHex, relay), + stalled = relay in stalled && !pager.isDone(user.pubkeyHex, relay), + ) + } } // A one-line breakdown of where each relay landed when the window settles — the snapshot to reach for @@ -282,6 +301,7 @@ class AccountGiftWrapsHistoryEoseManager( _exhausted.value = exhaustedByUser[user.pubkeyHex] ?: false _relayCount.value = 0 _reachedBack.value = null + _relayProgress.value = emptyMap() } return requestNewSubscription(historyListener(user, key)) } @@ -339,6 +359,7 @@ class AccountGiftWrapsHistoryEoseManager( // it hold the spinner. windowLoad.onRelaySettled(relay) markStalled(user.pubkeyHex, relay, "CLOSED: $message") + updateStatus(user) } override fun onCannotConnect( @@ -348,6 +369,7 @@ class AccountGiftWrapsHistoryEoseManager( ) { windowLoad.onRelaySettled(relay) markStalled(user.pubkeyHex, relay, "cannot connect: $message") + updateStatus(user) } } 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 8878f4e4d6..da49f746b4 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,6 +41,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +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 @@ -55,7 +56,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.RelayRea import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.RelayReachState import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal.ChatroomFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssemblerSubscription -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.RelayPagingProgress import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.ChatNewMessageViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.PrivateMessageEditFieldRow import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer @@ -236,6 +236,19 @@ fun ChatroomViewUI( val nip04Relays by nip04History.relayCount.collectAsStateWithLifecycle() val nip04Reached by nip04History.reachedBack.collectAsStateWithLifecycle() val nip04Progress by nip04History.relayProgress.collectAsStateWithLifecycle() + val giftWrapsProgress by giftWrapsHistory.relayProgress.collectAsStateWithLifecycle() + + // Both protocols' per-relay reach in one map for the in-stream markers: each contributes only while + // it's still paging (drops out once that protocol is exhausted). A relay that serves both (the DM + // inbox relays do) collapses to one marker — NIP-04's, since it's the per-conversation reach — which + // is close enough as a "how far back is this relay" cue. + val relayProgress = + remember(nip04Progress, giftWrapsProgress, nip04Exhausted, giftWrapsExhausted) { + buildMap { + if (!giftWrapsExhausted) putAll(giftWrapsProgress) + if (!nip04Exhausted) putAll(nip04Progress) + } + } val nip17Name = stringResource(R.string.chats_history_proto_nip17) val nip04Name = stringResource(R.string.chats_history_proto_nip04) @@ -265,14 +278,14 @@ fun ChatroomViewUI( DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Exhausted, nip04Relays, nip04Reached) } }, - // While NIP-04 is still converging, drop a marker into each gap for every relay whose - // reached-back cursor falls there: it sits below the oldest message that relay has loaded - // and slides down as the relay pages older. Hidden once every relay is done or stalled. + // While either protocol is still converging, drop a marker into each gap for every relay + // whose reached-back cursor falls there: it sits below the oldest message that relay has + // loaded and slides down as the relay pages older. Hidden once both protocols are done. markersInGap = - if (nip04Exhausted) { + if (relayProgress.isEmpty()) { null } else { - { newer, older -> RelayReachMarkersInGap(nip04Progress, newer, older) } + { newer, older -> RelayReachMarkersInGap(relayProgress, newer, older) } }, listStateObserver = { listState -> LoadOlderMessagesWhenScrolling(listState, accountViewModel) 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 f29d7f88ac..e22420f89e 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 @@ -22,6 +22,7 @@ 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.RelayPagingProgress import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.UntilLimitPager import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.WindowLoadTracker import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager @@ -45,18 +46,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap -/** How far back one relay has paged a conversation, for the per-relay progress markers. */ -data class RelayPagingProgress( - // The oldest createdAt this relay has loaded down to (its `until` cursor). The marker sits here and - // slides down (older) as the relay pages further back. - val reachedUntil: Long, - // The relay answered an empty page: it has nothing older, it has reached the bottom of its window. - val done: Boolean, - // The relay isn't answering right now (auth-walled CLOSE / unreachable / slow). It is NOT abandoned - // — its subscription stays open and it keeps trying to catch up — but it isn't currently advancing. - val stalled: Boolean, -) - /** * Loads older NIP-04 DMs (kind 4) for one conversation by `until`+`limit` paging — **per relay, * independently**. There are no lock-step rounds: every relay drives its own pages off its own cursor, From d20b02047b2e1fe9f083f39efa1de3c32b7751b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 20:08:54 +0000 Subject: [PATCH 063/103] feat(dm): demand-driven per-relay history paging via window-limit sentinels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the proactive walk-to-exhaustion with on-demand, per-relay paging driven by on-screen window-limit markers, so history loads only while the user is looking at a relay's frontier and a spam-dense relay never floods. UntilLimitPager: split the cursor into 'requested' (drives the REQ; moves ONLY on advance()) and 'reached' (oldest delivered; for markers). Leaving requested untouched on EOSE is what parks a relay — no auto-continuation. PerRelayLoadTracker (new): tracks which relays have a page in flight (for the spinner) with a silence watchdog to park relays that accept a REQ then go quiet. Replaces the window/round bookkeeping for the history loaders. All three history loaders (gift-wrap account-wide, rooms-list NIP-04, conversation NIP-04) rewritten: onEose parks instead of self-continuing; advance(relay) steps one relay one page; advanceAll() bootstraps the empty/initial boundary; exhausted = every relay done or stalled. UI: RelayWindowLimitMarkers renders each relay's marker at its reached depth AND acts as its load sentinel — while the marker is composed (on/near screen) it pulls that relay's next page (LaunchedEffect keyed on the reached cursor, so it keeps going page after page while visible) and stops when the marker scrolls off or the page fills the screen. Wired into both the conversation (gap markers) and the rooms list (interleaved between rooms); a BootstrapHistoryWhenEmpty drives advanceAll while the feed has nothing to scroll. Removes the round/give-up machinery's last users: drop loadMore/ loadEverything and the scroll-driven WidenHistoryWhen. --- .../eoseManagers/PerRelayLoadTracker.kt | 136 ++++++++ .../eoseManagers/UntilLimitPager.kt | 112 +++++-- .../AccountGiftWrapsHistoryEoseManager.kt | 297 +++++++----------- .../loggedIn/chats/feed/LoadingReplyNote.kt | 6 +- .../chats/feed/layouts/RelayReachMarker.kt | 58 ++++ .../loggedIn/chats/privateDM/ChatroomView.kt | 157 ++++----- .../ChatroomNip04HistorySubAssembler.kt | 253 ++++++--------- .../ChatroomListNip04HistorySubAssembler.kt | 273 ++++++---------- .../chats/rooms/feed/ChatroomListFeedView.kt | 171 ++++------ 9 files changed, 694 insertions(+), 769 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerRelayLoadTracker.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerRelayLoadTracker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerRelayLoadTracker.kt new file mode 100644 index 0000000000..824b107af7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerRelayLoadTracker.kt @@ -0,0 +1,136 @@ +/* + * 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.amethyst.service.relayClient.eoseManagers + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap + +/** + * Tracks which relays currently have a demand-driven history page **in flight**, so the loading card + * can show a spinner while any relay is fetching and clear it the moment they've all answered (or + * parked). Unlike [WindowLoadTracker] this has no notion of a "window" or "round" — relays are advanced + * one page at a time, independently, by their on-screen markers, so completion is simply "nothing in + * flight." + * + * A single backstop covers a relay that accepts a REQ and then goes silent (auth-walled / dead): if + * nothing has been heard from ANY in-flight relay for [silenceMs], the still-pending relays are dropped + * from the in-flight set (so the spinner clears) and reported to [onSilenced] so the owner can flag them + * stalled. Relays that answer with CLOSED / cannot-connect are settled directly by the owner and don't + * need the watchdog. + */ +class PerRelayLoadTracker( + private val name: String, + private val silenceMs: Long = 15_000L, + private val onSilenced: (Set) -> Unit = {}, +) { + private val _loading = MutableStateFlow(false) + val loading: StateFlow = _loading.asStateFlow() + + private val inFlight = ConcurrentHashMap.newKeySet() + + @Volatile + private var lastActivityMs = 0L + + @Volatile + private var watchdog: Job? = null + + @Volatile + private var scope: CoroutineScope? = null + + fun bind(scope: CoroutineScope) { + this.scope = scope + } + + fun isInFlight(relay: NormalizedRelayUrl) = inFlight.contains(relay) + + fun count() = inFlight.size + + /** A relay's next page was just requested. Raises the spinner and (re)arms the silence watchdog. */ + @Synchronized + fun onAdvance(relay: NormalizedRelayUrl) { + inFlight.add(relay) + lastActivityMs = System.currentTimeMillis() + _loading.value = true + ensureWatchdog() + } + + /** A sign of life from a relay (an event). Keeps the silence watchdog from firing. */ + fun onActivity() { + lastActivityMs = System.currentTimeMillis() + } + + /** A relay answered (EOSE / CLOSED / cannot-connect). Drops it from in-flight; clears the spinner if last. */ + @Synchronized + fun onSettled(relay: NormalizedRelayUrl) { + lastActivityMs = System.currentTimeMillis() + if (inFlight.remove(relay) && inFlight.isEmpty()) _loading.value = false + } + + /** Drops everything (e.g. account/conversation switched). */ + @Synchronized + fun reset() { + inFlight.clear() + _loading.value = false + watchdog?.cancel() + watchdog = null + } + + private fun ensureWatchdog() { + if (watchdog?.isActive == true) return + val s = scope ?: return + watchdog = + s.launch { + while (isActive) { + delay(WATCHDOG_TICK_MS) + val silenced = + synchronized(this@PerRelayLoadTracker) { + if (inFlight.isNotEmpty() && System.currentTimeMillis() - lastActivityMs > silenceMs) { + val pending = inFlight.toSet() + inFlight.clear() + _loading.value = false + pending + } else { + emptySet() + } + } + if (silenced.isNotEmpty()) { + Log.d(TAG) { "[$name] silenced (no response ${silenceMs}ms): ${silenced.map { it.url }}" } + onSilenced(silenced) + } + if (inFlight.isEmpty()) break + } + } + } + + companion object { + private const val TAG = "DMPagination" + private const val WATCHDOG_TICK_MS = 1_000L + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt index 3eb54055b1..1f31ec79f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt @@ -25,36 +25,45 @@ import java.util.concurrent.ConcurrentHashMap /** * Backward `until`+`limit` pagination cursor, tracked **independently per relay** (and per [K], e.g. - * per account or per conversation). + * per account or per conversation), and advanced **on demand** — one page at a time, only when the + * owner calls [advance]. * * The time-window model can't tell "this relay is empty" from "this is a gap" — a `since`/`until` * slice that returns nothing might just be a quiet stretch with older messages beneath it. Paging by * `until`+`limit` removes that ambiguity: a relay returns its N newest events older than `until`, * **skipping gaps**, so an empty page can only mean there is nothing older. * + * Two cursors are kept per relay, deliberately decoupled so a relay never pages further than it was + * asked to: + * - [requestedUntilFor] — the `until` the relay's REQ currently carries. Moves **only** in [advance]. + * Leaving it untouched on EOSE is what makes paging demand-driven: a relay that finished a page just + * parks at the same filter (no re-REQ) until the owner advances it again. + * - reached (see [reachedUntilFor]) — the oldest `created_at` the relay has actually delivered. Moves + * on EOSE. This is what the in-stream markers sit at; [advance] starts the next page just below it. + * * Stop signal (per relay): an **empty page followed by EOSE** ([onEose] with no events) marks that * relay [done][isDone]. A relay that returns anything — even fewer than the requested limit, since a - * relay may cap results below what we asked — is *not* done; its cursor advances to one second below - * the oldest event it sent and the owner asks it again (typically right away, per relay). Relays a - * relay can't be read from (CLOSED / unreachable / silent) are the owner's concern, not the cursor's: - * the owner flags them stalled and lets its [WindowLoadTracker] settle the load so they can't block - * exhaustion forever. + * relay may cap results below what we asked — is not done. * * Not internally synchronized: per-relay counters are touched on the relay IO threads (one relay's - * callbacks are serialized) and read on the owning scope after the load settles; fields are volatile. + * callbacks are serialized) and read on the owning scope; fields are volatile. */ class UntilLimitPager { private class RelayCursor { - // The `until` for this relay's next page; null until the first page (caller supplies the start). - @Volatile var until: Long? = null + // The `until` the REQ carries; null until the relay is first advanced. Moves only in advance(). + @Volatile var requestedUntil: Long? = null + + // The oldest created_at this relay has delivered; null until its first non-empty page. Moves on + // EOSE. The marker sits here and the next page starts just below it. + @Volatile var reachedUntil: Long? = null // Set once the relay answered an empty page with EOSE: there is nothing older on it. @Volatile var done: Boolean = false - // Per-round tallies, reset by [beginRound]: how many events arrived and the oldest among them. - @Volatile var roundCount: Int = 0 + // Per-page tallies, reset by [advance]: how many events arrived and the oldest among them. + @Volatile var pageCount: Int = 0 - @Volatile var roundOldest: Long = Long.MAX_VALUE + @Volatile var pageOldest: Long = Long.MAX_VALUE } private val perKey = ConcurrentHashMap>() @@ -66,12 +75,24 @@ class UntilLimitPager { relay: NormalizedRelayUrl, ) = cursorsFor(key).getOrPut(relay) { RelayCursor() } - /** The `until` to request next from [relay], or [start] if it has not been paged yet. */ - fun untilFor( + /** True once [relay] has been [advance]d at least once (so its REQ should be issued). */ + fun isArmed( + key: K, + relay: NormalizedRelayUrl, + ): Boolean = cursor(key, relay).requestedUntil != null + + /** The `until` [relay]'s REQ currently carries. Only meaningful once [isArmed]. */ + fun requestedUntilFor( + key: K, + relay: NormalizedRelayUrl, + ): Long? = cursor(key, relay).requestedUntil + + /** The oldest point [relay] has reached (its marker depth), or [start] if it hasn't delivered yet. */ + fun reachedUntilFor( key: K, relay: NormalizedRelayUrl, start: Long, - ): Long = cursor(key, relay).until ?: start + ): Long = cursor(key, relay).reachedUntil ?: start /** True once [relay] answered an empty page with EOSE — nothing older to ask it for. */ fun isDone( @@ -79,41 +100,54 @@ class UntilLimitPager { relay: NormalizedRelayUrl, ): Boolean = cursor(key, relay).done - /** Resets the per-round tallies for the relays a fresh round is about to request. */ - fun beginRound( + /** + * Steps [relay] to its next, older page: points its REQ just below the oldest event it has delivered + * (or [start] for its very first page) and clears the page tally. No-op (returns false) if the relay + * has already paged to the bottom ([done]). The owner re-issues the REQ after this (invalidateFilters). + */ + fun advance( key: K, - relays: Collection, - ) = relays.forEach { - val c = cursor(key, it) - c.roundCount = 0 - c.roundOldest = Long.MAX_VALUE + relay: NormalizedRelayUrl, + start: Long, + ): Boolean { + val c = cursor(key, relay) + if (c.done) return false + c.requestedUntil = + if (c.requestedUntil == null) { + start + } else { + (c.reachedUntil ?: start) - 1 + } + c.pageCount = 0 + c.pageOldest = Long.MAX_VALUE + return true } - /** Records one event for [relay] in the current round. */ + /** Records one event for [relay] in the current page. */ fun onEvent( key: K, relay: NormalizedRelayUrl, createdAt: Long, ) { val c = cursor(key, relay) - c.roundCount++ - if (createdAt < c.roundOldest) c.roundOldest = createdAt + c.pageCount++ + if (createdAt < c.pageOldest) c.pageOldest = createdAt } /** - * Finalizes [relay] for the round on its EOSE: an empty page marks it [done]; otherwise its cursor - * advances to just below the oldest event it returned (exclusive, so the next page makes progress - * and the relay can eventually reach an empty page). + * Finalizes [relay] for the page on its EOSE: an empty page marks it [done]; otherwise the reached + * cursor drops to the oldest event the page returned. The requested cursor is left alone so the relay + * parks until [advance] is called again. */ fun onEose( key: K, relay: NormalizedRelayUrl, ) { val c = cursor(key, relay) - if (c.roundCount == 0) { + if (c.pageCount == 0) { c.done = true } else { - c.until = c.roundOldest - 1 + c.reachedUntil = c.pageOldest } } @@ -123,13 +157,23 @@ class UntilLimitPager { all: Collection, ): List = all.filterNot { cursor(key, it).done } + /** Relays from [all] that have been armed (advanced at least once) and are not yet [done]. */ + fun armedRelays( + key: K, + all: Collection, + ): List = + all.filter { + val c = cursor(key, it) + c.requestedUntil != null && !c.done + } + /** - * The oldest point reached across [relays] — the minimum cursor (how far back paging has gone). - * Relays not yet paged count as [start]. Null when [relays] is empty. + * The oldest point reached across [relays] — the minimum reached cursor (how far back paging has + * gone). Relays that haven't delivered count as [start]. Null when [relays] is empty. */ - fun deepestUntil( + fun deepestReached( key: K, relays: Collection, start: Long, - ): Long? = relays.takeIf { it.isNotEmpty() }?.minOf { cursor(key, it).until ?: start } + ): Long? = relays.takeIf { it.isNotEmpty() }?.minOf { cursor(key, it).reachedUntil ?: start } } 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 527028c85c..53a1d0410d 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,10 +24,10 @@ 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.eoseManagers.WindowLoadTracker import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.Event @@ -40,32 +40,24 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter 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.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap /** * Loads the account's NIP-17 gift-wrap **history** — everything older than the one-week live tail - * ([AccountGiftWrapsEoseManager]) — by **`until`+`limit` paging, per relay, independently**. + * ([AccountGiftWrapsEoseManager]) — by **`until`+`limit` paging, per relay, on demand**. * - * There are no lock-step rounds: a single [loadMore] kicks off every relay that still has older - * history, and from then on each relay drives its own pages off its own cursor, continuing the instant - * it EOSEs a non-empty page ([onEose] → `pager.beginRound([relay])` + `invalidateFilters`; the - * subscription layer diffs per relay, so re-issuing only re-REQs the relay whose cursor moved). Fast - * relays race to the bottom of the history in back-to-back pages while slow / auth-walled relays catch - * up at their own pace in the background — none holds the others back. This mirrors the per-conversation - * NIP-04 loader ([com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomNip04HistorySubAssembler]). + * There is no proactive walk: each relay advances exactly one page when the UI calls [advance] for it, + * and then **parks** at its window limit. The on-screen window-limit markers are the drivers — a relay + * pages only while its marker is visible, and keeps paging (page after page) as long as it stays visible + * (see the rooms-list / conversation feed views). So a spam-dense relay never floods: the user has to + * scroll through its messages to pull more, and nothing is fetched while its marker is off screen. * - * A relay is *done* once it answers an empty page (nothing older). A relay that won't answer (auth - * CLOSE, unreachable, silent) is marked *stalled* for the logs but keeps its subscription open and keeps - * trying. The [loadingMore] spinner reflects whether anything is still actively advancing across one - * window spanning the whole walk; it clears — and [exhausted] flips — once every relay is either done or - * stalled (the [WindowLoadTracker] settles silent / unreachable relays via its silence + connect-grace - * backstops), without waiting on the slow ones beyond that. + * A relay is *done* once it answers an empty page; one that won't answer (auth CLOSE, unreachable, or + * silent past the load tracker's window) is flagged *stalled* but kept. [exhausted] flips once every + * relay is either done or stalled — nothing more is reachable right now. */ class AccountGiftWrapsHistoryEoseManager( client: INostrClient, @@ -73,85 +65,44 @@ class AccountGiftWrapsHistoryEoseManager( ) : PerUserEoseManager(client, allKeys) { override fun user(key: AccountQueryState) = key.account.userProfile() - // Per-relay cursors, keyed by account pubkey so switching accounts preserves each one's progress. + // Per-relay demand-driven cursors, keyed by account pubkey so switching accounts preserves progress. private val pager = UntilLimitPager() - // Users that have requested history at least once (else the manager stays idle, issuing no REQ). - private val started = ConcurrentHashMap.newKeySet() - - // The account behind each user pubkey, captured on subscribe so [loadMore] (UI thread) can read the - // DM relay list without the key. + // The account behind each user pubkey, captured on subscribe so the UI-thread API can read the DM + // relay list without the key. private val accounts = ConcurrentHashMap() - // Relays currently not advancing for a user (auth CLOSE / unreachable / silent). Tracked only for the - // logs; these relays are NOT given up — they keep their subscription and keep trying to catch up. + // Relays not currently advancing for a user (auth CLOSE / unreachable / silent). Kept (not given up) + // and surfaced as stalled in the markers; they resume if the user re-advances them. private val stalledRelays = ConcurrentHashMap>() - // This manager is shared across logged-in accounts (one singleton coordinator), so the single - // display flows below must follow whichever account is currently active. Per-account paging cursors - // live in [pager], so switching away and back preserves progress; [exhaustedByUser] lets the display - // flow repoint accurately on switch instead of leaking the previous account's state. + // Shared across accounts (singleton coordinator): repoint the display flows to the active account on + // switch instead of leaking the previous one's state. Cursors live in [pager]. @Volatile private var activeUser: HexKey? = null private val exhaustedByUser = ConcurrentHashMap() - private val windowLoad = WindowLoadTracker("giftwrap.history", tracksReqSends = true, onAbandoned = ::onRelaysStalled) + private val loadTracker = PerRelayLoadTracker("giftwrap.history", onSilenced = ::onRelaysSilenced) + val loadingMore: StateFlow = loadTracker.loading - // Exposed instead of windowLoad.loading directly: that flow starts `true` (it assumes a load is in - // flight from construction). Wired straight through, its `true` would wedge the scroll-driven loader - // — whose gate is `!loading` — so the first loadMore could never fire. This starts false and only - // goes true once paging actually begins (mirrored from windowLoad by the done collector). - private val _loadingMore = MutableStateFlow(false) - val loadingMore: StateFlow = _loadingMore.asStateFlow() - - // True once the window settles (every relay done or stalled) — nothing more is reachable right now. private val _exhausted = MutableStateFlow(false) val exhausted: StateFlow = _exhausted.asStateFlow() - // Status surfaced to the loading card: how many relays are still being paged, and the oldest point - // paging has reached (epoch seconds, the deepest cursor). + // Relays currently fetching a page (for the "asking N relays" status line). private val _relayCount = MutableStateFlow(0) val relayCount: StateFlow = _relayCount.asStateFlow() private val _reachedBack = MutableStateFlow(null) val reachedBack: StateFlow = _reachedBack.asStateFlow() - // Per-relay paging progress for the active account — the data the in-stream markers render, same as - // the per-conversation NIP-04 loader. Account-wide (gift wraps can't be filtered per room), so a - // marker shows how far back THIS relay has paged the account's gift-wrap history. + // Per-relay window limits — where each relay has paged to, done/stalled — the data the on-screen + // markers render and drive their advance from. private val _relayProgress = MutableStateFlow>(emptyMap()) val relayProgress: StateFlow> = _relayProgress.asStateFlow() - // Account scope for the done collector. Volatile: written on IO (newSub), read on UI. - @Volatile - private var scope: CoroutineScope? = null - - @Volatile - private var doneJob: Job? = null - - // The user whose window is in flight, read by the done collector when it settles. - @Volatile - private var windowUser: User? = null - - // Whether a paging window is currently running. Tracked ourselves rather than read from - // windowLoad.loading (which starts `true` before any window exists), so the first loadMore actually - // starts the window instead of mistaking the construction-time `true` for an in-flight one. - @Volatile - private var windowActive = false - - // The history floor (live-tail boundary) pinned for the current window. startUntil() is `now − 1w`, - // which drifts forward in real time — if it were recomputed per assembly, an un-advanced relay's - // filter (until = floor) would change every time ANY relay's EOSE triggers invalidateFilters, - // re-REQing relays that haven't moved. Pinning it per window keeps those filters stable so only a - // relay whose cursor genuinely advanced is re-REQed. - @Volatile - private var windowFloor = 0L - // History starts just below the live tail's one-week floor and pages backward from there. private fun startUntil() = TimeUtils.now() - AccountGiftWrapsEoseManager.LIVE_TAIL_SECONDS - private fun floor() = windowFloor.takeIf { it != 0L } ?: startUntil() - private fun daysAgo(epochSeconds: Long) = (TimeUtils.now() - epochSeconds) / TimeUtils.ONE_DAY override fun updateFilter( @@ -159,150 +110,124 @@ class AccountGiftWrapsHistoryEoseManager( since: SincePerRelayMap?, ): List { val user = user(key) - if (!key.account.isWriteable() || user.pubkeyHex !in started) return emptyList() - - // Every relay that still has older history to ask for, each at its own cursor. A relay whose - // cursor advanced since the last assembly re-REQs its next page; one still mid-page keeps its open - // REQ; a done relay drops out (its REQ closes). This is what lets relays run independently. + if (!key.account.isWriteable()) return emptyList() + // Only relays that have been advanced (armed) and aren't done carry a REQ. A relay that finished a + // page keeps the same `until` here, so re-assembly (triggered when ANOTHER relay advances) doesn't + // re-REQ it — it stays parked until the UI advances it again. val relays = key.account.dmRelays.flow.value - val active = pager.activeRelays(user.pubkeyHex, relays).toSet() - if (active.isEmpty()) return emptyList() + val armed = pager.armedRelays(user.pubkeyHex, relays) + if (armed.isEmpty()) return emptyList() DmRelayLog.log("giftwrap.history", key.account) - Log.d(TAG) { "[giftwrap.history] REQ ${active.size} relay(s) ${active.map { it.url }}, limit=$PAGE_LIMIT (until ${daysAgo(pager.untilFor(user.pubkeyHex, active.first(), floor()))}d…)" } - return active.flatMap { relay -> - filterGiftWrapsToPubkey( - relay = relay, - pubkey = user.pubkeyHex, - since = null, - until = pager.untilFor(user.pubkeyHex, relay, floor()), - limit = PAGE_LIMIT, - ) + return armed.flatMap { relay -> + val until = pager.requestedUntilFor(user.pubkeyHex, relay) ?: return@flatMap emptyList() + Log.d(TAG) { "[giftwrap.history] REQ ${relay.url} until ${daysAgo(until)}d, limit=$PAGE_LIMIT" } + filterGiftWrapsToPubkey(relay = relay, pubkey = user.pubkeyHex, since = null, until = until, limit = PAGE_LIMIT) } } - /** Starts (or resumes) per-relay paging of the gift-wrap history. Idempotent: safe to call again. */ - fun loadMore(user: User) { + /** Steps a single [relay] to its next, older page. Driven by that relay's on-screen window-limit marker. */ + fun advance( + user: User, + relay: NormalizedRelayUrl, + ) { + if (arm(user, relay)) { + _exhausted.value = false + updateStatus(user) + invalidateFilters() + } + } + + /** Steps every not-done, not-in-flight relay one page. For the empty/initial boundary (nothing to scroll). */ + fun advanceAll(user: User) { val account = accounts[user.pubkeyHex] ?: return - started.add(user.pubkeyHex) - val allRelays = account.dmRelays.flow.value - if (allRelays.isEmpty()) return - if (pager.activeRelays(user.pubkeyHex, allRelays).isEmpty()) { - // Everything already paged to the bottom. - exhaustedByUser[user.pubkeyHex] = true - _exhausted.value = true - return + var any = false + account.dmRelays.flow.value + .forEach { if (arm(user, it)) any = true } + if (any) { + _exhausted.value = false + updateStatus(user) + invalidateFilters() } - _exhausted.value = false - DmRelayLog.log("giftwrap.history", account) - windowUser = user - scope?.let { - ensureDoneCollector(it) - // One window spanning the whole per-relay pagination: it settles a relay only on that relay's - // empty-EOSE (done) or when it goes silent/stalled, never on a mid-history page, so the - // spinner tracks "is anything still advancing" rather than any single page. Start it only if - // none is running — a re-entrant loadMore (the scroll loader re-firing mid-pagination) must - // not reset the window and forget the relays that already finished. - if (!windowActive) { - windowActive = true - windowFloor = startUntil() - // Populate the relay count BEFORE raising the spinner, so the status card never renders a - // "loading from 0 relays" frame between loadingMore flipping true and the first progress. - updateStatus(user) - _loadingMore.value = true - windowLoad.startLoading(it) - } - windowLoad.setExpectedRelays(allRelays.toSet()) + } + + // 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 invalidate — the caller batches that. + private fun arm( + user: User, + relay: NormalizedRelayUrl, + ): Boolean { + val account = accounts[user.pubkeyHex] ?: return false + if (relay !in account.dmRelays.flow.value) return false + if (loadTracker.isInFlight(relay)) return false + if (!pager.advance(user.pubkeyHex, relay, startUntil())) return false + stalledRelays[user.pubkeyHex]?.remove(relay) + loadTracker.bind(account.scope) + loadTracker.onAdvance(relay) + return true + } + + private fun onRelaysSilenced(relays: Set) { + val pk = activeUser ?: return + relays.forEach { markStalled(pk, it, "no response (silence timeout)") } + accounts[pk]?.userProfile()?.let { + updateStatus(it) + recomputeExhausted(it) } - updateStatus(user) - Log.d(TAG) { "[giftwrap.history] paging ${allRelays.size} relay(s) independently: ${allRelays.map { it.url }}" } - invalidateFilters() } - // Mirrors the window's loading state into [_loadingMore] and, when it settles (every relay done or - // stalled), flips [exhausted] and clears [windowActive] so the next loadMore can start a fresh window. - private fun ensureDoneCollector(scope: CoroutineScope) { - if (doneJob?.isActive == true) return - doneJob = - scope.launch { - var wasLoading = false - windowLoad.loading.collect { loading -> - _loadingMore.value = loading && windowActive - if (!loading && wasLoading) { - windowActive = false - _loadingMore.value = false - windowUser?.let { user -> - exhaustedByUser[user.pubkeyHex] = true - if (activeUser == user.pubkeyHex) _exhausted.value = true - updateStatus(user) - logSettleSummary(user) - } - } - wasLoading = loading - } - } - } - - // WindowLoadTracker reports relays that accepted a REQ then went silent, or never got their REQ out. - // We do NOT give up on them (they may simply be slow and need to catch up) — we just record them as - // stalled for the logs and let them keep their open subscription. - private fun onRelaysStalled(relays: Set) { - started.forEach { pk -> relays.forEach { markStalled(pk, it, "no response (silence/connect timeout)") } } - windowUser?.let { updateStatus(it) } - } - - // Records [relay] as not currently advancing for [pk] and logs it once (the first time it stalls in - // this window). The relay is kept — it kept its subscription and keeps trying to catch up. private fun markStalled( pk: HexKey, relay: NormalizedRelayUrl, reason: String, ) { val firstTime = stalledRelays.getOrPut(pk) { ConcurrentHashMap.newKeySet() }.add(relay) - if (firstTime) Log.d(TAG) { "[giftwrap.history] ${relay.url} stalled — $reason (kept open, still trying)" } + if (firstTime) Log.d(TAG) { "[giftwrap.history] ${relay.url} stalled — $reason (kept, advance to retry)" } } private fun updateStatus(user: User) { val relays = accounts[user.pubkeyHex]?.dmRelays?.flow?.value ?: emptySet() - // "Asking N relays" on the status card: the ones still being paged (done relays have dropped out). - _relayCount.value = pager.activeRelays(user.pubkeyHex, relays).size - // Over ALL relays, not just the still-active ones: a relay that finished keeps its deep cursor, so - // "reached back to X" stays monotonic instead of jumping back to a newer date when the deepest - // relay drops out of the active set. - val start = floor() - _reachedBack.value = pager.deepestUntil(user.pubkeyHex, relays, start) - // Per-relay markers: where each relay's cursor sits and whether it's done / stalled. + _relayCount.value = loadTracker.count() + val start = startUntil() + _reachedBack.value = pager.deepestReached(user.pubkeyHex, relays, start) val stalled = stalledRelays[user.pubkeyHex] ?: emptySet() _relayProgress.value = relays.associateWith { relay -> RelayPagingProgress( - reachedUntil = pager.untilFor(user.pubkeyHex, relay, start), + reachedUntil = pager.reachedUntilFor(user.pubkeyHex, relay, start), done = pager.isDone(user.pubkeyHex, relay), stalled = relay in stalled && !pager.isDone(user.pubkeyHex, relay), ) } } - // A one-line breakdown of where each relay landed when the window settles — the snapshot to reach for - // when history didn't load tomorrow: who reached the bottom vs. who is still being retried. - private fun logSettleSummary(user: User) { + // 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(user: User) { val relays = accounts[user.pubkeyHex]?.dmRelays?.flow?.value ?: return - val done = relays.filter { pager.isDone(user.pubkeyHex, it) }.map { it.url } - val stillTrying = relays.filterNot { pager.isDone(user.pubkeyHex, it) }.map { it.url } - Log.d(TAG) { "[giftwrap.history] settled — done=$done still-trying=$stillTrying" } + if (relays.isEmpty()) return + val stalled = stalledRelays[user.pubkeyHex] ?: emptySet() + val pending = relays.any { !pager.isDone(user.pubkeyHex, it) && it !in stalled } + val ex = !pending + exhaustedByUser[user.pubkeyHex] = ex + if (activeUser == user.pubkeyHex) _exhausted.value = ex } override fun newSub(key: AccountQueryState): Subscription { val user = user(key) - scope = key.account.scope accounts[user.pubkeyHex] = key.account + loadTracker.bind(key.account.scope) if (activeUser != user.pubkeyHex) { activeUser = user.pubkeyHex // Account switched: repoint the shared display flows to this account's own state. + loadTracker.reset() _exhausted.value = exhaustedByUser[user.pubkeyHex] ?: false _relayCount.value = 0 _reachedBack.value = null _relayProgress.value = emptyMap() } + // Populate the per-relay markers (all relays at the floor, not done) so the UI can render their + // window-limit sentinels and pull the first page when they come into view. + updateStatus(user) return requestNewSubscription(historyListener(user, key)) } @@ -311,20 +236,13 @@ class AccountGiftWrapsHistoryEoseManager( key: AccountQueryState, ): SubscriptionListener = object : SubscriptionListener { - override fun onSubscriptionStarted( - relay: String, - forFilters: List, - ) { - windowLoad.onReqSent(relay) - } - override fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, forFilters: List?, ) { - windowLoad.onRelayEvent(relay) + loadTracker.onActivity() pager.onEvent(user.pubkeyHex, relay, event.createdAt) stalledRelays[user.pubkeyHex]?.remove(relay) } @@ -335,18 +253,14 @@ class AccountGiftWrapsHistoryEoseManager( ) { stalledRelays[user.pubkeyHex]?.remove(relay) pager.onEose(user.pubkeyHex, relay) + loadTracker.onSettled(relay) if (pager.isDone(user.pubkeyHex, relay)) { - // Reached the bottom on this relay: settle it for the spinner, nothing more to ask. - windowLoad.onRelaySettled(relay) Log.d(TAG) { "[giftwrap.history] ${relay.url} reached the bottom (done)" } - } else { - // This page had events: reset only this relay's tally and let it continue to its next - // page immediately, independent of every other relay. - pager.beginRound(user.pubkeyHex, listOf(relay)) } + // No auto-advance: the relay parks here until its marker asks for the next page. newEose(key, relay, TimeUtils.now(), forFilters) updateStatus(user) - invalidateFilters() + recomputeExhausted(user) } override fun onClosed( @@ -354,12 +268,10 @@ class AccountGiftWrapsHistoryEoseManager( relay: NormalizedRelayUrl, forFilters: List?, ) { - // A relay (e.g. an author's) may demand auth we can't satisfy and CLOSE. It's stalled, not - // done — keep its subscription so the pool can re-auth and it can catch up — but don't let - // it hold the spinner. - windowLoad.onRelaySettled(relay) + loadTracker.onSettled(relay) markStalled(user.pubkeyHex, relay, "CLOSED: $message") updateStatus(user) + recomputeExhausted(user) } override fun onCannotConnect( @@ -367,16 +279,17 @@ class AccountGiftWrapsHistoryEoseManager( message: String, forFilters: List?, ) { - windowLoad.onRelaySettled(relay) + loadTracker.onSettled(relay) markStalled(user.pubkeyHex, relay, "cannot connect: $message") updateStatus(user) + recomputeExhausted(user) } } companion object { private const val TAG = "DMPagination" - // Asked of every relay per page. Large on purpose: we want a whole band in one round where the + // Asked of every relay per page. Large on purpose: we want a whole band in one page where the // relay allows it. A relay returning fewer is treated as its own cap, NOT as "nothing more" — // only an empty page + EOSE ends a relay. private const val PAGE_LIMIT = 10000 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 bce658db6e..325cc2d558 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 @@ -127,10 +127,10 @@ fun LoadingReplyNote( .distinctUntilChanged() .filter { it } .collect { - Log.d("DMPagination") { "reply blank: widen → $protocol loadMore (searching for unloaded reply)" } + Log.d("DMPagination") { "reply blank: widen → $protocol advanceAll (searching for unloaded reply)" } when (protocol) { - DmReplyProtocol.NIP17 -> giftWrapsHistory.loadMore(accountViewModel.userProfile()) - DmReplyProtocol.NIP04 -> nip04History.loadMore() + DmReplyProtocol.NIP17 -> giftWrapsHistory.advanceAll(accountViewModel.userProfile()) + DmReplyProtocol.NIP04 -> nip04History.advanceAll() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt index 0556ab521d..2538b994e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt @@ -26,6 +26,9 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -56,6 +59,61 @@ data class RelayReach( val state: RelayReachState, ) +/** + * One relay's window-limit, used to both place a marker and act as the load sentinel for that relay. + * The marker sits at [reachedUntil] (the oldest point the relay has paged to). [advance] pulls that + * relay's next, older page; the renderer fires it while the marker is on screen (see + * [RelayWindowLimitMarkers]). + * + * @param key stable identity (protocol tag + relay url) so the sentinel survives list reorders. + */ +data class RelayWindowLimit( + val key: String, + val name: String, + val reachedUntil: Long, + val state: RelayReachState, + val advance: () -> Unit, +) + +/** + * Renders the window-limit markers for the relays whose limit falls in the gap between a newer message + * (at [newerCreatedAt]) and its next-older neighbour (at [olderCreatedAt], null at the oldest end), and + * — this is the load driver — makes each one a **sentinel**: while this gap is composed (i.e. on/near + * screen), it pulls that relay's next page, and keeps pulling as each page lands ([LaunchedEffect] keyed + * on the relay's reached cursor) for as long as the marker stays visible. When a page fills enough to + * push the marker off screen, or the user scrolls away, the gap is disposed and paging stops on its own. + * A done relay just shows its ✓ and drives nothing. + */ +@Composable +fun RelayWindowLimitMarkers( + limits: List, + newerCreatedAt: Long?, + olderCreatedAt: Long?, +) { + val here = + remember(limits, newerCreatedAt, olderCreatedAt) { + limits.filter { lim -> + newerCreatedAt != null && + newerCreatedAt > lim.reachedUntil && + (olderCreatedAt == null || olderCreatedAt <= lim.reachedUntil) + } + } + if (here.isEmpty()) return + + here.forEach { lim -> + if (lim.state != RelayReachState.DONE) { + // Keyed identity so the effect isn't torn down on reorder; keyed on the reached cursor so each + // returned page re-fires it (continue while visible). A stalled relay re-fires only on + // re-composition (scroll back into view) — a single retry, not a busy loop. + key(lim.key) { + LaunchedEffect(lim.reachedUntil, lim.state) { lim.advance() } + } + } + } + + RelayReachMarker(here.map { RelayReach(it.name, it.state) }) +} + /** * A thin divider drawn between two messages marking the point one or more relays have paged down to. * As a relay loads older history its reached cursor drops, so the caller places this marker further 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 da49f746b4..5f36dab968 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 @@ -24,7 +24,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -33,7 +32,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -41,6 +39,8 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle 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 @@ -51,9 +51,9 @@ import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMsAndDisp import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.DmHistoryLoadingCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.RelayReach -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.RelayReachMarker import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.RelayReachState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.RelayWindowLimit +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.RelayWindowLimitMarkers import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal.ChatroomFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.ChatNewMessageViewModel @@ -153,59 +153,37 @@ fun ChatroomView( ) } -// Rows from the oldest loaded message at which to prefetch the next, older window. -private const val PREFETCH_OLDER_MESSAGES = 3 - /** - * Scroll-driven history loader for a conversation. The thread is reverse-laid-out (newest at the - * bottom, index 0), so older messages (and the load-more boundary) live at the highest indices. It - * loads the next, older page whenever the oldest end is in view — including a thread too short to - * scroll, so sitting at the start of a one-message chat keeps walking history back to its real - * beginning (or until both protocols are exhausted). Each step is a bounded `until`+`limit` page that - * never re-downloads, so walking a short thread is cheap per step — gift wraps can't be filtered per - * room, so this advances the shared account-wide history window and the conversation's messages - * surface as its pages are decrypted. - * - * Each protocol advances via its own history manager's `loadMore`, gated only on ITS OWN loader/ - * exhausted state — so a slow protocol (e.g. NIP-04 waiting on a sluggish correspondent relay) never - * holds back the other. NIP-04 then pages every relay independently to completion on its own; gift - * wraps stay round-driven and re-step here as the oldest end stays in view. + * Bootstraps history when the conversation has no messages yet (the live tail came back empty for a + * thread whose newest message is older than a week). There's nothing on screen to host the per-relay + * window-limit markers that normally drive paging, so while the feed is empty we step every relay one + * page at a time (each protocol independently, gated on its own loader) until messages appear — at which + * point the on-screen markers take over — or the protocol is exhausted. Once the feed is Loaded this + * does nothing; paging is then purely demand-driven by the markers' visibility. */ @Composable -private fun LoadOlderMessagesWhenScrolling( - listState: LazyListState, +private fun BootstrapHistoryWhenEmpty( + feedContentState: FeedContentState, accountViewModel: AccountViewModel, ) { val giftWrapsHistory = remember(accountViewModel) { accountViewModel.dataSources().account.giftWrapsHistory } val nip04History = remember(accountViewModel) { accountViewModel.dataSources().chatroom.nip04History } + val feedState by feedContentState.feedContent.collectAsStateWithLifecycle() + val needsBootstrap = feedState is FeedState.Empty || feedState is FeedState.Loading - LaunchedEffect(listState, giftWrapsHistory, nip04History) { - val wantMore = - snapshotFlow { - val info = listState.layoutInfo - val total = info.totalItemsCount - val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1 - // The oldest end is in view (no overflow requirement, so a one-message thread that - // can't scroll still qualifies and walks history to its start). - total > 0 && lastVisible >= total - PREFETCH_OLDER_MESSAGES - }.distinctUntilChanged() - - launch { - combine(wantMore, giftWrapsHistory.loadingMore, giftWrapsHistory.exhausted) { want, loading, exhausted -> - want && !loading && !exhausted - }.distinctUntilChanged().filter { it }.collect { - Log.d("DMPagination") { "convo: widen (oldest in view) → giftwrap loadMore" } - giftWrapsHistory.loadMore(accountViewModel.userProfile()) - } - } - launch { - combine(wantMore, nip04History.loadingMore, nip04History.exhausted) { want, loading, exhausted -> - want && !loading && !exhausted - }.distinctUntilChanged().filter { it }.collect { - Log.d("DMPagination") { "convo: widen (oldest in view) → nip04 loadMore" } - nip04History.loadMore() - } - } + LaunchedEffect(needsBootstrap, giftWrapsHistory) { + if (!needsBootstrap) return@LaunchedEffect + combine(giftWrapsHistory.loadingMore, giftWrapsHistory.exhausted) { loading, exhausted -> !loading && !exhausted } + .distinctUntilChanged() + .filter { it } + .collect { giftWrapsHistory.advanceAll(accountViewModel.userProfile()) } + } + LaunchedEffect(needsBootstrap, nip04History) { + if (!needsBootstrap) return@LaunchedEffect + combine(nip04History.loadingMore, nip04History.exhausted) { loading, exhausted -> !loading && !exhausted } + .distinctUntilChanged() + .filter { it } + .collect { nip04History.advanceAll() } } } @@ -237,21 +215,31 @@ fun ChatroomViewUI( val nip04Reached by nip04History.reachedBack.collectAsStateWithLifecycle() val nip04Progress by nip04History.relayProgress.collectAsStateWithLifecycle() val giftWrapsProgress by giftWrapsHistory.relayProgress.collectAsStateWithLifecycle() + val user = accountViewModel.userProfile() - // Both protocols' per-relay reach in one map for the in-stream markers: each contributes only while - // it's still paging (drops out once that protocol is exhausted). A relay that serves both (the DM - // inbox relays do) collapses to one marker — NIP-04's, since it's the per-conversation reach — which - // is close enough as a "how far back is this relay" cue. - val relayProgress = - remember(nip04Progress, giftWrapsProgress, nip04Exhausted, giftWrapsExhausted) { - buildMap { - if (!giftWrapsExhausted) putAll(giftWrapsProgress) - if (!nip04Exhausted) putAll(nip04Progress) + // Both protocols' per-relay window limits, each carrying the advance() that pulls its own next page. + // Placed in the stream as sentinels (see RelayWindowLimitMarkers): a relay pages only while its + // marker is on screen, and keeps paging while it stays there. A protocol drops out once exhausted. + val limits = + remember(nip04Progress, giftWrapsProgress, nip04Exhausted, giftWrapsExhausted, user) { + buildList { + if (!giftWrapsExhausted) { + giftWrapsProgress.forEach { (relay, p) -> + add(RelayWindowLimit("17:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p)) { giftWrapsHistory.advance(user, relay) }) + } + } + if (!nip04Exhausted) { + nip04Progress.forEach { (relay, p) -> + add(RelayWindowLimit("04:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p)) { nip04History.advance(relay) }) + } + } } } val nip17Name = stringResource(R.string.chats_history_proto_nip17) val nip04Name = stringResource(R.string.chats_history_proto_nip04) + BootstrapHistoryWhenEmpty(feedViewModel.feedState, accountViewModel) + Column(Modifier.fillMaxHeight()) { ObserveRelayListForDMsAndDisplayIfNotFound(accountViewModel, nav) @@ -278,18 +266,15 @@ fun ChatroomViewUI( DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Exhausted, nip04Relays, nip04Reached) } }, - // While either protocol is still converging, drop a marker into each gap for every relay - // whose reached-back cursor falls there: it sits below the oldest message that relay has - // loaded and slides down as the relay pages older. Hidden once both protocols are done. + // Each relay's window-limit marker, placed at its reached cursor, doubles as the load + // sentinel that pulls that relay's next page while it's on screen (see + // RelayWindowLimitMarkers). Hidden once both protocols are exhausted. markersInGap = - if (relayProgress.isEmpty()) { + if (limits.isEmpty()) { null } else { - { newer, older -> RelayReachMarkersInGap(relayProgress, newer, older) } + { newer, older -> RelayWindowLimitMarkers(limits, newer, older) } }, - listStateObserver = { listState -> - LoadOlderMessagesWhenScrolling(listState, accountViewModel) - }, ) } @@ -311,40 +296,12 @@ fun ChatroomViewUI( } } -/** - * Renders the NIP-04 paging markers that belong between a message (at [newerCreatedAt]) and its - * next-older neighbour (at [olderCreatedAt], null at the oldest end): every relay whose reached-back - * cursor falls in `(olderCreatedAt, newerCreatedAt]`. A relay sits below the oldest message it has - * loaded, so as it pages older its cursor drops and the marker moves down the stream toward the others. - */ -@Composable -private fun RelayReachMarkersInGap( - progress: Map, - newerCreatedAt: Long?, - olderCreatedAt: Long?, -) { - val here = - remember(progress, newerCreatedAt, olderCreatedAt) { - progress.mapNotNull { (relay, p) -> - val reached = p.reachedUntil - val belongsHere = newerCreatedAt != null && newerCreatedAt > reached && (olderCreatedAt == null || olderCreatedAt <= reached) - if (!belongsHere) { - null - } else { - RelayReach( - name = relayShortName(relay), - state = - when { - p.done -> RelayReachState.DONE - p.stalled -> RelayReachState.STALLED - else -> RelayReachState.REACHING - }, - ) - } - } - } - RelayReachMarker(here) -} +private fun reachState(p: RelayPagingProgress): RelayReachState = + when { + p.done -> RelayReachState.DONE + p.stalled -> RelayReachState.STALLED + else -> RelayReachState.REACHING + } private fun relayShortName(relay: NormalizedRelayUrl): String = relay.url 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 e22420f89e..642618b3f5 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,10 +21,10 @@ 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.eoseManagers.WindowLoadTracker import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.Event @@ -38,34 +38,27 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap /** - * Loads older NIP-04 DMs (kind 4) for one conversation by `until`+`limit` paging — **per relay, - * independently**. There are no lock-step rounds: every relay drives its own pages off its own cursor, - * continuing the instant it EOSEs (the subscription layer diffs per relay, so re-issuing only re-REQs - * the relay whose cursor moved; the others' in-flight REQs are untouched). Fast relays race to the - * bottom of the conversation in a few back-to-back pages while slow / auth-walled relays catch up at - * their own pace in the background — none are abandoned, so they all converge on the same window. + * Loads older NIP-04 DMs (kind 4) for one conversation by **`until`+`limit` paging, per relay, on + * demand**. Each relay advances exactly one page when the conversation's on-screen window-limit marker + * for that relay asks ([advance]); otherwise it parks. Nothing is walked proactively — a relay pages + * only while its marker is visible and keeps paging while it stays visible. * - * A relay is *done* once it answers an empty page (nothing older). A relay that won't answer (auth - * CLOSE, unreachable, silent) is marked *stalled* for the markers but keeps its subscription open and - * keeps trying. The [loadingMore] spinner reflects whether anything is still actively advancing; it - * clears once every relay is either done or stalled, without waiting on the slow ones beyond that. + * A relay is *done* once it answers an empty page; one that won't answer (auth CLOSE, unreachable, or + * silent past the load tracker's window) is flagged *stalled* but kept. [exhausted] flips once every + * relay is either done or stalled. */ class ChatroomNip04HistorySubAssembler( client: INostrClient, allKeys: () -> Set, ) : PerUserAndFollowListEoseManager(client, allKeys) { // Keyed by (account, conversation) so each thread paginates independently — and so the same - // correspondent opened from two logged-in accounts doesn't share a cursor. ChatroomKey is a data - // class over the participant set, so it's a collision-free key (unlike its 32-bit hashCode/listId). + // correspondent opened from two logged-in accounts doesn't share a cursor. private data class ConvoKey( val account: HexKey, val room: ChatroomKey, @@ -74,20 +67,11 @@ class ChatroomNip04HistorySubAssembler( private fun convoKey(key: ChatroomQueryState) = ConvoKey(user(key).pubkeyHex, key.room) private val pager = UntilLimitPager() - private val started = ConcurrentHashMap.newKeySet() - // Relays currently not advancing for a conversation (auth CLOSE / unreachable / silent). Tracked for - // the progress markers; these relays are NOT given up — they keep their subscription and keep trying. private val stalledRelays = ConcurrentHashMap>() - private val windowLoad = WindowLoadTracker("convo.nip04.history", tracksReqSends = true, onAbandoned = ::onRelaysStalled) - - // Exposed instead of windowLoad.loading directly: that flow starts `true` (it assumes a load is in - // flight from construction). Wired straight through, its `true` would wedge the scroll-driven - // loader — whose gate is `!loading` — so the first loadMore could never fire. This starts false and - // only goes true once paging actually begins (mirrored from windowLoad by the done collector). - private val _loadingMore = MutableStateFlow(false) - val loadingMore: StateFlow = _loadingMore.asStateFlow() + private val loadTracker = PerRelayLoadTracker("convo.nip04.history", onSilenced = ::onRelaysSilenced) + val loadingMore: StateFlow = loadTracker.loading private val _exhausted = MutableStateFlow(false) val exhausted: StateFlow = _exhausted.asStateFlow() @@ -98,212 +82,157 @@ class ChatroomNip04HistorySubAssembler( private val _reachedBack = MutableStateFlow(null) val reachedBack: StateFlow = _reachedBack.asStateFlow() - // Per-relay paging progress for the conversation on screen — the data the in-stream markers render. private val _relayProgress = MutableStateFlow>(emptyMap()) val relayProgress: StateFlow> = _relayProgress.asStateFlow() // Shared across accounts/conversations (singleton coordinator): repoint the display flows to the - // conversation now on screen instead of leaking the previous one's state. Cursors live in [pager]. + // conversation now on screen. Cursors live in [pager]. @Volatile private var activeConvo: ConvoKey? = null private val exhaustedByConvo = ConcurrentHashMap() - @Volatile - private var scope: CoroutineScope? = null - - @Volatile - private var doneJob: Job? = null - - // Whether a paging window is currently running. We track it ourselves rather than reading - // windowLoad.loading (which starts `true` before any window exists), so the first loadMore actually - // starts the window instead of mistaking the construction-time `true` for an in-flight one. - @Volatile - private var windowActive = false - - // The history floor (live-tail boundary) pinned for the current window. startUntil() is `now − 1w`, - // which drifts forward in real time — if it were recomputed per assembly, an un-advanced relay's - // filter (until = floor) would change every time ANY relay's EOSE triggers invalidateFilters, - // re-REQing relays that haven't moved. Pinning it per window keeps those filters stable so only a - // relay whose cursor genuinely advanced is re-REQed. - @Volatile - private var windowFloor = 0L - private fun startUntil() = TimeUtils.now() - AccountGiftWrapsEoseManager.LIVE_TAIL_SECONDS - private fun floor() = windowFloor.takeIf { it != 0L } ?: startUntil() - override fun user(key: ChatroomQueryState) = key.account.userProfile() override fun list(key: ChatroomQueryState) = key.listId + private fun relaysFor(pk: ConvoKey): Nip04DmRelays? = allKeys().firstOrNull { convoKey(it) == pk }?.let { nip04DMRelays(it.room.users, it.account) } + override fun updateFilter( key: ChatroomQueryState, since: SincePerRelayMap?, ): List? { val pk = convoKey(key) val relays = nip04DMRelays(key.room.users, key.account) - if (!key.account.isWriteable() || pk !in started || relays == null) return emptyList() - - // Every relay that still has older history to ask for, each at its own cursor. A relay whose - // cursor advanced since the last assembly re-REQs its next page; one still mid-page keeps its - // open REQ; a done relay drops out (its REQ closes). This is what lets relays run independently. - val active = pager.activeRelays(pk, relays.all).toSet() - if (active.isEmpty()) return emptyList() + if (!key.account.isWriteable() || relays == null) return emptyList() + // Only armed (advanced, not done) relays carry a REQ, each at its own requested cursor. A parked + // relay keeps the same filter here, so re-assembly (another relay advancing) doesn't re-REQ it. + val armed = pager.armedRelays(pk, relays.all).toSet() + if (armed.isEmpty()) return emptyList() + DmRelayLog.log("convo.nip04.history", key.account) val scoped = Nip04DmRelays( - toMeRelays = relays.toMeRelays.filterKeys { it in active }, - fromMeRelays = relays.fromMeRelays.filterKeys { it in active }, + toMeRelays = relays.toMeRelays.filterKeys { it in armed }, + fromMeRelays = relays.fromMeRelays.filterKeys { it in armed }, ) return filterNip04DMsHistory(key.account, scoped, PAGE_LIMIT) { relay -> - pager.untilFor(pk, relay, floor()) + pager.requestedUntilFor(pk, relay) } } - /** Starts (or resumes) per-relay paging for every open conversation. Idempotent: safe to call again. */ - fun loadMore() { - val fullRelays = mutableSetOf() - var anyActive = false + /** Steps a single [relay] to its next, older page for the open conversation(s). Driven by its marker. */ + fun advance(relay: NormalizedRelayUrl) { + var any = false + allKeys().forEach { if (arm(it, relay)) any = true } + if (any) { + _exhausted.value = false + updateStatus() + invalidateFilters() + } + } + + /** Steps every not-done, not-in-flight relay one page. For a thread too short to scroll. */ + fun advanceAll() { + var any = false allKeys().forEach { key -> val relays = nip04DMRelays(key.room.users, key.account) ?: return@forEach - started.add(convoKey(key)) - fullRelays.addAll(relays.all) - if (pager.activeRelays(convoKey(key), relays.all).isNotEmpty()) anyActive = true - DmRelayLog.log("convo.nip04.history", key.account) + relays.all.forEach { if (arm(key, it)) any = true } } - if (fullRelays.isEmpty()) return - if (!anyActive) { - // Everything already paged to the bottom. - activeConvo?.let { exhaustedByConvo[it] = true } - _exhausted.value = true - return + if (any) { + _exhausted.value = false + updateStatus() + invalidateFilters() } - _exhausted.value = false - scope?.let { - ensureDoneCollector(it) - // One window spanning the whole per-relay pagination: it settles a relay only on that relay's - // empty-EOSE (done) or when it goes silent/stalled, never on a mid-history page, so the - // spinner tracks "is anything still advancing" rather than any single round. Start it only if - // none is running — a re-entrant loadMore (the scroll loader re-firing mid-pagination) must - // not reset the window and forget the relays that already finished. - if (!windowActive) { - windowActive = true - windowFloor = startUntil() - // Populate the relay count BEFORE raising the spinner, so the status card never renders - // a "loading from 0 relays" frame between loadingMore flipping true and the first progress. - publishProgress() - _loadingMore.value = true - windowLoad.startLoading(it) - } - windowLoad.setExpectedRelays(fullRelays) - } - publishProgress() - Log.d("DMPagination") { "[convo.nip04.history] paging ${fullRelays.size} relay(s) independently: ${fullRelays.map { it.url }}" } - invalidateFilters() } - // Mirrors the window's loading state into [_loadingMore] and, when it settles (every relay done or - // stalled), flips [exhausted] and clears [windowActive] so the next loadMore can start a fresh window. - private fun ensureDoneCollector(scope: CoroutineScope) { - if (doneJob?.isActive == true) return - doneJob = - scope.launch { - var wasLoading = false - windowLoad.loading.collect { loading -> - _loadingMore.value = loading && windowActive - if (!loading && wasLoading) { - windowActive = false - _loadingMore.value = false - activeConvo?.let { exhaustedByConvo[it] = true } - _exhausted.value = true - publishProgress() - logSettleSummary() - } - wasLoading = loading - } - } + private fun arm( + key: ChatroomQueryState, + relay: NormalizedRelayUrl, + ): Boolean { + val relays = nip04DMRelays(key.room.users, key.account) ?: return false + if (relay !in relays.all) return false + val pk = convoKey(key) + if (loadTracker.isInFlight(relay)) return false + if (!pager.advance(pk, relay, startUntil())) return false + stalledRelays[pk]?.remove(relay) + loadTracker.bind(key.account.scope) + loadTracker.onAdvance(relay) + return true } - // WindowLoadTracker reports relays that accepted a REQ then went silent, or never got their REQ out. - // We do NOT give up on them (they may simply be slow and need to catch up) — we just record them as - // stalled for the markers and let them keep their open subscription. - private fun onRelaysStalled(relays: Set) { - started.forEach { pk -> relays.forEach { markStalled(pk, it, "no response (silence/connect timeout)") } } - publishProgress() + private fun onRelaysSilenced(relays: Set) { + val pk = activeConvo ?: return + relays.forEach { markStalled(pk, it, "no response (silence timeout)") } + updateStatus() + recomputeExhausted() } - // Records [relay] as not currently advancing for [pk] and logs it once (the first time it stalls in - // this window). The relay is kept — it kept its subscription and keeps trying to catch up. private fun markStalled( pk: ConvoKey, relay: NormalizedRelayUrl, reason: String, ) { val firstTime = stalledRelays.getOrPut(pk) { ConcurrentHashMap.newKeySet() }.add(relay) - if (firstTime) Log.d("DMPagination") { "[convo.nip04.history] ${relay.url} stalled — $reason (kept open, still trying)" } + if (firstTime) Log.d("DMPagination") { "[convo.nip04.history] ${relay.url} stalled — $reason (kept, advance to retry)" } } - private fun relaysFor(pk: ConvoKey): Nip04DmRelays? = allKeys().firstOrNull { convoKey(it) == pk }?.let { nip04DMRelays(it.room.users, it.account) } - - private fun publishProgress() { + private fun updateStatus() { val pk = activeConvo ?: return val relays = relaysFor(pk) ?: return + _relayCount.value = loadTracker.count() + val start = startUntil() + _reachedBack.value = pager.deepestReached(pk, relays.all, start) val stalled = stalledRelays[pk] ?: emptySet() - val start = floor() _relayProgress.value = relays.all.associateWith { relay -> RelayPagingProgress( - reachedUntil = pager.untilFor(pk, relay, start), + reachedUntil = pager.reachedUntilFor(pk, relay, start), done = pager.isDone(pk, relay), stalled = relay in stalled && !pager.isDone(pk, relay), ) } - // "Asking N relays" on the status card: the ones still being paged (done relays have dropped out). - _relayCount.value = pager.activeRelays(pk, relays.all).size - _reachedBack.value = pager.deepestUntil(pk, relays.all, start) } - // A one-line breakdown of where each relay landed when the window settles — the snapshot to reach for - // when a conversation didn't load tomorrow: who reached the bottom vs. who is still being retried. - private fun logSettleSummary() { + private fun recomputeExhausted() { val pk = activeConvo ?: return val relays = relaysFor(pk) ?: return - val done = relays.all.filter { pager.isDone(pk, it) }.map { it.url } - val stillTrying = relays.all.filterNot { pager.isDone(pk, it) }.map { it.url } - Log.d("DMPagination") { "[convo.nip04.history] settled — done=$done still-trying=$stillTrying" } + if (relays.all.isEmpty()) return + val stalled = stalledRelays[pk] ?: emptySet() + val pending = relays.all.any { !pager.isDone(pk, it) && it !in stalled } + val ex = !pending + exhaustedByConvo[pk] = ex + if (activeConvo == pk) _exhausted.value = ex } override fun newSub(key: ChatroomQueryState): Subscription { - scope = key.account.scope val pk = convoKey(key) + loadTracker.bind(key.account.scope) if (activeConvo != pk) { activeConvo = pk - // A different conversation (or account) is on screen: repoint the display flows to it. + loadTracker.reset() _exhausted.value = exhaustedByConvo[pk] ?: false _relayCount.value = 0 _reachedBack.value = null _relayProgress.value = emptyMap() } + // Populate the per-relay markers (all relays at the floor, not done) so the UI can render their + // window-limit sentinels and pull the first page when they come into view. + updateStatus() return requestNewSubscription(historyListener(key)) } private fun historyListener(key: ChatroomQueryState): SubscriptionListener { val pk = convoKey(key) return object : SubscriptionListener { - override fun onSubscriptionStarted( - relay: String, - forFilters: List, - ) { - windowLoad.onReqSent(relay) - } - override fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, forFilters: List?, ) { - windowLoad.onRelayEvent(relay) + loadTracker.onActivity() pager.onEvent(pk, relay, event.createdAt) stalledRelays[pk]?.remove(relay) } @@ -314,18 +243,13 @@ class ChatroomNip04HistorySubAssembler( ) { stalledRelays[pk]?.remove(relay) pager.onEose(pk, relay) + loadTracker.onSettled(relay) if (pager.isDone(pk, relay)) { - // Reached the bottom on this relay: settle it for the spinner, nothing more to ask. - windowLoad.onRelaySettled(relay) Log.d("DMPagination") { "[convo.nip04.history] ${relay.url} reached the bottom (done)" } - } else { - // This page had events: reset only this relay's tally and let it continue to its - // next page immediately, independent of every other relay. - pager.beginRound(pk, listOf(relay)) } newEose(key, relay, TimeUtils.now(), forFilters) - publishProgress() - invalidateFilters() + updateStatus() + recomputeExhausted() } override fun onClosed( @@ -333,12 +257,10 @@ class ChatroomNip04HistorySubAssembler( relay: NormalizedRelayUrl, forFilters: List?, ) { - // A relay (e.g. the correspondent's) may demand auth we can't satisfy and CLOSE. It's - // stalled, not done — keep its subscription so the pool can re-auth and it can catch up — - // but don't let it hold the spinner. - windowLoad.onRelaySettled(relay) + loadTracker.onSettled(relay) markStalled(pk, relay, "CLOSED: $message") - publishProgress() + updateStatus() + recomputeExhausted() } override fun onCannotConnect( @@ -346,9 +268,10 @@ class ChatroomNip04HistorySubAssembler( message: String, forFilters: List?, ) { - windowLoad.onRelaySettled(relay) + loadTracker.onSettled(relay) markStalled(pk, relay, "cannot connect: $message") - publishProgress() + updateStatus() + recomputeExhausted() } } } 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 608f90286b..eeb934dfc8 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,9 +23,10 @@ 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.eoseManagers.WindowLoadTracker import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.Event @@ -38,56 +39,33 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter 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.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap /** - * Loads older NIP-04 DMs (kind 4) for the rooms list by `until`+`limit` paging, **per relay, - * independently** — the same model as the per-conversation loader - * ([com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomNip04HistorySubAssembler]) - * and the gift-wrap history loader + * Loads older NIP-04 DMs (kind 4) for the rooms list by **`until`+`limit` paging, per relay, on + * demand** — the same model as the gift-wrap history loader * ([com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsHistoryEoseManager]), - * but account-wide across the home (outbox, *from me*) + DM (inbox, *to me*) relays. - * - * Idle until [loadMore]. A single [loadMore] kicks off every relay that still has older history, and - * from then on each relay drives its own pages off its own cursor, continuing the instant it EOSEs a - * non-empty page ([onEose] → `pager.beginRound([relay])` + `invalidateFilters`; the subscription layer - * diffs per relay, so re-issuing only re-REQs the relay whose cursor moved). A relay is *done* on an - * empty page + EOSE; one that won't answer (auth CLOSE, unreachable, silent) is marked *stalled* but - * kept open. The whole history is [exhausted] once the window settles — every relay done or stalled — - * via the [WindowLoadTracker]'s silence + connect-grace backstops. + * across the account's home (outbox, *from me*) + DM (inbox, *to me*) relays. Each relay advances one + * page when its on-screen window-limit marker asks ([advance]); otherwise it parks. Nothing is walked + * proactively. */ class ChatroomListNip04HistorySubAssembler( client: INostrClient, allKeys: () -> Set, ) : PerUserEoseManager(client, allKeys) { private val pager = UntilLimitPager() - private val started = ConcurrentHashMap.newKeySet() private val accounts = ConcurrentHashMap() - - // Relays currently not advancing for a user (auth CLOSE / unreachable / silent). Tracked only for the - // logs; these relays are NOT given up — they keep their subscription and keep trying to catch up. private val stalledRelays = ConcurrentHashMap>() - // Shared across accounts (singleton coordinator): repoint the display flows to the active account on - // switch instead of leaking the previous one's state. Cursors live in [pager]. @Volatile private var activeUser: HexKey? = null private val exhaustedByUser = ConcurrentHashMap() - private val windowLoad = WindowLoadTracker("rooms.nip04.history", tracksReqSends = true, onAbandoned = ::onRelaysStalled) - - // Exposed instead of windowLoad.loading directly: that flow starts `true` (it assumes a load is in - // flight from construction). Wired straight through, its `true` would wedge the scroll-driven loader - // — whose gate is `!loading` — so the first loadMore could never fire. This starts false and only - // goes true once paging actually begins (mirrored from windowLoad by the done collector). - private val _loadingMore = MutableStateFlow(false) - val loadingMore: StateFlow = _loadingMore.asStateFlow() + private val loadTracker = PerRelayLoadTracker("rooms.nip04.history", onSilenced = ::onRelaysSilenced) + val loadingMore: StateFlow = loadTracker.loading private val _exhausted = MutableStateFlow(false) val exhausted: StateFlow = _exhausted.asStateFlow() @@ -98,172 +76,134 @@ class ChatroomListNip04HistorySubAssembler( private val _reachedBack = MutableStateFlow(null) val reachedBack: StateFlow = _reachedBack.asStateFlow() - @Volatile - private var scope: CoroutineScope? = null - - @Volatile - private var doneJob: Job? = null - - // The user whose window is in flight, read by the done collector when it settles. - @Volatile - private var windowUser: User? = null - - // Whether a paging window is currently running. Tracked ourselves rather than read from - // windowLoad.loading (which starts `true` before any window exists), so the first loadMore actually - // starts the window instead of mistaking the construction-time `true` for an in-flight one. - @Volatile - private var windowActive = false - - // The history floor (live-tail boundary) pinned for the current window. startUntil() is `now − 1w`, - // which drifts forward in real time — if it were recomputed per assembly, an un-advanced relay's - // filter (until = floor) would change every time ANY relay's EOSE triggers invalidateFilters, - // re-REQing relays that haven't moved. Pinning it per window keeps those filters stable so only a - // relay whose cursor genuinely advanced is re-REQed. - @Volatile - private var windowFloor = 0L + private val _relayProgress = MutableStateFlow>(emptyMap()) + val relayProgress: StateFlow> = _relayProgress.asStateFlow() private fun startUntil() = TimeUtils.now() - AccountGiftWrapsEoseManager.LIVE_TAIL_SECONDS - private fun floor() = windowFloor.takeIf { it != 0L } ?: startUntil() - override fun user(key: ChatroomListState) = key.account.userProfile() + private fun allRelays(account: Account) = (account.homeRelays.flow.value + account.dmRelays.flow.value).toSet() + override fun updateFilter( key: ChatroomListState, since: SincePerRelayMap?, ): List? { val user = user(key) - if (!key.account.isWriteable() || user.pubkeyHex !in started) return emptyList() - - // Every relay that still has older history to ask for, each at its own cursor. A relay whose - // cursor advanced since the last assembly re-REQs its next page; one still mid-page keeps its open - // REQ; a done relay drops out (its REQ closes). This is what lets relays run independently. + if (!key.account.isWriteable()) return emptyList() val homeRelays = key.account.homeRelays.flow.value val dmRelays = key.account.dmRelays.flow.value - val active = pager.activeRelays(user.pubkeyHex, (homeRelays + dmRelays).toSet()).toSet() - if (active.isEmpty()) return emptyList() + val armed = pager.armedRelays(user.pubkeyHex, (homeRelays + dmRelays).toSet()) + if (armed.isEmpty()) return emptyList() DmRelayLog.log("rooms.nip04.history", key.account) - Log.d("DMPagination") { "[rooms.nip04.history] REQ ${active.size} relay(s), limit=$PAGE_LIMIT fromMe(outbox)=${homeRelays.filter { it in active }.map { it.url }} toMe(inbox)=${dmRelays.filter { it in active }.map { it.url }}" } - return homeRelays.filter { it in active }.map { - filterNip04DMsFromMe(user, it, since = null, until = pager.untilFor(user.pubkeyHex, it, floor()), limit = PAGE_LIMIT) - } + - dmRelays.filter { it in active }.map { - filterNip04DMsToMe(user, it, since = null, until = pager.untilFor(user.pubkeyHex, it, floor()), limit = PAGE_LIMIT) + return armed.flatMap { relay -> + val until = pager.requestedUntilFor(user.pubkeyHex, relay) ?: return@flatMap emptyList() + buildList { + if (relay in homeRelays) add(filterNip04DMsFromMe(user, relay, since = null, until = until, limit = PAGE_LIMIT)) + if (relay in dmRelays) add(filterNip04DMsToMe(user, relay, since = null, until = until, limit = PAGE_LIMIT)) } + } } - /** Starts (or resumes) per-relay paging of the NIP-04 history. Idempotent: safe to call again. */ - fun loadMore(user: User) { + /** Steps a single [relay] to its next, older page. Driven by that relay's on-screen window-limit marker. */ + fun advance( + user: User, + relay: NormalizedRelayUrl, + ) { + if (arm(user, relay)) { + _exhausted.value = false + updateStatus(user) + invalidateFilters() + } + } + + /** Steps every not-done, not-in-flight relay one page. For the empty/initial boundary (nothing to scroll). */ + fun advanceAll(user: User) { val account = accounts[user.pubkeyHex] ?: return - started.add(user.pubkeyHex) - val all = (account.homeRelays.flow.value + account.dmRelays.flow.value).toSet() - if (all.isEmpty()) return - if (pager.activeRelays(user.pubkeyHex, all).isEmpty()) { - // Everything already paged to the bottom. - exhaustedByUser[user.pubkeyHex] = true - _exhausted.value = true - return + var any = false + allRelays(account).forEach { if (arm(user, it)) any = true } + if (any) { + _exhausted.value = false + updateStatus(user) + invalidateFilters() } - _exhausted.value = false - DmRelayLog.log("rooms.nip04.history", account) - windowUser = user - scope?.let { - ensureDoneCollector(it) - // One window spanning the whole per-relay pagination: it settles a relay only on that relay's - // empty-EOSE (done) or when it goes silent/stalled, never on a mid-history page, so the - // spinner tracks "is anything still advancing" rather than any single page. Start it only if - // none is running — a re-entrant loadMore (the scroll loader re-firing mid-pagination) must - // not reset the window and forget the relays that already finished. - if (!windowActive) { - windowActive = true - windowFloor = startUntil() - // Populate the relay count BEFORE raising the spinner, so the status card never renders a - // "loading from 0 relays" frame between loadingMore flipping true and the first progress. - updateStatus(user) - _loadingMore.value = true - windowLoad.startLoading(it) - } - windowLoad.setExpectedRelays(all) + } + + private fun arm( + user: User, + relay: NormalizedRelayUrl, + ): Boolean { + val account = accounts[user.pubkeyHex] ?: return false + if (relay !in allRelays(account)) return false + if (loadTracker.isInFlight(relay)) return false + if (!pager.advance(user.pubkeyHex, relay, startUntil())) return false + stalledRelays[user.pubkeyHex]?.remove(relay) + loadTracker.bind(account.scope) + loadTracker.onAdvance(relay) + return true + } + + private fun onRelaysSilenced(relays: Set) { + val pk = activeUser ?: return + relays.forEach { markStalled(pk, it, "no response (silence timeout)") } + accounts[pk]?.userProfile()?.let { + updateStatus(it) + recomputeExhausted(it) } - updateStatus(user) - Log.d("DMPagination") { "[rooms.nip04.history] paging ${all.size} relay(s) independently: ${all.map { it.url }}" } - invalidateFilters() } - // Mirrors the window's loading state into [_loadingMore] and, when it settles (every relay done or - // stalled), flips [exhausted] and clears [windowActive] so the next loadMore can start a fresh window. - private fun ensureDoneCollector(scope: CoroutineScope) { - if (doneJob?.isActive == true) return - doneJob = - scope.launch { - var wasLoading = false - windowLoad.loading.collect { loading -> - _loadingMore.value = loading && windowActive - if (!loading && wasLoading) { - windowActive = false - _loadingMore.value = false - windowUser?.let { user -> - exhaustedByUser[user.pubkeyHex] = true - if (activeUser == user.pubkeyHex) _exhausted.value = true - updateStatus(user) - logSettleSummary(user) - } - } - wasLoading = loading - } - } - } - - // WindowLoadTracker reports relays that accepted a REQ then went silent, or never got their REQ out. - // We do NOT give up on them (they may simply be slow and need to catch up) — we just record them as - // stalled for the logs and let them keep their open subscription. - private fun onRelaysStalled(relays: Set) { - started.forEach { pk -> relays.forEach { markStalled(pk, it, "no response (silence/connect timeout)") } } - } - - // Records [relay] as not currently advancing for [pk] and logs it once (the first time it stalls in - // this window). The relay is kept — it kept its subscription and keeps trying to catch up. private fun markStalled( pk: HexKey, relay: NormalizedRelayUrl, reason: String, ) { val firstTime = stalledRelays.getOrPut(pk) { ConcurrentHashMap.newKeySet() }.add(relay) - if (firstTime) Log.d("DMPagination") { "[rooms.nip04.history] ${relay.url} stalled — $reason (kept open, still trying)" } + if (firstTime) Log.d("DMPagination") { "[rooms.nip04.history] ${relay.url} stalled — $reason (kept, advance to retry)" } } private fun updateStatus(user: User) { val account = accounts[user.pubkeyHex] - val all = account?.let { (it.homeRelays.flow.value + it.dmRelays.flow.value).toSet() } ?: emptySet() - // "Asking N relays" on the status card: the ones still being paged (done relays have dropped out). - _relayCount.value = pager.activeRelays(user.pubkeyHex, all).size - // Over ALL relays, not just the still-active ones: a relay that finished keeps its deep cursor, so - // "reached back to X" stays monotonic instead of jumping back to a newer date when the deepest - // relay drops out of the active set. - _reachedBack.value = pager.deepestUntil(user.pubkeyHex, all, floor()) + val relays = account?.let { allRelays(it) } ?: emptySet() + _relayCount.value = loadTracker.count() + val start = startUntil() + _reachedBack.value = pager.deepestReached(user.pubkeyHex, relays, start) + val stalled = stalledRelays[user.pubkeyHex] ?: emptySet() + _relayProgress.value = + relays.associateWith { relay -> + RelayPagingProgress( + reachedUntil = pager.reachedUntilFor(user.pubkeyHex, relay, start), + done = pager.isDone(user.pubkeyHex, relay), + stalled = relay in stalled && !pager.isDone(user.pubkeyHex, relay), + ) + } } - // A one-line breakdown of where each relay landed when the window settles — the snapshot to reach for - // when history didn't load tomorrow: who reached the bottom vs. who is still being retried. - private fun logSettleSummary(user: User) { + private fun recomputeExhausted(user: User) { val account = accounts[user.pubkeyHex] ?: return - val all = (account.homeRelays.flow.value + account.dmRelays.flow.value).toSet() - val done = all.filter { pager.isDone(user.pubkeyHex, it) }.map { it.url } - val stillTrying = all.filterNot { pager.isDone(user.pubkeyHex, it) }.map { it.url } - Log.d("DMPagination") { "[rooms.nip04.history] settled — done=$done still-trying=$stillTrying" } + val relays = allRelays(account) + if (relays.isEmpty()) return + val stalled = stalledRelays[user.pubkeyHex] ?: emptySet() + val pending = relays.any { !pager.isDone(user.pubkeyHex, it) && it !in stalled } + val ex = !pending + exhaustedByUser[user.pubkeyHex] = ex + if (activeUser == user.pubkeyHex) _exhausted.value = ex } override fun newSub(key: ChatroomListState): Subscription { val user = user(key) - scope = key.account.scope accounts[user.pubkeyHex] = key.account + loadTracker.bind(key.account.scope) if (activeUser != user.pubkeyHex) { activeUser = user.pubkeyHex - // Account switched: repoint the shared display flows to this account's own state. + loadTracker.reset() _exhausted.value = exhaustedByUser[user.pubkeyHex] ?: false _relayCount.value = 0 _reachedBack.value = null + _relayProgress.value = emptyMap() } + // Populate the per-relay markers (all relays at the floor, not done) so the UI can render their + // window-limit sentinels and pull the first page when they come into view. + updateStatus(user) return requestNewSubscription(historyListener(user, key)) } @@ -272,20 +212,13 @@ class ChatroomListNip04HistorySubAssembler( key: ChatroomListState, ): SubscriptionListener = object : SubscriptionListener { - override fun onSubscriptionStarted( - relay: String, - forFilters: List, - ) { - windowLoad.onReqSent(relay) - } - override fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, forFilters: List?, ) { - windowLoad.onRelayEvent(relay) + loadTracker.onActivity() pager.onEvent(user.pubkeyHex, relay, event.createdAt) stalledRelays[user.pubkeyHex]?.remove(relay) } @@ -296,18 +229,13 @@ class ChatroomListNip04HistorySubAssembler( ) { stalledRelays[user.pubkeyHex]?.remove(relay) pager.onEose(user.pubkeyHex, relay) + loadTracker.onSettled(relay) if (pager.isDone(user.pubkeyHex, relay)) { - // Reached the bottom on this relay: settle it for the spinner, nothing more to ask. - windowLoad.onRelaySettled(relay) Log.d("DMPagination") { "[rooms.nip04.history] ${relay.url} reached the bottom (done)" } - } else { - // This page had events: reset only this relay's tally and let it continue to its next - // page immediately, independent of every other relay. - pager.beginRound(user.pubkeyHex, listOf(relay)) } newEose(key, relay, TimeUtils.now(), forFilters) updateStatus(user) - invalidateFilters() + recomputeExhausted(user) } override fun onClosed( @@ -315,11 +243,10 @@ class ChatroomListNip04HistorySubAssembler( relay: NormalizedRelayUrl, forFilters: List?, ) { - // A relay may demand auth we can't satisfy and CLOSE. It's stalled, not done — keep its - // subscription so the pool can re-auth and it can catch up — but don't let it hold the - // spinner. - windowLoad.onRelaySettled(relay) + loadTracker.onSettled(relay) markStalled(user.pubkeyHex, relay, "CLOSED: $message") + updateStatus(user) + recomputeExhausted(user) } override fun onCannotConnect( @@ -327,8 +254,10 @@ class ChatroomListNip04HistorySubAssembler( message: String, forFilters: List?, ) { - windowLoad.onRelaySettled(relay) + loadTracker.onSettled(relay) markStalled(user.pubkeyHex, relay, "cannot connect: $message") + updateStatus(user) + recomputeExhausted(user) } } 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 092bd87f5c..cf0c60f5df 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 @@ -32,7 +32,6 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -41,6 +40,7 @@ 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 @@ -51,11 +51,15 @@ import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.DmHistoryLoadingCard +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.RelayReachState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.RelayWindowLimit +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.RelayWindowLimitMarkers import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChatroomHeaderCompose 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.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent @@ -65,6 +69,7 @@ import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import java.io.Serializable @Composable @@ -103,24 +108,14 @@ private fun CrossFadeState( val nip04Exhausted by nip04History.exhausted.collectAsStateWithLifecycle() val historyExhausted = giftWrapsExhausted && nip04Exhausted - // While the whole list is empty there is no LazyColumn to scroll, so hunt BOTH protocols for the - // first rooms (no stall-gate while searching) until they appear or each is exhausted. The two run - // independently — NIP-04 and NIP-17 have very different histories and depths. + // While the whole list is empty there is no LazyColumn to host the per-relay window-limit markers + // that normally drive paging, so we step every relay one page at a time (each protocol independently) + // to hunt for the first rooms until they appear or the protocol is exhausted. Once rooms load the + // markers take over and paging becomes demand-driven by their visibility. val user = accountViewModel.userProfile() - WidenHistoryWhen( - "empty.nip17", - giftWrapsHistory.loadingMore, - giftWrapsHistory.exhausted, - roomCount = null, - loadMore = { giftWrapsHistory.loadMore(user) }, - ) { feedState is FeedState.Empty } - WidenHistoryWhen( - "empty.nip04", - nip04History.loadingMore, - nip04History.exhausted, - roomCount = null, - loadMore = { nip04History.loadMore(user) }, - ) { feedState is FeedState.Empty } + val bootstrap = feedState is FeedState.Empty || feedState is FeedState.Loading + BootstrapHistoryWhenEmpty(bootstrap, giftWrapsHistory.loadingMore, giftWrapsHistory.exhausted) { giftWrapsHistory.advanceAll(user) } + BootstrapHistoryWhenEmpty(bootstrap, nip04History.loadingMore, nip04History.exhausted) { nip04History.advanceAll(user) } CrossfadeIfEnabled( targetState = feedState, @@ -170,51 +165,40 @@ private fun FeedLoaded( val nip04Exhausted by nip04History.exhausted.collectAsStateWithLifecycle() val user = accountViewModel.userProfile() - // NIP-17 and NIP-04 have very different histories and depths (e.g. NIP-04 reaching back to 2023 - // while NIP-17 is shallow), so each protocol gets its OWN trigger keyed to its OWN oldest loaded - // room — otherwise the deeper protocol's tail pins the boundary to the bottom and the shallower - // one never loads until the user scrolls all the way past it. Each is gated only on its own loader - // and "is my oldest room near the bottom of the viewport", so while the boundary is in view it keeps - // paging to exhaustion (no stall-gate — a visible card means the user is waiting for more). Public / - // group / ephemeral rooms are membership-based and excluded. - WidenHistoryWhen( - "scroll.nip17", - giftWrapsHistory.loadingMore, - giftWrapsHistory.exhausted, - roomCount = { items.list.count { it.event is ChatroomKeyable && it.event !is PrivateDmEvent } }, - loadMore = { giftWrapsHistory.loadMore(user) }, - ) { - val info = listState.layoutInfo - val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1 - val oldest = items.list.indexOfLast { it.event is ChatroomKeyable && it.event !is PrivateDmEvent } - info.totalItemsCount > 0 && (oldest < 0 || lastVisible >= oldest - PREFETCH_PRIVATE_CHATS) - } - WidenHistoryWhen( - "scroll.nip04", - nip04History.loadingMore, - nip04History.exhausted, - roomCount = { items.list.count { it.event is PrivateDmEvent } }, - loadMore = { nip04History.loadMore(user) }, - ) { - val info = listState.layoutInfo - val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1 - val oldest = items.list.indexOfLast { it.event is PrivateDmEvent } - info.totalItemsCount > 0 && (oldest < 0 || lastVisible >= oldest - PREFETCH_PRIVATE_CHATS) - } - // 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. The two sit at different depths (NIP-04's typically deeper), - // so the user sees each protocol load where its own history actually ends. + // up" and collapses when it runs dry. val giftWrapsRelays by giftWrapsHistory.relayCount.collectAsStateWithLifecycle() val giftWrapsReached by giftWrapsHistory.reachedBack.collectAsStateWithLifecycle() val nip04Relays by nip04History.relayCount.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 } val oldestNip04Index = items.list.indexOfLast { it.event is PrivateDmEvent } + // Each relay's window limit, carrying the advance() that pulls its OWN next page. Placed in the list + // at its reached depth as a sentinel (see RelayWindowLimitMarkers): a relay pages only while its + // 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) { + buildList { + if (!giftWrapsExhausted) { + giftWrapsProgress.forEach { (relay, p) -> + add(RelayWindowLimit("17:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p)) { giftWrapsHistory.advance(user, relay) }) + } + } + if (!nip04Exhausted) { + nip04Progress.forEach { (relay, p) -> + add(RelayWindowLimit("04:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p)) { nip04History.advance(user, relay) }) + } + } + } + } + LazyColumn( contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, @@ -243,71 +227,52 @@ private fun FeedLoaded( if (index == oldestNip04Index) { DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Exhausted, nip04Relays, nip04Reached) } - } - // Protocols with no room loaded yet (e.g. only public rooms so far): show their card at the end. - if (oldestNip17Index < 0 && (loadingGiftWraps || !giftWrapsExhausted)) { - item(key = "nip17Footer") { - DmHistoryLoadingCard(nip17Name, "NIP-17", loadingGiftWraps, giftWrapsExhausted, giftWrapsRelays, giftWrapsReached) - } - } - if (oldestNip04Index < 0 && (loadingNip04 || !nip04Exhausted)) { - item(key = "nip04Footer") { - DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Exhausted, nip04Relays, nip04Reached) - } + // Per-relay window-limit markers/sentinels belonging in the gap toward the next-older room: + // each pulls its relay's next page while it's on screen. olderCreatedAt is null past the + // oldest loaded room, so relays that have reached the bottom of the list sit there. + RelayWindowLimitMarkers( + limits, + item.createdAt(), + items.list.getOrNull(index + 1)?.createdAt(), + ) } } } -// How many rows ahead of the oldest loaded private chat to start widening, so older private -// history lands before the user scrolls into the (membership-based) public/group rooms below it. -private const val PREFETCH_PRIVATE_CHATS = 5 - /** - * Drives ONE protocol's history paging from a scroll/empty trigger. While [wantMore] is true and that - * protocol isn't already loading or [exhausted], it keeps calling [loadMore] round after round until - * the history is genuinely exhausted (an empty `until`+`limit` page) — there is no stall-gate: if the - * boundary card is in view the user is waiting for more, so we don't stop just because a band of older - * messages surfaced no new conversation row. Paging naturally stops when [wantMore] goes false (the - * boundary scrolls out of view) or the protocol exhausts. - * - * [wantMore] and [roomCount] are read inside a snapshotFlow, so they may observe live Compose state - * (scroll position, the feed list). [roomCount] is only carried for the log line; pass `null` when the - * caller has no room measure (the empty feed, hunting for the first room). - * - * Each protocol gets its own instance, gated only on its own loader, so NIP-04 and NIP-17 — which have - * very different histories — page independently as the user scrolls. + * Bootstraps history while the rooms list has nothing to scroll (empty / still loading): steps every + * relay one page at a time, gated only on its own loader, until rooms appear or the protocol exhausts. + * Once rooms load this stops and the per-relay window-limit markers drive paging on demand. */ @Composable -private fun WidenHistoryWhen( - trigger: String, +private fun BootstrapHistoryWhenEmpty( + active: Boolean, loadingMore: StateFlow, exhausted: StateFlow, - roomCount: (() -> Int)?, - loadMore: () -> Unit, - wantMore: () -> Boolean, + advanceAll: () -> Unit, ) { - LaunchedEffect(loadingMore, exhausted) { - combine( - // Carries this protocol's room count (>= 0) while a widen is wanted, or NOT_WANTED otherwise. - snapshotFlow { if (wantMore()) (roomCount?.invoke() ?: STILL_SEARCHING) else NOT_WANTED }, - loadingMore, - exhausted, - ) { count, loading, exhaustedNow -> - if (count != NOT_WANTED && !loading && !exhaustedNow) count else NOT_WANTED - }.distinctUntilChanged() - .collect { count -> - if (count == NOT_WANTED) return@collect - Log.d("DMPagination") { "rooms.list: widen ($trigger) → loadMore (rooms=$count)" } - loadMore() - } + LaunchedEffect(active, loadingMore, exhausted) { + if (!active) return@LaunchedEffect + combine(loadingMore, exhausted) { loading, exhaustedNow -> !loading && !exhaustedNow } + .distinctUntilChanged() + .filter { it } + .collect { advanceAll() } } } -// Sentinels for the widen-trigger flow: NOT_WANTED suppresses widening; STILL_SEARCHING is the count a -// caller without a private-room measure reports while it wants to keep widening (e.g. the empty feed). -private const val NOT_WANTED = -1 -private const val STILL_SEARCHING = 0 +private fun reachState(p: RelayPagingProgress) = + when { + p.done -> RelayReachState.DONE + p.stalled -> RelayReachState.STALLED + else -> RelayReachState.REACHING + } + +private fun relayShortName(relay: NormalizedRelayUrl): String = + relay.url + .substringAfter("://") + .trimEnd('/') + .substringBefore('/') // Stable per-chatroom key — derived from chatroom identity, not the latest // message id, so reorders move the row instead of recreating it. Compose From eaddceba16f367bcff347bdc08e63c977a28a134 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 20:30:19 +0000 Subject: [PATCH 064/103] fix(dm): harden pager loop guard and foreground-only status writes Audit follow-ups to the demand-driven paging change: - UntilLimitPager.onEose: require the reached cursor to move strictly older each page. A misbehaving relay that returns events but none older than already reached would otherwise pin the cursor and the on-screen sentinel would re-request the same window forever; treat it as the bottom. - updateStatus (gift-wrap + rooms-list NIP-04): only write the shared display StateFlows for the foreground account, mirroring the existing exhausted guard, so a background account's late EOSE can't clobber the on-screen relay count / reached-back / per-relay markers. - Add UntilLimitPagerTest covering the requested/reached split, park-on- EOSE, done-on-empty, the strict-older guard, and per-relay independence. --- .../eoseManagers/UntilLimitPager.kt | 11 +- .../AccountGiftWrapsHistoryEoseManager.kt | 3 + .../ChatroomListNip04HistorySubAssembler.kt | 3 + .../eoseManagers/UntilLimitPagerTest.kt | 113 ++++++++++++++++++ 4 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPagerTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt index 1f31ec79f0..c650fb2ac5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt @@ -147,7 +147,16 @@ class UntilLimitPager { if (c.pageCount == 0) { c.done = true } else { - c.reachedUntil = c.pageOldest + // The reached cursor must move strictly older every page (the next page asks `until = + // reached - 1`). A relay that returns events but none older than we already have — a + // misbehaving relay echoing the same newest events — would otherwise pin the cursor and the + // on-screen sentinel would re-request the same window forever. Treat that as the bottom. + val prev = c.reachedUntil + if (prev == null || c.pageOldest < prev) { + c.reachedUntil = c.pageOldest + } else { + c.done = true + } } } 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 53a1d0410d..ff05a9b22d 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 @@ -185,6 +185,9 @@ class AccountGiftWrapsHistoryEoseManager( } private fun updateStatus(user: User) { + // The display flows are singletons shown for the foreground account; a background account's late + // EOSE must not overwrite them (its cursors still advance in the pager). + if (activeUser != user.pubkeyHex) return val relays = accounts[user.pubkeyHex]?.dmRelays?.flow?.value ?: emptySet() _relayCount.value = loadTracker.count() val start = startUntil() 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 eeb934dfc8..02f2b6922c 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 @@ -162,6 +162,9 @@ class ChatroomListNip04HistorySubAssembler( } private fun updateStatus(user: User) { + // The display flows are singletons shown for the foreground account; a background account's late + // EOSE must not overwrite them (its cursors still advance in the pager). + if (activeUser != user.pubkeyHex) return val account = accounts[user.pubkeyHex] val relays = account?.let { allRelays(it) } ?: emptySet() _relayCount.value = loadTracker.count() 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 new file mode 100644 index 0000000000..ce8b4fb083 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPagerTest.kt @@ -0,0 +1,113 @@ +/* + * 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.amethyst.service.relayClient.eoseManagers + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class UntilLimitPagerTest { + private val key = "acct" + private val relayA = RelayUrlNormalizer.normalizeOrNull("wss://a.relay")!! + private val relayB = RelayUrlNormalizer.normalizeOrNull("wss://b.relay")!! + private val start = 1_000L + + @Test + fun unarmedRelayIsNotRequestedButCountsAsActive() { + val pager = UntilLimitPager() + assertFalse(pager.isArmed(key, relayA)) + assertEquals(emptyList(), pager.armedRelays(key, listOf(relayA))) + // not done, so still "active" (there is history to ask for once advanced) + assertEquals(listOf(relayA), pager.activeRelays(key, listOf(relayA))) + // marker sits at the floor until it delivers + assertEquals(start, pager.reachedUntilFor(key, relayA, start)) + } + + @Test + fun firstAdvanceRequestsTheFloorThenSubsequentPagesStepBelowReached() { + val pager = UntilLimitPager() + + assertTrue(pager.advance(key, relayA, start)) + assertTrue(pager.isArmed(key, relayA)) + assertEquals(start, pager.requestedUntilFor(key, relayA)) + + // page returns events; oldest seen = 800 + pager.onEvent(key, relayA, 900) + pager.onEvent(key, relayA, 800) + pager.onEose(key, relayA) + assertEquals(800L, pager.reachedUntilFor(key, relayA, start)) + // EOSE does NOT move the requested cursor — the relay parks at the same filter + assertEquals(start, pager.requestedUntilFor(key, relayA)) + + // next advance steps to reached - 1 + assertTrue(pager.advance(key, relayA, start)) + assertEquals(799L, pager.requestedUntilFor(key, relayA)) + } + + @Test + fun emptyPageMarksRelayDoneAndBlocksFurtherAdvance() { + val pager = UntilLimitPager() + pager.advance(key, relayA, start) + pager.onEose(key, relayA) // no events + assertTrue(pager.isDone(key, relayA)) + assertFalse(pager.advance(key, relayA, start)) + assertEquals(emptyList(), pager.activeRelays(key, listOf(relayA))) + assertEquals(emptyList(), pager.armedRelays(key, listOf(relayA))) + } + + @Test + fun aPageThatDoesNotStepOlderEndsTheRelayInsteadOfLooping() { + val pager = UntilLimitPager() + pager.advance(key, relayA, start) + pager.onEvent(key, relayA, 800) + pager.onEose(key, relayA) + assertEquals(800L, pager.reachedUntilFor(key, relayA, start)) + + // misbehaving relay: next page echoes an event no older than what we already reached + pager.advance(key, relayA, start) // requested = 799 + pager.onEvent(key, relayA, 900) // newer than reached(800) — not strictly older + pager.onEose(key, relayA) + assertTrue("a non-advancing page should end the relay, not re-loop", pager.isDone(key, relayA)) + assertEquals(800L, pager.reachedUntilFor(key, relayA, start)) + } + + @Test + fun relaysAreTrackedIndependently() { + val pager = UntilLimitPager() + pager.advance(key, relayA, start) + pager.onEvent(key, relayA, 500) + pager.onEose(key, relayA) + // B never advanced + assertEquals(listOf(relayA), pager.armedRelays(key, listOf(relayA, relayB))) + assertEquals(500L, pager.reachedUntilFor(key, relayA, start)) + assertEquals(start, pager.reachedUntilFor(key, relayB, start)) + // deepest reached across both = A's 500 (B counts as the floor) + assertEquals(500L, pager.deepestReached(key, listOf(relayA, relayB), start)) + } + + @Test + fun deepestReachedIsNullWhenNoRelays() { + val pager = UntilLimitPager() + assertEquals(null, pager.deepestReached(key, emptyList(), start)) + } +} From 9ac835557ac5162862b93639298e41fa74cc03bf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 21:15:57 +0000 Subject: [PATCH 065/103] fix(dm): stop window-limit sentinels re-paging on relay connection churn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-relay sentinel keyed its LaunchedEffect on (reachedUntil, state). The state component meant every REACHING<->STALLED transition restarted the effect and re-issued advance(), so a flaky or auth-walled relay's reconnect/ timeout churn re-paged the window on a completely static screen (observed: vitor.nostr1.com + auth.nostr1.com re-REQing on every ping timeout / auth CLOSE with no user interaction). Key the sentinel on reachedUntil ALONE — a landed page is the only thing that should pull the next one. A stalled relay now parks until its cursor moves or its marker is scrolled back into view (one retry, not a loop). Also: the empty-feed bootstrap now triggers on FeedState.Empty only (not the transient Loading that navigation flashes through) and is debounced, so re-opening Messages / a conversation that already has content no longer kicks a one-round advanceAll across every relay. --- .../chats/feed/layouts/RelayReachMarker.kt | 19 ++++++++------- .../loggedIn/chats/privateDM/ChatroomView.kt | 10 +++++++- .../chats/rooms/feed/ChatroomListFeedView.kt | 24 ++++++++++++------- 3 files changed, 36 insertions(+), 17 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt index 2538b994e1..2151bbe6c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt @@ -79,10 +79,14 @@ data class RelayWindowLimit( * Renders the window-limit markers for the relays whose limit falls in the gap between a newer message * (at [newerCreatedAt]) and its next-older neighbour (at [olderCreatedAt], null at the oldest end), and * — this is the load driver — makes each one a **sentinel**: while this gap is composed (i.e. on/near - * screen), it pulls that relay's next page, and keeps pulling as each page lands ([LaunchedEffect] keyed - * on the relay's reached cursor) for as long as the marker stays visible. When a page fills enough to - * push the marker off screen, or the user scrolls away, the gap is disposed and paging stops on its own. - * A done relay just shows its ✓ and drives nothing. + * screen), it pulls that relay's next page and keeps pulling as each page lands, so a relay pages on + * while its marker stays on screen and stops when a page pushes it off or the user scrolls away. + * + * The sentinel keys on the reached cursor ALONE — never on the relay's reach state. Keying on state + * would re-fire on every `REACHING ⇄ STALLED` flip, so a flaky/auth relay's connection churn would + * re-page the window on a completely static screen. A stalled relay therefore parks until the cursor + * moves or the marker is scrolled back into view (a single retry, not a loop). A done relay shows ✓ and + * drives nothing. */ @Composable fun RelayWindowLimitMarkers( @@ -102,11 +106,10 @@ fun RelayWindowLimitMarkers( here.forEach { lim -> if (lim.state != RelayReachState.DONE) { - // Keyed identity so the effect isn't torn down on reorder; keyed on the reached cursor so each - // returned page re-fires it (continue while visible). A stalled relay re-fires only on - // re-composition (scroll back into view) — a single retry, not a busy loop. + // Keyed identity so the effect isn't torn down on reorder; keyed on the reached cursor ONLY so + // it re-fires per landed page (continue while visible) but NOT on stall/unstall churn. key(lim.key) { - LaunchedEffect(lim.reachedUntil, lim.state) { lim.advance() } + LaunchedEffect(lim.reachedUntil) { lim.advance() } } } } 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 5f36dab968..cf512326ef 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 @@ -65,6 +65,7 @@ import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter @@ -169,10 +170,13 @@ private fun BootstrapHistoryWhenEmpty( val giftWrapsHistory = remember(accountViewModel) { accountViewModel.dataSources().account.giftWrapsHistory } val nip04History = remember(accountViewModel) { accountViewModel.dataSources().chatroom.nip04History } val feedState by feedContentState.feedContent.collectAsStateWithLifecycle() - val needsBootstrap = feedState is FeedState.Empty || feedState is FeedState.Loading + // Empty only (never the transient Loading navigation flashes through), and debounced below, so + // re-opening a conversation that has messages doesn't kick a hunt. + val needsBootstrap = feedState is FeedState.Empty LaunchedEffect(needsBootstrap, giftWrapsHistory) { if (!needsBootstrap) return@LaunchedEffect + delay(BOOTSTRAP_DEBOUNCE_MS) combine(giftWrapsHistory.loadingMore, giftWrapsHistory.exhausted) { loading, exhausted -> !loading && !exhausted } .distinctUntilChanged() .filter { it } @@ -180,6 +184,7 @@ private fun BootstrapHistoryWhenEmpty( } LaunchedEffect(needsBootstrap, nip04History) { if (!needsBootstrap) return@LaunchedEffect + delay(BOOTSTRAP_DEBOUNCE_MS) combine(nip04History.loadingMore, nip04History.exhausted) { loading, exhausted -> !loading && !exhausted } .distinctUntilChanged() .filter { it } @@ -187,6 +192,9 @@ private fun BootstrapHistoryWhenEmpty( } } +// Ignore the transient empty feed that navigation flashes through before messages re-appear. +private const val BOOTSTRAP_DEBOUNCE_MS = 1200L + @Composable fun ChatroomViewUI( room: ChatroomKey, 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 cf0c60f5df..e0342ed08a 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 @@ -66,6 +66,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -108,12 +109,12 @@ private fun CrossFadeState( val nip04Exhausted by nip04History.exhausted.collectAsStateWithLifecycle() val historyExhausted = giftWrapsExhausted && nip04Exhausted - // While the whole list is empty there is no LazyColumn to host the per-relay window-limit markers - // that normally drive paging, so we step every relay one page at a time (each protocol independently) - // to hunt for the first rooms until they appear or the protocol is exhausted. Once rooms load the - // markers take over and paging becomes demand-driven by their visibility. + // 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 + // transient Loading that navigation flashes through) and debounced, so re-opening Messages with rooms + // 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 || feedState is FeedState.Loading + val bootstrap = feedState is FeedState.Empty BootstrapHistoryWhenEmpty(bootstrap, giftWrapsHistory.loadingMore, giftWrapsHistory.exhausted) { giftWrapsHistory.advanceAll(user) } BootstrapHistoryWhenEmpty(bootstrap, nip04History.loadingMore, nip04History.exhausted) { nip04History.advanceAll(user) } @@ -241,9 +242,12 @@ private fun FeedLoaded( } /** - * Bootstraps history while the rooms list has nothing to scroll (empty / still loading): steps every - * relay one page at a time, gated only on its own loader, until rooms appear or the protocol exhausts. - * Once rooms load this stops and the per-relay window-limit markers drive paging on demand. + * Bootstraps history while the rooms list is genuinely empty: steps every relay one page at a time, + * gated only on its own loader, until rooms appear or the protocol exhausts. Once rooms load this stops + * and the per-relay window-limit markers drive paging on demand. + * + * Leads with a debounce so the brief Empty/Loading flash that navigation passes through does NOT trigger + * a hunt; if [active] drops before it elapses (rooms loaded) the effect cancels and nothing pages. */ @Composable private fun BootstrapHistoryWhenEmpty( @@ -254,6 +258,7 @@ private fun BootstrapHistoryWhenEmpty( ) { LaunchedEffect(active, loadingMore, exhausted) { if (!active) return@LaunchedEffect + delay(BOOTSTRAP_DEBOUNCE_MS) combine(loadingMore, exhausted) { loading, exhaustedNow -> !loading && !exhaustedNow } .distinctUntilChanged() .filter { it } @@ -261,6 +266,9 @@ private fun BootstrapHistoryWhenEmpty( } } +// Ignore the transient empty feed that navigation flashes through before the rooms re-appear. +private const val BOOTSTRAP_DEBOUNCE_MS = 1200L + private fun reachState(p: RelayPagingProgress) = when { p.done -> RelayReachState.DONE From c4b1eaaa3d3d386412415596e40401c654a423a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 21:41:07 +0000 Subject: [PATCH 066/103] fix(dm): pin the history floor so un-delivered relays don't re-page; add sentinel/bootstrap trace logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The floor (now - 7d) was recomputed on every call, so it drifted forward over time. For a relay that hadn't delivered yet (reachedUntil == null), reachedUntilFor returned that drifting floor, so its marker's reached cursor kept changing, the sentinel's LaunchedEffect(reachedUntil) key changed, and it re-fired advance() — observed as rooms.nip04.history re-advancing a silent vitor.nostr1.com every ~15s, with the retry's 'until' moving NEWER each time. Pin the floor once per account/conversation for the session (matching the pre-rewrite behavior) in all three history loaders, so an un-delivered relay parks at a stable cursor and its sentinel fires once. Also add diagnostics to catch this class of bug quickly: one log line per marker sentinel fire (key + reachedUntil — a loop repeats the same key, a drift shows a moving cursor) and one per advanceAll (empty-feed bootstrap). --- .../AccountGiftWrapsHistoryEoseManager.kt | 14 ++++++++++---- .../chats/feed/layouts/RelayReachMarker.kt | 8 +++++++- .../datasource/ChatroomNip04HistorySubAssembler.kt | 11 ++++++++--- .../ChatroomListNip04HistorySubAssembler.kt | 11 ++++++++--- 4 files changed, 33 insertions(+), 11 deletions(-) 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 ff05a9b22d..8630cd87fa 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 @@ -100,8 +100,13 @@ class AccountGiftWrapsHistoryEoseManager( private val _relayProgress = MutableStateFlow>(emptyMap()) val relayProgress: StateFlow> = _relayProgress.asStateFlow() - // History starts just below the live tail's one-week floor and pages backward from there. - private fun startUntil() = TimeUtils.now() - AccountGiftWrapsEoseManager.LIVE_TAIL_SECONDS + // History starts just below the live tail's one-week floor and pages backward from there. Pinned per + // account 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. The + // live tail covers everything newer than the floor. + private val pinnedFloor = ConcurrentHashMap() + + private fun startUntil(pk: HexKey) = pinnedFloor.getOrPut(pk) { TimeUtils.now() - AccountGiftWrapsEoseManager.LIVE_TAIL_SECONDS } private fun daysAgo(epochSeconds: Long) = (TimeUtils.now() - epochSeconds) / TimeUtils.ONE_DAY @@ -144,6 +149,7 @@ class AccountGiftWrapsHistoryEoseManager( account.dmRelays.flow.value .forEach { if (arm(user, it)) any = true } if (any) { + Log.d(TAG) { "[giftwrap.history] advanceAll (empty-feed bootstrap)" } _exhausted.value = false updateStatus(user) invalidateFilters() @@ -159,7 +165,7 @@ class AccountGiftWrapsHistoryEoseManager( val account = accounts[user.pubkeyHex] ?: return false if (relay !in account.dmRelays.flow.value) return false if (loadTracker.isInFlight(relay)) return false - if (!pager.advance(user.pubkeyHex, relay, startUntil())) return false + if (!pager.advance(user.pubkeyHex, relay, startUntil(user.pubkeyHex))) return false stalledRelays[user.pubkeyHex]?.remove(relay) loadTracker.bind(account.scope) loadTracker.onAdvance(relay) @@ -190,7 +196,7 @@ class AccountGiftWrapsHistoryEoseManager( if (activeUser != user.pubkeyHex) return val relays = accounts[user.pubkeyHex]?.dmRelays?.flow?.value ?: emptySet() _relayCount.value = loadTracker.count() - val start = startUntil() + val start = startUntil(user.pubkeyHex) _reachedBack.value = pager.deepestReached(user.pubkeyHex, relays, start) val stalled = stalledRelays[user.pubkeyHex] ?: emptySet() _relayProgress.value = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt index 2151bbe6c6..cd7da3264b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.HalfPadding +import com.vitorpamplona.quartz.utils.Log /** How far one relay has paged into the conversation, for an in-stream progress marker. */ enum class RelayReachState { @@ -109,7 +110,12 @@ fun RelayWindowLimitMarkers( // Keyed identity so the effect isn't torn down on reorder; keyed on the reached cursor ONLY so // it re-fires per landed page (continue while visible) but NOT on stall/unstall churn. key(lim.key) { - LaunchedEffect(lim.reachedUntil) { lim.advance() } + LaunchedEffect(lim.reachedUntil) { + // One line per sentinel fire — a re-fire LOOP shows the same key firing over and over + // (and whether its reached cursor is drifting, which would point at a non-pinned floor). + Log.d("DMPagination") { "marker fire ${lim.key} reachedUntil=${lim.reachedUntil}" } + lim.advance() + } } } } 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 642618b3f5..b358bf348b 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 @@ -91,7 +91,11 @@ class ChatroomNip04HistorySubAssembler( private var activeConvo: ConvoKey? = null private val exhaustedByConvo = ConcurrentHashMap() - private fun startUntil() = TimeUtils.now() - AccountGiftWrapsEoseManager.LIVE_TAIL_SECONDS + // Pinned per conversation for the session — must not drift forward, or an un-delivered relay's marker + // would keep moving and re-trigger its sentinel. See AccountGiftWrapsHistoryEoseManager. + private val pinnedFloor = ConcurrentHashMap() + + private fun startUntil(pk: ConvoKey) = pinnedFloor.getOrPut(pk) { TimeUtils.now() - AccountGiftWrapsEoseManager.LIVE_TAIL_SECONDS } override fun user(key: ChatroomQueryState) = key.account.userProfile() @@ -141,6 +145,7 @@ class ChatroomNip04HistorySubAssembler( relays.all.forEach { if (arm(key, it)) any = true } } if (any) { + Log.d("DMPagination") { "[convo.nip04.history] advanceAll (empty-thread bootstrap)" } _exhausted.value = false updateStatus() invalidateFilters() @@ -155,7 +160,7 @@ class ChatroomNip04HistorySubAssembler( if (relay !in relays.all) return false val pk = convoKey(key) if (loadTracker.isInFlight(relay)) return false - if (!pager.advance(pk, relay, startUntil())) return false + if (!pager.advance(pk, relay, startUntil(pk))) return false stalledRelays[pk]?.remove(relay) loadTracker.bind(key.account.scope) loadTracker.onAdvance(relay) @@ -182,7 +187,7 @@ class ChatroomNip04HistorySubAssembler( val pk = activeConvo ?: return val relays = relaysFor(pk) ?: return _relayCount.value = loadTracker.count() - val start = startUntil() + val start = startUntil(pk) _reachedBack.value = pager.deepestReached(pk, relays.all, start) val stalled = stalledRelays[pk] ?: emptySet() _relayProgress.value = 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 02f2b6922c..2ea913b2a8 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 @@ -79,7 +79,11 @@ class ChatroomListNip04HistorySubAssembler( private val _relayProgress = MutableStateFlow>(emptyMap()) val relayProgress: StateFlow> = _relayProgress.asStateFlow() - private fun startUntil() = TimeUtils.now() - AccountGiftWrapsEoseManager.LIVE_TAIL_SECONDS + // Pinned per account for the session — must not drift forward, or an un-delivered relay's marker + // would keep moving and re-trigger its sentinel. See AccountGiftWrapsHistoryEoseManager. + private val pinnedFloor = ConcurrentHashMap() + + private fun startUntil(pk: HexKey) = pinnedFloor.getOrPut(pk) { TimeUtils.now() - AccountGiftWrapsEoseManager.LIVE_TAIL_SECONDS } override fun user(key: ChatroomListState) = key.account.userProfile() @@ -123,6 +127,7 @@ class ChatroomListNip04HistorySubAssembler( var any = false allRelays(account).forEach { if (arm(user, it)) any = true } if (any) { + Log.d("DMPagination") { "[rooms.nip04.history] advanceAll (empty-feed bootstrap)" } _exhausted.value = false updateStatus(user) invalidateFilters() @@ -136,7 +141,7 @@ class ChatroomListNip04HistorySubAssembler( val account = accounts[user.pubkeyHex] ?: return false if (relay !in allRelays(account)) return false if (loadTracker.isInFlight(relay)) return false - if (!pager.advance(user.pubkeyHex, relay, startUntil())) return false + if (!pager.advance(user.pubkeyHex, relay, startUntil(user.pubkeyHex))) return false stalledRelays[user.pubkeyHex]?.remove(relay) loadTracker.bind(account.scope) loadTracker.onAdvance(relay) @@ -168,7 +173,7 @@ class ChatroomListNip04HistorySubAssembler( val account = accounts[user.pubkeyHex] val relays = account?.let { allRelays(it) } ?: emptySet() _relayCount.value = loadTracker.count() - val start = startUntil() + val start = startUntil(user.pubkeyHex) _reachedBack.value = pager.deepestReached(user.pubkeyHex, relays, start) val stalled = stalledRelays[user.pubkeyHex] ?: emptySet() _relayProgress.value = From a4368c403a0c5871e367c0c16005903d41163c98 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 22:26:19 +0000 Subject: [PATCH 067/103] fix(dm): stop the history loading card flickering between back-to-back pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PerRelayLoadTracker dropped the loading flag the instant in-flight emptied, so a relay paging page-after-page (each page settles, then the marker fires the next) flipped loading true->false->true every page — and the card's icon hard- cut spinner -> paused-glyph -> spinner, a visible blink per page. Let loading=false linger ~600ms after the last page settles; cancel the linger the moment the next page starts. Back-to-back pages now show a steady spinner; the card still flips to paused/done once paging genuinely stops. --- .../eoseManagers/PerRelayLoadTracker.kt | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerRelayLoadTracker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerRelayLoadTracker.kt index 824b107af7..14a504ae48 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerRelayLoadTracker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerRelayLoadTracker.kt @@ -61,6 +61,10 @@ class PerRelayLoadTracker( @Volatile private var watchdog: Job? = null + // Delays dropping the spinner so back-to-back pages don't flicker it off between each one. + @Volatile + private var clearJob: Job? = null + @Volatile private var scope: CoroutineScope? = null @@ -75,6 +79,8 @@ class PerRelayLoadTracker( /** A relay's next page was just requested. Raises the spinner and (re)arms the silence watchdog. */ @Synchronized fun onAdvance(relay: NormalizedRelayUrl) { + clearJob?.cancel() // a new page is starting — keep the spinner up, no flicker + clearJob = null inFlight.add(relay) lastActivityMs = System.currentTimeMillis() _loading.value = true @@ -86,17 +92,41 @@ class PerRelayLoadTracker( lastActivityMs = System.currentTimeMillis() } - /** A relay answered (EOSE / CLOSED / cannot-connect). Drops it from in-flight; clears the spinner if last. */ + /** + * A relay answered (EOSE / CLOSED / cannot-connect). Drops it from in-flight. When the last one + * settles, the spinner is dropped after a short linger rather than immediately, so a relay paging + * page-after-page (each page settles then the marker fires the next) keeps a steady spinner instead + * of flickering it off for the few ms between pages. The linger is cancelled the moment a new page + * starts ([onAdvance]). + */ @Synchronized fun onSettled(relay: NormalizedRelayUrl) { lastActivityMs = System.currentTimeMillis() - if (inFlight.remove(relay) && inFlight.isEmpty()) _loading.value = false + if (inFlight.remove(relay) && inFlight.isEmpty()) scheduleClear() + } + + private fun scheduleClear() { + clearJob?.cancel() + val s = scope + if (s == null) { + _loading.value = false + return + } + clearJob = + s.launch { + delay(LOADING_LINGER_MS) + synchronized(this@PerRelayLoadTracker) { + if (inFlight.isEmpty()) _loading.value = false + } + } } /** Drops everything (e.g. account/conversation switched). */ @Synchronized fun reset() { inFlight.clear() + clearJob?.cancel() + clearJob = null _loading.value = false watchdog?.cancel() watchdog = null @@ -132,5 +162,9 @@ class PerRelayLoadTracker( companion object { private const val TAG = "DMPagination" private const val WATCHDOG_TICK_MS = 1_000L + + // How long to keep the spinner up after the last page settles, to bridge the gap to the next + // back-to-back page so the card doesn't flicker between every page. + private const val LOADING_LINGER_MS = 600L } } From 0394ec2a75dc9abcf2ba9f18d1fc5c0711ef6c45 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 4 Jun 2026 18:32:32 -0400 Subject: [PATCH 068/103] fix(dm): drive window-limit paging off viewport visibility, not row composition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-relay window-limit sentinel placed its advance() effect inside the gap row that currently hosts the marker, so its identity rode that row. Any feed reorder (a live DM bumping a room, or a slow relay dribbling a history page) moved the gap to a different row, tore the keyed LaunchedEffect down and recreated it, and re-fired advance() on a static screen — re-arming stalled/ auth relays into a 15s silence-watchdog storm and risking an unprompted page-back for delivering relays. Hoist the driver above the list: RelayWindowLimitSentinels now holds one stable effect per non-done limit (keyed by lim.key) that watches the LazyListState and fires advance() only when the marker's gap is among the currently visible rows AND it just scrolled into view OR its reached cursor moved (a page landed). A reorder that keeps the marker on the same side of the fold changes neither, so it no longer re-pages. RelayWindowLimitMarkers is now pure UI. Wired in the rooms list (inline) and the conversation (via a new sentinels slot on ChatFeedView). Verified on device: static rooms list drops from a perpetual ~15s re-fire/ silence storm to 2 silence events and only legit cursor-moved advances, while demand-driven paging (page back while the marker is visible) is preserved. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../loggedIn/chats/feed/ChatFeedView.kt | 12 ++ .../chats/feed/layouts/RelayReachMarker.kt | 105 +++++++++++++----- .../loggedIn/chats/privateDM/ChatroomView.kt | 16 ++- .../chats/rooms/feed/ChatroomListFeedView.kt | 5 + 4 files changed, 106 insertions(+), 32 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt index d4a1cc2737..dd6384eb51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt @@ -68,6 +68,10 @@ fun RefreshingChatroomFeedView( // createdAt bounds, so a caller (private DMs) can draw per-relay paging markers at the depth each // relay has reached. No-op for callers without per-relay progress. markersInGap: (@Composable (newerCreatedAt: Long?, olderCreatedAt: Long?) -> Unit)? = null, + // Optional hoisted load driver: handed the loaded message list and its scroll state once (above the + // LazyColumn), so a caller (private DMs) can drive demand-driven paging off viewport visibility + // rather than per-row composition. No-op for callers that don't paginate. + sentinels: (@Composable (items: List, listState: LazyListState) -> Unit)? = null, ) { SaveableFeedState(feedContentState, scrollStateKey) { listState -> listStateObserver(listState) @@ -82,6 +86,7 @@ fun RefreshingChatroomFeedView( avoidDraft, olderBoundary, markersInGap, + sentinels, ) } } @@ -98,6 +103,7 @@ fun RenderChatFeedView( avoidDraft: DraftTagState? = null, olderBoundary: (@Composable () -> Unit)? = null, markersInGap: (@Composable (newerCreatedAt: Long?, olderCreatedAt: Long?) -> Unit)? = null, + sentinels: (@Composable (items: List, listState: LazyListState) -> Unit)? = null, ) { val feedState by feed.feedContent.collectAsStateWithLifecycle() @@ -127,6 +133,7 @@ fun RenderChatFeedView( avoidDraft, olderBoundary, markersInGap, + sentinels, ) } } @@ -145,9 +152,14 @@ fun ChatFeedLoaded( avoidDraft: DraftTagState? = null, olderBoundary: (@Composable () -> Unit)? = null, markersInGap: (@Composable (newerCreatedAt: Long?, olderCreatedAt: Long?) -> Unit)? = null, + sentinels: (@Composable (items: List, listState: LazyListState) -> Unit)? = null, ) { val items by loaded.feed.collectAsStateWithLifecycle() + // Hoisted load driver (above the LazyColumn): pages each relay off viewport visibility, so feed + // reorders no longer re-fire paging. The per-gap markers below are pure UI. + sentinels?.invoke(items.list, listState) + LaunchedEffect(items.list.firstOrNull()) { if (listState.firstVisibleItemIndex <= 1) { listState.animateScrollToItem(0) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt index cd7da3264b..44a201dcfa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -29,6 +30,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.key import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -41,6 +44,8 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.HalfPadding import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.distinctUntilChanged /** How far one relay has paged into the conversation, for an in-stream progress marker. */ enum class RelayReachState { @@ -61,10 +66,10 @@ data class RelayReach( ) /** - * One relay's window-limit, used to both place a marker and act as the load sentinel for that relay. - * The marker sits at [reachedUntil] (the oldest point the relay has paged to). [advance] pulls that - * relay's next, older page; the renderer fires it while the marker is on screen (see - * [RelayWindowLimitMarkers]). + * One relay's window-limit: places a marker and carries the [advance] that pulls that relay's next, + * older page. The marker sits at [reachedUntil] (the oldest point the relay has paged to); + * [RelayWindowLimitMarkers] draws it and [RelayWindowLimitSentinels] fires [advance] while it is on + * screen. * * @param key stable identity (protocol tag + relay url) so the sentinel survives list reorders. */ @@ -77,17 +82,74 @@ data class RelayWindowLimit( ) /** - * Renders the window-limit markers for the relays whose limit falls in the gap between a newer message - * (at [newerCreatedAt]) and its next-older neighbour (at [olderCreatedAt], null at the oldest end), and - * — this is the load driver — makes each one a **sentinel**: while this gap is composed (i.e. on/near - * screen), it pulls that relay's next page and keeps pulling as each page lands, so a relay pages on - * while its marker stays on screen and stops when a page pushes it off or the user scrolls away. + * Drives demand-driven paging for every limit, **hoisted above the list** so its identity does not ride + * on which row currently hosts the marker. Each non-done limit gets one stable effect (keyed by + * [RelayWindowLimit.key]) that watches the [listState] and pulls that relay's next page when its marker + * is on screen. * - * The sentinel keys on the reached cursor ALONE — never on the relay's reach state. Keying on state - * would re-fire on every `REACHING ⇄ STALLED` flip, so a flaky/auth relay's connection churn would - * re-page the window on a completely static screen. A stalled relay therefore parks until the cursor - * moves or the marker is scrolled back into view (a single retry, not a loop). A done relay shows ✓ and - * drives nothing. + * Why hoisted: the marker for a limit lives in exactly one gap (between the two rows straddling its + * reached cursor). Placing the sentinel *inside* that row made its effect's identity ride the hosting + * row — so any feed reorder (a live DM, or a slow relay dribbling a history page) moved the gap to a + * different row, tore the effect down and recreated it, and re-fired `advance()` on a static screen. + * That re-armed stalled/auth relays into a silence-watchdog storm and could walk a delivering relay + * back a window with no scroll. Hoisting the effect and driving it off **viewport visibility** instead + * of composition presence removes that coupling. + * + * Fires `advance()` when (and only when) the marker's gap is among the currently visible rows AND either + * it just scrolled into view OR its reached cursor moved (a page landed — keep paging while visible). + * A reorder that keeps the marker on the same side of the fold changes neither, so it no longer re-fires. + * A done relay drives nothing. + * + * @param createdAtAt createdAt of the list item at an index (null past the ends / for non-message rows), + * so the visible-gap test mirrors [RelayWindowLimitMarkers]'s placement against only the on-screen rows. + */ +@Composable +fun RelayWindowLimitSentinels( + limits: List, + listState: LazyListState, + createdAtAt: (index: Int) -> Long?, +) { + limits.forEach { lim -> + if (lim.state == RelayReachState.DONE) return@forEach + key(lim.key) { + val reached = rememberUpdatedState(lim.reachedUntil) + val advance = rememberUpdatedState(lim.advance) + val getAt = rememberUpdatedState(createdAtAt) + LaunchedEffect(Unit) { + snapshotFlow { + val r = reached.value + val at = getAt.value + // Visible if any on-screen row is the "newer" side of the gap holding this cursor — + // the same predicate RelayWindowLimitMarkers uses to place the marker, but over the + // visible rows only. + val onScreen = + listState.layoutInfo.visibleItemsInfo.any { info -> + val newer = at(info.index) ?: return@any false + val older = at(info.index + 1) + newer > r && (older == null || older <= r) + } + // Pair so distinctUntilChanged also lets a landed page (r moved) re-fire while visible, + // not just the off→on-screen transition. + onScreen to r + }.distinctUntilChanged() + .collect { (onScreen, r) -> + if (onScreen) { + // One line per sentinel fire — a re-fire LOOP would show the same key firing + // over and over (and whether its reached cursor is drifting). + Log.d("DMPagination") { "marker fire ${lim.key} reachedUntil=$r" } + advance.value() + } + } + } + } + } +} + +/** + * Renders the window-limit markers for the relays whose limit falls in the gap between a newer message + * (at [newerCreatedAt]) and its next-older neighbour (at [olderCreatedAt], null at the oldest end). Pure + * UI: the load driving lives in [RelayWindowLimitSentinels], so this can be (re)placed freely per row on + * every feed reorder without triggering any paging. */ @Composable fun RelayWindowLimitMarkers( @@ -105,21 +167,6 @@ fun RelayWindowLimitMarkers( } if (here.isEmpty()) return - here.forEach { lim -> - if (lim.state != RelayReachState.DONE) { - // Keyed identity so the effect isn't torn down on reorder; keyed on the reached cursor ONLY so - // it re-fires per landed page (continue while visible) but NOT on stall/unstall churn. - key(lim.key) { - LaunchedEffect(lim.reachedUntil) { - // One line per sentinel fire — a re-fire LOOP shows the same key firing over and over - // (and whether its reached cursor is drifting, which would point at a non-pinned floor). - Log.d("DMPagination") { "marker fire ${lim.key} reachedUntil=${lim.reachedUntil}" } - lim.advance() - } - } - } - } - RelayReachMarker(here.map { RelayReach(it.name, it.state) }) } 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 cf512326ef..9231e0b4e4 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 @@ -54,6 +54,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatro import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.RelayReachState import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.RelayWindowLimit import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.RelayWindowLimitMarkers +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.RelayWindowLimitSentinels import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal.ChatroomFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.ChatNewMessageViewModel @@ -274,15 +275,24 @@ fun ChatroomViewUI( DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Exhausted, nip04Relays, nip04Reached) } }, - // Each relay's window-limit marker, placed at its reached cursor, doubles as the load - // sentinel that pulls that relay's next page while it's on screen (see - // RelayWindowLimitMarkers). Hidden once both protocols are exhausted. + // Each relay's window-limit marker, placed at its reached cursor (pure UI). Hidden once + // both protocols are exhausted. markersInGap = if (limits.isEmpty()) { null } else { { newer, older -> RelayWindowLimitMarkers(limits, newer, older) } }, + // The hoisted load driver that pulls each relay's next page while its marker is on screen, + // off viewport visibility (see RelayWindowLimitSentinels) so feed reorders don't re-page. + sentinels = + if (limits.isEmpty()) { + null + } else { + { items, listState -> + RelayWindowLimitSentinels(limits, listState) { index -> items.getOrNull(index)?.event?.createdAt } + } + }, ) } 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 e0342ed08a..9189d8b3ab 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 @@ -54,6 +54,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.DmHistoryLoading import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.RelayReachState import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.RelayWindowLimit import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.RelayWindowLimitMarkers +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.RelayWindowLimitSentinels import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChatroomHeaderCompose import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding @@ -200,6 +201,10 @@ private fun FeedLoaded( } } + // Hoisted load driver: pulls each relay's next page off viewport visibility, so feed reorders + // (a live DM bumping a room) no longer re-fire paging. The markers below are pure UI. + RelayWindowLimitSentinels(limits, listState) { index -> items.list.getOrNull(index)?.createdAt() } + LazyColumn( contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, From b9c2c646fd5313a316be3aae250bd42a61fa0ca5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 22:48:06 +0000 Subject: [PATCH 069/103] feat(dm): log a 'window settled' line when every relay is done or stalled The demand-driven rewrite dropped the round model's 'load done: all relays' completion line, leaving no aggregate signal for 'this is as far as history goes right now'. Log one per protocol on the exhausted false->true edge, with the done vs stalled relay breakdown, restoring that diagnostic. --- .../nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt | 6 ++++++ .../datasource/ChatroomNip04HistorySubAssembler.kt | 6 ++++++ .../datasource/ChatroomListNip04HistorySubAssembler.kt | 6 ++++++ 3 files changed, 18 insertions(+) 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 8630cd87fa..179cad473f 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 @@ -217,7 +217,13 @@ class AccountGiftWrapsHistoryEoseManager( val stalled = stalledRelays[user.pubkeyHex] ?: emptySet() val pending = relays.any { !pager.isDone(user.pubkeyHex, it) && it !in stalled } val ex = !pending + val was = exhaustedByUser[user.pubkeyHex] ?: false exhaustedByUser[user.pubkeyHex] = ex + if (ex && !was) { + val done = relays.filter { pager.isDone(user.pubkeyHex, it) }.map { it.url } + val stuck = relays.filter { it in stalled && !pager.isDone(user.pubkeyHex, it) }.map { it.url } + Log.d(TAG) { "[giftwrap.history] window settled (nothing more reachable) — done=$done stalled=$stuck" } + } if (activeUser == user.pubkeyHex) _exhausted.value = ex } 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 b358bf348b..d1d433a37b 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 @@ -207,7 +207,13 @@ class ChatroomNip04HistorySubAssembler( val stalled = stalledRelays[pk] ?: emptySet() val pending = relays.all.any { !pager.isDone(pk, it) && it !in stalled } val ex = !pending + val was = exhaustedByConvo[pk] ?: false exhaustedByConvo[pk] = ex + if (ex && !was) { + val done = relays.all.filter { pager.isDone(pk, it) }.map { it.url } + val stuck = relays.all.filter { it in stalled && !pager.isDone(pk, it) }.map { it.url } + Log.d("DMPagination") { "[convo.nip04.history] window settled (nothing more reachable) — done=$done stalled=$stuck" } + } if (activeConvo == pk) _exhausted.value = ex } 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 2ea913b2a8..721ca082d4 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 @@ -193,7 +193,13 @@ class ChatroomListNip04HistorySubAssembler( val stalled = stalledRelays[user.pubkeyHex] ?: emptySet() val pending = relays.any { !pager.isDone(user.pubkeyHex, it) && it !in stalled } val ex = !pending + val was = exhaustedByUser[user.pubkeyHex] ?: false exhaustedByUser[user.pubkeyHex] = ex + if (ex && !was) { + val done = relays.filter { pager.isDone(user.pubkeyHex, it) }.map { it.url } + val stuck = relays.filter { it in stalled && !pager.isDone(user.pubkeyHex, it) }.map { it.url } + Log.d("DMPagination") { "[rooms.nip04.history] window settled (nothing more reachable) — done=$done stalled=$stuck" } + } if (activeUser == user.pubkeyHex) _exhausted.value = ex } From a2b7eac701e46c84dac1bd285434a51a786a911e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 22:57:37 +0000 Subject: [PATCH 070/103] feat(dm): show 'waiting on N relays' on the paused history card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card's count was loadTracker.count() = relays in-flight, which drops to 0 the moment they all stall — so historySubtitle hit its relayCount<=0 guard and showed just the bare protocol tag ('NIP-17' / 'NIP-04'), with no hint of how many relays it was waiting on. Expose a stalledCount (not-done relays that can't be reached right now) from all three history loaders and render it on the paused card as 'waiting on N relays' when nothing is in flight. Active fetching still shows 'N relays'; a parked-but- reachable protocol still shows just the tag (it isn't waiting on anything — it resumes on scroll). --- .../AccountGiftWrapsHistoryEoseManager.kt | 7 ++++++ .../chats/feed/DmLoadMoreIndicator.kt | 25 +++++++++++++------ .../loggedIn/chats/feed/LoadingReplyNote.kt | 8 +++++- .../loggedIn/chats/privateDM/ChatroomView.kt | 6 +++-- .../ChatroomNip04HistorySubAssembler.kt | 6 +++++ .../ChatroomListNip04HistorySubAssembler.kt | 6 +++++ .../chats/rooms/feed/ChatroomListFeedView.kt | 6 +++-- amethyst/src/main/res/values/strings.xml | 1 + 8 files changed, 53 insertions(+), 12 deletions(-) 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 179cad473f..d84c9cbe36 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 @@ -92,6 +92,11 @@ class AccountGiftWrapsHistoryEoseManager( private val _relayCount = MutableStateFlow(0) val relayCount: StateFlow = _relayCount.asStateFlow() + // Relays that aren't done but can't be reached right now (auth CLOSE / unreachable / silent). Surfaced + // on the paused card as "waiting on N relays" — they aren't in-flight, so [relayCount] wouldn't show them. + private val _stalledCount = MutableStateFlow(0) + val stalledCount: StateFlow = _stalledCount.asStateFlow() + private val _reachedBack = MutableStateFlow(null) val reachedBack: StateFlow = _reachedBack.asStateFlow() @@ -199,6 +204,7 @@ class AccountGiftWrapsHistoryEoseManager( val start = startUntil(user.pubkeyHex) _reachedBack.value = pager.deepestReached(user.pubkeyHex, relays, start) val stalled = stalledRelays[user.pubkeyHex] ?: emptySet() + _stalledCount.value = relays.count { it in stalled && !pager.isDone(user.pubkeyHex, it) } _relayProgress.value = relays.associateWith { relay -> RelayPagingProgress( @@ -237,6 +243,7 @@ class AccountGiftWrapsHistoryEoseManager( loadTracker.reset() _exhausted.value = exhaustedByUser[user.pubkeyHex] ?: false _relayCount.value = 0 + _stalledCount.value = 0 _reachedBack.value = null _relayProgress.value = emptyMap() } 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 50208d4375..f854e0c75c 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 @@ -77,6 +77,7 @@ fun DmHistoryLoadingCard( loading: Boolean, exhausted: Boolean, relayCount: Int, + stalledCount: Int, reachedBack: Long?, modifier: Modifier = Modifier, ) { @@ -154,7 +155,7 @@ fun DmHistoryLoadingCard( if (done) { stringResource(R.string.chats_history_reached_start, protocolName) } else { - historySubtitle(protocolTag, relayCount, reachedBack) + historySubtitle(protocolTag, relayCount, stalledCount, reachedBack) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -170,19 +171,29 @@ fun DmHistoryLoadingCard( internal fun historySubtitle( protocolTag: String, relayCount: Int, + stalledCount: Int, reachedBack: Long?, ): String { - // A transient frame can carry loading=true with relayCount=0 (the count updates a beat after the - // spinner flips, and again as the last relay settles); don't render a nonsensical "0 relays". - if (relayCount <= 0) return protocolTag val backLabel = remember(reachedBack) { reachedBack?.let { SimpleDateFormat("MMM yyyy", Locale.getDefault()).format(Date(it * 1000)) } } - val relays = pluralStringResource(R.plurals.chats_history_relays, relayCount, relayCount) + // Middle segment: the relays actively fetching ("N relays"), or — when none are in flight but some + // can't be reached — what we're waiting on ("waiting on N relays"). With neither, just the tag, since + // a paged-out-but-parked protocol isn't waiting on anything (it resumes on scroll). + val middle = + when { + relayCount > 0 -> pluralStringResource(R.plurals.chats_history_relays, relayCount, relayCount) + stalledCount > 0 -> + stringResource( + R.string.chats_history_waiting, + pluralStringResource(R.plurals.chats_history_relays, stalledCount, stalledCount), + ) + else -> return protocolTag + } return if (backLabel != null) { - stringResource(R.string.chats_history_subtitle, protocolTag, relays, backLabel) + stringResource(R.string.chats_history_subtitle, protocolTag, middle, backLabel) } else { - stringResource(R.string.chats_history_subtitle_no_date, protocolTag, relays) + stringResource(R.string.chats_history_subtitle_no_date, protocolTag, middle) } } 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 325cc2d558..a0b88bb3b8 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 @@ -104,6 +104,11 @@ fun LoadingReplyNote( 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 @@ -117,6 +122,7 @@ fun LoadingReplyNote( val exhausted by exhaustedFlow.collectAsStateWithLifecycle() val relayCount by relayCountFlow.collectAsStateWithLifecycle() + val stalledCount by stalledCountFlow.collectAsStateWithLifecycle() val reachedBack by reachedBackFlow.collectAsStateWithLifecycle() LaunchedEffect(protocol, loadingFlow, exhaustedFlow) { @@ -180,7 +186,7 @@ fun LoadingReplyNote( // still asking, and how far back it has paged. Hidden once history runs dry. if (!isExhausted) { Text( - text = historySubtitle(protocolTag, relayCount, reachedBack), + text = historySubtitle(protocolTag, relayCount, stalledCount, reachedBack), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, 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 9231e0b4e4..c65f72bcfd 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 @@ -219,8 +219,10 @@ fun ChatroomViewUI( 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() @@ -271,8 +273,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, giftWrapsReached) - DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Exhausted, nip04Relays, nip04Reached) + DmHistoryLoadingCard(nip17Name, "NIP-17", loadingGiftWraps, giftWrapsExhausted, giftWrapsRelays, giftWrapsStalled, giftWrapsReached) + DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Exhausted, nip04Relays, nip04Stalled, nip04Reached) } }, // 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 d1d433a37b..9d90ac0d3a 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 @@ -79,6 +79,10 @@ class ChatroomNip04HistorySubAssembler( private val _relayCount = MutableStateFlow(0) val relayCount: StateFlow = _relayCount.asStateFlow() + // Not-done relays that can't be reached right now — shown as "waiting on N relays" on the paused card. + private val _stalledCount = MutableStateFlow(0) + val stalledCount: StateFlow = _stalledCount.asStateFlow() + private val _reachedBack = MutableStateFlow(null) val reachedBack: StateFlow = _reachedBack.asStateFlow() @@ -190,6 +194,7 @@ class ChatroomNip04HistorySubAssembler( val start = startUntil(pk) _reachedBack.value = pager.deepestReached(pk, relays.all, start) val stalled = stalledRelays[pk] ?: emptySet() + _stalledCount.value = relays.all.count { it in stalled && !pager.isDone(pk, it) } _relayProgress.value = relays.all.associateWith { relay -> RelayPagingProgress( @@ -225,6 +230,7 @@ class ChatroomNip04HistorySubAssembler( loadTracker.reset() _exhausted.value = exhaustedByConvo[pk] ?: false _relayCount.value = 0 + _stalledCount.value = 0 _reachedBack.value = null _relayProgress.value = emptyMap() } 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 721ca082d4..43e433d4a6 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 @@ -73,6 +73,10 @@ class ChatroomListNip04HistorySubAssembler( private val _relayCount = MutableStateFlow(0) val relayCount: StateFlow = _relayCount.asStateFlow() + // Not-done relays that can't be reached right now — shown as "waiting on N relays" on the paused card. + private val _stalledCount = MutableStateFlow(0) + val stalledCount: StateFlow = _stalledCount.asStateFlow() + private val _reachedBack = MutableStateFlow(null) val reachedBack: StateFlow = _reachedBack.asStateFlow() @@ -176,6 +180,7 @@ class ChatroomListNip04HistorySubAssembler( val start = startUntil(user.pubkeyHex) _reachedBack.value = pager.deepestReached(user.pubkeyHex, relays, start) val stalled = stalledRelays[user.pubkeyHex] ?: emptySet() + _stalledCount.value = relays.count { it in stalled && !pager.isDone(user.pubkeyHex, it) } _relayProgress.value = relays.associateWith { relay -> RelayPagingProgress( @@ -212,6 +217,7 @@ class ChatroomListNip04HistorySubAssembler( loadTracker.reset() _exhausted.value = exhaustedByUser[user.pubkeyHex] ?: false _relayCount.value = 0 + _stalledCount.value = 0 _reachedBack.value = null _relayProgress.value = emptyMap() } 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 9189d8b3ab..e05edb9d62 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 @@ -171,8 +171,10 @@ private fun FeedLoaded( // 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() @@ -228,10 +230,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, giftWrapsReached) + DmHistoryLoadingCard(nip17Name, "NIP-17", loadingGiftWraps, giftWrapsExhausted, giftWrapsRelays, giftWrapsStalled, giftWrapsReached) } if (index == oldestNip04Index) { - DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Exhausted, nip04Relays, nip04Reached) + DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Exhausted, nip04Relays, nip04Stalled, nip04Reached) } // Per-relay window-limit markers/sentinels belonging in the gap toward the next-older room: diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 5bcb9d3f40..76f759f4dd 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -284,6 +284,7 @@ Looking for the original message… %1$s · %2$s · back to %3$s %1$s · %2$s + waiting on %1$s %1$d relay %1$d relays From 61324aa7f0c1bdba2cf827a464a195061c6fde62 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 23:04:27 +0000 Subject: [PATCH 071/103] feat(dm): tap the history card to see per-relay window positions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card summarized progress but hid the per-relay detail that relayProgress already carries. Make the card tappable: it opens a popup listing every relay with its state (✓ done · … stalled · ↓ reaching) and how far back it has paged ('back to '), deepest-reaching first. Passes each loader's relayProgress through to the card; empty progress keeps the card non-interactive. --- .../chats/feed/DmLoadMoreIndicator.kt | 100 +++++++++++++++++- .../loggedIn/chats/privateDM/ChatroomView.kt | 4 +- .../chats/rooms/feed/ChatroomListFeedView.kt | 4 +- amethyst/src/main/res/values/strings.xml | 2 + 4 files changed, 105 insertions(+), 5 deletions(-) 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 f854e0c75c..18204e83cd 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 @@ -26,19 +26,25 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -47,11 +53,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource 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.normalizer.NormalizedRelayUrl import kotlinx.coroutines.delay import java.text.SimpleDateFormat import java.util.Date @@ -69,6 +79,8 @@ private const val ALL_DONE_VISIBLE_MS = 2200L * @param protocolName human label woven into sentences, e.g. "encrypted" / "legacy". * @param protocolTag short technical tag for the subtitle, e.g. "NIP-17" / "NIP-04". * @param reachedBack epoch seconds of the oldest point reached so far (the deepest `until` cursor). + * @param relayProgress per-relay reach (where each relay's window is, done/stalled). Tapping the card + * opens a popup listing them; pass empty to make the card non-interactive. */ @Composable fun DmHistoryLoadingCard( @@ -79,6 +91,7 @@ fun DmHistoryLoadingCard( relayCount: Int, stalledCount: Int, reachedBack: Long?, + relayProgress: Map = emptyMap(), modifier: Modifier = Modifier, ) { // Once exhausted, show "All caught up" for a beat, then collapse. Reset if it un-exhausts. @@ -93,6 +106,11 @@ fun DmHistoryLoadingCard( } } + var showRelays by remember { mutableStateOf(false) } + if (showRelays) { + DmHistoryRelayDialog(protocolTag, relayProgress) { showRelays = false } + } + AnimatedVisibility( visible = !collapsed, modifier = modifier, @@ -103,7 +121,8 @@ fun DmHistoryLoadingCard( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), + .padding(horizontal = 16.dp, vertical = 8.dp) + .then(if (relayProgress.isNotEmpty()) Modifier.clickable { showRelays = true } else Modifier), shape = RoundedCornerShape(14.dp), color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), tonalElevation = 2.dp, @@ -197,3 +216,82 @@ internal fun historySubtitle( stringResource(R.string.chats_history_subtitle_no_date, protocolTag, middle) } } + +/** + * Popup shown when the history card is tapped: one row per relay with its state glyph (✓ done, … stalled, + * ↓ still reaching) and how far back it has paged ("back to "), deepest-reaching first. + */ +@Composable +private fun DmHistoryRelayDialog( + protocolTag: String, + relayProgress: Map, + onDismiss: () -> Unit, +) { + val df = remember { SimpleDateFormat("MMM d, yyyy", Locale.getDefault()) } + val rows = remember(relayProgress) { relayProgress.entries.sortedBy { it.value.reachedUntil } } + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dismiss)) } + }, + title = { Text(stringResource(R.string.chats_history_relays_title, protocolTag)) }, + text = { + Column( + Modifier + .heightIn(max = 360.dp) + .verticalScroll(rememberScrollState()), + ) { + rows.forEach { (relay, p) -> + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = relayStateGlyph(p), + color = relayStateColor(p), + fontWeight = FontWeight.Bold, + modifier = Modifier.width(22.dp), + ) + Text( + text = relayShortName(relay), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(R.string.chats_history_relay_back, df.format(Date(p.reachedUntil * 1000))), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } + } + }, + ) +} + +private fun relayStateGlyph(p: RelayPagingProgress) = + when { + p.done -> "✓" + p.stalled -> "…" + else -> "↓" + } + +@Composable +private fun relayStateColor(p: RelayPagingProgress): Color = + when { + p.done -> MaterialTheme.colorScheme.primary + p.stalled -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + +private fun relayShortName(relay: NormalizedRelayUrl): String = + relay.url + .substringAfter("://") + .trimEnd('/') + .substringBefore('/') 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 c65f72bcfd..3941b836d6 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 @@ -273,8 +273,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) - DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Exhausted, nip04Relays, nip04Stalled, nip04Reached) + DmHistoryLoadingCard(nip17Name, "NIP-17", loadingGiftWraps, giftWrapsExhausted, giftWrapsRelays, giftWrapsStalled, giftWrapsReached, giftWrapsProgress) + DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Exhausted, nip04Relays, nip04Stalled, nip04Reached, nip04Progress) } }, // 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/rooms/feed/ChatroomListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt index e05edb9d62..7b3d12603e 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 @@ -230,10 +230,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) + DmHistoryLoadingCard(nip17Name, "NIP-17", loadingGiftWraps, giftWrapsExhausted, giftWrapsRelays, giftWrapsStalled, giftWrapsReached, giftWrapsProgress) } if (index == oldestNip04Index) { - DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Exhausted, nip04Relays, nip04Stalled, nip04Reached) + DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Exhausted, nip04Relays, nip04Stalled, nip04Reached, nip04Progress) } // Per-relay window-limit markers/sentinels belonging in the gap toward the next-older room: diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 76f759f4dd..eefc29e15c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -285,6 +285,8 @@ %1$s · %2$s · back to %3$s %1$s · %2$s waiting on %1$s + %1$s · history by relay + back to %1$s %1$d relay %1$d relays From 813110cc292470f800e6e688607a5df52032376e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 00:53:55 +0000 Subject: [PATCH 072/103] fix(dm): distinguish 'caught up' from 'couldn't reach relays' in history/reply cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The terminal state of both DM history cards equated two very different things: a relay that genuinely bottomed out (empty page = done) and one that merely stalled (auth CLOSE / offline / 15s silent). `exhausted` flips true when every relay is done OR stalled, and the card rendered that unconditionally as "All caught up" — so a chat whose messages sit on a stalled relay claimed completion while a lot of history was still unfetched. The reply placeholder had the same flaw and, worse, collapsed to a bare 👀 (post_not_found_short) that told the user nothing. Split the terminal state by stalledCount: - caughtUp (all done): keeps "All caught up" and the lingering collapse. - incomplete (>=1 stalled): "Some relays didn't respond · N unreachable", error-coloured glyph, stays put (no collapse). Both cards are tappable into the existing per-relay popup, so the user can see exactly which relays stalled (… in error colour) vs reached the bottom (✓). The reply placeholder now says "Couldn't find this message" with an honest subtitle — "N relays unreachable · tap to see which" when stalled, or "Searched every relay · tap to see" when it genuinely isn't in history — instead of 👀. --- .../chats/feed/DmLoadMoreIndicator.kt | 102 ++++++++++++------ .../loggedIn/chats/feed/LoadingReplyNote.kt | 82 ++++++++++---- amethyst/src/main/res/values/strings.xml | 9 ++ 3 files changed, 139 insertions(+), 54 deletions(-) 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 18204e83cd..802c1981e5 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 @@ -94,11 +94,18 @@ fun DmHistoryLoadingCard( relayProgress: Map = emptyMap(), modifier: Modifier = Modifier, ) { - // Once exhausted, show "All caught up" for a beat, then collapse. Reset if it un-exhausts. + // Exhausted ("nothing more reachable right now") splits two ways and must NOT read the same: + // - caughtUp: every relay genuinely bottomed out (empty page). This is the real "all caught up". + // - incomplete: we stopped only because some relays are stalled (auth-walled / offline / silent), + // so messages may still be out there. It must say so, stay put, and let the user tap to see which. + val caughtUp = exhausted && stalledCount <= 0 + val incomplete = exhausted && stalledCount > 0 + + // Only the genuine caught-up state lingers then collapses; an incomplete window stays so it can be acted on. var collapsed by remember { mutableStateOf(false) } - LaunchedEffect(exhausted) { + LaunchedEffect(caughtUp) { collapsed = - if (exhausted) { + if (caughtUp) { delay(ALL_DONE_VISIBLE_MS) true } else { @@ -127,7 +134,13 @@ fun DmHistoryLoadingCard( color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), tonalElevation = 2.dp, ) { - Crossfade(targetState = exhausted, animationSpec = tween(500), label = "dmHistoryState") { done -> + val phase = + when { + caughtUp -> HistoryPhase.CaughtUp + incomplete -> HistoryPhase.Incomplete + else -> HistoryPhase.Loading + } + Crossfade(targetState = phase, animationSpec = tween(500), label = "dmHistoryState") { state -> Row( Modifier .fillMaxWidth() @@ -135,35 +148,47 @@ fun DmHistoryLoadingCard( verticalAlignment = Alignment.CenterVertically, ) { Box(Modifier.size(22.dp), contentAlignment = Alignment.Center) { - if (done) { - Text( - "✓", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - ) - } else if (loading) { - CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) - } else { - // Paused: not caught up, but not actively loading (the rooms-list auto-fill - // stopped short of exhaustion, or we're between round-model pages). Show a - // static "more" glyph so the icon slot is never blank — loading resumes on scroll. - Text( - "⋯", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + when (state) { + HistoryPhase.CaughtUp -> + Text( + "✓", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + HistoryPhase.Incomplete -> + // Same glyph the per-relay dialog uses for a stalled relay, same error colour — + // signals "stopped early, some relays didn't answer", not "done". + Text( + "…", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.error, + ) + HistoryPhase.Loading -> + if (loading) { + CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + // Paused: not caught up, but not actively loading (the rooms-list auto-fill + // stopped short of exhaustion, or we're between round-model pages). Show a + // static "more" glyph so the icon slot is never blank — resumes on scroll. + Text( + "⋯", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } Spacer(Modifier.width(14.dp)) Column(Modifier.weight(1f)) { Text( text = - if (done) { - stringResource(R.string.chats_history_all_caught_up) - } else { - stringResource(R.string.chats_history_older, protocolName) + when (state) { + HistoryPhase.CaughtUp -> stringResource(R.string.chats_history_all_caught_up) + HistoryPhase.Incomplete -> stringResource(R.string.chats_history_incomplete) + HistoryPhase.Loading -> stringResource(R.string.chats_history_older, protocolName) }, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, @@ -171,10 +196,10 @@ fun DmHistoryLoadingCard( ) Text( text = - if (done) { - stringResource(R.string.chats_history_reached_start, protocolName) - } else { - historySubtitle(protocolTag, relayCount, stalledCount, reachedBack) + when (state) { + HistoryPhase.CaughtUp -> stringResource(R.string.chats_history_reached_start, protocolName) + HistoryPhase.Incomplete -> incompleteSubtitle(stalledCount) + HistoryPhase.Loading -> historySubtitle(protocolTag, relayCount, stalledCount, reachedBack) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -186,6 +211,19 @@ fun DmHistoryLoadingCard( } } +/** The three terminal-vs-loading faces of the history card: still loading/paused, genuinely caught up, or + * stopped early because relays stalled. Kept distinct so an incomplete window never reads as "all caught up". */ +private enum class HistoryPhase { Loading, CaughtUp, Incomplete } + +/** Subtitle for the "stopped early" state: how many relays we couldn't reach, with a hint to tap for the list. + * Shared with the reply placeholder so both read identically. */ +@Composable +internal fun incompleteSubtitle(stalledCount: Int): String = + stringResource( + R.string.chats_history_incomplete_sub, + pluralStringResource(R.plurals.chats_history_relays, stalledCount, stalledCount), + ) + @Composable internal fun historySubtitle( protocolTag: String, @@ -222,7 +260,7 @@ internal fun historySubtitle( * ↓ still reaching) and how far back it has paged ("back to "), deepest-reaching first. */ @Composable -private fun DmHistoryRelayDialog( +internal fun DmHistoryRelayDialog( protocolTag: String, relayProgress: Map, onDismiss: () -> Unit, 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 a0b88bb3b8..82e419beb6 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 @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -38,7 +39,9 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight @@ -46,8 +49,10 @@ 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.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -114,6 +119,11 @@ fun LoadingReplyNote( DmReplyProtocol.NIP17 -> giftWrapsHistory.reachedBack DmReplyProtocol.NIP04 -> nip04History.reachedBack } + val relayProgressFlow: StateFlow> = + when (protocol) { + DmReplyProtocol.NIP17 -> giftWrapsHistory.relayProgress + DmReplyProtocol.NIP04 -> nip04History.relayProgress + } val protocolTag = when (protocol) { DmReplyProtocol.NIP17 -> "NIP-17" @@ -124,6 +134,7 @@ fun LoadingReplyNote( val relayCount by relayCountFlow.collectAsStateWithLifecycle() val stalledCount by stalledCountFlow.collectAsStateWithLifecycle() val reachedBack by reachedBackFlow.collectAsStateWithLifecycle() + val relayProgress by relayProgressFlow.collectAsStateWithLifecycle() LaunchedEffect(protocol, loadingFlow, exhaustedFlow) { // Step the next, older page whenever the previous one has settled and history isn't exhausted. @@ -141,30 +152,54 @@ fun LoadingReplyNote( } } + // Tapping opens the same per-relay popup the history card uses, so when the search gives up the user + // can see exactly which relays were reached and which stalled. Empty progress keeps it non-interactive. + var showRelays by remember { mutableStateOf(false) } + if (showRelays) { + DmHistoryRelayDialog(protocolTag, relayProgress) { showRelays = false } + } + // Same chrome as DmHistoryLoadingCard (the older-history status card at the oldest end) so an // unloaded reply reads as the same kind of "reaching back into history" state, just inline in the // quote: rounded translucent surface, a spinner-in-a-box, then the status line. Surface( - modifier = modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + .then(if (relayProgress.isNotEmpty()) Modifier.clickable { showRelays = true } else Modifier), shape = RoundedCornerShape(14.dp), color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), tonalElevation = 2.dp, ) { + // When the walk gives up it splits the same way the history card does: some relays stalled + // (couldn't reach them — the message may still be out there) vs every relay genuinely bottomed + // out (it really isn't in your history). Either way we say what happened instead of a bare glyph. + val stalledOut = exhausted && stalledCount > 0 Crossfade(targetState = exhausted, animationSpec = tween(500), label = "loadingReplyState") { isExhausted -> Row( Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { Box(Modifier.size(22.dp), contentAlignment = Alignment.Center) { - if (isExhausted) { - Text( - "⋯", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + when { + stalledOut -> + // Stalled-out: same red "…" the per-relay dialog and history card use for unreachable. + Text( + "…", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.error, + ) + isExhausted -> + // Genuinely searched everything and it isn't there. + Text( + "✕", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + else -> CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) } } Spacer(Modifier.width(14.dp)) @@ -172,7 +207,7 @@ fun LoadingReplyNote( Text( text = if (isExhausted) { - stringRes(R.string.post_not_found_short) + stringRes(R.string.chats_reply_not_found) } else { stringRes(R.string.chats_reply_searching_history) }, @@ -182,17 +217,20 @@ fun LoadingReplyNote( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - // Same status line as the oldest-end card: which protocol, how many relays it's - // still asking, and how far back it has paged. Hidden once history runs dry. - if (!isExhausted) { - Text( - text = historySubtitle(protocolTag, relayCount, stalledCount, reachedBack), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } + // While loading: which protocol, how many relays, how far back. When it gives up: either + // "N relays unreachable · tap to see which" (stalled) or "searched every relay · tap to see". + Text( + text = + when { + stalledOut -> incompleteSubtitle(stalledCount) + isExhausted -> stringRes(R.string.chats_reply_searched) + else -> historySubtitle(protocolTag, relayCount, stalledCount, reachedBack) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index eefc29e15c..df2f52b147 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -287,6 +287,15 @@ waiting on %1$s %1$s · history by relay back to %1$s + + Some relays didn\'t respond + + %1$s unreachable · tap to see which + + Couldn\'t find this message + + Searched every relay · tap to see %1$d relay %1$d relays From 0e4d2e96bc18222572a69c7608f8baa031e3101c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 13:19:24 +0000 Subject: [PATCH 073/103] docs(dm): rewrite the DM pagination design doc to match the code The doc had drifted from the implementation on two material points: - "Update 3" claimed the rooms-list and gift-wrap *history* managers still use the round model. They were both migrated to the per-relay until+limit model (commits 60b8629a, 9f0ecd54); all history paging is now per-relay. The round model (WindowLoadTracker) survives only as the live-tail completion barrier. - The rooms-list "stall-gate" it described was removed (commit 98fb8720); the rooms list now pages to exhaustion off marker visibility like the convo. Restructured so the current architecture is authoritative and up front (two layers, the two completion models and where each lives, the marker/sentinel driver, per-relay NIP-04 filter scoping, terminal-state split, reply placeholder, diagnostics, Tor self-heal), with a component map and review notes. The superseded time-slice and round-model history are kept clearly labelled under "Design evolution (historical)". --- ...6-06-01-dm-live-tail-and-history-slices.md | 372 ++++++++++++------ 1 file changed, 248 insertions(+), 124 deletions(-) diff --git a/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md b/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md index 4be0d089c0..7eff4de30e 100644 --- a/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md +++ b/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md @@ -1,4 +1,10 @@ -# DM loading: live tail + bounded history slices +# DM loading: live tail + per-relay history paging + +> **Status:** authoritative as of 2026-06-05. The "Current architecture" +> section below describes the code as it actually stands. The original +> time-slice design and the round-model history are kept at the bottom under +> **Design evolution (historical)** — they are superseded and no longer match +> the code; don't trust them for how it works today. ## Problem @@ -14,161 +20,279 @@ re-streamed the entire history from the new floor. Traces showed this directly: ``` Each step re-downloaded everything it already had plus the new slice — the -"getting all events over and over again" the window owner reported. It also -cascaded: a few pixels of scroll walked the window to the 10-year backstop, -because widening pulls older *messages* but the rooms list is keyed by -*conversation*, so a handful of busy correspondents flood thousands of events -without adding a single new row, and the "scrolled near the oldest room" trigger -never clears. +"getting all events over and over again" the owner reported. It also cascaded: +a few pixels of scroll walked the window to the 10-year backstop, because +widening pulls older *messages* but the rooms list is keyed by *conversation*, +so a handful of busy correspondents flood thousands of events without adding a +single new row, and the "scrolled near the oldest room" trigger never clears. -## Design (owner's call) +--- -Split each DM protocol into two responsibilities: +## Current architecture -1. **Live tail** — keep the existing filters at a fixed ~1-week floor with **no - `until`** (open to the future). Never widens. New messages always arrive. -2. **History slices** — new assemblers that load *the past* in **bounded - `since`+`until` slices**, each fetched once. Widening fetches only the new - band `[newFloor, previousFloor]`; the data already held in `[previousFloor, - now]` is never re-requested. +### Two layers per protocol -Because consecutive slices are disjoint, re-issuing the (advanced) historical -filter does not re-stream earlier slices — they live in `LocalCache`. The -NIP-17 ±2-day wrapper-timestamp margin is applied to the slice `since` (via -`filterGiftWrapsToPubkey`), giving a 2-day overlap between adjacent slices so no -gap can open from a randomized outer timestamp. NIP-04 (kind 4) uses exact -timestamps, so its slices need no margin. +Each DM protocol — **NIP-17** gift wraps (kind 1059) and **NIP-04** legacy DMs +(kind 4) — is split into two independent responsibilities: -### Slice math (gift-wrap history window) +1. **Live tail** — a fixed ~1-week floor with **no `until`**, open to the + future. Never widens. New messages always arrive here. Backed by the + **round model** (`WindowLoadTracker`): one REQ fanned to every relay, "done" + when all settle, drives the boot spinner. +2. **History** — everything *older* than the week floor, paged **backward by + `until`+`limit`, per relay, on demand**. Backed by the **per-relay model** + (`UntilLimitPager` + `PerRelayLoadTracker`), driven by on-screen markers. -> Superseded by the two updates below — kept for the history of the design. The -> `TimeWindowPagination` class this described has been removed; the history -> managers now page by `until`+`limit` per relay (`UntilLimitPager`). +The two are disjoint in time, so re-issuing a history page never re-streams the +live tail, and consecutive history pages never re-stream each other. -`TimeWindowPagination.since` starts at `now − 1week` (= the live-tail floor). +| Surface | Live-tail manager (round) | History manager (per-relay) | +|---|---|---| +| Account gift wraps (NIP-17) | `AccountGiftWrapsEoseManager` | `AccountGiftWrapsHistoryEoseManager` | +| Conversation NIP-04 | `ChatroomNip04SubAssembler` | `ChatroomNip04HistorySubAssembler` | +| Rooms-list NIP-04 | `ChatroomListNip04SubAssembler` | `ChatroomListNip04HistorySubAssembler` | -- `loadMore`: `until = window.since` (current floor); `window.loadMore()` moves - `since` back geometrically; new slice = `[window.since, until]`. -- `loadEverything`: `until = window.since`; `window.loadAll()` → `since = floor`; - slice = `[floor, until]` — one request for the remaining past. -- `updateFilter` returns the **current slice** only (or empty before the first - `loadMore`), so the manager is idle until the user asks for older history. +Accessed from the UI via `accountViewModel.dataSources()` as +`.account.giftWrapsHistory`, `.chatroom.nip04History`, +`.chatroomList.nip04History`. -### Rooms-list cascade stop (stall-gate) +### The history paging primitive: `UntilLimitPager` -The rooms list auto-fill widens only while it makes progress: it remembers the -private-room count at the last widen and stops once a widen brings in no new -private room (kept on the history manager so it survives leaving/reopening the -screen). "Fill until full **or nothing new found**", instead of walking to the -10-year backstop. +The time-window model can't tell "this relay is empty" from "this is a gap" — a +`since`/`until` slice that returns nothing might just be a quiet stretch above +older messages. Paging by `until`+`limit` removes that ambiguity: a relay +returns up to `limit` (**10000**) of its newest events older than the cursor, +**skipping gaps**, so an **empty page + EOSE is a gap-proof "nothing older"**. -## Touch list +Per relay, two cursors are kept deliberately decoupled: -- `commons/.../FilterGiftWrapsToPubkey.kt`, `amethyst/.../FilterNip04DMsToMe.kt`, - `FilterNip04DMsFromMe.kt` — add optional `until`. -- `AccountGiftWrapsEoseManager` — becomes the live tail (fixed week, no until). -- `AccountGiftWrapsHistoryEoseManager` (new) — owns the window, bounded slices, - `loadMore`/`loadEverything`/`exhausted`, per-load instrumentation. -- `ChatroomListNip04SubAssembler` / `ChatroomNip04SubAssembler` — live tail. -- `ChatroomListNip04HistorySubAssembler` / `ChatroomNip04HistorySubAssembler` - (new) — follow the gift-wrap history slice bounds. -- `AccountFilterAssembler`, `ChatroomListFilterAssembler`, - `ChatroomFilterAssembler`, `RelaySubscriptionsCoordinator` — wire the new - managers. -- `ChatroomListFeedView`, `ChatroomView` — point "load older" at the history - managers; combine live+history `loadingMore` for spinners; add the stall-gate. +- `requestedUntil` — the `until` the REQ carries. Moves **only** in `advance()`. + Leaving it untouched on EOSE is what makes paging demand-driven: a relay that + finished a page just **parks** at the same filter (no re-REQ) until advanced. +- `reachedUntil` — the oldest `created_at` actually delivered. Moves on EOSE. + The in-stream markers sit here; the next page starts at `reachedUntil − 1`. -## Update: time-slices → `until`+`limit` paging +Stop signals: an empty page marks the relay **`done`**. A relay returning fewer +than `limit` is treated as its own cap, **not** exhaustion. A misbehaving relay +that returns events but none older than already reached (echoing its newest +events) is also treated as the bottom, so its marker can't re-request the same +window forever. Tested in `UntilLimitPagerTest.kt`. -The time-slice history (above) bounded re-downloads but still couldn't tell -"this relay is empty" from "this is a gap" — an empty slice might sit above -older messages, so the only stop was the 10-year `maxLookback`, and a wide late -slice could pull a 20k-event firehose. +### The two completion models (and where each lives) -The history managers now page **backward by `until`+`limit`, per relay** -(`UntilLimitPager`). A relay returns up to `limit` (10000) events older than its -cursor, **skipping gaps**, so an empty page + EOSE is a gap-proof "nothing older" -signal. A relay returning fewer is treated as its own cap, not exhaustion (only -empty ends it). A relay answering CLOSED isn't "empty" (it may answer post-auth), -so the **global** exhausted flag flips only when a whole round advances no relay -at all. `limit` also caps per-request volume. +**Round model — `WindowLoadTracker` (live tail only).** One REQ is fanned to all +relays; the window is "done" only when **every** expected relay reaches a +terminal signal (`settled ⊇ expected`), with backstops for stragglers (idle / +silence / connect-grace / absolute cap). `loading` starts **`true`**. This is a +*barrier*: nobody moves on until the cohort answers. It is the right shape for +the one-shot fixed-window backfill the live tail does. -Both NIP-04 history managers now paginate themselves (per relay) instead of -following the gift-wrap slice; `loadEverything` pages to the end by auto-issuing -the next round until exhausted. The live tail and stall-gate are unchanged. +> Note: the silence + connect-grace backstops are gated behind `tracksReqSends`, +> and **none of the three live-tail managers pass `tracksReqSends = true`**, so +> in current use only the settle / idle / cap paths ever fire. The REQ-aware +> machinery is dormant in production — see "Things to scrutinize". -## Update 2: NIP-04 filters scoped per relay +**Per-relay model — `UntilLimitPager` + `PerRelayLoadTracker` (all history).** +Each relay advances to its next page the instant *it* EOSEs, independent of the +others; the subscription layer diffs per relay, so re-issuing only re-REQs the +relay whose cursor moved. `loading` starts **`false`** (a `true` start would +wedge the scroll loader's `!loading` gate on first open). Fast relays race to +the bottom in back-to-back pages; slow / auth-walled relays catch up at their +own pace and **none are abandoned** — a stalled relay keeps its subscription +open and resumes when re-advanced. This removes the round model's +slowest-relay coupling, which matters most on the conversation screen where the +fan-out includes correspondents' (often auth-walled, slow) relays. -A conversation's NIP-04 filters named the whole participant set on every relay, -so a relay that belongs to one correspondent was still asked about all of them -(`{authors:[bob,charlie]}` sent to a relay that is only charlie's), and the -`from-me` leg (`authors:[me]`) was sent to the correspondents' inbox relays — -which auth-walled relays (ditto: "all authors must be authenticated") reject -outright, stalling the load. +`exhausted` (per history manager) flips true when **every relay is `done` OR +`stalled`** — "nothing more reachable right now". A merely *parked* relay (more +to load, just not advancing) keeps it false. -`Nip04DmRelays` is now two **per-relay key maps** (`relay → which keys to name -there`), built from the outbox model: +> All three history managers (`AccountGiftWrapsHistoryEoseManager`, +> `ChatroomNip04HistorySubAssembler`, `ChatroomListNip04HistorySubAssembler`) +> are structurally the same per-relay loader. The earlier round-model history +> (and the rooms-list "stall-gate") was fully removed — see Design evolution. + +### What drives `advance()`: on-screen markers, off viewport visibility + +History paging is demand-driven by **per-relay window-limit markers** placed in +the message stream, not by a scroll-position trigger: + +- **`RelayWindowLimit`** — one per (protocol, relay): its `reachedUntil` depth, + its `RelayReachState` (`REACHING ↓` / `STALLED …` / `DONE ✓`), and the + `advance()` that pulls *that relay's* next page. Built in the feed views from + each history manager's `relayProgress` map (gift wraps + NIP-04 combined; a + protocol drops out of the list once `exhausted`). +- **`RelayWindowLimitSentinels`** — the load *driver*, **hoisted above the + `LazyColumn`** (via `ChatFeedView`'s `sentinels` slot). Each non-done limit + gets one stable effect (keyed by `protocol:url`) that watches `listState` and + fires `advance()` when its gap is among the **currently visible rows** AND + either it just scrolled into view OR its `reachedUntil` moved (a page landed — + keep paging while visible). Driving off **viewport visibility** instead of row + composition is deliberate: an earlier version placed the sentinel *inside* the + hosting row, so any feed reorder (a live DM, a slow relay dribbling a page) + tore the effect down and re-fired `advance()` on a static screen — re-arming + stalled relays into a silence-watchdog storm. (commit `0394ec2a`) +- **`RelayWindowLimitMarkers` / `RelayReachMarker`** — pure UI (via the + `markersInGap` slot): the "Relay sync: ✓ 8 · ↓ 1" divider at each relay's + reached depth. Can be re-placed on every reorder without triggering paging. +- **`BootstrapHistoryWhenEmpty`** — when the feed is genuinely `Empty` (the live + tail came back empty for a thread/list whose newest message is older than a + week) there are no rows to host markers, so this steps every relay one page at + a time (debounced 1200ms, gated per loader on `!loading && !exhausted`) until + messages appear and the markers take over, or the protocol exhausts. + +### NIP-04 per-relay filter scoping (`Nip04DmRelays`) + +A conversation's NIP-04 filters previously named the whole participant set on +every relay, so a relay belonging to one correspondent was asked about all of +them, and the `from-me` leg (`authors:[me]`) was sent to correspondents' inbox +relays — which auth-walled relays reject outright ("all authors must be +authenticated"), stalling the load. + +`Nip04DmRelays` (in `FilterNip04DMs.kt`) is now two **per-relay key maps** +(`relay → which keys to name there`), built from the outbox model: - **to me** (`#p:[me]`) — my inbox carries the whole group; each correspondent's outbox carries only that correspondent. - **from me** (`authors:[me]`) — my outbox carries the whole group; each correspondent's inbox carries only that correspondent. -Relays shared across roles union their key sets, so a relay only ever sees the -keys that actually own it. +So a relay only ever sees the keys that actually own it. The **conversation** +history manager scopes its REQ to the armed relays' key sets this way; the +**rooms-list** and **gift-wrap** history managers query only the account's *own* +relays (home outbox `from-me` + DM inbox `to-me`, via `filterNip04DMsFromMe` / +`filterNip04DMsToMe` and `filterGiftWrapsToPubkey`), which is why their fan-out +stays fast and reachable. -## Update 3: per-relay independent paging + in-stream markers (convo only) +### Status card terminal states (`DmHistoryLoadingCard`) -The round model paced every relay at the slowest one: each `loadMore` issued one -page to all active relays and waited for the slowest to settle before the next. -Fast own-relays that hold the whole conversation were stuck behind a -correspondent's 15 s timeout. +One card per protocol at its oldest-loaded boundary. While paging it shows the +protocol tag, "N relays" being asked, and the reach-back date; it is tappable +into a per-relay popup (`DmHistoryRelayDialog`) listing every relay with +`✓` done / `…` stalled / `↓` reaching and how far back each paged. -`ChatroomNip04HistorySubAssembler` was rewritten to page **each relay -independently, no rounds**. A relay continues to its next page the instant *it* -EOSEs (the subscription layer diffs per relay, so re-issuing only re-REQs the -relay whose cursor moved; the others' in-flight REQs are untouched). Fast relays -race to the bottom in back-to-back pages; slow / auth-walled relays catch up at -their own pace — **none are abandoned** (they keep their subscription open and -keep trying), so every relay converges on the same window. +Because `exhausted` conflates `done` and `stalled`, the terminal state is split +on `stalledCount` so it can't overclaim (commit `813110cc`): -- A relay is **done** on an empty page; one that won't answer (auth CLOSE, - unreachable, silent) is flagged **stalled** but kept open. -- `loadingMore` reflects "is anything still advancing"; it clears once every - relay is done or stalled. It is exposed as a flow that **starts `false`** (not - `windowLoad.loading`, which starts `true` and would wedge the scroll loader's - `!loading` gate on first open), and the assembler tracks `windowActive` itself - so the first `loadMore` actually starts the window. -- `relayProgress` (`relay → reached-back / done / stalled`) feeds **in-stream - markers** (`RelayReachMarker`, wired through `ChatFeedView.markersInGap`): a - thin divider per relay at the depth it has reached, sliding down as it pages - and converging — `↓` reaching, `…` stalled, `✓` done. +- **caught up** (every relay `done`, `stalledCount == 0`) → "All caught up", + lingers ~2.2s then collapses. +- **incomplete** (≥1 stalled) → "Some relays didn't respond · N unreachable", + error-coloured `…`, **stays put** (no collapse), tappable to see which. -The **rooms-list and gift-wrap** history managers still use the round model -(`AccountGiftWrapsHistoryEoseManager`, -`ChatroomListNip04HistorySubAssembler`) — they query only the account's own -(fast, reachable) relays, so the lock-step never bites there. Only the -conversation screen, which fans out to correspondents' relays, needed the -per-relay rewrite. +### Reply placeholder (`LoadingReplyNote`) -### Window completion backstops (`WindowLoadTracker`) +A reply whose target message hasn't been paged in yet isn't *missing*, it's +older than the loaded window (and for gift wraps the rumor id isn't even +queryable — only the outer 1059 wrap is). Instead of the generic `BlankNote` +("post not found"), `LoadingReplyNote` actively walks the relevant protocol's +history backward (kicking `advanceAll` each time a page settles) until the +target decrypts (the surrounding `WatchNoteEvent` crossfades the real message in +and disposes this) or the protocol exhausts. Its terminal state mirrors the +card: "Couldn't find this message" + an honest subtitle ("N relays unreachable · +tap to see which" when stalled, "Searched every relay · tap to see" when +genuinely done), tappable into the same per-relay popup. Wired via +`ChatMessageCompose.RenderReply` → `WatchNoteEvent(onBlank = …)`, with the pager +chosen by the parent event's protocol (`DmReplyProtocol.NIP17` / `NIP04`). -The shared window tracker finishes when every relay reaches a terminal signal -(EOSE / CLOSED / cannot-connect), with three backstops for misbehaving relays: -**idle** (every still-waited relay was heard from and the stream went quiet), -**silence** (a relay that got its REQ but answered nothing for 10 s), and -**connect-grace** (a relay that never even received its REQ within 15 s, stuck -connecting). The two REQ-aware backstops are gated behind `tracksReqSends`, set -only by the convo manager — without it an always-empty `reqSentAt` would make -every relay look connect-stalled and complete the window before its REQs even -went out. Silent relays are reported via `onAbandoned`; the tracker only stops -waiting, the owner decides what to do (the convo keeps them and flags stalled). +### Diagnostics -## Diagnostics - -The whole path logs under one tag, **`DMPagination`** (debug builds): +Everything logs under one tag, **`DMPagination`** (debug builds): `DmRelayDiagnosticsLogger` folds the per-relay connection timeline (REQ sent, connect/disconnect, CLOSED/NOTICE/OK-fail) into it; `DmRelayLog` prints the -"relays by source" breakdown per subscription; and each assembler logs its -milestones (paging start, a relay reaching the bottom / stalling with the -reason, the settle summary of done-vs-still-trying). +"relays by source" breakdown (NIP-65 in/out, DM list, private storage, local) +per subscription so an unexpected relay can be traced to the list it leaks in +from; and each assembler logs its milestones (paging start, a relay reaching +the bottom / stalling with the reason, the "window settled" summary of +done-vs-still-trying, each marker fire). + +### Related fix: Tor guard-sample self-heal + +`TorService` gained a `noUsableGuards()` check that, on init, inspects Arti's +persisted `guards.json` and wipes the on-disk state if a non-empty guard set has +**zero** usable guards (all `disabled` / `unlisted`). This recovers the +long-standing "can't connect to Tor → relays permanently unreachable" wedge +(Arti disables guards past a 0.7 indeterminate-failure ratio, never re-enables +them, and can't replenish once the 60-slot sample is full). Orthogonal to +pagination, but it lived here because unreachable relays were part of the same +"DM history stuck / relays never answer" symptom this branch chased. + +--- + +## Component map (vs `origin/main`) + +**New, transport-agnostic (`service/relayClient/eoseManagers/`)** +- `UntilLimitPager.kt` — per-relay `until`+`limit` cursor. *(+ test)* +- `PerRelayLoadTracker.kt` — per-relay in-flight tracker + silence watchdog. +- `WindowLoadTracker.kt` — round/barrier completion tracker (live tail). *(+ silence test)* +- `RelayPagingProgress.kt` — `(reachedUntil, done, stalled)` per relay. +- `DmRelayLog.kt`, diagnostics/`DmRelayDiagnosticsLogger.kt` — `DMPagination` logs. + +**Managers / assemblers** +- `AccountGiftWrapsEoseManager.kt` (live tail) + `AccountGiftWrapsHistoryEoseManager.kt` (new, history). +- `ChatroomNip04SubAssembler.kt` (live tail) + `ChatroomNip04HistorySubAssembler.kt` (new, history). +- `ChatroomListNip04SubAssembler.kt` (live tail) + `ChatroomListNip04HistorySubAssembler.kt` (new, history). +- `FilterNip04DMs.kt` (per-relay `Nip04DmRelays`, live + history builders), `FilterNip04DMsFromMe/ToMe.kt`, `FilterGiftWrapsToPubkey.kt` — `until`/`limit` added. +- `AccountFilterAssembler`, `ChatroomFilterAssembler`, `ChatroomListFilterAssembler` — wire the new managers. + +**UI (`ui/screen/loggedIn/chats/`)** +- `feed/DmLoadMoreIndicator.kt` — `DmHistoryLoadingCard` + per-relay dialog. +- `feed/LoadingReplyNote.kt` — history-walking reply placeholder. +- `feed/layouts/RelayReachMarker.kt` — `RelayWindowLimit` + sentinels (driver) + markers (UI). +- `feed/ChatFeedView.kt` — `markersInGap` + `sentinels` slots. +- `feed/ChatMessageCompose.kt` — reply `onBlank` wiring. +- `privateDM/ChatroomView.kt`, `rooms/feed/ChatroomListFeedView.kt` — assemble cards/markers/sentinels, `BootstrapHistoryWhenEmpty`. +- `res/values/strings.xml` — `chats_history_*` / `chats_reply_*`. + +--- + +## Things to scrutinize (review notes) + +1. **`exhausted` conflates `done` + `stalled`** at the manager level. The cards + now distinguish them via `stalledCount`, but other consumers (the scroll + `!loading` gates, `LoadingReplyNote`'s advance loop) treat stalled as + terminal. Intentional (don't hammer dead relays), but confirm it's desired. +2. **`PerRelayLoadTracker.lastActivityMs` is global, not per-relay** — once the + chatty relays finish, a legitimately-slow relay gets the full 15s silence + window and can be marked stalled mid-delivery of a 10000-event page. +3. **"All caught up" can still be technically-true-but-misleading** when + `stalledCount == 0` yet a chat's messages live on a relay *not in the + account's NIP-17 inbox list* — an outbox-coverage gap the card can't detect. +4. **`WindowLoadTracker`'s REQ-aware backstops are dormant** in production + (no live-tail manager sets `tracksReqSends`). Either the live tail should + adopt them or the round model could be slimmer for its current role. +5. **`PAGE_LIMIT = 10000`** caps per-request volume but a single page can still + be a large payload on a dense relay. + +--- + +## Design evolution (historical — superseded, do not trust for current behavior) + +These sections describe earlier iterations, kept for context. The code has +moved past all of them. + +### v1 — time-slice history (superseded by `UntilLimitPager`) + +History was first loaded in bounded `since`+`until` **time slices** +(`TimeWindowPagination`, now deleted): `loadMore` fetched only the new band +`[newFloor, previousFloor]`, with a NIP-17 ±2-day wrapper-timestamp margin on +the slice `since` for gift wraps. This bounded re-downloads but still couldn't +tell an empty relay from a gap (an empty slice might sit above older messages), +so the only stop was a 10-year `maxLookback`, and a wide late slice could pull a +20k-event firehose. Replaced by per-relay `until`+`limit` paging. + +### v2 — round-model history + rooms-list "stall-gate" (both removed) + +History paging once used the **round model** (`WindowLoadTracker`): each +`loadMore` issued one page to all active relays and waited for the slowest to +settle before the next — pacing every relay at the slowest one. The rooms list +additionally had a **stall-gate**: an auto-fill loop that widened only while it +brought in new private rooms, stopping once a widen added none (to avoid the +conversation-keyed cascade). + +Both are gone. All history paging is now per-relay independent +(`PerRelayLoadTracker`), and the rooms list pages to exhaustion off marker +visibility like the conversation (commit `98fb8720` dropped the stall-gate; +`60b8629a` / `9f0ecd54` moved gift-wrap and rooms-list history onto the per-relay +model). `WindowLoadTracker` survives **only** as the live-tail completion +barrier. An earlier revision of this doc ("Update 3") still claimed rooms-list +and gift-wrap history used the round model — that is no longer true. From 7ef6224c19a1d3812201166db2b7de4cdb25d022 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 14:09:12 +0000 Subject: [PATCH 074/103] refactor(dm): extract per-relay paging primitives to quartz + add BackwardRelayPager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1+2 of generalizing the DM history pagination into a reusable toolkit. Move the four transport-agnostic primitives out of the amethyst module into quartz's jvmAndroid source set, new package `nip01Core.relay.client.paging`: UntilLimitPager, PerRelayLoadTracker, WindowLoadTracker, RelayPagingProgress. They were already pure Kotlin (no Android deps); jvmAndroid keeps their java.util.concurrent / @Synchronized concurrency without a KMP-atomics rewrite, while making them visible to amethyst, desktop, and quartz's jvmAndroidTest (geode in-process relay) for the integration tests to come. Add BackwardRelayPager: the generic per-relay backward-pagination engine that collapses the ~80%-identical pager+tracker+status+exhausted bookkeeping the three DM history loaders each reimplement. It owns the cursors, in-flight + silence tracking, stalled set, pinned floor, and the display StateFlows (relayProgress / exhausted / reachedBack / relayCount / stalledCount); the caller supplies only the filter builder, the subscription wiring, and a relaysFor(key) lookup. Not yet wired into the managers — that swap is step 3. Pure relocation + new component; no behavior change. UntilLimitPagerTest and WindowLoadTrackerSilenceTest stay in amethyst (they use JUnit) with explicit imports added, and both still pass against the relocated classes. --- .../AccountGiftWrapsEoseManager.kt | 4 +- .../AccountGiftWrapsHistoryEoseManager.kt | 6 +- .../chats/feed/DmLoadMoreIndicator.kt | 2 +- .../loggedIn/chats/feed/LoadingReplyNote.kt | 2 +- .../loggedIn/chats/privateDM/ChatroomView.kt | 2 +- .../ChatroomNip04HistorySubAssembler.kt | 6 +- .../datasource/ChatroomNip04SubAssembler.kt | 4 +- .../ChatroomListNip04HistorySubAssembler.kt | 6 +- .../ChatroomListNip04SubAssembler.kt | 4 +- .../chats/rooms/feed/ChatroomListFeedView.kt | 2 +- .../eoseManagers/UntilLimitPagerTest.kt | 1 + .../WindowLoadTrackerSilenceTest.kt | 1 + .../relay/client/paging/BackwardRelayPager.kt | 331 ++++++++++++++++++ .../client/paging}/PerRelayLoadTracker.kt | 2 +- .../client/paging}/RelayPagingProgress.kt | 2 +- .../relay/client/paging}/UntilLimitPager.kt | 2 +- .../relay/client/paging}/WindowLoadTracker.kt | 2 +- 17 files changed, 356 insertions(+), 23 deletions(-) create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/BackwardRelayPager.kt rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers => quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging}/PerRelayLoadTracker.kt (98%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers => quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging}/RelayPagingProgress.kt (96%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers => quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging}/UntilLimitPager.kt (99%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers => quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging}/WindowLoadTracker.kt (99%) 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 From 1c7e073b480dcdca657f2c82ffab1161556150f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 16:23:30 +0000 Subject: [PATCH 075/103] refactor(dm): wire the three history managers onto BackwardRelayPager + tests Step 3+tests of the pagination generalization. The gift-wrap, conversation NIP-04, and rooms-list NIP-04 history managers each reimplemented the same per-relay cursor / in-flight / stall / exhausted / display-flow bookkeeping; they now delegate all of it to the shared BackwardRelayPager and keep only what is genuinely theirs: building the protocol's REQ filters, the relaysFor lookup, and forwarding subscription callbacks. ~550 lines of duplicated logic removed; the public API (loadingMore/exhausted/relayCount/stalledCount/ reachedBack/relayProgress, advance/advanceAll) is unchanged, so the UI is untouched. Behaviour-preserving. Tests (quartz jvmAndroidTest): - BackwardRelayPagerTest drives the engine's callbacks directly and pins the logic that backed the bugs in this branch: empty page -> done -> caught up; a CLOSED / cannot-connect relay -> stalled -> exhausted-but-INCOMPLETE (stalledCount > 0, not "all caught up"); re-advance clears a stall; the reached cursor is the deepest across relays; a done relay won't re-advance; advanceAll arms only not-done relays; switching the active key repoints the display flows and restores a backgrounded key's terminal state. - UntilLimitPagingRelayTest drives a real NostrClient against the in-process relay (geode) to pin the wire contract the design rests on: a backward until+limit walk returns each event exactly once (no re-download), newest first, capped at the limit, with an empty page + EOSE as the gap-proof stop. --- .../AccountGiftWrapsHistoryEoseManager.kt | 200 +++------------- .../ChatroomNip04HistorySubAssembler.kt | 180 ++------------ .../ChatroomListNip04HistorySubAssembler.kt | 182 +++----------- .../client/paging/BackwardRelayPagerTest.kt | 224 ++++++++++++++++++ .../paging/UntilLimitPagingRelayTest.kt | 100 ++++++++ 5 files changed, 405 insertions(+), 481 deletions(-) create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/BackwardRelayPagerTest.kt create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPagingRelayTest.kt 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 0752d5b9b7..0b96b1d755 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 @@ -30,9 +30,8 @@ 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.BackwardRelayPager 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 @@ -40,9 +39,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import java.util.concurrent.ConcurrentHashMap /** @@ -55,9 +52,10 @@ import java.util.concurrent.ConcurrentHashMap * (see the rooms-list / conversation feed views). So a spam-dense relay never floods: the user has to * scroll through its messages to pull more, and nothing is fetched while its marker is off screen. * - * A relay is *done* once it answers an empty page; one that won't answer (auth CLOSE, unreachable, or - * silent past the load tracker's window) is flagged *stalled* but kept. [exhausted] flips once every - * relay is either done or stalled — nothing more is reachable right now. + * The per-relay cursor / stall / exhaustion bookkeeping lives in the shared [BackwardRelayPager]; this + * class only builds the gift-wrap REQ filters and forwards relay callbacks into the pager. A relay is + * *done* once it answers an empty page; one that won't answer (auth CLOSE, unreachable, or silent) is + * flagged *stalled* but kept. [exhausted] flips once every relay is either done or stalled. */ class AccountGiftWrapsHistoryEoseManager( client: INostrClient, @@ -65,53 +63,22 @@ class AccountGiftWrapsHistoryEoseManager( ) : PerUserEoseManager(client, allKeys) { override fun user(key: AccountQueryState) = key.account.userProfile() - // Per-relay demand-driven cursors, keyed by account pubkey so switching accounts preserves progress. - private val pager = UntilLimitPager() - - // The account behind each user pubkey, captured on subscribe so the UI-thread API can read the DM - // relay list without the key. + // The account behind each user pubkey, captured on subscribe so the pager's relaysFor lookup and the + // advance() API can read the DM relay list (and the account scope) without the key. private val accounts = ConcurrentHashMap() - // Relays not currently advancing for a user (auth CLOSE / unreachable / silent). Kept (not given up) - // and surfaced as stalled in the markers; they resume if the user re-advances them. - private val stalledRelays = ConcurrentHashMap>() + // Per-relay demand-driven paging, keyed by account pubkey so switching accounts preserves progress. + private val pager = + BackwardRelayPager("giftwrap.history") { pk -> + accounts[pk]?.dmRelays?.flow?.value + } - // Shared across accounts (singleton coordinator): repoint the display flows to the active account on - // switch instead of leaking the previous one's state. Cursors live in [pager]. - @Volatile - private var activeUser: HexKey? = null - private val exhaustedByUser = ConcurrentHashMap() - - private val loadTracker = PerRelayLoadTracker("giftwrap.history", onSilenced = ::onRelaysSilenced) - val loadingMore: StateFlow = loadTracker.loading - - private val _exhausted = MutableStateFlow(false) - val exhausted: StateFlow = _exhausted.asStateFlow() - - // Relays currently fetching a page (for the "asking N relays" status line). - private val _relayCount = MutableStateFlow(0) - val relayCount: StateFlow = _relayCount.asStateFlow() - - // Relays that aren't done but can't be reached right now (auth CLOSE / unreachable / silent). Surfaced - // on the paused card as "waiting on N relays" — they aren't in-flight, so [relayCount] wouldn't show them. - private val _stalledCount = MutableStateFlow(0) - val stalledCount: StateFlow = _stalledCount.asStateFlow() - - private val _reachedBack = MutableStateFlow(null) - val reachedBack: StateFlow = _reachedBack.asStateFlow() - - // Per-relay window limits — where each relay has paged to, done/stalled — the data the on-screen - // markers render and drive their advance from. - private val _relayProgress = MutableStateFlow>(emptyMap()) - val relayProgress: StateFlow> = _relayProgress.asStateFlow() - - // History starts just below the live tail's one-week floor and pages backward from there. Pinned per - // account 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. The - // live tail covers everything newer than the floor. - private val pinnedFloor = ConcurrentHashMap() - - private fun startUntil(pk: HexKey) = pinnedFloor.getOrPut(pk) { TimeUtils.now() - AccountGiftWrapsEoseManager.LIVE_TAIL_SECONDS } + 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 private fun daysAgo(epochSeconds: Long) = (TimeUtils.now() - epochSeconds) / TimeUtils.ONE_DAY @@ -130,8 +97,8 @@ class AccountGiftWrapsHistoryEoseManager( DmRelayLog.log("giftwrap.history", key.account) return armed.flatMap { relay -> val until = pager.requestedUntilFor(user.pubkeyHex, relay) ?: return@flatMap emptyList() - Log.d(TAG) { "[giftwrap.history] REQ ${relay.url} until ${daysAgo(until)}d, limit=$PAGE_LIMIT" } - filterGiftWrapsToPubkey(relay = relay, pubkey = user.pubkeyHex, since = null, until = until, limit = PAGE_LIMIT) + Log.d(TAG) { "[giftwrap.history] REQ ${relay.url} until ${daysAgo(until)}d, limit=${pager.pageLimit}" } + filterGiftWrapsToPubkey(relay = relay, pubkey = user.pubkeyHex, since = null, until = until, limit = pager.pageLimit) } } @@ -140,116 +107,25 @@ class AccountGiftWrapsHistoryEoseManager( user: User, relay: NormalizedRelayUrl, ) { - if (arm(user, relay)) { - _exhausted.value = false - updateStatus(user) - invalidateFilters() - } + val account = accounts[user.pubkeyHex] ?: return + if (pager.advance(user.pubkeyHex, relay, account.scope)) invalidateFilters() } /** Steps every not-done, not-in-flight relay one page. For the empty/initial boundary (nothing to scroll). */ fun advanceAll(user: User) { val account = accounts[user.pubkeyHex] ?: return - var any = false - account.dmRelays.flow.value - .forEach { if (arm(user, it)) any = true } - if (any) { + if (pager.advanceAll(user.pubkeyHex, account.scope)) { Log.d(TAG) { "[giftwrap.history] advanceAll (empty-feed bootstrap)" } - _exhausted.value = false - updateStatus(user) invalidateFilters() } } - // 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 invalidate — the caller batches that. - private fun arm( - user: User, - relay: NormalizedRelayUrl, - ): Boolean { - val account = accounts[user.pubkeyHex] ?: return false - if (relay !in account.dmRelays.flow.value) return false - if (loadTracker.isInFlight(relay)) return false - if (!pager.advance(user.pubkeyHex, relay, startUntil(user.pubkeyHex))) return false - stalledRelays[user.pubkeyHex]?.remove(relay) - loadTracker.bind(account.scope) - loadTracker.onAdvance(relay) - return true - } - - private fun onRelaysSilenced(relays: Set) { - val pk = activeUser ?: return - relays.forEach { markStalled(pk, it, "no response (silence timeout)") } - accounts[pk]?.userProfile()?.let { - updateStatus(it) - recomputeExhausted(it) - } - } - - private fun markStalled( - pk: HexKey, - relay: NormalizedRelayUrl, - reason: String, - ) { - val firstTime = stalledRelays.getOrPut(pk) { ConcurrentHashMap.newKeySet() }.add(relay) - if (firstTime) Log.d(TAG) { "[giftwrap.history] ${relay.url} stalled — $reason (kept, advance to retry)" } - } - - private fun updateStatus(user: User) { - // The display flows are singletons shown for the foreground account; a background account's late - // EOSE must not overwrite them (its cursors still advance in the pager). - if (activeUser != user.pubkeyHex) return - val relays = accounts[user.pubkeyHex]?.dmRelays?.flow?.value ?: emptySet() - _relayCount.value = loadTracker.count() - val start = startUntil(user.pubkeyHex) - _reachedBack.value = pager.deepestReached(user.pubkeyHex, relays, start) - val stalled = stalledRelays[user.pubkeyHex] ?: emptySet() - _stalledCount.value = relays.count { it in stalled && !pager.isDone(user.pubkeyHex, it) } - _relayProgress.value = - relays.associateWith { relay -> - RelayPagingProgress( - reachedUntil = pager.reachedUntilFor(user.pubkeyHex, relay, start), - done = pager.isDone(user.pubkeyHex, relay), - stalled = relay in stalled && !pager.isDone(user.pubkeyHex, 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(user: User) { - val relays = accounts[user.pubkeyHex]?.dmRelays?.flow?.value ?: return - if (relays.isEmpty()) return - val stalled = stalledRelays[user.pubkeyHex] ?: emptySet() - val pending = relays.any { !pager.isDone(user.pubkeyHex, it) && it !in stalled } - val ex = !pending - val was = exhaustedByUser[user.pubkeyHex] ?: false - exhaustedByUser[user.pubkeyHex] = ex - if (ex && !was) { - val done = relays.filter { pager.isDone(user.pubkeyHex, it) }.map { it.url } - val stuck = relays.filter { it in stalled && !pager.isDone(user.pubkeyHex, it) }.map { it.url } - Log.d(TAG) { "[giftwrap.history] window settled (nothing more reachable) — done=$done stalled=$stuck" } - } - if (activeUser == user.pubkeyHex) _exhausted.value = ex - } - override fun newSub(key: AccountQueryState): Subscription { val user = user(key) accounts[user.pubkeyHex] = key.account - loadTracker.bind(key.account.scope) - if (activeUser != user.pubkeyHex) { - activeUser = user.pubkeyHex - // Account switched: repoint the shared display flows to this account's own state. - loadTracker.reset() - _exhausted.value = exhaustedByUser[user.pubkeyHex] ?: false - _relayCount.value = 0 - _stalledCount.value = 0 - _reachedBack.value = null - _relayProgress.value = emptyMap() - } - // Populate the per-relay markers (all relays at the floor, not done) so the UI can render their - // window-limit sentinels and pull the first page when they come into view. - updateStatus(user) + // Repoint the shared display flows to this account and populate the per-relay markers (all relays + // at the floor, not done) so the UI can render their sentinels and pull the first page on view. + pager.activate(user.pubkeyHex) return requestNewSubscription(historyListener(user, key)) } @@ -264,25 +140,18 @@ class AccountGiftWrapsHistoryEoseManager( relay: NormalizedRelayUrl, forFilters: List?, ) { - loadTracker.onActivity() pager.onEvent(user.pubkeyHex, relay, event.createdAt) - stalledRelays[user.pubkeyHex]?.remove(relay) } override fun onEose( relay: NormalizedRelayUrl, forFilters: List?, ) { - stalledRelays[user.pubkeyHex]?.remove(relay) - pager.onEose(user.pubkeyHex, relay) - loadTracker.onSettled(relay) - if (pager.isDone(user.pubkeyHex, relay)) { + if (pager.onEose(user.pubkeyHex, relay)) { Log.d(TAG) { "[giftwrap.history] ${relay.url} reached the bottom (done)" } } // No auto-advance: the relay parks here until its marker asks for the next page. newEose(key, relay, TimeUtils.now(), forFilters) - updateStatus(user) - recomputeExhausted(user) } override fun onClosed( @@ -290,10 +159,7 @@ class AccountGiftWrapsHistoryEoseManager( relay: NormalizedRelayUrl, forFilters: List?, ) { - loadTracker.onSettled(relay) - markStalled(user.pubkeyHex, relay, "CLOSED: $message") - updateStatus(user) - recomputeExhausted(user) + pager.onClosed(user.pubkeyHex, relay, message) } override fun onCannotConnect( @@ -301,19 +167,11 @@ class AccountGiftWrapsHistoryEoseManager( message: String, forFilters: List?, ) { - loadTracker.onSettled(relay) - markStalled(user.pubkeyHex, relay, "cannot connect: $message") - updateStatus(user) - recomputeExhausted(user) + pager.onCannotConnect(user.pubkeyHex, relay, message) } } companion object { private const val TAG = "DMPagination" - - // Asked of every relay per page. Large on purpose: we want a whole band in one page where the - // relay allows it. A relay returning fewer is treated as its own cap, NOT as "nothing more" — - // only an empty page + EOSE ends a relay. - private const val PAGE_LIMIT = 10000 } } 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 71ceebcd16..6244c2218c 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 @@ -22,14 +22,12 @@ 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.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.BackwardRelayPager 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 @@ -38,10 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import java.util.concurrent.ConcurrentHashMap /** * Loads older NIP-04 DMs (kind 4) for one conversation by **`until`+`limit` paging, per relay, on @@ -49,9 +44,10 @@ import java.util.concurrent.ConcurrentHashMap * for that relay asks ([advance]); otherwise it parks. Nothing is walked proactively — a relay pages * only while its marker is visible and keeps paging while it stays visible. * - * A relay is *done* once it answers an empty page; one that won't answer (auth CLOSE, unreachable, or - * silent past the load tracker's window) is flagged *stalled* but kept. [exhausted] flips once every - * relay is either done or stalled. + * The per-relay cursor / stall / exhaustion bookkeeping lives in the shared [BackwardRelayPager]; this + * class only builds the (per-relay scoped) NIP-04 REQ filters and forwards relay callbacks into it. A + * relay is *done* once it answers an empty page; one that won't answer (auth CLOSE, unreachable, or + * silent) is flagged *stalled* but kept. [exhausted] flips once every relay is either done or stalled. */ class ChatroomNip04HistorySubAssembler( client: INostrClient, @@ -66,47 +62,22 @@ class ChatroomNip04HistorySubAssembler( private fun convoKey(key: ChatroomQueryState) = ConvoKey(user(key).pubkeyHex, key.room) - private val pager = UntilLimitPager() + // The conversation's relay set for a key, resolved via the outbox model (per-relay scoped). + private fun relaysFor(pk: ConvoKey): Collection? = allKeys().firstOrNull { convoKey(it) == pk }?.let { nip04DMRelays(it.room.users, it.account)?.all } - private val stalledRelays = ConcurrentHashMap>() + private val pager = BackwardRelayPager("convo.nip04.history", relaysFor = ::relaysFor) - private val loadTracker = PerRelayLoadTracker("convo.nip04.history", onSilenced = ::onRelaysSilenced) - val loadingMore: StateFlow = loadTracker.loading - - private val _exhausted = MutableStateFlow(false) - val exhausted: StateFlow = _exhausted.asStateFlow() - - private val _relayCount = MutableStateFlow(0) - val relayCount: StateFlow = _relayCount.asStateFlow() - - // Not-done relays that can't be reached right now — shown as "waiting on N relays" on the paused card. - private val _stalledCount = MutableStateFlow(0) - val stalledCount: StateFlow = _stalledCount.asStateFlow() - - private val _reachedBack = MutableStateFlow(null) - val reachedBack: StateFlow = _reachedBack.asStateFlow() - - private val _relayProgress = MutableStateFlow>(emptyMap()) - val relayProgress: StateFlow> = _relayProgress.asStateFlow() - - // Shared across accounts/conversations (singleton coordinator): repoint the display flows to the - // conversation now on screen. Cursors live in [pager]. - @Volatile - private var activeConvo: ConvoKey? = null - private val exhaustedByConvo = ConcurrentHashMap() - - // Pinned per conversation for the session — must not drift forward, or an un-delivered relay's marker - // would keep moving and re-trigger its sentinel. See AccountGiftWrapsHistoryEoseManager. - private val pinnedFloor = ConcurrentHashMap() - - private fun startUntil(pk: ConvoKey) = pinnedFloor.getOrPut(pk) { TimeUtils.now() - AccountGiftWrapsEoseManager.LIVE_TAIL_SECONDS } + 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 override fun user(key: ChatroomQueryState) = key.account.userProfile() override fun list(key: ChatroomQueryState) = key.listId - private fun relaysFor(pk: ConvoKey): Nip04DmRelays? = allKeys().firstOrNull { convoKey(it) == pk }?.let { nip04DMRelays(it.room.users, it.account) } - override fun updateFilter( key: ChatroomQueryState, since: SincePerRelayMap?, @@ -125,7 +96,7 @@ class ChatroomNip04HistorySubAssembler( toMeRelays = relays.toMeRelays.filterKeys { it in armed }, fromMeRelays = relays.fromMeRelays.filterKeys { it in armed }, ) - return filterNip04DMsHistory(key.account, scoped, PAGE_LIMIT) { relay -> + return filterNip04DMsHistory(key.account, scoped, pager.pageLimit) { relay -> pager.requestedUntilFor(pk, relay) } } @@ -133,110 +104,24 @@ class ChatroomNip04HistorySubAssembler( /** Steps a single [relay] to its next, older page for the open conversation(s). Driven by its marker. */ fun advance(relay: NormalizedRelayUrl) { var any = false - allKeys().forEach { if (arm(it, relay)) any = true } - if (any) { - _exhausted.value = false - updateStatus() - invalidateFilters() - } + allKeys().forEach { if (pager.advance(convoKey(it), relay, it.account.scope)) any = true } + if (any) invalidateFilters() } /** Steps every not-done, not-in-flight relay one page. For a thread too short to scroll. */ fun advanceAll() { var any = false - allKeys().forEach { key -> - val relays = nip04DMRelays(key.room.users, key.account) ?: return@forEach - relays.all.forEach { if (arm(key, it)) any = true } - } + allKeys().forEach { if (pager.advanceAll(convoKey(it), it.account.scope)) any = true } if (any) { Log.d("DMPagination") { "[convo.nip04.history] advanceAll (empty-thread bootstrap)" } - _exhausted.value = false - updateStatus() invalidateFilters() } } - private fun arm( - key: ChatroomQueryState, - relay: NormalizedRelayUrl, - ): Boolean { - val relays = nip04DMRelays(key.room.users, key.account) ?: return false - if (relay !in relays.all) return false - val pk = convoKey(key) - if (loadTracker.isInFlight(relay)) return false - if (!pager.advance(pk, relay, startUntil(pk))) return false - stalledRelays[pk]?.remove(relay) - loadTracker.bind(key.account.scope) - loadTracker.onAdvance(relay) - return true - } - - private fun onRelaysSilenced(relays: Set) { - val pk = activeConvo ?: return - relays.forEach { markStalled(pk, it, "no response (silence timeout)") } - updateStatus() - recomputeExhausted() - } - - private fun markStalled( - pk: ConvoKey, - relay: NormalizedRelayUrl, - reason: String, - ) { - val firstTime = stalledRelays.getOrPut(pk) { ConcurrentHashMap.newKeySet() }.add(relay) - if (firstTime) Log.d("DMPagination") { "[convo.nip04.history] ${relay.url} stalled — $reason (kept, advance to retry)" } - } - - private fun updateStatus() { - val pk = activeConvo ?: return - val relays = relaysFor(pk) ?: return - _relayCount.value = loadTracker.count() - val start = startUntil(pk) - _reachedBack.value = pager.deepestReached(pk, relays.all, start) - val stalled = stalledRelays[pk] ?: emptySet() - _stalledCount.value = relays.all.count { it in stalled && !pager.isDone(pk, it) } - _relayProgress.value = - relays.all.associateWith { relay -> - RelayPagingProgress( - reachedUntil = pager.reachedUntilFor(pk, relay, start), - done = pager.isDone(pk, relay), - stalled = relay in stalled && !pager.isDone(pk, relay), - ) - } - } - - private fun recomputeExhausted() { - val pk = activeConvo ?: return - val relays = relaysFor(pk) ?: return - if (relays.all.isEmpty()) return - val stalled = stalledRelays[pk] ?: emptySet() - val pending = relays.all.any { !pager.isDone(pk, it) && it !in stalled } - val ex = !pending - val was = exhaustedByConvo[pk] ?: false - exhaustedByConvo[pk] = ex - if (ex && !was) { - val done = relays.all.filter { pager.isDone(pk, it) }.map { it.url } - val stuck = relays.all.filter { it in stalled && !pager.isDone(pk, it) }.map { it.url } - Log.d("DMPagination") { "[convo.nip04.history] window settled (nothing more reachable) — done=$done stalled=$stuck" } - } - if (activeConvo == pk) _exhausted.value = ex - } - override fun newSub(key: ChatroomQueryState): Subscription { - val pk = convoKey(key) - loadTracker.bind(key.account.scope) - if (activeConvo != pk) { - activeConvo = pk - loadTracker.reset() - _exhausted.value = exhaustedByConvo[pk] ?: false - _relayCount.value = 0 - _stalledCount.value = 0 - _reachedBack.value = null - _relayProgress.value = emptyMap() - } - // Populate the per-relay markers (all relays at the floor, not done) so the UI can render their - // window-limit sentinels and pull the first page when they come into view. - updateStatus() + // Repoint the shared display flows to this conversation and populate the per-relay markers (all + // relays at the floor, not done) so the UI can render their sentinels and pull the first page. + pager.activate(convoKey(key)) return requestNewSubscription(historyListener(key)) } @@ -249,24 +134,17 @@ class ChatroomNip04HistorySubAssembler( relay: NormalizedRelayUrl, forFilters: List?, ) { - loadTracker.onActivity() pager.onEvent(pk, relay, event.createdAt) - stalledRelays[pk]?.remove(relay) } override fun onEose( relay: NormalizedRelayUrl, forFilters: List?, ) { - stalledRelays[pk]?.remove(relay) - pager.onEose(pk, relay) - loadTracker.onSettled(relay) - if (pager.isDone(pk, relay)) { + if (pager.onEose(pk, relay)) { Log.d("DMPagination") { "[convo.nip04.history] ${relay.url} reached the bottom (done)" } } newEose(key, relay, TimeUtils.now(), forFilters) - updateStatus() - recomputeExhausted() } override fun onClosed( @@ -274,10 +152,7 @@ class ChatroomNip04HistorySubAssembler( relay: NormalizedRelayUrl, forFilters: List?, ) { - loadTracker.onSettled(relay) - markStalled(pk, relay, "CLOSED: $message") - updateStatus() - recomputeExhausted() + pager.onClosed(pk, relay, message) } override fun onCannotConnect( @@ -285,15 +160,8 @@ class ChatroomNip04HistorySubAssembler( message: String, forFilters: List?, ) { - loadTracker.onSettled(relay) - markStalled(pk, relay, "cannot connect: $message") - updateStatus() - recomputeExhausted() + pager.onCannotConnect(pk, relay, message) } } } - - companion object { - private const val PAGE_LIMIT = 10000 - } } 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 c325a676db..a05b75eae7 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 @@ -24,14 +24,12 @@ 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.PerUserEoseManager -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.BackwardRelayPager 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 @@ -39,9 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import java.util.concurrent.ConcurrentHashMap /** @@ -50,49 +46,31 @@ import java.util.concurrent.ConcurrentHashMap * ([com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsHistoryEoseManager]), * across the account's home (outbox, *from me*) + DM (inbox, *to me*) relays. Each relay advances one * page when its on-screen window-limit marker asks ([advance]); otherwise it parks. Nothing is walked - * proactively. + * proactively. The per-relay cursor / stall / exhaustion bookkeeping lives in [BackwardRelayPager]. */ class ChatroomListNip04HistorySubAssembler( client: INostrClient, allKeys: () -> Set, ) : PerUserEoseManager(client, allKeys) { - private val pager = UntilLimitPager() private val accounts = ConcurrentHashMap() - private val stalledRelays = ConcurrentHashMap>() - - @Volatile - private var activeUser: HexKey? = null - private val exhaustedByUser = ConcurrentHashMap() - - private val loadTracker = PerRelayLoadTracker("rooms.nip04.history", onSilenced = ::onRelaysSilenced) - val loadingMore: StateFlow = loadTracker.loading - - private val _exhausted = MutableStateFlow(false) - val exhausted: StateFlow = _exhausted.asStateFlow() - - private val _relayCount = MutableStateFlow(0) - val relayCount: StateFlow = _relayCount.asStateFlow() - - // Not-done relays that can't be reached right now — shown as "waiting on N relays" on the paused card. - private val _stalledCount = MutableStateFlow(0) - val stalledCount: StateFlow = _stalledCount.asStateFlow() - - private val _reachedBack = MutableStateFlow(null) - val reachedBack: StateFlow = _reachedBack.asStateFlow() - - private val _relayProgress = MutableStateFlow>(emptyMap()) - val relayProgress: StateFlow> = _relayProgress.asStateFlow() - - // Pinned per account for the session — must not drift forward, or an un-delivered relay's marker - // would keep moving and re-trigger its sentinel. See AccountGiftWrapsHistoryEoseManager. - private val pinnedFloor = ConcurrentHashMap() - - private fun startUntil(pk: HexKey) = pinnedFloor.getOrPut(pk) { TimeUtils.now() - AccountGiftWrapsEoseManager.LIVE_TAIL_SECONDS } - - override fun user(key: ChatroomListState) = key.account.userProfile() private fun allRelays(account: Account) = (account.homeRelays.flow.value + account.dmRelays.flow.value).toSet() + // Paged across the account's own home (outbox) + DM (inbox) relays, keyed by account pubkey. + private val pager = + BackwardRelayPager("rooms.nip04.history") { pk -> + accounts[pk]?.let { allRelays(it) } + } + + 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 + + override fun user(key: ChatroomListState) = key.account.userProfile() + override fun updateFilter( key: ChatroomListState, since: SincePerRelayMap?, @@ -107,8 +85,8 @@ class ChatroomListNip04HistorySubAssembler( return armed.flatMap { relay -> val until = pager.requestedUntilFor(user.pubkeyHex, relay) ?: return@flatMap emptyList() buildList { - if (relay in homeRelays) add(filterNip04DMsFromMe(user, relay, since = null, until = until, limit = PAGE_LIMIT)) - if (relay in dmRelays) add(filterNip04DMsToMe(user, relay, since = null, until = until, limit = PAGE_LIMIT)) + if (relay in homeRelays) add(filterNip04DMsFromMe(user, relay, since = null, until = until, limit = pager.pageLimit)) + if (relay in dmRelays) add(filterNip04DMsToMe(user, relay, since = null, until = until, limit = pager.pageLimit)) } } } @@ -118,112 +96,25 @@ class ChatroomListNip04HistorySubAssembler( user: User, relay: NormalizedRelayUrl, ) { - if (arm(user, relay)) { - _exhausted.value = false - updateStatus(user) - invalidateFilters() - } + val account = accounts[user.pubkeyHex] ?: return + if (pager.advance(user.pubkeyHex, relay, account.scope)) invalidateFilters() } /** Steps every not-done, not-in-flight relay one page. For the empty/initial boundary (nothing to scroll). */ fun advanceAll(user: User) { val account = accounts[user.pubkeyHex] ?: return - var any = false - allRelays(account).forEach { if (arm(user, it)) any = true } - if (any) { + if (pager.advanceAll(user.pubkeyHex, account.scope)) { Log.d("DMPagination") { "[rooms.nip04.history] advanceAll (empty-feed bootstrap)" } - _exhausted.value = false - updateStatus(user) invalidateFilters() } } - private fun arm( - user: User, - relay: NormalizedRelayUrl, - ): Boolean { - val account = accounts[user.pubkeyHex] ?: return false - if (relay !in allRelays(account)) return false - if (loadTracker.isInFlight(relay)) return false - if (!pager.advance(user.pubkeyHex, relay, startUntil(user.pubkeyHex))) return false - stalledRelays[user.pubkeyHex]?.remove(relay) - loadTracker.bind(account.scope) - loadTracker.onAdvance(relay) - return true - } - - private fun onRelaysSilenced(relays: Set) { - val pk = activeUser ?: return - relays.forEach { markStalled(pk, it, "no response (silence timeout)") } - accounts[pk]?.userProfile()?.let { - updateStatus(it) - recomputeExhausted(it) - } - } - - private fun markStalled( - pk: HexKey, - relay: NormalizedRelayUrl, - reason: String, - ) { - val firstTime = stalledRelays.getOrPut(pk) { ConcurrentHashMap.newKeySet() }.add(relay) - if (firstTime) Log.d("DMPagination") { "[rooms.nip04.history] ${relay.url} stalled — $reason (kept, advance to retry)" } - } - - private fun updateStatus(user: User) { - // The display flows are singletons shown for the foreground account; a background account's late - // EOSE must not overwrite them (its cursors still advance in the pager). - if (activeUser != user.pubkeyHex) return - val account = accounts[user.pubkeyHex] - val relays = account?.let { allRelays(it) } ?: emptySet() - _relayCount.value = loadTracker.count() - val start = startUntil(user.pubkeyHex) - _reachedBack.value = pager.deepestReached(user.pubkeyHex, relays, start) - val stalled = stalledRelays[user.pubkeyHex] ?: emptySet() - _stalledCount.value = relays.count { it in stalled && !pager.isDone(user.pubkeyHex, it) } - _relayProgress.value = - relays.associateWith { relay -> - RelayPagingProgress( - reachedUntil = pager.reachedUntilFor(user.pubkeyHex, relay, start), - done = pager.isDone(user.pubkeyHex, relay), - stalled = relay in stalled && !pager.isDone(user.pubkeyHex, relay), - ) - } - } - - private fun recomputeExhausted(user: User) { - val account = accounts[user.pubkeyHex] ?: return - val relays = allRelays(account) - if (relays.isEmpty()) return - val stalled = stalledRelays[user.pubkeyHex] ?: emptySet() - val pending = relays.any { !pager.isDone(user.pubkeyHex, it) && it !in stalled } - val ex = !pending - val was = exhaustedByUser[user.pubkeyHex] ?: false - exhaustedByUser[user.pubkeyHex] = ex - if (ex && !was) { - val done = relays.filter { pager.isDone(user.pubkeyHex, it) }.map { it.url } - val stuck = relays.filter { it in stalled && !pager.isDone(user.pubkeyHex, it) }.map { it.url } - Log.d("DMPagination") { "[rooms.nip04.history] window settled (nothing more reachable) — done=$done stalled=$stuck" } - } - if (activeUser == user.pubkeyHex) _exhausted.value = ex - } - override fun newSub(key: ChatroomListState): Subscription { val user = user(key) accounts[user.pubkeyHex] = key.account - loadTracker.bind(key.account.scope) - if (activeUser != user.pubkeyHex) { - activeUser = user.pubkeyHex - loadTracker.reset() - _exhausted.value = exhaustedByUser[user.pubkeyHex] ?: false - _relayCount.value = 0 - _stalledCount.value = 0 - _reachedBack.value = null - _relayProgress.value = emptyMap() - } - // Populate the per-relay markers (all relays at the floor, not done) so the UI can render their - // window-limit sentinels and pull the first page when they come into view. - updateStatus(user) + // Repoint the shared display flows to this account and populate the per-relay markers (all relays + // at the floor, not done) so the UI can render their sentinels and pull the first page on view. + pager.activate(user.pubkeyHex) return requestNewSubscription(historyListener(user, key)) } @@ -238,24 +129,17 @@ class ChatroomListNip04HistorySubAssembler( relay: NormalizedRelayUrl, forFilters: List?, ) { - loadTracker.onActivity() pager.onEvent(user.pubkeyHex, relay, event.createdAt) - stalledRelays[user.pubkeyHex]?.remove(relay) } override fun onEose( relay: NormalizedRelayUrl, forFilters: List?, ) { - stalledRelays[user.pubkeyHex]?.remove(relay) - pager.onEose(user.pubkeyHex, relay) - loadTracker.onSettled(relay) - if (pager.isDone(user.pubkeyHex, relay)) { + if (pager.onEose(user.pubkeyHex, relay)) { Log.d("DMPagination") { "[rooms.nip04.history] ${relay.url} reached the bottom (done)" } } newEose(key, relay, TimeUtils.now(), forFilters) - updateStatus(user) - recomputeExhausted(user) } override fun onClosed( @@ -263,10 +147,7 @@ class ChatroomListNip04HistorySubAssembler( relay: NormalizedRelayUrl, forFilters: List?, ) { - loadTracker.onSettled(relay) - markStalled(user.pubkeyHex, relay, "CLOSED: $message") - updateStatus(user) - recomputeExhausted(user) + pager.onClosed(user.pubkeyHex, relay, message) } override fun onCannotConnect( @@ -274,14 +155,7 @@ class ChatroomListNip04HistorySubAssembler( message: String, forFilters: List?, ) { - loadTracker.onSettled(relay) - markStalled(user.pubkeyHex, relay, "cannot connect: $message") - updateStatus(user) - recomputeExhausted(user) + pager.onCannotConnect(user.pubkeyHex, relay, message) } } - - companion object { - private const val PAGE_LIMIT = 10000 - } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/BackwardRelayPagerTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/BackwardRelayPagerTest.kt new file mode 100644 index 0000000000..8e233a956f --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/BackwardRelayPagerTest.kt @@ -0,0 +1,224 @@ +/* + * 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 kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * State-machine tests for [BackwardRelayPager]: drive its relay callbacks directly (no network) and + * assert the cursor / done / stalled / exhausted bookkeeping — the logic that backed the "All caught up + * while messages missing" and the stalled-vs-done bugs. The relay's own `until`+`limit`+EOSE wire + * behaviour is covered separately against the in-process relay in `UntilLimitPagingRelayTest`. + */ +class BackwardRelayPagerTest { + private val r1 = NormalizedRelayUrl("wss://r1.example/") + private val r2 = NormalizedRelayUrl("wss://r2.example/") + private val r3 = NormalizedRelayUrl("wss://r3.example/") + private val key = "acct" + + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + @AfterTest + fun tearDown() { + scope.cancel() + } + + private fun pagerOf(vararg relays: NormalizedRelayUrl): BackwardRelayPager = BackwardRelayPager("test") { relays.toList() }.also { it.activate(key) } + + @Test + fun firstPageRequestsTheFloorAndAnEmptyPageIsCaughtUp() { + val p = pagerOf(r1) + assertFalse(p.exhausted.value) + + assertTrue(p.advance(key, r1, scope)) + // The very first page asks `until = floor`. + assertEquals(p.floorFor(key), p.requestedUntilFor(key, r1)) + + // Empty page + EOSE → that relay is done; the only relay is done → genuinely caught up. + assertTrue(p.onEose(key, r1)) + assertTrue( + p.relayProgress.value + .getValue(r1) + .done, + ) + assertTrue(p.exhausted.value) + assertEquals(0, p.stalledCount.value) + } + + @Test + fun nonEmptyPageMovesTheCursorThenBottomsOut() { + val p = pagerOf(r1) + p.advance(key, r1, scope) + + // A page of three events; the oldest is 80, so the reached cursor drops to 80 (not done). + p.onEvent(key, r1, 100) + p.onEvent(key, r1, 80) + p.onEvent(key, r1, 90) + assertFalse(p.onEose(key, r1)) + assertFalse( + p.relayProgress.value + .getValue(r1) + .done, + ) + assertEquals(80L, p.reachedBack.value) + assertFalse(p.exhausted.value) + + // The next page must start strictly below the oldest reached (80 → until 79). + assertTrue(p.advance(key, r1, scope)) + assertEquals(79L, p.requestedUntilFor(key, r1)) + + // Empty page now → done → caught up. + assertTrue(p.onEose(key, r1)) + assertTrue(p.exhausted.value) + assertEquals(0, p.stalledCount.value) + } + + @Test + fun aStalledRelayMakesExhaustionIncompleteNotCaughtUp() { + val p = pagerOf(r1, r2) + p.advance(key, r1, scope) + p.advance(key, r2, scope) + + // r1 genuinely bottoms out; r2 is still pending, so not exhausted yet. + p.onEose(key, r1) + assertFalse(p.exhausted.value) + + // r2 auth-walls the REQ → stalled (kept, not done). + p.onClosed(key, r2, "auth-required") + assertTrue( + p.relayProgress.value + .getValue(r2) + .stalled, + ) + assertFalse( + p.relayProgress.value + .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) + } + + @Test + fun cannotConnectAlsoStalls() { + val p = pagerOf(r1) + p.advance(key, r1, scope) + p.onCannotConnect(key, r1, "offline") + assertTrue( + p.relayProgress.value + .getValue(r1) + .stalled, + ) + assertTrue(p.exhausted.value) + assertEquals(1, p.stalledCount.value) + } + + @Test + fun reAdvancingAStalledRelayClearsTheStallAndUnExhausts() { + val p = pagerOf(r1) + p.advance(key, r1, scope) + p.onClosed(key, r1, "auth-required") + assertTrue(p.exhausted.value) + assertEquals(1, p.stalledCount.value) + + // Retrying it re-arms the relay: no longer stalled, no longer exhausted. + assertTrue(p.advance(key, r1, scope)) + assertFalse( + p.relayProgress.value + .getValue(r1) + .stalled, + ) + assertFalse(p.exhausted.value) + assertEquals(0, p.stalledCount.value) + } + + @Test + fun reachedBackIsTheDeepestCursorAcrossRelays() { + val p = pagerOf(r1, r2) + p.advance(key, r1, scope) + p.advance(key, r2, scope) + + p.onEvent(key, r1, 500) + p.onEose(key, r1) // r1 reached 500 + + p.onEvent(key, r2, 300) + p.onEose(key, r2) // r2 reached 300 + + // Deepest = the oldest point any relay has reached. + assertEquals(300L, p.reachedBack.value) + } + + @Test + fun aDoneRelayWillNotAdvanceAgain() { + val p = pagerOf(r1) + p.advance(key, r1, scope) + p.onEose(key, r1) // empty → done + assertFalse(p.advance(key, r1, scope)) + } + + @Test + fun advanceAllArmsEveryNotDoneRelay() { + val p = pagerOf(r1, r2, r3) + // r2 already finished; advanceAll should arm only r1 and r3. + p.advance(key, r2, scope) + p.onEose(key, r2) + + assertTrue(p.advanceAll(key, scope)) + assertEquals(setOf(r1, r3), p.armedRelays(key, listOf(r1, r2, r3)).toSet()) + } + + @Test + fun switchingActiveKeyRepointsTheDisplayFlows() { + val keyA = "a" + val keyB = "b" + val relaysByKey = mapOf(keyA to listOf(r1), keyB to listOf(r2)) + val p = BackwardRelayPager("test") { relaysByKey[it] } + + p.activate(keyA) + p.advance(keyA, r1, scope) + p.onClosed(keyA, r1, "auth-required") // A: exhausted + 1 stalled + assertTrue(p.exhausted.value) + assertEquals(1, p.stalledCount.value) + + // Switching to a fresh key B repoints the flows to B's own state: nothing stalled, and its + // reach sits at B's floor (no history fetched yet — the markers start at the live-tail boundary). + p.activate(keyB) + assertFalse(p.exhausted.value) + assertEquals(0, p.stalledCount.value) + assertEquals(p.floorFor(keyB), p.reachedBack.value) + + // Switching back to A restores its remembered terminal state. + p.activate(keyA) + assertTrue(p.exhausted.value) + assertEquals(1, p.stalledCount.value) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPagingRelayTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPagingRelayTest.kt new file mode 100644 index 0000000000..d422ec580a --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPagingRelayTest.kt @@ -0,0 +1,100 @@ +/* + * 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.geode.fixtures.SyntheticEvents +import com.vitorpamplona.geode.testing.RelayClientTest +import com.vitorpamplona.geode.testing.collectUntilEose +import com.vitorpamplona.geode.testing.preload +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Pins down the relay-side contract the whole [UntilLimitPager] / [BackwardRelayPager] design rests on, + * against the in-process relay: a backward `until`+`limit` walk returns each event **exactly once** + * (no re-download), in **newest-first** capped pages, and an **empty page + EOSE** is the gap-proof + * stop. If a relay ever stopped honouring this (e.g. oldest-first, or ignoring `until`), these break — + * which is exactly the signal the pager's correctness depends on. + */ +class UntilLimitPagingRelayTest : RelayClientTest() { + @Test + fun backwardUntilLimitWalkCoversEveryEventOnceAndStopsOnEmptyPage() = + runBlocking { + // 250 regular events, createdAt 1..250 (distinct pubkeys so none collapse). + defaultRelay.preload(SyntheticEvents.batch(TOTAL, kind = KIND)) + + val seenIds = mutableSetOf() + var totalReceived = 0 + var pages = 0 + var until: Long? = null + + while (pages < SAFETY_CAP) { + val (events, eose) = + client.collectUntilEose( + defaultRelayUrl, + Filter(kinds = listOf(KIND), until = until, limit = LIMIT), + ) + assertTrue(eose, "every page must end with EOSE") + + if (events.isEmpty()) break // gap-proof stop: empty page = nothing older + + pages++ + assertTrue(events.size <= LIMIT, "page must respect the limit") + // Newest-first + cursor honoured: nothing newer than the cursor leaks into a later page. + until?.let { cursor -> assertTrue(events.all { it.createdAt <= cursor }, "page must be older than the cursor") } + + events.forEach { e: Event -> + seenIds.add(e.id) + totalReceived++ + } + until = events.minOf { it.createdAt } - 1 + } + + // No re-download: total delivered equals the corpus, and every id is distinct. + assertEquals(TOTAL, totalReceived, "no event should be delivered twice across pages") + assertEquals(TOTAL, seenIds.size, "every event fetched exactly once") + // 250 / 100 → 100 + 100 + 50, then an empty page stops the walk. + assertEquals(3, pages) + } + + @Test + fun anEmptyRelayAnswersOneEmptyPageWithEose() = + runBlocking { + val (events, eose) = + client.collectUntilEose( + defaultRelayUrl, + Filter(kinds = listOf(KIND), until = null, limit = LIMIT), + ) + assertTrue(eose) + assertEquals(0, events.size) + } + + companion object { + private const val KIND = 1 + private const val TOTAL = 250 + private const val LIMIT = 100 + private const val SAFETY_CAP = 10 + } +} From 3871d3bd1d339521f9346fd6e8713b965bc73fbb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 16:28:29 +0000 Subject: [PATCH 076/103] docs(dm): reflect the BackwardRelayPager extraction in the design doc The per-relay paging primitives moved to quartz (nip01Core/relay/client/paging, jvmAndroid source set) and the three history managers now delegate their shared bookkeeping to BackwardRelayPager. Update the architecture note and the component map (new toolkit location + the two new quartz tests) accordingly. --- ...6-06-01-dm-live-tail-and-history-slices.md | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md b/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md index 7eff4de30e..bf0609d128 100644 --- a/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md +++ b/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md @@ -109,8 +109,15 @@ to load, just not advancing) keeps it false. > All three history managers (`AccountGiftWrapsHistoryEoseManager`, > `ChatroomNip04HistorySubAssembler`, `ChatroomListNip04HistorySubAssembler`) -> are structurally the same per-relay loader. The earlier round-model history -> (and the rooms-list "stall-gate") was fully removed — see Design evolution. +> were structurally the same per-relay loader, so that bookkeeping is now a +> single reusable engine — **`BackwardRelayPager`** (in quartz, +> `nip01Core/relay/client/paging/`). It owns the cursors, in-flight + silence +> tracking, stalled set, pinned floor, and the display flows; each manager +> supplies only its REQ-filter builder, a `relaysFor(key)` lookup, and the +> subscription wiring (it forwards relay callbacks via +> `onEvent`/`onEose`/`onClosed`/`onCannotConnect` and re-issues filters after +> `advance`/`advanceAll`). The earlier round-model history (and the rooms-list +> "stall-gate") was fully removed — see Design evolution. ### What drives `advance()`: on-screen markers, off viewport visibility @@ -220,11 +227,17 @@ pagination, but it lived here because unreachable relays were part of the same ## Component map (vs `origin/main`) -**New, transport-agnostic (`service/relayClient/eoseManagers/`)** -- `UntilLimitPager.kt` — per-relay `until`+`limit` cursor. *(+ test)* +**Reusable paging toolkit (quartz, `nip01Core/relay/client/paging/`)** — moved +out of amethyst so desktop / CLI / any feed can reuse it; in the `jvmAndroid` +source set (uses `java.util.concurrent`), visible to amethyst + desktop + quartz's +`jvmAndroidTest` (geode in-process relay). +- `UntilLimitPager.kt` — per-relay `until`+`limit` cursor. *(+ `UntilLimitPagerTest` in amethyst)* - `PerRelayLoadTracker.kt` — per-relay in-flight tracker + silence watchdog. -- `WindowLoadTracker.kt` — round/barrier completion tracker (live tail). *(+ silence test)* +- `WindowLoadTracker.kt` — round/barrier completion tracker (live tail). *(+ silence test in amethyst)* - `RelayPagingProgress.kt` — `(reachedUntil, done, stalled)` per relay. +- `BackwardRelayPager.kt` — the generic per-relay backward-pagination engine the + three history managers delegate to. *(+ `BackwardRelayPagerTest` state-machine + + `UntilLimitPagingRelayTest` geode wire-contract test)* - `DmRelayLog.kt`, diagnostics/`DmRelayDiagnosticsLogger.kt` — `DMPagination` logs. **Managers / assemblers** From c3ed7e65c9427053cd23bc10842960925b662c49 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 16:50:07 +0000 Subject: [PATCH 077/103] refactor(dm): extract relay-reach markers to commons (shared with desktop) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 (UI), stage 1. Move the per-relay reach markers — RelayReachState, RelayReach, RelayWindowLimit, RelayWindowLimitSentinels (the hoisted, visibility-driven paging driver), RelayWindowLimitMarkers, and RelayReachMarker — out of amethyst into commons/commonMain (com.vitorpamplona.amethyst.commons.ui.feeds) so the Desktop chats UI (and any feed) can render the same in-stream paging progress, not just Android. De-Android-ified: the one Android string (chats_history_relay_sync) becomes a CMP composeResources string in commons; the two theme constants (DividerThickness, HalfPadding) are inlined (0.25.dp / padding(5.dp)) so the shared component carries no app-theme dependency. Logic is otherwise byte-identical. The Android conversation + rooms-list views now import the shared version; no behaviour change. --- .../loggedIn/chats/privateDM/ChatroomView.kt | 8 +++---- .../chats/rooms/feed/ChatroomListFeedView.kt | 8 +++---- .../composeResources/values/strings.xml | 3 +++ .../commons/ui/feeds}/RelayReachMarker.kt | 21 +++++++++++-------- 4 files changed, 23 insertions(+), 17 deletions(-) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds}/RelayReachMarker.kt (93%) 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 f809fe168c..a856c1f8c2 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,6 +41,10 @@ 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.commons.ui.feeds.RelayReachState +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayWindowLimit +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayWindowLimitMarkers +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayWindowLimitSentinels import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel @@ -50,10 +54,6 @@ import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMsAndDisp import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.DmHistoryLoadingCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.RelayReachState -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.RelayWindowLimit -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.RelayWindowLimitMarkers -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.RelayWindowLimitSentinels import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal.ChatroomFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.ChatNewMessageViewModel 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 3792ab56e9..68620f2cb2 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,10 @@ import com.vitorpamplona.amethyst.R 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.commons.ui.feeds.RelayReachState +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayWindowLimit +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayWindowLimitMarkers +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayWindowLimitSentinels import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty @@ -50,10 +54,6 @@ import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.DmHistoryLoadingCard -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.RelayReachState -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.RelayWindowLimit -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.RelayWindowLimitMarkers -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.RelayWindowLimitSentinels import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChatroomHeaderCompose import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding diff --git a/commons/src/commonMain/composeResources/values/strings.xml b/commons/src/commonMain/composeResources/values/strings.xml index bccf1e297c..da98c3d19e 100644 --- a/commons/src/commonMain/composeResources/values/strings.xml +++ b/commons/src/commonMain/composeResources/values/strings.xml @@ -54,4 +54,7 @@ User avatar Navigate + + + Relay sync: diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt index 44a201dcfa..fa88fccddc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/RelayReachMarker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt @@ -18,10 +18,11 @@ * 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.ui.screen.loggedIn.chats.feed.layouts +package com.vitorpamplona.amethyst.commons.ui.feeds import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme @@ -35,19 +36,21 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.HalfPadding +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.chats_history_relay_sync import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.distinctUntilChanged +import org.jetbrains.compose.resources.stringResource -/** How far one relay has paged into the conversation, for an in-stream progress marker. */ +// A relay-reach divider is hair-thin; inlined here so the shared component carries no app-theme dep. +private val DividerThickness = 0.25.dp + +/** How far one relay has paged into a feed's history, for an in-stream progress marker. */ enum class RelayReachState { // Still paging older — its marker slides down (older) as it advances. REACHING, @@ -89,7 +92,7 @@ data class RelayWindowLimit( * * Why hoisted: the marker for a limit lives in exactly one gap (between the two rows straddling its * reached cursor). Placing the sentinel *inside* that row made its effect's identity ride the hosting - * row — so any feed reorder (a live DM, or a slow relay dribbling a history page) moved the gap to a + * row — so any feed reorder (a live message, or a slow relay dribbling a history page) moved the gap to a * different row, tore the effect down and recreated it, and re-fired `advance()` on a static screen. * That re-armed stalled/auth relays into a silence-watchdog storm and could walk a delivering relay * back a window with no scroll. Hoisting the effect and driving it off **viewport visibility** instead @@ -189,11 +192,11 @@ fun RelayReachMarker(entries: List) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = HalfPadding, + modifier = Modifier.padding(5.dp), ) { HorizontalDivider(modifier = Modifier.weight(1f), thickness = DividerThickness) Text( - text = stringResource(R.string.chats_history_relay_sync), + text = stringResource(Res.string.chats_history_relay_sync), color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 11.sp, fontWeight = FontWeight.Medium, From ace9d20690933ce18d2c35f01a1568060d30a3df Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 16:58:34 +0000 Subject: [PATCH 078/103] refactor(dm): extract the history status card to commons (shared with desktop) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 (UI), stage 2. Move DmHistoryLoadingCard + its tap-through per-relay dialog + the historySubtitle/incompleteSubtitle helpers out of amethyst into commons/commonMain (com.vitorpamplona.amethyst.commons.ui.feeds), so the same "older history / all caught up / some relays didn't respond" boundary card can back any per-relay BackwardRelayPager feed on Android and Desktop. The card's localized date formatting (SimpleDateFormat/Locale) can't live in commons commonMain (it targets iOS/linux/macOS, no java.*), so it's injected as a formatReachDate: (epochSeconds) -> String lambda — the platform that renders the card supplies its native formatter, no i18n regression. Android passes formatHistoryReachDate (new HistoryDateFormat.kt). The dialog's per-relay reach now uses that same month-precision formatter (was "MMM d, yyyy", now "MMM yyyy") — a negligible cosmetic change. Its ~11 strings move to commons composeResources, including the module's first (chats_history_relays). The three Android call sites (ChatroomView, ChatroomListFeedView, LoadingReplyNote) now import the shared card/helpers and pass the formatter; no behaviour change. The old amethyst string copies are left in place (harmless, separate resource namespace) for a later cleanup pass. --- .../loggedIn/chats/feed/HistoryDateFormat.kt | 32 +++++++ .../loggedIn/chats/feed/LoadingReplyNote.kt | 7 +- .../loggedIn/chats/privateDM/ChatroomView.kt | 7 +- .../chats/rooms/feed/ChatroomListFeedView.kt | 7 +- .../composeResources/values/strings.xml | 17 +++- .../commons/ui/feeds/DmHistoryLoadingCard.kt | 90 +++++++++++-------- 6 files changed, 113 insertions(+), 47 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/HistoryDateFormat.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DmLoadMoreIndicator.kt => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DmHistoryLoadingCard.kt (77%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/HistoryDateFormat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/HistoryDateFormat.kt new file mode 100644 index 0000000000..7b6ab94391 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/HistoryDateFormat.kt @@ -0,0 +1,32 @@ +/* + * 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.amethyst.ui.screen.loggedIn.chats.feed + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * The Android locale date formatter for the shared [com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard] + * — passed in so the shared (KMP) card carries no `java.time` dependency. Formats a paging reach point + * (epoch seconds) to a short month-year label, e.g. "Jun 2026". + */ +fun formatHistoryReachDate(epochSeconds: Long): String = SimpleDateFormat("MMM yyyy", Locale.getDefault()).format(Date(epochSeconds * 1000)) 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 d606811988..695a15fcdd 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,6 +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.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 @@ -156,7 +159,7 @@ fun LoadingReplyNote( // can see exactly which relays were reached and which stalled. Empty progress keeps it non-interactive. var showRelays by remember { mutableStateOf(false) } if (showRelays) { - DmHistoryRelayDialog(protocolTag, relayProgress) { showRelays = false } + DmHistoryRelayDialog(protocolTag, relayProgress, ::formatHistoryReachDate) { showRelays = false } } // Same chrome as DmHistoryLoadingCard (the older-history status card at the oldest end) so an @@ -224,7 +227,7 @@ fun LoadingReplyNote( when { stalledOut -> incompleteSubtitle(stalledCount) isExhausted -> stringRes(R.string.chats_reply_searched) - else -> historySubtitle(protocolTag, relayCount, stalledCount, reachedBack) + else -> historySubtitle(protocolTag, relayCount, stalledCount, reachedBack, ::formatHistoryReachDate) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, 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 a856c1f8c2..decd0d2efe 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 @@ -39,6 +39,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState @@ -52,8 +53,8 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMsAndDisplayIfNotFound import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.DmHistoryLoadingCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.formatHistoryReachDate import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal.ChatroomFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.ChatNewMessageViewModel @@ -273,8 +274,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) - DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Exhausted, nip04Relays, nip04Stalled, nip04Reached, nip04Progress) + DmHistoryLoadingCard(nip17Name, "NIP-17", loadingGiftWraps, giftWrapsExhausted, giftWrapsRelays, giftWrapsStalled, giftWrapsReached, giftWrapsProgress, ::formatHistoryReachDate) + DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Exhausted, nip04Relays, nip04Stalled, nip04Reached, nip04Progress, ::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/rooms/feed/ChatroomListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt index 68620f2cb2..923c20473e 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 @@ -37,6 +37,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.ui.feeds.DmHistoryLoadingCard import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState @@ -53,7 +54,7 @@ import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.DmHistoryLoadingCard +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.formatHistoryReachDate import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChatroomHeaderCompose import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding @@ -230,10 +231,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) + DmHistoryLoadingCard(nip17Name, "NIP-17", loadingGiftWraps, giftWrapsExhausted, giftWrapsRelays, giftWrapsStalled, giftWrapsReached, giftWrapsProgress, ::formatHistoryReachDate) } if (index == oldestNip04Index) { - DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Exhausted, nip04Relays, nip04Stalled, nip04Reached, nip04Progress) + DmHistoryLoadingCard(nip04Name, "NIP-04", loadingNip04, nip04Exhausted, nip04Relays, nip04Stalled, nip04Reached, nip04Progress, ::formatHistoryReachDate) } // Per-relay window-limit markers/sentinels belonging in the gap toward the next-older room: diff --git a/commons/src/commonMain/composeResources/values/strings.xml b/commons/src/commonMain/composeResources/values/strings.xml index da98c3d19e..baae5cad1b 100644 --- a/commons/src/commonMain/composeResources/values/strings.xml +++ b/commons/src/commonMain/composeResources/values/strings.xml @@ -55,6 +55,21 @@ User avatar Navigate - + Relay sync: + Older %1$s messages + All caught up + Reached the start of your %1$s messages + %1$s · %2$s · back to %3$s + %1$s · %2$s + waiting on %1$s + Some relays didn\'t respond + %1$s unreachable · tap to see which + %1$s · history by relay + back to %1$s + Dismiss + + %1$d relay + %1$d relays + diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DmLoadMoreIndicator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DmHistoryLoadingCard.kt similarity index 77% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DmLoadMoreIndicator.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DmHistoryLoadingCard.kt index be71ae466a..8388088669 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DmLoadMoreIndicator.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DmHistoryLoadingCard.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.ui.screen.loggedIn.chats.feed +package com.vitorpamplona.amethyst.commons.ui.feeds import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade @@ -54,33 +54,48 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.pluralStringResource -import androidx.compose.ui.res.stringResource 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.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.action_dismiss +import com.vitorpamplona.amethyst.commons.resources.chats_history_all_caught_up +import com.vitorpamplona.amethyst.commons.resources.chats_history_incomplete +import com.vitorpamplona.amethyst.commons.resources.chats_history_incomplete_sub +import com.vitorpamplona.amethyst.commons.resources.chats_history_older +import com.vitorpamplona.amethyst.commons.resources.chats_history_reached_start +import com.vitorpamplona.amethyst.commons.resources.chats_history_relay_back +import com.vitorpamplona.amethyst.commons.resources.chats_history_relays +import com.vitorpamplona.amethyst.commons.resources.chats_history_relays_title +import com.vitorpamplona.amethyst.commons.resources.chats_history_subtitle +import com.vitorpamplona.amethyst.commons.resources.chats_history_subtitle_no_date +import com.vitorpamplona.amethyst.commons.resources.chats_history_waiting 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 -import java.util.Date -import java.util.Locale +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource // How long the "All caught up" state lingers before the card collapses away. private const val ALL_DONE_VISIBLE_MS = 2200L /** - * The DM "older history" status card, shown at one protocol's oldest-loaded boundary (rooms list and - * conversation). It tells the user exactly what the app is reaching for: which protocol, how many - * relays it is asking, and how far back it has paged. When that protocol runs dry it does NOT just - * vanish — it crossfades to an "All caught up" state, holds for a beat, then collapses away. + * The "older history" status card for a per-relay [BackwardRelayPager]-backed feed, shown at one + * protocol's oldest-loaded boundary (rooms list and conversation). It tells the user exactly what the + * app is reaching for: which protocol, how many relays it is asking, and how far back it has paged. + * When that protocol runs dry it does NOT just vanish — it crossfades to an "All caught up" state, + * holds for a beat, then collapses away. When it stops short because relays stalled it says so and + * stays put (an *incomplete* window — messages may still be out there). + * + * Shared across front ends; pass the platform's locale date formatter as [formatReachDate] so the card + * carries no `java.time` / `NSDateFormatter` dependency. * * @param protocolName human label woven into sentences, e.g. "encrypted" / "legacy". * @param protocolTag short technical tag for the subtitle, e.g. "NIP-17" / "NIP-04". * @param reachedBack epoch seconds of the oldest point reached so far (the deepest `until` cursor). * @param relayProgress per-relay reach (where each relay's window is, done/stalled). Tapping the card * opens a popup listing them; pass empty to make the card non-interactive. + * @param formatReachDate formats an epoch-seconds reach point to a short label (e.g. "Jun 2026"). */ @Composable fun DmHistoryLoadingCard( @@ -92,6 +107,7 @@ fun DmHistoryLoadingCard( stalledCount: Int, reachedBack: Long?, relayProgress: Map = emptyMap(), + formatReachDate: (epochSeconds: Long) -> String, modifier: Modifier = Modifier, ) { // Exhausted ("nothing more reachable right now") splits two ways and must NOT read the same: @@ -115,7 +131,7 @@ fun DmHistoryLoadingCard( var showRelays by remember { mutableStateOf(false) } if (showRelays) { - DmHistoryRelayDialog(protocolTag, relayProgress) { showRelays = false } + DmHistoryRelayDialog(protocolTag, relayProgress, formatReachDate) { showRelays = false } } AnimatedVisibility( @@ -169,9 +185,9 @@ fun DmHistoryLoadingCard( if (loading) { CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) } else { - // Paused: not caught up, but not actively loading (the rooms-list auto-fill - // stopped short of exhaustion, or we're between round-model pages). Show a - // static "more" glyph so the icon slot is never blank — resumes on scroll. + // Paused: not caught up, but not actively loading (the auto-fill stopped short + // of exhaustion, or we're between pages). Show a static "more" glyph so the + // icon slot is never blank — resumes on scroll. Text( "⋯", style = MaterialTheme.typography.titleMedium, @@ -186,9 +202,9 @@ fun DmHistoryLoadingCard( Text( text = when (state) { - HistoryPhase.CaughtUp -> stringResource(R.string.chats_history_all_caught_up) - HistoryPhase.Incomplete -> stringResource(R.string.chats_history_incomplete) - HistoryPhase.Loading -> stringResource(R.string.chats_history_older, protocolName) + HistoryPhase.CaughtUp -> stringResource(Res.string.chats_history_all_caught_up) + HistoryPhase.Incomplete -> stringResource(Res.string.chats_history_incomplete) + HistoryPhase.Loading -> stringResource(Res.string.chats_history_older, protocolName) }, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, @@ -197,9 +213,9 @@ fun DmHistoryLoadingCard( Text( text = when (state) { - HistoryPhase.CaughtUp -> stringResource(R.string.chats_history_reached_start, protocolName) + HistoryPhase.CaughtUp -> stringResource(Res.string.chats_history_reached_start, protocolName) HistoryPhase.Incomplete -> incompleteSubtitle(stalledCount) - HistoryPhase.Loading -> historySubtitle(protocolTag, relayCount, stalledCount, reachedBack) + HistoryPhase.Loading -> historySubtitle(protocolTag, relayCount, stalledCount, reachedBack, formatReachDate) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -218,40 +234,38 @@ private enum class HistoryPhase { Loading, CaughtUp, Incomplete } /** Subtitle for the "stopped early" state: how many relays we couldn't reach, with a hint to tap for the list. * Shared with the reply placeholder so both read identically. */ @Composable -internal fun incompleteSubtitle(stalledCount: Int): String = +fun incompleteSubtitle(stalledCount: Int): String = stringResource( - R.string.chats_history_incomplete_sub, - pluralStringResource(R.plurals.chats_history_relays, stalledCount, stalledCount), + Res.string.chats_history_incomplete_sub, + pluralStringResource(Res.plurals.chats_history_relays, stalledCount, stalledCount), ) @Composable -internal fun historySubtitle( +fun historySubtitle( protocolTag: String, relayCount: Int, stalledCount: Int, reachedBack: Long?, + formatReachDate: (epochSeconds: Long) -> String, ): String { - val backLabel = - remember(reachedBack) { - reachedBack?.let { SimpleDateFormat("MMM yyyy", Locale.getDefault()).format(Date(it * 1000)) } - } + val backLabel = remember(reachedBack) { reachedBack?.let(formatReachDate) } // Middle segment: the relays actively fetching ("N relays"), or — when none are in flight but some // can't be reached — what we're waiting on ("waiting on N relays"). With neither, just the tag, since // a paged-out-but-parked protocol isn't waiting on anything (it resumes on scroll). val middle = when { - relayCount > 0 -> pluralStringResource(R.plurals.chats_history_relays, relayCount, relayCount) + relayCount > 0 -> pluralStringResource(Res.plurals.chats_history_relays, relayCount, relayCount) stalledCount > 0 -> stringResource( - R.string.chats_history_waiting, - pluralStringResource(R.plurals.chats_history_relays, stalledCount, stalledCount), + Res.string.chats_history_waiting, + pluralStringResource(Res.plurals.chats_history_relays, stalledCount, stalledCount), ) else -> return protocolTag } return if (backLabel != null) { - stringResource(R.string.chats_history_subtitle, protocolTag, middle, backLabel) + stringResource(Res.string.chats_history_subtitle, protocolTag, middle, backLabel) } else { - stringResource(R.string.chats_history_subtitle_no_date, protocolTag, middle) + stringResource(Res.string.chats_history_subtitle_no_date, protocolTag, middle) } } @@ -260,19 +274,19 @@ internal fun historySubtitle( * ↓ still reaching) and how far back it has paged ("back to "), deepest-reaching first. */ @Composable -internal fun DmHistoryRelayDialog( +fun DmHistoryRelayDialog( protocolTag: String, relayProgress: Map, + formatReachDate: (epochSeconds: Long) -> String, onDismiss: () -> Unit, ) { - val df = remember { SimpleDateFormat("MMM d, yyyy", Locale.getDefault()) } val rows = remember(relayProgress) { relayProgress.entries.sortedBy { it.value.reachedUntil } } AlertDialog( onDismissRequest = onDismiss, confirmButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.dismiss)) } + TextButton(onClick = onDismiss) { Text(stringResource(Res.string.action_dismiss)) } }, - title = { Text(stringResource(R.string.chats_history_relays_title, protocolTag)) }, + title = { Text(stringResource(Res.string.chats_history_relays_title, protocolTag)) }, text = { Column( Modifier @@ -301,7 +315,7 @@ internal fun DmHistoryRelayDialog( ) Spacer(Modifier.width(8.dp)) Text( - text = stringResource(R.string.chats_history_relay_back, df.format(Date(p.reachedUntil * 1000))), + text = stringResource(Res.string.chats_history_relay_back, formatReachDate(p.reachedUntil)), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, From f631b0fbf1a40e149b97713690acf901c0190631 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 16:59:49 +0000 Subject: [PATCH 079/103] docs(dm): reflect the commons UI extraction in the design doc The relay-reach markers and the history status card now live in commons/ui/feeds (shared Android + Desktop), with the card taking an injected date formatter. Update the component map's UI section accordingly. --- ...26-06-01-dm-live-tail-and-history-slices.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md b/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md index bf0609d128..6fe5b37aef 100644 --- a/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md +++ b/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md @@ -247,14 +247,22 @@ source set (uses `java.util.concurrent`), visible to amethyst + desktop + quartz - `FilterNip04DMs.kt` (per-relay `Nip04DmRelays`, live + history builders), `FilterNip04DMsFromMe/ToMe.kt`, `FilterGiftWrapsToPubkey.kt` — `until`/`limit` added. - `AccountFilterAssembler`, `ChatroomFilterAssembler`, `ChatroomListFilterAssembler` — wire the new managers. -**UI (`ui/screen/loggedIn/chats/`)** -- `feed/DmLoadMoreIndicator.kt` — `DmHistoryLoadingCard` + per-relay dialog. -- `feed/LoadingReplyNote.kt` — history-walking reply placeholder. -- `feed/layouts/RelayReachMarker.kt` — `RelayWindowLimit` + sentinels (driver) + markers (UI). +**Shared UI (commons, `commons/ui/feeds/`)** — extracted from amethyst so Android + +Desktop (and any per-relay feed) render the same widgets; CMP `composeResources` +strings, no app-theme / `java.time` deps. +- `RelayReachMarker.kt` — `RelayWindowLimit` + sentinels (the hoisted, visibility-driven + paging driver) + markers (pure UI) + `RelayReachMarker`/`RelayReachState`. +- `DmHistoryLoadingCard.kt` — the boundary status card + per-relay tap dialog + + `historySubtitle`/`incompleteSubtitle`. Takes a `formatReachDate: (epochSeconds) -> String` + so each platform supplies its locale date formatter. + +**Android UI (`amethyst/ui/screen/loggedIn/chats/`)** +- `feed/LoadingReplyNote.kt` — history-walking reply placeholder (uses the shared subtitle helpers/dialog). +- `feed/HistoryDateFormat.kt` — `formatHistoryReachDate`, the Android locale formatter passed into the shared card. - `feed/ChatFeedView.kt` — `markersInGap` + `sentinels` slots. - `feed/ChatMessageCompose.kt` — reply `onBlank` wiring. - `privateDM/ChatroomView.kt`, `rooms/feed/ChatroomListFeedView.kt` — assemble cards/markers/sentinels, `BootstrapHistoryWhenEmpty`. -- `res/values/strings.xml` — `chats_history_*` / `chats_reply_*`. +- `res/values/strings.xml` — `chats_reply_*` (the card's `chats_history_*` now live in commons). --- From 4fc24950e3738ad2186d18c635771c23452c2b48 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 17:19:06 +0000 Subject: [PATCH 080/103] refactor(dm): cleanup + DRY/test the relay-reach gap predicate Audit follow-up. - Remove the 12 history-card strings (chats_history_older / all_caught_up / reached_start / relay_sync / subtitle{,_no_date} / waiting / relays_title / relay_back / incomplete{,_sub} + the chats_history_relays plural) from amethyst's default strings.xml: they moved to commons composeResources with the card and have zero remaining amethyst references. They were branch-new and not yet translated, so removing the default key is a clean, orphan-free delete. Kept chats_history_proto_* (still the card's protocolName) and chats_reply_*. - Extract the "does this relay's reached cursor fall in this gap" check, which was duplicated (with off-by-one-prone >/<= boundaries) between marker placement (RelayWindowLimitMarkers) and the paging driver (RelayWindowLimitSentinels), into a single pure reachedFallsInGap(); both now call it so they can't disagree about which gap a cursor lives in. Add RelayReachMarkerTest pinning every boundary (newer strictly >, older inclusive <=, null ends). - Fix a garbled comment in BackwardRelayPager.onSilenced. --- amethyst/src/main/res/values/strings.xml | 18 ----- .../commons/ui/feeds/RelayReachMarker.kt | 26 +++++-- .../commons/ui/feeds/RelayReachMarkerTest.kt | 73 +++++++++++++++++++ .../relay/client/paging/BackwardRelayPager.kt | 4 +- 4 files changed, 93 insertions(+), 28 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarkerTest.kt diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index df2f52b147..2d2e356ea6 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -275,31 +275,13 @@ Loading feed Loading account Load entire history - Older %1$s messages - All caught up - Reached the start of your %1$s messages encrypted legacy - Relay sync: Looking for the original message… - %1$s · %2$s · back to %3$s - %1$s · %2$s - waiting on %1$s - %1$s · history by relay - back to %1$s - - Some relays didn\'t respond - - %1$s unreachable · tap to see which Couldn\'t find this message Searched every relay · tap to see - - %1$d relay - %1$d relays - "Error loading replies: " Try again No notifications yet. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt index fa88fccddc..4d4fc9e21b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt @@ -50,6 +50,22 @@ import org.jetbrains.compose.resources.stringResource // A relay-reach divider is hair-thin; inlined here so the shared component carries no app-theme dep. private val DividerThickness = 0.25.dp +/** + * True when [reachedUntil] falls in the gap between a newer message (at [newerCreatedAt]) and its + * next-older neighbour (at [olderCreatedAt], null past the oldest end): the newer side is strictly + * newer than the cursor and the older side is at or below it (or absent). This single predicate both + * places the marker ([RelayWindowLimitMarkers]) and decides when its paging sentinel is on screen + * ([RelayWindowLimitSentinels]), so the two can never disagree about which gap a cursor lives in. + */ +internal fun reachedFallsInGap( + reachedUntil: Long, + newerCreatedAt: Long?, + olderCreatedAt: Long?, +): Boolean = + newerCreatedAt != null && + newerCreatedAt > reachedUntil && + (olderCreatedAt == null || olderCreatedAt <= reachedUntil) + /** How far one relay has paged into a feed's history, for an in-stream progress marker. */ enum class RelayReachState { // Still paging older — its marker slides down (older) as it advances. @@ -127,9 +143,7 @@ fun RelayWindowLimitSentinels( // visible rows only. val onScreen = listState.layoutInfo.visibleItemsInfo.any { info -> - val newer = at(info.index) ?: return@any false - val older = at(info.index + 1) - newer > r && (older == null || older <= r) + reachedFallsInGap(r, at(info.index), at(info.index + 1)) } // Pair so distinctUntilChanged also lets a landed page (r moved) re-fire while visible, // not just the off→on-screen transition. @@ -162,11 +176,7 @@ fun RelayWindowLimitMarkers( ) { val here = remember(limits, newerCreatedAt, olderCreatedAt) { - limits.filter { lim -> - newerCreatedAt != null && - newerCreatedAt > lim.reachedUntil && - (olderCreatedAt == null || olderCreatedAt <= lim.reachedUntil) - } + limits.filter { reachedFallsInGap(it.reachedUntil, newerCreatedAt, olderCreatedAt) } } if (here.isEmpty()) return diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarkerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarkerTest.kt new file mode 100644 index 0000000000..596677410d --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarkerTest.kt @@ -0,0 +1,73 @@ +/* + * 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.amethyst.commons.ui.feeds + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The gap predicate places the per-relay reach marker AND gates its paging sentinel, so an off-by-one + * here would either render a marker in the wrong gap or fire (or never fire) paging. The boundaries are + * deliberately asymmetric — newer side strictly `>`, older side `<=` — so each edge is pinned here. + */ +class RelayReachMarkerTest { + @Test + fun cursorStrictlyBetweenTwoMessagesIsInTheGap() { + // gap is (older=80, newer=100]; a cursor reached down to 90 sits in it. + assertTrue(reachedFallsInGap(reachedUntil = 90, newerCreatedAt = 100, olderCreatedAt = 80)) + } + + @Test + fun cursorAtTheOldestEndWithNoOlderNeighbourIsInTheGap() { + // Past the oldest loaded row (olderCreatedAt null): any cursor below the last message sits here. + assertTrue(reachedFallsInGap(reachedUntil = 50, newerCreatedAt = 100, olderCreatedAt = null)) + } + + @Test + fun noNewerRowMeansNotInThisGap() { + // newerCreatedAt null = nothing on the newer side (e.g. a non-message row) → never placed here. + assertFalse(reachedFallsInGap(reachedUntil = 50, newerCreatedAt = null, olderCreatedAt = 20)) + } + + @Test + fun newerSideIsStrictlyNewer_equalDoesNotCount() { + // The marker belongs in the gap *below* the message it reached, not at the message itself. + assertFalse(reachedFallsInGap(reachedUntil = 100, newerCreatedAt = 100, olderCreatedAt = 50)) + } + + @Test + fun olderSideIsInclusive_equalCounts() { + // older == reached: the cursor sits exactly at the older message → still this gap (`<=`). + assertTrue(reachedFallsInGap(reachedUntil = 80, newerCreatedAt = 100, olderCreatedAt = 80)) + } + + @Test + fun gapWhoseOlderNeighbourIsStillNewerThanTheCursorIsNotIt() { + // older=90 is newer than the cursor (70): the cursor lives in a deeper gap, not this one. + assertFalse(reachedFallsInGap(reachedUntil = 70, newerCreatedAt = 100, olderCreatedAt = 90)) + } + + @Test + fun cursorNewerThanTheNewerRowIsNotInThisGap() { + assertFalse(reachedFallsInGap(reachedUntil = 150, newerCreatedAt = 100, olderCreatedAt = 80)) + } +} 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 index 586e45a517..21474a810f 100644 --- 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 @@ -245,8 +245,8 @@ class BackwardRelayPager( 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. + // The tracker's silence watchdog fired: the still-pending relays went quiet after their REQ. Flag them + // (for the active key) stalled but 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)") } From 82ffd4c16bb22df3e46c9df8699fa3dc1247ea74 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 17:35:23 +0000 Subject: [PATCH 081/103] test(dm): cover the WindowLoadTracker idle backstop + its heard-from gate Adds WindowLoadTrackerIdleTest, the one WindowLoadTracker backstop that had no coverage (silence and connect-grace are already pinned by WindowLoadTrackerSilenceTest): - idle completes a window when a relay streams stored events but never EOSEs, once the stream goes quiet for idleTimeout (the "every relay settled" path can never finish such a relay). - the idle gate holds the window open while a still-pending relay has never been heard from, so a slow connect isn't mistaken for a quiet stream; once that relay delivers anything and goes quiet, idle then completes it. Real-time tests with a short idleTimeout, matching the silence suite's style. --- .../eoseManagers/WindowLoadTrackerIdleTest.kt | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTrackerIdleTest.kt diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTrackerIdleTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTrackerIdleTest.kt new file mode 100644 index 0000000000..73ff94d800 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTrackerIdleTest.kt @@ -0,0 +1,85 @@ +/* + * 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.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 +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.time.Duration.Companion.milliseconds + +/** + * Real-time tests for the **idle** backstop: when a relay streams stored events but never sends EOSE, + * the window can't complete on "every relay settled", so the idle timer finishes it once the stream + * goes quiet — but only after every still-pending relay has been *heard from*, so a slow connect (a + * relay that hasn't answered yet) is never mistaken for a stream that has gone quiet. + */ +class WindowLoadTrackerIdleTest { + private val good = NormalizedRelayUrl("wss://vitor.nostr1.com/") + private val streamer = NormalizedRelayUrl("wss://relay.damus.io/") + + @Test + fun idleBackstopCompletesARelayThatStreamsButNeverEoses() = + runBlocking { + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val tracker = WindowLoadTracker(name = "test", idleTimeout = 50.milliseconds) + + tracker.startLoading(scope) + tracker.setExpectedRelays(setOf(good, streamer)) + tracker.onRelaySettled(good) // `good` finishes with an EOSE + // `streamer` keeps delivering stored events but never sends EOSE — so "every relay settled" + // can never complete this window. Its events keep it "heard from" (idle gate satisfied). + tracker.onRelayEvent(streamer) + tracker.onRelayEvent(streamer) + + // The only way out is the idle backstop, once the stream stays quiet for idleTimeout. + withTimeout(3000) { tracker.loading.first { !it } } + scope.cancel() + } + + @Test + fun idleBackstopWaitsUntilEveryPendingRelayHasBeenHeardFrom() = + runBlocking { + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + // tracksReqSends = false so the silence/connect-grace backstops are OFF and only idle is in play. + val tracker = WindowLoadTracker(name = "test", tracksReqSends = false, idleTimeout = 50.milliseconds) + + tracker.startLoading(scope) + tracker.setExpectedRelays(setOf(good, streamer)) + tracker.onRelaySettled(good) + // `streamer` has NOT been heard from yet (still connecting). The idle gate must hold the window + // open — a connection gap is not a quiet stream. Wait well past idleTimeout AND a watchdog tick. + Thread.sleep(800) + assertTrue("idle must not fire while a pending relay has never been heard from", tracker.loading.value) + + // Once it delivers something (now heard-from) and the stream goes quiet, idle completes it. + tracker.onRelayEvent(streamer) + withTimeout(3000) { tracker.loading.first { !it } } + scope.cancel() + } +} From b672611c522d6592578832d8c236202d62f6c8d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 17:55:28 +0000 Subject: [PATCH 082/103] feat(dm): log the NIP-42 AUTH handshake in the DM diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DM trail showed REQ + CLOSED/NOTICE/OK-fail but was blind to the entire NIP-42 handshake — incoming AUTH challenges hit `else -> {}`, the AUTH send is not a REQ so onSent skipped it, and the auth OK(true) was filtered out by the OK(fail)-only guard. So a CLOSED 'auth-required' followed by a re-REQ was unattributable: you couldn't tell whether the relay challenged, whether we signed+sent AUTH, whether it was accepted, and whether the re-REQ was the post-auth syncFilters or just a blind retry. Add three lines under the DMPagination tag (DM relays only, debug): - `AUTH challenge <- relay ''` (incoming AuthMessage) - `AUTH -> relay (id ...)` (our AuthCmd reply; id remembered) - `AUTH accepted|REJECTED <- relay '...'` (the OK matched to that auth id) With these, the post-CLOSED sequence reads end to end: challenge → AUTH → accept → re-REQ (→ events/EOSE), so an auth-walled DM relay's load can be confirmed or pinned to the exact link that breaks. --- .../diagnostics/DmRelayDiagnosticsLogger.kt | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt index 14deeb3313..4aefae6ba6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt @@ -23,10 +23,12 @@ package com.vitorpamplona.amethyst.service.relayClient.diagnostics import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent @@ -42,8 +44,10 @@ import com.vitorpamplona.quartz.utils.Log * rejection and OK failures — into the single `DMPagination` log tag with an * elapsed-time prefix, so a slow cold boot can be attributed (connection? relay * response?) and a silent failure to load (e.g. a relay answering CLOSED - * "auth-required" / "restricted") becomes visible. Per-event and auth-challenge - * lines are intentionally omitted to keep the trail readable. + * "auth-required" / "restricted") becomes visible. Per-event lines are omitted to + * keep the trail readable, but the NIP-42 AUTH handshake (challenge in, AUTH out, + * accept/reject) IS logged: an auth-walled relay's whole load hinges on whether + * that round-trip closes and a re-REQ follows, so it has to be attributable here. * * The connection listener fires for EVERY relay the app talks to (hundreds, under * the outbox model). To keep this readable we only log relays that are part of the @@ -64,6 +68,11 @@ class DmRelayDiagnosticsLogger( // hundreds of unrelated follow/outbox relays is filtered out. private val dmPathRelays = mutableSetOf() + // Ids of the AUTH events we've sent to DM relays, so the relay's OK can be tagged "AUTH accepted / + // REJECTED" (the OK that decides whether the post-auth re-REQ will actually be served) instead of + // being lost among ordinary event OKs. + private val authEventIds = mutableSetOf() + private fun isDmRelay(relay: IRelayClient) = relay.url in dmPathRelays private val listener = @@ -88,6 +97,15 @@ class DmRelayDiagnosticsLogger( cmd: Command, success: Boolean, ) { + if (cmd is AuthCmd) { + // Our reply to a relay's NIP-42 challenge. Remember the id so the relay's OK can be + // tagged as the auth result below. Only for relays already on the DM path. + if (isDmRelay(relay)) { + authEventIds.add(cmd.event.id) + Log.d(TAG) { "[+${at()}ms] AUTH -> ${relay.url.url} success=$success (id ${cmd.event.id.take(8)})" } + } + return + } if (!isDmReq(cmdStr)) return dmPathRelays.add(relay.url) reqSubId(cmdStr)?.let { dmSubIds.add(it) } @@ -100,6 +118,11 @@ class DmRelayDiagnosticsLogger( msg: Message, ) { when (msg) { + is AuthMessage -> + // The relay's NIP-42 challenge. Without it (and the AUTH/OK that follow) an + // auth-walled relay can never serve, so it's the first link to look for. + if (isDmRelay(relay)) Log.d(TAG) { "[+${at()}ms] AUTH challenge <- ${relay.url.url} '${msg.challenge.take(40)}'" } + is NoticeMessage -> if (isDmRelay(relay)) Log.d(TAG) { "[+${at()}ms] NOTICE <- ${relay.url.url} '${msg.message}'" } @@ -109,7 +132,11 @@ class DmRelayDiagnosticsLogger( } is OkMessage -> - if (!msg.success && isDmRelay(relay)) { + if (msg.eventId in authEventIds) { + // The auth result: "accepted" means a syncFilters re-REQ should now be served; + // "REJECTED" means this relay will keep refusing and never serve our DMs. + Log.d(TAG) { "[+${at()}ms] AUTH ${if (msg.success) "accepted" else "REJECTED"} <- ${relay.url.url} '${msg.message}'" } + } else if (!msg.success && isDmRelay(relay)) { Log.d(TAG) { "[+${at()}ms] OK(fail) <- ${relay.url.url} '${msg.message}'" } } From 48333f000728353d49cd76cd2067c0ae8e7385a4 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 5 Jun 2026 16:44:35 -0400 Subject: [PATCH 083/103] refactor(dm): drop dead pagination code with no production caller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes machinery that ships but is unreachable from any live path: - WindowLoadTracker's REQ-aware backstops (silence + connect-grace). All three live-tail managers construct it with the default tracksReqSends = false, so onReqSent/reqSentAt/silencedOut/connectStalled and the onAbandoned reporting could never fire. Only WindowLoadTrackerSilenceTest exercised them, so it goes too. accountedFor collapses to "settled". - trackingListener's onEachEvent param — no caller ever passed it. - UntilLimitPager.isArmed() / activeRelays() — only the unit test called them; the pager uses armedRelays() in production. - Orphaned string chats_load_entire_history, left behind when the "load entire history" button was removed. Co-Authored-By: Claude Opus 4.8 (1M context) --- amethyst/src/main/res/values/strings.xml | 1 - .../eoseManagers/UntilLimitPagerTest.kt | 8 +- .../eoseManagers/WindowLoadTrackerIdleTest.kt | 3 +- .../WindowLoadTrackerSilenceTest.kt | 152 ------------------ .../relay/client/paging/UntilLimitPager.kt | 14 +- .../relay/client/paging/WindowLoadTracker.kt | 104 ++---------- 6 files changed, 17 insertions(+), 265 deletions(-) delete mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTrackerSilenceTest.kt diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 2d2e356ea6..e6cc2ebc1d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -274,7 +274,6 @@ Generate a new key Loading feed Loading account - Load entire history encrypted legacy Looking for the original message… 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 cd436b8371..c957c97355 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 @@ -34,12 +34,10 @@ class UntilLimitPagerTest { private val start = 1_000L @Test - fun unarmedRelayIsNotRequestedButCountsAsActive() { + fun unarmedRelayIsNotRequestedAndSitsAtTheFloor() { val pager = UntilLimitPager() - assertFalse(pager.isArmed(key, relayA)) + // never advanced, so it carries no REQ assertEquals(emptyList(), pager.armedRelays(key, listOf(relayA))) - // not done, so still "active" (there is history to ask for once advanced) - assertEquals(listOf(relayA), pager.activeRelays(key, listOf(relayA))) // marker sits at the floor until it delivers assertEquals(start, pager.reachedUntilFor(key, relayA, start)) } @@ -49,7 +47,6 @@ class UntilLimitPagerTest { val pager = UntilLimitPager() assertTrue(pager.advance(key, relayA, start)) - assertTrue(pager.isArmed(key, relayA)) assertEquals(start, pager.requestedUntilFor(key, relayA)) // page returns events; oldest seen = 800 @@ -72,7 +69,6 @@ class UntilLimitPagerTest { pager.onEose(key, relayA) // no events assertTrue(pager.isDone(key, relayA)) assertFalse(pager.advance(key, relayA, start)) - assertEquals(emptyList(), pager.activeRelays(key, listOf(relayA))) assertEquals(emptyList(), pager.armedRelays(key, listOf(relayA))) } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTrackerIdleTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTrackerIdleTest.kt index 73ff94d800..7179fed63c 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTrackerIdleTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTrackerIdleTest.kt @@ -66,8 +66,7 @@ class WindowLoadTrackerIdleTest { fun idleBackstopWaitsUntilEveryPendingRelayHasBeenHeardFrom() = runBlocking { val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - // tracksReqSends = false so the silence/connect-grace backstops are OFF and only idle is in play. - val tracker = WindowLoadTracker(name = "test", tracksReqSends = false, idleTimeout = 50.milliseconds) + val tracker = WindowLoadTracker(name = "test", idleTimeout = 50.milliseconds) tracker.startLoading(scope) tracker.setExpectedRelays(setOf(good, streamer)) 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 deleted file mode 100644 index 7d922288ea..0000000000 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTrackerSilenceTest.kt +++ /dev/null @@ -1,152 +0,0 @@ -/* - * 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.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 -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test -import java.util.concurrent.atomic.AtomicReference -import kotlin.time.Duration.Companion.milliseconds - -/** - * Real-time (not virtual-time) tests: the tracker's watchdog reads the wall clock, so a short - * [silenceTimeout] with real delays is the honest way to exercise the silence backstop. - */ -class WindowLoadTrackerSilenceTest { - private val good = NormalizedRelayUrl("wss://vitor.nostr1.com/") - private val silent = NormalizedRelayUrl("wss://relay.ditto.pub/") - - @Test - fun silentRelayDoesNotBlockTheLoadAndIsReportedAsAbandoned() = - runBlocking { - val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val abandoned = AtomicReference>(emptySet()) - val tracker = - WindowLoadTracker( - name = "test", - tracksReqSends = true, - silenceTimeout = 50.milliseconds, - onAbandoned = { abandoned.set(it) }, - ) - - tracker.startLoading(scope) - tracker.setExpectedRelays(setOf(good, silent)) - // Both received the REQ; only the good relay answers (an EOSE settles it). - tracker.onReqSent(good.url) - tracker.onReqSent(silent.url) - tracker.onRelaySettled(good) - - // The good relay is settled and the silent one trips the silence backstop, so the load - // completes without ever hearing from the silent relay. - withTimeout(3000) { tracker.loading.first { !it } } - - assertEquals(setOf(silent), abandoned.get()) - scope.cancel() - } - - @Test - fun aRelayStuckBeforeItsReqStopsBlockingButIsNotGivenUp() = - runBlocking { - val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val abandoned = AtomicReference>(emptySet()) - val tracker = - WindowLoadTracker( - name = "test", - tracksReqSends = true, - connectGrace = 50.milliseconds, - onAbandoned = { abandoned.set(it) }, - ) - - tracker.startLoading(scope) - tracker.setExpectedRelays(setOf(good, silent)) - // `good` answers; `silent` is stuck connecting — it never even receives its REQ. - tracker.onReqSent(good.url) - tracker.onRelaySettled(good) - - // The connect-grace backstop completes the load without the stuck relay... - withTimeout(3000) { tracker.loading.first { !it } } - // ...but it must NOT be given up: a slow connect deserves a retry next round. - assertEquals(emptySet(), abandoned.get()) - scope.cancel() - } - - @Test - fun withoutReqTrackingAStalledRelayKeepsBlockingUntilItSettles() = - runBlocking { - val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - // A tracker that does NOT feed onReqSent (giftwrap / rooms): the REQ-aware backstops must - // stay off, or every never-heard-from relay would look stalled and the window would finish - // before its REQs even went out (the connect-storm regression). - val tracker = - WindowLoadTracker( - name = "test", - tracksReqSends = false, - silenceTimeout = 50.milliseconds, - connectGrace = 50.milliseconds, - ) - - tracker.startLoading(scope) - tracker.setExpectedRelays(setOf(good, silent)) - tracker.onRelaySettled(good) - - // `silent` was never heard from and never got a REQ; well past both short backstops it must - // STILL block, because this tracker doesn't track REQ sends. - Thread.sleep(400) - assertTrue("non-req-tracking tracker must not abandon a stalled relay", tracker.loading.value) - - // Only an actual terminal signal completes it. - tracker.onRelaySettled(silent) - withTimeout(3000) { tracker.loading.first { !it } } - scope.cancel() - } - - @Test - fun aSilentRelayThatNeverGotAReqStillBlocksUntilItSettles() = - runBlocking { - val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val tracker = WindowLoadTracker(name = "test", tracksReqSends = true, silenceTimeout = 50.milliseconds) - - tracker.startLoading(scope) - tracker.setExpectedRelays(setOf(good, silent)) - tracker.onReqSent(good.url) - tracker.onRelaySettled(good) - // `silent` is still connecting: no onReqSent, so the silence clock never starts and the - // load must stay open (a connection gap must not be mistaken for a dead relay). - - Thread.sleep(400) // well past silenceTimeout - assertTrue("still loading while a relay has not even been sent its REQ", tracker.loading.value) - - // Once it connects, gets its REQ, and stays silent, the backstop then completes the load. - tracker.onReqSent(silent.url) - withTimeout(3000) { tracker.loading.first { !it } } - - scope.cancel() - } -} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPager.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPager.kt index 28e05484ab..a648c0a998 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPager.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPager.kt @@ -75,13 +75,7 @@ class UntilLimitPager { relay: NormalizedRelayUrl, ) = cursorsFor(key).getOrPut(relay) { RelayCursor() } - /** True once [relay] has been [advance]d at least once (so its REQ should be issued). */ - fun isArmed( - key: K, - relay: NormalizedRelayUrl, - ): Boolean = cursor(key, relay).requestedUntil != null - - /** The `until` [relay]'s REQ currently carries. Only meaningful once [isArmed]. */ + /** The `until` [relay]'s REQ currently carries. Only meaningful once it has been [advance]d. */ fun requestedUntilFor( key: K, relay: NormalizedRelayUrl, @@ -160,12 +154,6 @@ class UntilLimitPager { } } - /** Relays from [all] that still have older history to ask for: not yet empty-EOSE'd ([done]). */ - fun activeRelays( - key: K, - all: Collection, - ): List = all.filterNot { cursor(key, it).done } - /** Relays from [all] that have been armed (advanced at least once) and are not yet [done]. */ fun armedRelays( key: K, diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/WindowLoadTracker.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/WindowLoadTracker.kt index 51ac163be6..fa5b16b47d 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/WindowLoadTracker.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/WindowLoadTracker.kt @@ -58,40 +58,17 @@ import kotlin.time.Duration.Companion.seconds * mistakes a half-loaded window for a finished one, which is exactly how a load reports "1 event" * when a hundred are still on the way. * - * Three backstops cover misbehaving relays. If every relay we're still waiting on has at least been + * Two backstops cover misbehaving relays. If every relay we're still waiting on has at least been * *heard from* (any event, EOSE, CLOSED, or cannot-connect) but one streamed events without ever * sending EOSE, an [idleTimeout] of quiet completes the load — the "heard from" gate is what keeps - * this from firing in a connection gap. A relay that *received our REQ* ([onReqSent]) but then went - * completely silent — no event, no EOSE, no CLOSED — for [silenceTimeout] stops blocking the load: an - * auth-walled relay (ditto, paid relays) commonly accepts the REQ and answers nothing, and measuring - * from REQ-delivery (not window start) means a slow connect doesn't count against it. Such relays are - * reported to [onAbandoned] so the owner can react — drop them from its pager, or keep them and flag - * them stalled (the convo history keeps trying). A relay that never even - * *receives* its REQ (stuck connecting / reconnecting, so it can neither settle nor go "silent") stops - * blocking the round after [connectGrace] from the load start — but it is NOT given up (it may be a - * genuinely slow connect), so the owner keeps it and retries it next round. And an [absoluteCap] is - * the final ceiling on a window that somehow defeats all of the above. - * - * The two REQ-aware backstops (silence + connect-grace) only make sense when the owner actually feeds - * [onReqSent], so they are gated behind [tracksReqSends]. A tracker that does NOT track REQ sends keeps - * the plain settle / idle / cap behavior — otherwise, with an always-empty [reqSentAt], EVERY relay - * would look "connect-stalled" after [connectGrace] and the window would complete before its REQs even - * went out (e.g. during a slow connect storm), prematurely declaring an empty round done. + * this from firing in a connection gap. And an [absoluteCap] is the final ceiling on a window that + * somehow defeats the above. */ class WindowLoadTracker( // Short label for the DMPagination logs (e.g. "giftwrap", "rooms.nip04", "convo.nip04"). private val name: String = "dm", - // Whether the owner feeds [onReqSent]; enables the silence + connect-grace backstops. Off by - // default so trackers that don't track REQ sends are unaffected by them. - private val tracksReqSends: Boolean = false, private val idleTimeout: Duration = 3.seconds, - private val silenceTimeout: Duration = 10.seconds, - private val connectGrace: Duration = 15.seconds, private val absoluteCap: Duration = 5.minutes, - // Invoked when a load finishes with the relays that received a REQ but stayed silent past - // [silenceTimeout]. The owner decides what to do — drop them from its pager, or keep them open and - // flag them stalled. The tracker itself only stops waiting on them; it does not give them up. - private val onAbandoned: (Set) -> Unit = {}, ) { private val _loading = MutableStateFlow(true) val loading: StateFlow = _loading.asStateFlow() @@ -109,10 +86,6 @@ class WindowLoadTracker( // [expected] the stored backfill is complete on every relay and the load is done. private val settled = ConcurrentHashMap.newKeySet() - // When the REQ was actually delivered to each relay (post-connect). The silence backstop measures - // from here, not window start, so a slow connect isn't mistaken for a dead relay. - private val reqSentAt = ConcurrentHashMap() - private var watchdog: Job? = null // Incremented on every (re)start so a stale watchdog that wakes right as a new load begins @@ -124,10 +97,6 @@ class WindowLoadTracker( @Volatile private var lastActivityMs = 0L - // Wall-clock the current window began; the connect-grace backstop measures from here. - @Volatile - private var loadStartMs = 0L - /** Begins a fresh window load: clears the per-relay sets, raises [loading], and arms the watchdog. */ @Synchronized fun startLoading(scope: CoroutineScope) { @@ -135,10 +104,7 @@ class WindowLoadTracker( expected = emptySet() heardFrom.clear() settled.clear() - reqSentAt.clear() - val nowMs = System.currentTimeMillis() - lastActivityMs = nowMs - loadStartMs = nowMs + lastActivityMs = System.currentTimeMillis() val wasLoading = _loading.value _loading.value = true Log.d(TAG) { "[$name] load start" + if (!wasLoading) "" else " (restart)" } @@ -164,15 +130,14 @@ class WindowLoadTracker( ): Boolean { if (gen != generation || !_loading.value) return false if (expected.isNotEmpty()) { - // Once every relay is accounted for — settled, gone silent after its REQ, or stuck before - // its REQ even went out — nothing more is coming for this round. - if (expected.all { accountedFor(it, now) }) { - finish("settled/silent") + // Once every relay has reached a terminal signal, nothing more is coming for this round. + if (settled.containsAll(expected)) { + finish("settled") return false } // Idle backstop: every relay we're still waiting on has at least streamed something (so this - // isn't a connection gap) and the stream has gone quiet. Accounted-for relays don't count. - val stillWaiting = expected.filterNot { accountedFor(it, now) } + // isn't a connection gap) and the stream has gone quiet. Settled relays don't count. + val stillWaiting = expected.filterNot { settled.contains(it) } if (stillWaiting.all { heardFrom.contains(it) } && now - lastActivityMs >= idleTimeout.inWholeMilliseconds) { finish("idle") return false @@ -185,30 +150,6 @@ class WindowLoadTracker( return true } - // A relay no longer worth waiting on this round: it reached a terminal signal, went silent after - // its REQ, or never even received its REQ within the connect grace. - private fun accountedFor( - relay: NormalizedRelayUrl, - now: Long, - ): Boolean = settled.contains(relay) || silencedOut(relay, now) || connectStalled(relay, now) - - // A relay that received its REQ but produced no signal at all for [silenceTimeout]. Measured from - // REQ-delivery so a slow connect (which has no [reqSentAt] yet) is never counted as silent. These - // are reported to [onAbandoned] on finish (accepting a REQ then answering nothing usually means an - // auth-walled / dead relay) — but whether to give them up is the owner's call, not the tracker's. - private fun silencedOut( - relay: NormalizedRelayUrl, - now: Long, - ): Boolean = tracksReqSends && relay !in heardFrom && (reqSentAt[relay]?.let { now - it >= silenceTimeout.inWholeMilliseconds } ?: false) - - // A relay that is still expected but has neither been heard from nor even received its REQ within - // [connectGrace] of the load start — i.e. stuck connecting / reconnecting. It stops blocking the - // round, but is NOT given up (it may simply be a slow connect): the owner retries it next round. - private fun connectStalled( - relay: NormalizedRelayUrl, - now: Long, - ): Boolean = tracksReqSends && relay !in heardFrom && !reqSentAt.containsKey(relay) && now - loadStartMs >= connectGrace.inWholeMilliseconds - /** Records which relays the current REQ was sent to. Completes immediately if there are none. */ @Synchronized fun setExpectedRelays(relays: Set) { @@ -220,16 +161,6 @@ class WindowLoadTracker( } } - /** - * Records that the REQ was delivered to [relayUrl] (post-connect). Starts that relay's silence clock. - * Ignored for relays outside the current [expected] set (or before it is known). - */ - @Synchronized - fun onReqSent(relayUrl: String) { - val relay = expected.firstOrNull { it.url == relayUrl } ?: return - reqSentAt.putIfAbsent(relay, System.currentTimeMillis()) - } - /** A non-terminal sign of life from [relay] (a stored or live event). Keeps the idle timer alive. */ fun onRelayEvent(relay: NormalizedRelayUrl) { heardFrom.add(relay) @@ -254,11 +185,7 @@ class WindowLoadTracker( if (!_loading.value) return watchdog?.cancel() watchdog = null - // Report the silent relays BEFORE flipping [loading]: the owner reacts to loading=false by - // recomputing state from its pager, so its reaction to these has to land first. - val abandoned = expected.filterTo(mutableSetOf()) { silencedOut(it, System.currentTimeMillis()) } - Log.d(TAG) { "[$name] load done: $reason" + if (abandoned.isEmpty()) "" else " (silent: ${abandoned.map { it.url }})" } - if (abandoned.isNotEmpty()) onAbandoned(abandoned) + Log.d(TAG) { "[$name] load done: $reason" } _loading.value = false } @@ -271,14 +198,10 @@ class WindowLoadTracker( /** * Builds the standard [SubscriptionListener] that feeds this tracker. Every event (stored backfill * included) is a non-terminal sign of life from its relay; an EOSE, CLOSED, or cannot-connect settles - * that relay. [onEachEvent] is invoked for every event (stored or live) for optional instrumentation; - * [forward] carries the EOSE / live-event signal so the owning EOSE manager can record the relay's - * timestamp (its usual `newEose`). + * that relay. [forward] carries the EOSE / live-event signal so the owning EOSE manager can record the + * relay's timestamp (its usual `newEose`). */ -fun WindowLoadTracker.trackingListener( - onEachEvent: (Event) -> Unit = {}, - forward: (NormalizedRelayUrl, List?) -> Unit, -): SubscriptionListener = +fun WindowLoadTracker.trackingListener(forward: (NormalizedRelayUrl, List?) -> Unit): SubscriptionListener = object : SubscriptionListener { override fun onEose( relay: NormalizedRelayUrl, @@ -295,7 +218,6 @@ fun WindowLoadTracker.trackingListener( forFilters: List?, ) { onRelayEvent(relay) - onEachEvent(event) if (isLive) { forward(relay, forFilters) } From e9f2f1d7aaebecf785b453806cbd55c2063b5aeb Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 5 Jun 2026 16:45:04 -0400 Subject: [PATCH 084/103] refactor(dm): tighten visibility of internal-only paging helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BackwardRelayPager.floorFor was public but only ever called inside the pager (and its same-module test) — narrow to internal. - RelayReachMarker composable was public but only rendered by RelayWindowLimitMarkers in the same file; the public entry points are RelayWindowLimitMarkers/Sentinels — make it private. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt | 2 +- .../quartz/nip01Core/relay/client/paging/BackwardRelayPager.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt index 4d4fc9e21b..f886f93383 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt @@ -196,7 +196,7 @@ fun RelayWindowLimitMarkers( * Reads e.g. "Relay sync: ✓ 8 · ↓ 1" or "Relay sync: ↓ nostr.wine". */ @Composable -fun RelayReachMarker(entries: List) { +private fun RelayReachMarker(entries: List) { if (entries.isEmpty()) return Row( 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 index 21474a810f..094f4b749d 100644 --- 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 @@ -132,7 +132,7 @@ class BackwardRelayPager( 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 } + internal fun floorFor(key: K): Long = pinnedFloor.getOrPut(key) { TimeUtils.now() - liveTailSeconds } // --- Filter building support: the caller assembles the actual REQ from these. --- From cd6537bfd590b91bba85a94bd76fc05d768b8c1f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 5 Jun 2026 17:10:38 -0400 Subject: [PATCH 085/103] refactor(dm): unify per-relay reach vocabulary + clarify Nip04 routing name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two naming families described the same concept — a relay's position in its backward history walk — and the UI name overloaded the heavily-used "window" and REQ "limit" terms. Collapse onto one vocabulary ("Reach"): - RelayWindowLimit -> RelayReachCursor - RelayWindowLimitMarkers -> RelayReachMarkers - RelayWindowLimitSentinels -> RelayReachSentinels And rename the NIP-04 per-relay routing map so it reads as a map, not a list: - Nip04DmRelays (class) / nip04DMRelays (factory) -> Nip04DmRelayRouting / nip04DmRelayRouting Pure rename — no behavior change. Design doc updated to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-06-01-dm-live-tail-and-history-slices.md | 14 +++++------ .../loggedIn/chats/privateDM/ChatroomView.kt | 18 +++++++------- .../ChatroomNip04HistorySubAssembler.kt | 6 ++--- .../privateDM/datasource/FilterNip04DMs.kt | 12 +++++----- .../chats/rooms/feed/ChatroomListFeedView.kt | 16 ++++++------- .../commons/ui/feeds/RelayReachMarker.kt | 24 +++++++++---------- 6 files changed, 45 insertions(+), 45 deletions(-) diff --git a/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md b/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md index 6fe5b37aef..2f51b44378 100644 --- a/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md +++ b/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md @@ -124,12 +124,12 @@ to load, just not advancing) keeps it false. History paging is demand-driven by **per-relay window-limit markers** placed in the message stream, not by a scroll-position trigger: -- **`RelayWindowLimit`** — one per (protocol, relay): its `reachedUntil` depth, +- **`RelayReachCursor`** — one per (protocol, relay): its `reachedUntil` depth, its `RelayReachState` (`REACHING ↓` / `STALLED …` / `DONE ✓`), and the `advance()` that pulls *that relay's* next page. Built in the feed views from each history manager's `relayProgress` map (gift wraps + NIP-04 combined; a protocol drops out of the list once `exhausted`). -- **`RelayWindowLimitSentinels`** — the load *driver*, **hoisted above the +- **`RelayReachSentinels`** — the load *driver*, **hoisted above the `LazyColumn`** (via `ChatFeedView`'s `sentinels` slot). Each non-done limit gets one stable effect (keyed by `protocol:url`) that watches `listState` and fires `advance()` when its gap is among the **currently visible rows** AND @@ -139,7 +139,7 @@ the message stream, not by a scroll-position trigger: hosting row, so any feed reorder (a live DM, a slow relay dribbling a page) tore the effect down and re-fired `advance()` on a static screen — re-arming stalled relays into a silence-watchdog storm. (commit `0394ec2a`) -- **`RelayWindowLimitMarkers` / `RelayReachMarker`** — pure UI (via the +- **`RelayReachMarkers` / `RelayReachMarker`** — pure UI (via the `markersInGap` slot): the "Relay sync: ✓ 8 · ↓ 1" divider at each relay's reached depth. Can be re-placed on every reorder without triggering paging. - **`BootstrapHistoryWhenEmpty`** — when the feed is genuinely `Empty` (the live @@ -148,7 +148,7 @@ the message stream, not by a scroll-position trigger: a time (debounced 1200ms, gated per loader on `!loading && !exhausted`) until messages appear and the markers take over, or the protocol exhausts. -### NIP-04 per-relay filter scoping (`Nip04DmRelays`) +### NIP-04 per-relay filter scoping (`Nip04DmRelayRouting`) A conversation's NIP-04 filters previously named the whole participant set on every relay, so a relay belonging to one correspondent was asked about all of @@ -156,7 +156,7 @@ them, and the `from-me` leg (`authors:[me]`) was sent to correspondents' inbox relays — which auth-walled relays reject outright ("all authors must be authenticated"), stalling the load. -`Nip04DmRelays` (in `FilterNip04DMs.kt`) is now two **per-relay key maps** +`Nip04DmRelayRouting` (in `FilterNip04DMs.kt`) is now two **per-relay key maps** (`relay → which keys to name there`), built from the outbox model: - **to me** (`#p:[me]`) — my inbox carries the whole group; each correspondent's @@ -244,13 +244,13 @@ source set (uses `java.util.concurrent`), visible to amethyst + desktop + quartz - `AccountGiftWrapsEoseManager.kt` (live tail) + `AccountGiftWrapsHistoryEoseManager.kt` (new, history). - `ChatroomNip04SubAssembler.kt` (live tail) + `ChatroomNip04HistorySubAssembler.kt` (new, history). - `ChatroomListNip04SubAssembler.kt` (live tail) + `ChatroomListNip04HistorySubAssembler.kt` (new, history). -- `FilterNip04DMs.kt` (per-relay `Nip04DmRelays`, live + history builders), `FilterNip04DMsFromMe/ToMe.kt`, `FilterGiftWrapsToPubkey.kt` — `until`/`limit` added. +- `FilterNip04DMs.kt` (per-relay `Nip04DmRelayRouting`, live + history builders), `FilterNip04DMsFromMe/ToMe.kt`, `FilterGiftWrapsToPubkey.kt` — `until`/`limit` added. - `AccountFilterAssembler`, `ChatroomFilterAssembler`, `ChatroomListFilterAssembler` — wire the new managers. **Shared UI (commons, `commons/ui/feeds/`)** — extracted from amethyst so Android + Desktop (and any per-relay feed) render the same widgets; CMP `composeResources` strings, no app-theme / `java.time` deps. -- `RelayReachMarker.kt` — `RelayWindowLimit` + sentinels (the hoisted, visibility-driven +- `RelayReachMarker.kt` — `RelayReachCursor` + sentinels (the hoisted, visibility-driven paging driver) + markers (pure UI) + `RelayReachMarker`/`RelayReachState`. - `DmHistoryLoadingCard.kt` — the boundary status card + per-relay tap dialog + `historySubtitle`/`incompleteSubtitle`. Takes a `formatReachDate: (epochSeconds) -> String` diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt index decd0d2efe..4134d58681 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt @@ -42,10 +42,10 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachSentinels import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState -import com.vitorpamplona.amethyst.commons.ui.feeds.RelayWindowLimit -import com.vitorpamplona.amethyst.commons.ui.feeds.RelayWindowLimitMarkers -import com.vitorpamplona.amethyst.commons.ui.feeds.RelayWindowLimitSentinels import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel @@ -230,19 +230,19 @@ fun ChatroomViewUI( val user = accountViewModel.userProfile() // Both protocols' per-relay window limits, each carrying the advance() that pulls its own next page. - // Placed in the stream as sentinels (see RelayWindowLimitMarkers): a relay pages only while its + // Placed in the stream as sentinels (see RelayReachMarkers): a relay pages only while its // marker is on screen, and keeps paging while it stays there. A protocol drops out once exhausted. val limits = remember(nip04Progress, giftWrapsProgress, nip04Exhausted, giftWrapsExhausted, user) { buildList { if (!giftWrapsExhausted) { giftWrapsProgress.forEach { (relay, p) -> - add(RelayWindowLimit("17:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p)) { giftWrapsHistory.advance(user, relay) }) + add(RelayReachCursor("17:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p)) { giftWrapsHistory.advance(user, relay) }) } } if (!nip04Exhausted) { nip04Progress.forEach { (relay, p) -> - add(RelayWindowLimit("04:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p)) { nip04History.advance(relay) }) + add(RelayReachCursor("04:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p)) { nip04History.advance(relay) }) } } } @@ -284,16 +284,16 @@ fun ChatroomViewUI( if (limits.isEmpty()) { null } else { - { newer, older -> RelayWindowLimitMarkers(limits, newer, older) } + { newer, older -> RelayReachMarkers(limits, newer, older) } }, // The hoisted load driver that pulls each relay's next page while its marker is on screen, - // off viewport visibility (see RelayWindowLimitSentinels) so feed reorders don't re-page. + // off viewport visibility (see RelayReachSentinels) so feed reorders don't re-page. sentinels = if (limits.isEmpty()) { null } else { { items, listState -> - RelayWindowLimitSentinels(limits, listState) { index -> items.getOrNull(index)?.event?.createdAt } + RelayReachSentinels(limits, listState) { index -> items.getOrNull(index)?.event?.createdAt } } }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04HistorySubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04HistorySubAssembler.kt index 6244c2218c..f777c318f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04HistorySubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04HistorySubAssembler.kt @@ -63,7 +63,7 @@ class ChatroomNip04HistorySubAssembler( private fun convoKey(key: ChatroomQueryState) = ConvoKey(user(key).pubkeyHex, key.room) // The conversation's relay set for a key, resolved via the outbox model (per-relay scoped). - private fun relaysFor(pk: ConvoKey): Collection? = allKeys().firstOrNull { convoKey(it) == pk }?.let { nip04DMRelays(it.room.users, it.account)?.all } + private fun relaysFor(pk: ConvoKey): Collection? = allKeys().firstOrNull { convoKey(it) == pk }?.let { nip04DmRelayRouting(it.room.users, it.account)?.all } private val pager = BackwardRelayPager("convo.nip04.history", relaysFor = ::relaysFor) @@ -83,7 +83,7 @@ class ChatroomNip04HistorySubAssembler( since: SincePerRelayMap?, ): List? { val pk = convoKey(key) - val relays = nip04DMRelays(key.room.users, key.account) + val relays = nip04DmRelayRouting(key.room.users, key.account) if (!key.account.isWriteable() || relays == null) return emptyList() // Only armed (advanced, not done) relays carry a REQ, each at its own requested cursor. A parked @@ -92,7 +92,7 @@ class ChatroomNip04HistorySubAssembler( if (armed.isEmpty()) return emptyList() DmRelayLog.log("convo.nip04.history", key.account) val scoped = - Nip04DmRelays( + Nip04DmRelayRouting( toMeRelays = relays.toMeRelays.filterKeys { it in armed }, fromMeRelays = relays.fromMeRelays.filterKeys { it in armed }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt index a1217546c8..5072ce8db0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt @@ -44,7 +44,7 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent * Scoping the key set per relay is what keeps us from sending, e.g., `authors=[bob]` to a relay that * is only charlie's — a filter that relay has no reason to serve. */ -class Nip04DmRelays( +class Nip04DmRelayRouting( val toMeRelays: Map>, val fromMeRelays: Map>, ) { @@ -57,10 +57,10 @@ private fun addAll( keys: Collection, ) = relays.forEach { map.getOrPut(it) { mutableSetOf() }.addAll(keys) } -fun nip04DMRelays( +fun nip04DmRelayRouting( group: Set?, account: Account?, -): Nip04DmRelays? { +): Nip04DmRelayRouting? { if (group.isNullOrEmpty() || account == null) return null val userOutboxRelays = account.homeRelays.flow.value @@ -98,7 +98,7 @@ fun nip04DMRelays( addAll(fromMe, inbox, listOf(it)) } - return Nip04DmRelays(toMe, fromMe) + return Nip04DmRelayRouting(toMe, fromMe) } private fun toMeFilter( @@ -148,7 +148,7 @@ fun filterNip04DMs( windowStart: Long, ): List? { if (group.isNullOrEmpty() || account == null) return null - val relays = nip04DMRelays(group, account) ?: return null + val relays = nip04DmRelayRouting(group, account) ?: return null return relays.toMeRelays.map { (relay, authors) -> toMeFilter(relay, authors, account, since = windowStart, until = null, limit = null) } + relays.fromMeRelays.map { (relay, pTags) -> fromMeFilter(relay, pTags, account, since = windowStart, until = null, limit = null) } } @@ -160,7 +160,7 @@ fun filterNip04DMs( */ fun filterNip04DMsHistory( account: Account, - relays: Nip04DmRelays, + relays: Nip04DmRelayRouting, limit: Int, untilFor: (NormalizedRelayUrl) -> Long?, ): List = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt index 923c20473e..3a4e54e0ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt @@ -40,10 +40,10 @@ import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachSentinels import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState -import com.vitorpamplona.amethyst.commons.ui.feeds.RelayWindowLimit -import com.vitorpamplona.amethyst.commons.ui.feeds.RelayWindowLimitMarkers -import com.vitorpamplona.amethyst.commons.ui.feeds.RelayWindowLimitSentinels import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty @@ -185,7 +185,7 @@ private fun FeedLoaded( val oldestNip04Index = items.list.indexOfLast { it.event is PrivateDmEvent } // Each relay's window limit, carrying the advance() that pulls its OWN next page. Placed in the list - // at its reached depth as a sentinel (see RelayWindowLimitMarkers): a relay pages only while its + // at its reached depth as a sentinel (see RelayReachMarkers): a relay pages only while its // marker is on screen and keeps paging while it stays there, so a spam-dense relay never floods — // you have to scroll through its messages to pull more. A protocol drops out once exhausted. val limits = @@ -193,12 +193,12 @@ private fun FeedLoaded( buildList { if (!giftWrapsExhausted) { giftWrapsProgress.forEach { (relay, p) -> - add(RelayWindowLimit("17:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p)) { giftWrapsHistory.advance(user, relay) }) + add(RelayReachCursor("17:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p)) { giftWrapsHistory.advance(user, relay) }) } } if (!nip04Exhausted) { nip04Progress.forEach { (relay, p) -> - add(RelayWindowLimit("04:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p)) { nip04History.advance(user, relay) }) + add(RelayReachCursor("04:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p)) { nip04History.advance(user, relay) }) } } } @@ -206,7 +206,7 @@ private fun FeedLoaded( // Hoisted load driver: pulls each relay's next page off viewport visibility, so feed reorders // (a live DM bumping a room) no longer re-fire paging. The markers below are pure UI. - RelayWindowLimitSentinels(limits, listState) { index -> items.list.getOrNull(index)?.createdAt() } + RelayReachSentinels(limits, listState) { index -> items.list.getOrNull(index)?.createdAt() } LazyColumn( contentPadding = rememberFeedContentPadding(FeedPadding), @@ -240,7 +240,7 @@ private fun FeedLoaded( // Per-relay window-limit markers/sentinels belonging in the gap toward the next-older room: // each pulls its relay's next page while it's on screen. olderCreatedAt is null past the // oldest loaded room, so relays that have reached the bottom of the list sit there. - RelayWindowLimitMarkers( + RelayReachMarkers( limits, item.createdAt(), items.list.getOrNull(index + 1)?.createdAt(), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt index f886f93383..a136bdbd09 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt @@ -54,8 +54,8 @@ private val DividerThickness = 0.25.dp * True when [reachedUntil] falls in the gap between a newer message (at [newerCreatedAt]) and its * next-older neighbour (at [olderCreatedAt], null past the oldest end): the newer side is strictly * newer than the cursor and the older side is at or below it (or absent). This single predicate both - * places the marker ([RelayWindowLimitMarkers]) and decides when its paging sentinel is on screen - * ([RelayWindowLimitSentinels]), so the two can never disagree about which gap a cursor lives in. + * places the marker ([RelayReachMarkers]) and decides when its paging sentinel is on screen + * ([RelayReachSentinels]), so the two can never disagree about which gap a cursor lives in. */ internal fun reachedFallsInGap( reachedUntil: Long, @@ -87,12 +87,12 @@ data class RelayReach( /** * One relay's window-limit: places a marker and carries the [advance] that pulls that relay's next, * older page. The marker sits at [reachedUntil] (the oldest point the relay has paged to); - * [RelayWindowLimitMarkers] draws it and [RelayWindowLimitSentinels] fires [advance] while it is on + * [RelayReachMarkers] draws it and [RelayReachSentinels] fires [advance] while it is on * screen. * * @param key stable identity (protocol tag + relay url) so the sentinel survives list reorders. */ -data class RelayWindowLimit( +data class RelayReachCursor( val key: String, val name: String, val reachedUntil: Long, @@ -103,7 +103,7 @@ data class RelayWindowLimit( /** * Drives demand-driven paging for every limit, **hoisted above the list** so its identity does not ride * on which row currently hosts the marker. Each non-done limit gets one stable effect (keyed by - * [RelayWindowLimit.key]) that watches the [listState] and pulls that relay's next page when its marker + * [RelayReachCursor.key]) that watches the [listState] and pulls that relay's next page when its marker * is on screen. * * Why hoisted: the marker for a limit lives in exactly one gap (between the two rows straddling its @@ -120,11 +120,11 @@ data class RelayWindowLimit( * A done relay drives nothing. * * @param createdAtAt createdAt of the list item at an index (null past the ends / for non-message rows), - * so the visible-gap test mirrors [RelayWindowLimitMarkers]'s placement against only the on-screen rows. + * so the visible-gap test mirrors [RelayReachMarkers]'s placement against only the on-screen rows. */ @Composable -fun RelayWindowLimitSentinels( - limits: List, +fun RelayReachSentinels( + limits: List, listState: LazyListState, createdAtAt: (index: Int) -> Long?, ) { @@ -139,7 +139,7 @@ fun RelayWindowLimitSentinels( val r = reached.value val at = getAt.value // Visible if any on-screen row is the "newer" side of the gap holding this cursor — - // the same predicate RelayWindowLimitMarkers uses to place the marker, but over the + // the same predicate RelayReachMarkers uses to place the marker, but over the // visible rows only. val onScreen = listState.layoutInfo.visibleItemsInfo.any { info -> @@ -165,12 +165,12 @@ fun RelayWindowLimitSentinels( /** * Renders the window-limit markers for the relays whose limit falls in the gap between a newer message * (at [newerCreatedAt]) and its next-older neighbour (at [olderCreatedAt], null at the oldest end). Pure - * UI: the load driving lives in [RelayWindowLimitSentinels], so this can be (re)placed freely per row on + * UI: the load driving lives in [RelayReachSentinels], so this can be (re)placed freely per row on * every feed reorder without triggering any paging. */ @Composable -fun RelayWindowLimitMarkers( - limits: List, +fun RelayReachMarkers( + limits: List, newerCreatedAt: Long?, olderCreatedAt: Long?, ) { From 910037ad8e803bc3b808afaf6355bf40c6b4db05 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 5 Jun 2026 17:48:28 -0400 Subject: [PATCH 086/103] refactor(quartz): add TimeUtils.nowMillis, drop raw System calls in pagers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relay-paging trackers measured elapsed wall-clock with System.currentTimeMillis() directly. Introduce a multiplatform millisecond clock (currentTimeMillis expect/actual across jvm/android/ios/macos/linux, mirroring the existing currentTimeSeconds) exposed as TimeUtils.nowMillis(), and route PerRelayLoadTracker + WindowLoadTracker through it. TimeUtils.now() is seconds, so it can't be used for the ms-scale silence / idle / linger timers — nowMillis() is the correct primitive. No behavior change (same underlying clock on JVM/Android). Note: the two trackers stay in jvmAndroid for now — @Synchronized has no commonMain equivalent. ConcurrentHashMap is kept deliberately: LargeCache is a sorted ConcurrentSkipListMap (Comparable, compareTo-identity keys) and the pager keys (ConvoKey not Comparable; ChatroomKey.compareTo is hashCode-based) don't satisfy that, so ConcurrentHashMap is the correct structure here. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../vitorpamplona/quartz/utils/Platform.android.kt | 2 ++ .../kotlin/com/vitorpamplona/quartz/utils/Platform.kt | 2 ++ .../com/vitorpamplona/quartz/utils/TimeUtils.kt | 2 ++ .../com/vitorpamplona/quartz/utils/Platform.ios.kt | 2 ++ .../relay/client/paging/PerRelayLoadTracker.kt | 9 +++++---- .../relay/client/paging/WindowLoadTracker.kt | 11 ++++++----- .../com/vitorpamplona/quartz/utils/Platform.jvm.kt | 2 ++ .../com/vitorpamplona/quartz/utils/Platform.linux.kt | 9 +++++++++ .../com/vitorpamplona/quartz/utils/Platform.macos.kt | 2 ++ 9 files changed, 32 insertions(+), 9 deletions(-) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Platform.android.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Platform.android.kt index 88befaaa2c..1acffd1427 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Platform.android.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/Platform.android.kt @@ -23,3 +23,5 @@ package com.vitorpamplona.quartz.utils actual fun platform() = "Android" actual fun currentTimeSeconds() = System.currentTimeMillis() / 1000 + +actual fun currentTimeMillis() = System.currentTimeMillis() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Platform.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Platform.kt index b007a052eb..3a49a2df69 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Platform.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Platform.kt @@ -23,3 +23,5 @@ package com.vitorpamplona.quartz.utils expect fun platform(): String expect fun currentTimeSeconds(): Long + +expect fun currentTimeMillis(): Long diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt index 90ca1272c8..b8f1a09ec8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt @@ -36,6 +36,8 @@ object TimeUtils { fun now() = currentTimeSeconds() + fun nowMillis() = currentTimeMillis() + fun tenSecondsFromNow() = now() + TEN_SECONDS fun tenSecondsAgo() = now() - TEN_SECONDS diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.ios.kt b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.ios.kt index 9f2b842edb..fee99bbf15 100644 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.ios.kt +++ b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.ios.kt @@ -31,3 +31,5 @@ actual fun currentTimeSeconds(): Long { // NSDate().timeIntervalSince1970 returns seconds since 1970-01-01 00:00:00 UTC return (NSDate().timeIntervalSince1970).toLong() } + +actual fun currentTimeMillis(): Long = (NSDate().timeIntervalSince1970 * 1000).toLong() diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/PerRelayLoadTracker.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/PerRelayLoadTracker.kt index 5b97e2fefe..12333caf38 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/PerRelayLoadTracker.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/PerRelayLoadTracker.kt @@ -22,6 +22,7 @@ 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.Job import kotlinx.coroutines.delay @@ -82,14 +83,14 @@ class PerRelayLoadTracker( clearJob?.cancel() // a new page is starting — keep the spinner up, no flicker clearJob = null inFlight.add(relay) - lastActivityMs = System.currentTimeMillis() + lastActivityMs = TimeUtils.nowMillis() _loading.value = true ensureWatchdog() } /** A sign of life from a relay (an event). Keeps the silence watchdog from firing. */ fun onActivity() { - lastActivityMs = System.currentTimeMillis() + lastActivityMs = TimeUtils.nowMillis() } /** @@ -101,7 +102,7 @@ class PerRelayLoadTracker( */ @Synchronized fun onSettled(relay: NormalizedRelayUrl) { - lastActivityMs = System.currentTimeMillis() + lastActivityMs = TimeUtils.nowMillis() if (inFlight.remove(relay) && inFlight.isEmpty()) scheduleClear() } @@ -141,7 +142,7 @@ class PerRelayLoadTracker( delay(WATCHDOG_TICK_MS) val silenced = synchronized(this@PerRelayLoadTracker) { - if (inFlight.isNotEmpty() && System.currentTimeMillis() - lastActivityMs > silenceMs) { + if (inFlight.isNotEmpty() && TimeUtils.nowMillis() - lastActivityMs > silenceMs) { val pending = inFlight.toSet() inFlight.clear() _loading.value = false diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/WindowLoadTracker.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/WindowLoadTracker.kt index fa5b16b47d..c5d99df778 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/WindowLoadTracker.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/WindowLoadTracker.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter 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.Job import kotlinx.coroutines.delay @@ -104,17 +105,17 @@ class WindowLoadTracker( expected = emptySet() heardFrom.clear() settled.clear() - lastActivityMs = System.currentTimeMillis() + lastActivityMs = TimeUtils.nowMillis() val wasLoading = _loading.value _loading.value = true Log.d(TAG) { "[$name] load start" + if (!wasLoading) "" else " (restart)" } watchdog?.cancel() watchdog = scope.launch { - val deadline = System.currentTimeMillis() + absoluteCap.inWholeMilliseconds + val deadline = TimeUtils.nowMillis() + absoluteCap.inWholeMilliseconds while (isActive) { delay(IDLE_CHECK_MS) - if (!tick(gen, System.currentTimeMillis(), deadline)) break + if (!tick(gen, TimeUtils.nowMillis(), deadline)) break } } } @@ -164,7 +165,7 @@ class WindowLoadTracker( /** A non-terminal sign of life from [relay] (a stored or live event). Keeps the idle timer alive. */ fun onRelayEvent(relay: NormalizedRelayUrl) { heardFrom.add(relay) - lastActivityMs = System.currentTimeMillis() + lastActivityMs = TimeUtils.nowMillis() } /** @@ -173,7 +174,7 @@ class WindowLoadTracker( */ @Synchronized fun onRelaySettled(relay: NormalizedRelayUrl) { - lastActivityMs = System.currentTimeMillis() + lastActivityMs = TimeUtils.nowMillis() heardFrom.add(relay) settled.add(relay) if (expected.isNotEmpty() && settled.containsAll(expected)) finish("all relays") diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Platform.jvm.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Platform.jvm.kt index d4b4f6b5e5..d6979a6e06 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Platform.jvm.kt +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/Platform.jvm.kt @@ -23,3 +23,5 @@ package com.vitorpamplona.quartz.utils actual fun platform() = "JVM" actual fun currentTimeSeconds() = System.currentTimeMillis() / 1000 + +actual fun currentTimeMillis() = System.currentTimeMillis() diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/Platform.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/Platform.linux.kt index 7e4b5fc506..0b02bf8b58 100644 --- a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/Platform.linux.kt +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/Platform.linux.kt @@ -38,3 +38,12 @@ actual fun currentTimeSeconds(): Long { return ts.tv_sec } } + +@OptIn(ExperimentalForeignApi::class) +actual fun currentTimeMillis(): Long { + memScoped { + val ts = alloc() + clock_gettime(CLOCK_REALTIME, ts.ptr) + return ts.tv_sec * 1000 + ts.tv_nsec / 1_000_000 + } +} diff --git a/quartz/src/macosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.macos.kt b/quartz/src/macosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.macos.kt index 75f7e49519..49c1daf844 100644 --- a/quartz/src/macosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.macos.kt +++ b/quartz/src/macosMain/kotlin/com/vitorpamplona/quartz/utils/Platform.macos.kt @@ -26,3 +26,5 @@ import platform.Foundation.timeIntervalSince1970 actual fun platform() = "macOS" actual fun currentTimeSeconds(): Long = (NSDate().timeIntervalSince1970).toLong() + +actual fun currentTimeMillis(): Long = (NSDate().timeIntervalSince1970 * 1000).toLong() From 62236171797427994d7d8a7023e75520e7f047b3 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 5 Jun 2026 18:50:21 -0400 Subject: [PATCH 087/103] refactor(dm): move paging cursors onto the model, drop the keyed pager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The history pagers were keyed by account / (account, conversation) inside the quartz engine, with all per-key state in inner hashmaps + an activeKey + activate() machinery. But the loaders are per-account-VM and the on-screen scope is single-active, so the key was redundant indirection. Move the per-relay cursor *state* onto the domain object whose lifetime it should share: - UntilLimitPager is now keyless (per-relay cursors + a pinned floor only) and lives in commonMain (LargeCache — keyed only by relay url, which is Comparable + equals-consistent, so the sorted cache is safe; kotlin.concurrent.Volatile for the fields). It is stored on: * Chatroom.nip04History (per conversation) * ChatroomList.giftWrapHistory (account NIP-17) * ChatroomList.nip04History (account rooms-list NIP-04) The LocalCache object graph is now the partition; cursors are dropped exactly when the cached messages they describe are pruned, and survive an account switch (no re-page on switch-back). - BackwardRelayPager is now a keyless single-active orchestrator: it owns only the transient bits (in-flight tracker, stalled set, display flows) and binds to the active scope's cursors via bind(cursors, scope, relaysFor). Removed activeKey / activate() / the per-key exhausted+floor+stalled maps. Safe as single-active because history relays only arm while their markers are on-screen, so a backgrounded scope emits no callbacks. The three assemblers resolve the scope's cursors from the account's chatroomList and bind on newSub; the redundant `user` arg dropped from the account-level advance/advanceAll (callers updated). Behaviour change: switching between two conversations no longer keeps both rooms' cursors live in one engine — each room's cursors persist on its own Chatroom instead, so reopening a room restores its progress (strictly better). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AccountGiftWrapsHistoryEoseManager.kt | 67 ++--- .../loggedIn/chats/feed/LoadingReplyNote.kt | 2 +- .../loggedIn/chats/privateDM/ChatroomView.kt | 4 +- .../ChatroomNip04HistorySubAssembler.kt | 67 ++--- .../ChatroomListNip04HistorySubAssembler.kt | 55 ++-- .../chats/rooms/feed/ChatroomListFeedView.kt | 8 +- .../eoseManagers/UntilLimitPagerTest.kt | 79 +++--- .../commons/model/privateChats/Chatroom.kt | 7 + .../model/privateChats/ChatroomList.kt | 8 + .../relay/client/paging/UntilLimitPager.kt | 71 +++-- .../relay/client/paging/BackwardRelayPager.kt | 256 ++++++++---------- .../client/paging/BackwardRelayPagerTest.kt | 138 ++++++---- 12 files changed, 353 insertions(+), 409 deletions(-) rename quartz/src/{jvmAndroid => commonMain}/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPager.kt (75%) 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 0b96b1d755..ea32227c49 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 @@ -21,14 +21,11 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey -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.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.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.paging.BackwardRelayPager import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress @@ -40,7 +37,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.flow.StateFlow -import java.util.concurrent.ConcurrentHashMap /** * Loads the account's NIP-17 gift-wrap **history** — everything older than the one-week live tail @@ -52,10 +48,12 @@ import java.util.concurrent.ConcurrentHashMap * (see the rooms-list / conversation feed views). So a spam-dense relay never floods: the user has to * scroll through its messages to pull more, and nothing is fetched while its marker is off screen. * - * The per-relay cursor / stall / exhaustion bookkeeping lives in the shared [BackwardRelayPager]; this - * class only builds the gift-wrap REQ filters and forwards relay callbacks into the pager. A relay is - * *done* once it answers an empty page; one that won't answer (auth CLOSE, unreachable, or silent) is - * flagged *stalled* but kept. [exhausted] flips once every relay is either done or stalled. + * The per-relay cursors live on the account's [ChatroomList][com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList] + * (so they share the lifetime of the cached gift-wraps); this class binds the single-active + * [BackwardRelayPager] orchestrator to them on [newSub], builds the gift-wrap REQ filters, and forwards + * relay callbacks into the pager. A relay is *done* once it answers an empty page; one that won't answer + * (auth CLOSE, unreachable, or silent) is flagged *stalled* but kept. [exhausted] flips once every relay + * is either done or stalled. */ class AccountGiftWrapsHistoryEoseManager( client: INostrClient, @@ -63,15 +61,7 @@ class AccountGiftWrapsHistoryEoseManager( ) : PerUserEoseManager(client, allKeys) { override fun user(key: AccountQueryState) = key.account.userProfile() - // The account behind each user pubkey, captured on subscribe so the pager's relaysFor lookup and the - // advance() API can read the DM relay list (and the account scope) without the key. - private val accounts = ConcurrentHashMap() - - // Per-relay demand-driven paging, keyed by account pubkey so switching accounts preserves progress. - private val pager = - BackwardRelayPager("giftwrap.history") { pk -> - accounts[pk]?.dmRelays?.flow?.value - } + private val pager = BackwardRelayPager("giftwrap.history") val loadingMore: StateFlow = pager.loadingMore val exhausted: StateFlow = pager.exhausted @@ -86,53 +76,42 @@ class AccountGiftWrapsHistoryEoseManager( key: AccountQueryState, since: SincePerRelayMap?, ): List { - val user = user(key) if (!key.account.isWriteable()) return emptyList() // Only relays that have been advanced (armed) and aren't done carry a REQ. A relay that finished a // page keeps the same `until` here, so re-assembly (triggered when ANOTHER relay advances) doesn't // re-REQ it — it stays parked until the UI advances it again. val relays = key.account.dmRelays.flow.value - val armed = pager.armedRelays(user.pubkeyHex, relays) + val armed = pager.armedRelays(relays) if (armed.isEmpty()) return emptyList() DmRelayLog.log("giftwrap.history", key.account) return armed.flatMap { relay -> - val until = pager.requestedUntilFor(user.pubkeyHex, relay) ?: return@flatMap emptyList() + val until = pager.requestedUntilFor(relay) ?: return@flatMap emptyList() Log.d(TAG) { "[giftwrap.history] REQ ${relay.url} until ${daysAgo(until)}d, limit=${pager.pageLimit}" } - filterGiftWrapsToPubkey(relay = relay, pubkey = user.pubkeyHex, since = null, until = until, limit = pager.pageLimit) + filterGiftWrapsToPubkey(relay = relay, pubkey = key.account.userProfile().pubkeyHex, since = null, until = until, limit = pager.pageLimit) } } /** Steps a single [relay] to its next, older page. Driven by that relay's on-screen window-limit marker. */ - fun advance( - user: User, - relay: NormalizedRelayUrl, - ) { - val account = accounts[user.pubkeyHex] ?: return - if (pager.advance(user.pubkeyHex, relay, account.scope)) invalidateFilters() + fun advance(relay: NormalizedRelayUrl) { + if (pager.advance(relay)) invalidateFilters() } /** Steps every not-done, not-in-flight relay one page. For the empty/initial boundary (nothing to scroll). */ - fun advanceAll(user: User) { - val account = accounts[user.pubkeyHex] ?: return - if (pager.advanceAll(user.pubkeyHex, account.scope)) { + fun advanceAll() { + if (pager.advanceAll()) { Log.d(TAG) { "[giftwrap.history] advanceAll (empty-feed bootstrap)" } invalidateFilters() } } override fun newSub(key: AccountQueryState): Subscription { - val user = user(key) - accounts[user.pubkeyHex] = key.account - // Repoint the shared display flows to this account and populate the per-relay markers (all relays - // at the floor, not done) so the UI can render their sentinels and pull the first page on view. - pager.activate(user.pubkeyHex) - return requestNewSubscription(historyListener(user, key)) + // Repoint the single-active orchestrator at this account's gift-wrap cursors (on its ChatroomList) + // and the relays it fans out to, refreshing the display flows from the restored progress. + pager.bind(key.account.chatroomList.giftWrapHistory, key.account.scope) { key.account.dmRelays.flow.value } + return requestNewSubscription(historyListener(key)) } - private fun historyListener( - user: User, - key: AccountQueryState, - ): SubscriptionListener = + private fun historyListener(key: AccountQueryState): SubscriptionListener = object : SubscriptionListener { override fun onEvent( event: Event, @@ -140,14 +119,14 @@ class AccountGiftWrapsHistoryEoseManager( relay: NormalizedRelayUrl, forFilters: List?, ) { - pager.onEvent(user.pubkeyHex, relay, event.createdAt) + pager.onEvent(relay, event.createdAt) } override fun onEose( relay: NormalizedRelayUrl, forFilters: List?, ) { - if (pager.onEose(user.pubkeyHex, relay)) { + if (pager.onEose(relay)) { Log.d(TAG) { "[giftwrap.history] ${relay.url} reached the bottom (done)" } } // No auto-advance: the relay parks here until its marker asks for the next page. @@ -159,7 +138,7 @@ class AccountGiftWrapsHistoryEoseManager( relay: NormalizedRelayUrl, forFilters: List?, ) { - pager.onClosed(user.pubkeyHex, relay, message) + pager.onClosed(relay, message) } override fun onCannotConnect( @@ -167,7 +146,7 @@ class AccountGiftWrapsHistoryEoseManager( message: String, forFilters: List?, ) { - pager.onCannotConnect(user.pubkeyHex, relay, message) + pager.onCannotConnect(relay, message) } } 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 695a15fcdd..26103f0c43 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 @@ -149,7 +149,7 @@ fun LoadingReplyNote( .collect { Log.d("DMPagination") { "reply blank: widen → $protocol advanceAll (searching for unloaded reply)" } when (protocol) { - DmReplyProtocol.NIP17 -> giftWrapsHistory.advanceAll(accountViewModel.userProfile()) + DmReplyProtocol.NIP17 -> giftWrapsHistory.advanceAll() DmReplyProtocol.NIP04 -> nip04History.advanceAll() } } 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 4134d58681..48c7947194 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 @@ -182,7 +182,7 @@ private fun BootstrapHistoryWhenEmpty( combine(giftWrapsHistory.loadingMore, giftWrapsHistory.exhausted) { loading, exhausted -> !loading && !exhausted } .distinctUntilChanged() .filter { it } - .collect { giftWrapsHistory.advanceAll(accountViewModel.userProfile()) } + .collect { giftWrapsHistory.advanceAll() } } LaunchedEffect(needsBootstrap, nip04History) { if (!needsBootstrap) return@LaunchedEffect @@ -237,7 +237,7 @@ fun ChatroomViewUI( buildList { if (!giftWrapsExhausted) { giftWrapsProgress.forEach { (relay, p) -> - add(RelayReachCursor("17:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p)) { giftWrapsHistory.advance(user, relay) }) + add(RelayReachCursor("17:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p)) { giftWrapsHistory.advance(relay) }) } } if (!nip04Exhausted) { 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 f777c318f9..09fe595fb9 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 @@ -24,7 +24,6 @@ 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.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.paging.BackwardRelayPager import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress @@ -33,7 +32,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.flow.StateFlow @@ -44,28 +42,18 @@ import kotlinx.coroutines.flow.StateFlow * for that relay asks ([advance]); otherwise it parks. Nothing is walked proactively — a relay pages * only while its marker is visible and keeps paging while it stays visible. * - * The per-relay cursor / stall / exhaustion bookkeeping lives in the shared [BackwardRelayPager]; this - * class only builds the (per-relay scoped) NIP-04 REQ filters and forwards relay callbacks into it. A - * relay is *done* once it answers an empty page; one that won't answer (auth CLOSE, unreachable, or - * silent) is flagged *stalled* but kept. [exhausted] flips once every relay is either done or stalled. + * The per-relay cursors live on the conversation's [Chatroom][com.vitorpamplona.amethyst.commons.model.privateChats.Chatroom] + * (so reopening the room keeps its progress); this class binds the single-active [BackwardRelayPager] + * orchestrator to the open room's cursors on [newSub], builds the (per-relay scoped) NIP-04 REQ + * filters, and forwards relay callbacks into the pager. A relay is *done* once it answers an empty page; + * one that won't answer (auth CLOSE, unreachable, or silent) is flagged *stalled* but kept. [exhausted] + * flips once every relay is either done or stalled. */ class ChatroomNip04HistorySubAssembler( client: INostrClient, allKeys: () -> Set, ) : PerUserAndFollowListEoseManager(client, allKeys) { - // Keyed by (account, conversation) so each thread paginates independently — and so the same - // correspondent opened from two logged-in accounts doesn't share a cursor. - private data class ConvoKey( - val account: HexKey, - val room: ChatroomKey, - ) - - private fun convoKey(key: ChatroomQueryState) = ConvoKey(user(key).pubkeyHex, key.room) - - // The conversation's relay set for a key, resolved via the outbox model (per-relay scoped). - private fun relaysFor(pk: ConvoKey): Collection? = allKeys().firstOrNull { convoKey(it) == pk }?.let { nip04DmRelayRouting(it.room.users, it.account)?.all } - - private val pager = BackwardRelayPager("convo.nip04.history", relaysFor = ::relaysFor) + private val pager = BackwardRelayPager("convo.nip04.history") val loadingMore: StateFlow = pager.loadingMore val exhausted: StateFlow = pager.exhausted @@ -78,17 +66,22 @@ class ChatroomNip04HistorySubAssembler( override fun list(key: ChatroomQueryState) = key.listId + // This conversation's persistent paging cursors, held on its Chatroom (per account + room). + private fun cursorsFor(key: ChatroomQueryState) = + key.account.chatroomList + .getOrCreatePrivateChatroom(key.room) + .nip04History + override fun updateFilter( key: ChatroomQueryState, since: SincePerRelayMap?, ): List? { - val pk = convoKey(key) val relays = nip04DmRelayRouting(key.room.users, key.account) if (!key.account.isWriteable() || relays == null) return emptyList() // Only armed (advanced, not done) relays carry a REQ, each at its own requested cursor. A parked // relay keeps the same filter here, so re-assembly (another relay advancing) doesn't re-REQ it. - val armed = pager.armedRelays(pk, relays.all).toSet() + val armed = pager.armedRelays(relays.all).toSet() if (armed.isEmpty()) return emptyList() DmRelayLog.log("convo.nip04.history", key.account) val scoped = @@ -97,51 +90,46 @@ class ChatroomNip04HistorySubAssembler( fromMeRelays = relays.fromMeRelays.filterKeys { it in armed }, ) return filterNip04DMsHistory(key.account, scoped, pager.pageLimit) { relay -> - pager.requestedUntilFor(pk, relay) + pager.requestedUntilFor(relay) } } - /** Steps a single [relay] to its next, older page for the open conversation(s). Driven by its marker. */ + /** Steps a single [relay] to its next, older page for the open conversation. Driven by its marker. */ fun advance(relay: NormalizedRelayUrl) { - var any = false - allKeys().forEach { if (pager.advance(convoKey(it), relay, it.account.scope)) any = true } - if (any) invalidateFilters() + if (pager.advance(relay)) invalidateFilters() } /** Steps every not-done, not-in-flight relay one page. For a thread too short to scroll. */ fun advanceAll() { - var any = false - allKeys().forEach { if (pager.advanceAll(convoKey(it), it.account.scope)) any = true } - if (any) { + if (pager.advanceAll()) { Log.d("DMPagination") { "[convo.nip04.history] advanceAll (empty-thread bootstrap)" } invalidateFilters() } } override fun newSub(key: ChatroomQueryState): Subscription { - // Repoint the shared display flows to this conversation and populate the per-relay markers (all - // relays at the floor, not done) so the UI can render their sentinels and pull the first page. - pager.activate(convoKey(key)) + // Repoint the single-active orchestrator at this conversation's cursors (on its Chatroom) and the + // relays it fans out to, refreshing the display flows from the restored progress. + pager.bind(cursorsFor(key), key.account.scope) { nip04DmRelayRouting(key.room.users, key.account)?.all } return requestNewSubscription(historyListener(key)) } - private fun historyListener(key: ChatroomQueryState): SubscriptionListener { - val pk = convoKey(key) - return object : SubscriptionListener { + private fun historyListener(key: ChatroomQueryState): SubscriptionListener = + object : SubscriptionListener { override fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, forFilters: List?, ) { - pager.onEvent(pk, relay, event.createdAt) + pager.onEvent(relay, event.createdAt) } override fun onEose( relay: NormalizedRelayUrl, forFilters: List?, ) { - if (pager.onEose(pk, relay)) { + if (pager.onEose(relay)) { Log.d("DMPagination") { "[convo.nip04.history] ${relay.url} reached the bottom (done)" } } newEose(key, relay, TimeUtils.now(), forFilters) @@ -152,7 +140,7 @@ class ChatroomNip04HistorySubAssembler( relay: NormalizedRelayUrl, forFilters: List?, ) { - pager.onClosed(pk, relay, message) + pager.onClosed(relay, message) } override fun onCannotConnect( @@ -160,8 +148,7 @@ class ChatroomNip04HistorySubAssembler( message: String, forFilters: List?, ) { - pager.onCannotConnect(pk, relay, message) + pager.onCannotConnect(relay, message) } } - } } 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 a05b75eae7..7c62a8d2ad 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,12 +21,10 @@ 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.PerUserEoseManager 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.BackwardRelayPager import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress @@ -38,7 +36,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.flow.StateFlow -import java.util.concurrent.ConcurrentHashMap /** * Loads older NIP-04 DMs (kind 4) for the rooms list by **`until`+`limit` paging, per relay, on @@ -46,21 +43,17 @@ import java.util.concurrent.ConcurrentHashMap * ([com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsHistoryEoseManager]), * across the account's home (outbox, *from me*) + DM (inbox, *to me*) relays. Each relay advances one * page when its on-screen window-limit marker asks ([advance]); otherwise it parks. Nothing is walked - * proactively. The per-relay cursor / stall / exhaustion bookkeeping lives in [BackwardRelayPager]. + * proactively. The per-relay cursors live on the account's + * [ChatroomList][com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList]; the single-active + * [BackwardRelayPager] orchestrator binds to them on [newSub]. */ class ChatroomListNip04HistorySubAssembler( client: INostrClient, allKeys: () -> Set, ) : PerUserEoseManager(client, allKeys) { - private val accounts = ConcurrentHashMap() - private fun allRelays(account: Account) = (account.homeRelays.flow.value + account.dmRelays.flow.value).toSet() - // Paged across the account's own home (outbox) + DM (inbox) relays, keyed by account pubkey. - private val pager = - BackwardRelayPager("rooms.nip04.history") { pk -> - accounts[pk]?.let { allRelays(it) } - } + private val pager = BackwardRelayPager("rooms.nip04.history") val loadingMore: StateFlow = pager.loadingMore val exhausted: StateFlow = pager.exhausted @@ -79,11 +72,11 @@ class ChatroomListNip04HistorySubAssembler( if (!key.account.isWriteable()) return emptyList() val homeRelays = key.account.homeRelays.flow.value val dmRelays = key.account.dmRelays.flow.value - val armed = pager.armedRelays(user.pubkeyHex, (homeRelays + dmRelays).toSet()) + val armed = pager.armedRelays((homeRelays + dmRelays).toSet()) if (armed.isEmpty()) return emptyList() DmRelayLog.log("rooms.nip04.history", key.account) return armed.flatMap { relay -> - val until = pager.requestedUntilFor(user.pubkeyHex, relay) ?: return@flatMap emptyList() + val until = pager.requestedUntilFor(relay) ?: return@flatMap emptyList() buildList { if (relay in homeRelays) add(filterNip04DMsFromMe(user, relay, since = null, until = until, limit = pager.pageLimit)) if (relay in dmRelays) add(filterNip04DMsToMe(user, relay, since = null, until = until, limit = pager.pageLimit)) @@ -92,36 +85,26 @@ class ChatroomListNip04HistorySubAssembler( } /** Steps a single [relay] to its next, older page. Driven by that relay's on-screen window-limit marker. */ - fun advance( - user: User, - relay: NormalizedRelayUrl, - ) { - val account = accounts[user.pubkeyHex] ?: return - if (pager.advance(user.pubkeyHex, relay, account.scope)) invalidateFilters() + fun advance(relay: NormalizedRelayUrl) { + if (pager.advance(relay)) invalidateFilters() } /** Steps every not-done, not-in-flight relay one page. For the empty/initial boundary (nothing to scroll). */ - fun advanceAll(user: User) { - val account = accounts[user.pubkeyHex] ?: return - if (pager.advanceAll(user.pubkeyHex, account.scope)) { + fun advanceAll() { + if (pager.advanceAll()) { Log.d("DMPagination") { "[rooms.nip04.history] advanceAll (empty-feed bootstrap)" } invalidateFilters() } } override fun newSub(key: ChatroomListState): Subscription { - val user = user(key) - accounts[user.pubkeyHex] = key.account - // Repoint the shared display flows to this account and populate the per-relay markers (all relays - // at the floor, not done) so the UI can render their sentinels and pull the first page on view. - pager.activate(user.pubkeyHex) - return requestNewSubscription(historyListener(user, key)) + // Repoint the single-active orchestrator at this account's rooms-list NIP-04 cursors (on its + // ChatroomList) and the relays it fans out to, refreshing the flows from the restored progress. + pager.bind(key.account.chatroomList.nip04History, key.account.scope) { allRelays(key.account) } + return requestNewSubscription(historyListener(key)) } - private fun historyListener( - user: User, - key: ChatroomListState, - ): SubscriptionListener = + private fun historyListener(key: ChatroomListState): SubscriptionListener = object : SubscriptionListener { override fun onEvent( event: Event, @@ -129,14 +112,14 @@ class ChatroomListNip04HistorySubAssembler( relay: NormalizedRelayUrl, forFilters: List?, ) { - pager.onEvent(user.pubkeyHex, relay, event.createdAt) + pager.onEvent(relay, event.createdAt) } override fun onEose( relay: NormalizedRelayUrl, forFilters: List?, ) { - if (pager.onEose(user.pubkeyHex, relay)) { + if (pager.onEose(relay)) { Log.d("DMPagination") { "[rooms.nip04.history] ${relay.url} reached the bottom (done)" } } newEose(key, relay, TimeUtils.now(), forFilters) @@ -147,7 +130,7 @@ class ChatroomListNip04HistorySubAssembler( relay: NormalizedRelayUrl, forFilters: List?, ) { - pager.onClosed(user.pubkeyHex, relay, message) + pager.onClosed(relay, message) } override fun onCannotConnect( @@ -155,7 +138,7 @@ class ChatroomListNip04HistorySubAssembler( message: String, forFilters: List?, ) { - pager.onCannotConnect(user.pubkeyHex, relay, message) + pager.onCannotConnect(relay, message) } } } 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 3a4e54e0ed..9301863e9f 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 @@ -117,8 +117,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(user) } - BootstrapHistoryWhenEmpty(bootstrap, nip04History.loadingMore, nip04History.exhausted) { nip04History.advanceAll(user) } + BootstrapHistoryWhenEmpty(bootstrap, giftWrapsHistory.loadingMore, giftWrapsHistory.exhausted) { giftWrapsHistory.advanceAll() } + BootstrapHistoryWhenEmpty(bootstrap, nip04History.loadingMore, nip04History.exhausted) { nip04History.advanceAll() } CrossfadeIfEnabled( targetState = feedState, @@ -193,12 +193,12 @@ private fun FeedLoaded( buildList { if (!giftWrapsExhausted) { giftWrapsProgress.forEach { (relay, p) -> - add(RelayReachCursor("17:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p)) { giftWrapsHistory.advance(user, relay) }) + add(RelayReachCursor("17:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p)) { giftWrapsHistory.advance(relay) }) } } if (!nip04Exhausted) { nip04Progress.forEach { (relay, p) -> - add(RelayReachCursor("04:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p)) { nip04History.advance(user, relay) }) + add(RelayReachCursor("04:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p)) { nip04History.advance(relay) }) } } } 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 c957c97355..597e783493 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 @@ -28,83 +28,82 @@ import org.junit.Assert.assertTrue import org.junit.Test class UntilLimitPagerTest { - private val key = "acct" private val relayA = RelayUrlNormalizer.normalizeOrNull("wss://a.relay")!! private val relayB = RelayUrlNormalizer.normalizeOrNull("wss://b.relay")!! private val start = 1_000L @Test fun unarmedRelayIsNotRequestedAndSitsAtTheFloor() { - val pager = UntilLimitPager() + val pager = UntilLimitPager() // never advanced, so it carries no REQ - assertEquals(emptyList(), pager.armedRelays(key, listOf(relayA))) + assertEquals(emptyList(), pager.armedRelays(listOf(relayA))) // marker sits at the floor until it delivers - assertEquals(start, pager.reachedUntilFor(key, relayA, start)) + assertEquals(start, pager.reachedUntilFor(relayA, start)) } @Test fun firstAdvanceRequestsTheFloorThenSubsequentPagesStepBelowReached() { - val pager = UntilLimitPager() + val pager = UntilLimitPager() - assertTrue(pager.advance(key, relayA, start)) - assertEquals(start, pager.requestedUntilFor(key, relayA)) + assertTrue(pager.advance(relayA, start)) + assertEquals(start, pager.requestedUntilFor(relayA)) // page returns events; oldest seen = 800 - pager.onEvent(key, relayA, 900) - pager.onEvent(key, relayA, 800) - pager.onEose(key, relayA) - assertEquals(800L, pager.reachedUntilFor(key, relayA, start)) + pager.onEvent(relayA, 900) + pager.onEvent(relayA, 800) + pager.onEose(relayA) + assertEquals(800L, pager.reachedUntilFor(relayA, start)) // EOSE does NOT move the requested cursor — the relay parks at the same filter - assertEquals(start, pager.requestedUntilFor(key, relayA)) + assertEquals(start, pager.requestedUntilFor(relayA)) // next advance steps to reached - 1 - assertTrue(pager.advance(key, relayA, start)) - assertEquals(799L, pager.requestedUntilFor(key, relayA)) + assertTrue(pager.advance(relayA, start)) + assertEquals(799L, pager.requestedUntilFor(relayA)) } @Test fun emptyPageMarksRelayDoneAndBlocksFurtherAdvance() { - val pager = UntilLimitPager() - pager.advance(key, relayA, start) - pager.onEose(key, relayA) // no events - assertTrue(pager.isDone(key, relayA)) - assertFalse(pager.advance(key, relayA, start)) - assertEquals(emptyList(), pager.armedRelays(key, listOf(relayA))) + val pager = UntilLimitPager() + pager.advance(relayA, start) + pager.onEose(relayA) // no events + assertTrue(pager.isDone(relayA)) + assertFalse(pager.advance(relayA, start)) + assertEquals(emptyList(), pager.armedRelays(listOf(relayA))) } @Test fun aPageThatDoesNotStepOlderEndsTheRelayInsteadOfLooping() { - val pager = UntilLimitPager() - pager.advance(key, relayA, start) - pager.onEvent(key, relayA, 800) - pager.onEose(key, relayA) - assertEquals(800L, pager.reachedUntilFor(key, relayA, start)) + val pager = UntilLimitPager() + pager.advance(relayA, start) + pager.onEvent(relayA, 800) + pager.onEose(relayA) + assertEquals(800L, pager.reachedUntilFor(relayA, start)) // misbehaving relay: next page echoes an event no older than what we already reached - pager.advance(key, relayA, start) // requested = 799 - pager.onEvent(key, relayA, 900) // newer than reached(800) — not strictly older - pager.onEose(key, relayA) - assertTrue("a non-advancing page should end the relay, not re-loop", pager.isDone(key, relayA)) - assertEquals(800L, pager.reachedUntilFor(key, relayA, start)) + pager.advance(relayA, start) // requested = 799 + pager.onEvent(relayA, 900) // newer than reached(800) — not strictly older + pager.onEose(relayA) + assertTrue("a non-advancing page should end the relay, not re-loop", pager.isDone(relayA)) + assertEquals(800L, pager.reachedUntilFor(relayA, start)) } @Test fun relaysAreTrackedIndependently() { - val pager = UntilLimitPager() - pager.advance(key, relayA, start) - pager.onEvent(key, relayA, 500) - pager.onEose(key, relayA) + val pager = UntilLimitPager() + pager.advance(relayA, start) + pager.onEvent(relayA, 500) + pager.onEose(relayA) // B never advanced - assertEquals(listOf(relayA), pager.armedRelays(key, listOf(relayA, relayB))) - assertEquals(500L, pager.reachedUntilFor(key, relayA, start)) - assertEquals(start, pager.reachedUntilFor(key, relayB, start)) + assertEquals(listOf(relayA), pager.armedRelays(listOf(relayA, relayB))) + assertEquals(500L, pager.reachedUntilFor(relayA, start)) + assertEquals(start, pager.reachedUntilFor(relayB, start)) // deepest reached across both = A's 500 (B counts as the floor) - assertEquals(500L, pager.deepestReached(key, listOf(relayA, relayB), start)) + assertEquals(500L, pager.deepestReached(listOf(relayA, relayB), start)) } @Test fun deepestReachedIsNullWhenNoRelays() { - val pager = UntilLimitPager() - assertEquals(null, pager.deepestReached(key, emptyList(), start)) + val pager = UntilLimitPager() + assertEquals(null, pager.deepestReached(emptyList(), start)) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt index 43678ca38a..0379f030ae 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.WeakReference import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.UntilLimitPager import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip14Subject.subject import com.vitorpamplona.quartz.utils.TimeUtils @@ -46,6 +47,12 @@ class Chatroom : NotesGatherer { var ownerSentMessage: Boolean = false var newestMessage: Note? = null + // Per-conversation NIP-04 history paging cursors, held here so reopening this room keeps its + // progress and the cursors share the lifetime of the cached messages. The conversation history + // loader binds its (single-active) orchestrator to this. Lazy — most rooms in the rooms list are + // never opened for history paging, so they never allocate it. + val nip04History by lazy { UntilLimitPager() } + // Per-instance lock shared by previously @Synchronized methods. private val syncLock = KmpLock() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatroomList.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatroomList.kt index 3e2d25893e..6585317fa5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatroomList.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatroomList.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.model.privateChats import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.UntilLimitPager import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.utils.cache.LargeCache @@ -34,6 +35,13 @@ class ChatroomList( var rooms = LargeCache() private set + // Account-level DM history paging cursors (one scope per account), held here so they share the + // lifetime of the cached messages and are dropped when the cache prunes them. The account-level + // history loaders bind their orchestrator to these. (Per-conversation NIP-04 cursors live on the + // individual [Chatroom] instead.) + val giftWrapHistory = UntilLimitPager() + val nip04History = UntilLimitPager() + private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom = rooms.getOrCreate(key) { Chatroom() } fun getOrCreatePrivateChatroom(user: User): Chatroom { diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPager.kt similarity index 75% rename from quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPager.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPager.kt index a648c0a998..8e0431d2db 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPager.kt @@ -21,13 +21,20 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.paging import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import java.util.concurrent.ConcurrentHashMap +import com.vitorpamplona.quartz.utils.cache.LargeCache +import kotlin.concurrent.Volatile /** - * Backward `until`+`limit` pagination cursor, tracked **independently per relay** (and per [K], e.g. - * per account or per conversation), and advanced **on demand** — one page at a time, only when the + * Backward `until`+`limit` pagination cursors for **one scope** (one account, or one conversation), + * tracked **independently per relay** and advanced **on demand** — one page at a time, only when the * owner calls [advance]. * + * This is pure per-relay paging *state*, with no orchestration (no spinner / stall / exhaustion / live + * flows — those are [BackwardRelayPager]'s job). It is meant to live on the owning domain object (a + * `Chatroom` for a conversation, a `ChatroomList` for the account-level feeds), so the "how far each + * relay has paged" it records shares the lifetime of the cached messages it describes and is dropped + * with them. That is why it carries no key: the object graph *is* the partition. + * * The time-window model can't tell "this relay is empty" from "this is a gap" — a `since`/`until` * slice that returns nothing might just be a quiet stretch with older messages beneath it. Paging by * `until`+`limit` removes that ambiguity: a relay returns its N newest events older than `until`, @@ -46,9 +53,11 @@ import java.util.concurrent.ConcurrentHashMap * relay may cap results below what we asked — is not done. * * Not internally synchronized: per-relay counters are touched on the relay IO threads (one relay's - * callbacks are serialized) and read on the owning scope; fields are volatile. + * callbacks are serialized) and read on the owning scope; fields are volatile and the relay map is a + * thread-safe [LargeCache]. Keyed only by [NormalizedRelayUrl], which is `Comparable` and consistent + * with `equals`, so the sorted cache identifies relays correctly. */ -class UntilLimitPager { +class UntilLimitPager { private class RelayCursor { // The `until` the REQ carries; null until the relay is first advanced. Moves only in advance(). @Volatile var requestedUntil: Long? = null @@ -66,33 +75,30 @@ class UntilLimitPager { @Volatile var pageOldest: Long = Long.MAX_VALUE } - private val perKey = ConcurrentHashMap>() + private val cursors = LargeCache() - private fun cursorsFor(key: K) = perKey.getOrPut(key) { ConcurrentHashMap() } + /** + * The session-pinned history floor this scope pages down from (typically `now − liveTail`, set by the + * owner on first advance). Kept here so it persists with the scope and does not drift forward on + * recompute — an undelivered relay's marker sits at this floor, and a moving floor would re-trigger + * its on-screen sentinel. + */ + @Volatile + var floor: Long? = null - private fun cursor( - key: K, - relay: NormalizedRelayUrl, - ) = cursorsFor(key).getOrPut(relay) { RelayCursor() } + private fun cursor(relay: NormalizedRelayUrl) = cursors.getOrCreate(relay) { RelayCursor() } /** The `until` [relay]'s REQ currently carries. Only meaningful once it has been [advance]d. */ - fun requestedUntilFor( - key: K, - relay: NormalizedRelayUrl, - ): Long? = cursor(key, relay).requestedUntil + fun requestedUntilFor(relay: NormalizedRelayUrl): Long? = cursor(relay).requestedUntil /** The oldest point [relay] has reached (its marker depth), or [start] if it hasn't delivered yet. */ fun reachedUntilFor( - key: K, relay: NormalizedRelayUrl, start: Long, - ): Long = cursor(key, relay).reachedUntil ?: start + ): Long = cursor(relay).reachedUntil ?: start /** True once [relay] answered an empty page with EOSE — nothing older to ask it for. */ - fun isDone( - key: K, - relay: NormalizedRelayUrl, - ): Boolean = cursor(key, relay).done + fun isDone(relay: NormalizedRelayUrl): Boolean = cursor(relay).done /** * Steps [relay] to its next, older page: points its REQ just below the oldest event it has delivered @@ -100,11 +106,10 @@ class UntilLimitPager { * has already paged to the bottom ([done]). The owner re-issues the REQ after this (invalidateFilters). */ fun advance( - key: K, relay: NormalizedRelayUrl, start: Long, ): Boolean { - val c = cursor(key, relay) + val c = cursor(relay) if (c.done) return false c.requestedUntil = if (c.requestedUntil == null) { @@ -119,11 +124,10 @@ class UntilLimitPager { /** Records one event for [relay] in the current page. */ fun onEvent( - key: K, relay: NormalizedRelayUrl, createdAt: Long, ) { - val c = cursor(key, relay) + val c = cursor(relay) c.pageCount++ if (createdAt < c.pageOldest) c.pageOldest = createdAt } @@ -133,11 +137,8 @@ class UntilLimitPager { * cursor drops to the oldest event the page returned. The requested cursor is left alone so the relay * parks until [advance] is called again. */ - fun onEose( - key: K, - relay: NormalizedRelayUrl, - ) { - val c = cursor(key, relay) + fun onEose(relay: NormalizedRelayUrl) { + val c = cursor(relay) if (c.pageCount == 0) { c.done = true } else { @@ -155,12 +156,9 @@ class UntilLimitPager { } /** Relays from [all] that have been armed (advanced at least once) and are not yet [done]. */ - fun armedRelays( - key: K, - all: Collection, - ): List = + fun armedRelays(all: Collection): List = all.filter { - val c = cursor(key, it) + val c = cursor(it) c.requestedUntil != null && !c.done } @@ -169,8 +167,7 @@ class UntilLimitPager { * gone). Relays that haven't delivered count as [start]. Null when [relays] is empty. */ fun deepestReached( - key: K, relays: Collection, start: Long, - ): Long? = relays.takeIf { it.isNotEmpty() }?.minOf { cursor(key, it).reachedUntil ?: start } + ): Long? = relays.takeIf { it.isNotEmpty() }?.minOf { cursor(it).reachedUntil ?: start } } 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 index 094f4b749d..29578c236f 100644 --- 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 @@ -32,15 +32,19 @@ 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. + * relay advancing independently the moment *it* settles, never paced by the slowest one. * - * 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]). + * This is the **single-active orchestrator** around the paging state. The state itself (the per-relay + * [UntilLimitPager] cursors) does NOT live here — it lives on the owning domain object (a `Chatroom` + * for a conversation, a `ChatroomList` for the account-level feeds), so its lifetime matches the cached + * messages it describes. One orchestrator drives whichever scope is on screen; calling [bind] repoints + * it at that scope's cursors. This is safe because history relays only ever arm while their on-screen + * markers are visible, 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 + * history floor live on the bound [UntilLimitPager]. * * 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 @@ -48,14 +52,7 @@ import java.util.concurrent.ConcurrentHashMap * - **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. + * - **Which scope's cursors + which relays.** Supplied together by [bind]. * * ### 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 @@ -69,7 +66,7 @@ import java.util.concurrent.ConcurrentHashMap * 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( +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 @@ -78,30 +75,20 @@ class BackwardRelayPager( // 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. + // The active scope, set by [bind]: its persistent per-relay cursors (which live on the owning domain + // object) and the lookup for the relay set it fans out to. @Volatile - private var activeKey: K? = null + private var cursors: UntilLimitPager? = null + + @Volatile + private var relaysFor: () -> Collection? = { null } + + // Relays not advancing for the active scope (auth CLOSE / unreachable / silent). Transient: cleared + // 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"). */ val loadingMore: StateFlow = loadTracker.loading @@ -131,65 +118,73 @@ class BackwardRelayPager( /** 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). */ - internal fun floorFor(key: K): Long = pinnedFloor.getOrPut(key) { TimeUtils.now() - liveTailSeconds } + // 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 sentinel). + private fun floor(): Long { + val c = cursors ?: return TimeUtils.now() - liveTailSeconds + return c.floor ?: (TimeUtils.now() - liveTailSeconds).also { c.floor = it } + } + + /** + * Repoints to a scope (call on subscribe / when the on-screen scope changes): its persistent + * [scopeCursors] (held on the owning model object), the [scope] for the silence watchdog, and the + * [relaysForScope] lookup. Resets the transient orchestration (in-flight, stalled) and recomputes + * the display flows from the bound cursors — so a previously-paged scope restores its progress + * instead of restarting. + */ + fun bind( + scopeCursors: UntilLimitPager, + scope: CoroutineScope, + relaysForScope: () -> Collection?, + ) { + cursors = scopeCursors + relaysFor = relaysForScope + loadTracker.bind(scope) + loadTracker.reset() + stalledRelays.clear() + updateStatus() + recomputeExhausted() + } // --- 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) + /** Relays of the active scope that have been advanced (armed) and aren't done — i.e. carry a REQ. */ + fun armedRelays(relays: Collection): List = cursors?.armedRelays(relays) ?: emptyList() - /** 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) + /** The `until` [relay]'s next page should carry (null if it isn't armed / no scope bound). */ + fun requestedUntilFor(relay: NormalizedRelayUrl): Long? = cursors?.requestedUntilFor(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) + /** 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() 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 + /** Steps every not-done, not-in-flight relay of the active scope one page. For a scope too small to scroll. */ + fun advanceAll(): Boolean { + val relays = relaysFor() ?: return false var any = false - relays.forEach { if (arm(key, it, scope)) any = true } + relays.forEach { if (arm(it)) any = true } if (any) { - if (activeKey == key) _exhausted.value = false - updateStatus(key) + _exhausted.value = false + updateStatus() } 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 + // (no scope bound, unknown relay, already fetching, or already done). Caller batches the recompute. + private fun arm(relay: NormalizedRelayUrl): Boolean { + val c = cursors ?: return false + val relays = relaysFor() ?: 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) + if (!c.advance(relay, floor())) return false + stalledRelays.remove(relay) loadTracker.onAdvance(relay) return true } @@ -198,126 +193,97 @@ class BackwardRelayPager( /** 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) + cursors?.onEvent(relay, createdAt) + stalledRelays.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) + fun onEose(relay: NormalizedRelayUrl): Boolean { + val c = cursors ?: return false + stalledRelays.remove(relay) + c.onEose(relay) loadTracker.onSettled(relay) - val done = pager.isDone(key, relay) - updateStatus(key) - recomputeExhausted(key) + val done = c.isDone(relay) + updateStatus() + recomputeExhausted() 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) + markStalled(relay, "CLOSED: $message") + updateStatus() + recomputeExhausted() } /** [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) + markStalled(relay, "cannot connect: $message") + updateStatus() + recomputeExhausted() } // The tracker's silence watchdog fired: the still-pending relays went quiet after their REQ. Flag them - // (for the active key) stalled but kept, so the window can settle instead of hanging on a dead relay. + // stalled but 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) + relays.forEach { markStalled(it, "no response (silence timeout)") } + updateStatus() + recomputeExhausted() } 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)" } + if (stalledRelays.add(relay)) Log.d(TAG) { "[$name] ${relay.url} stalled — $reason (kept, advance to retry)" } } - // --- Display-flow management. --- + // --- Display-flow recompute (from the bound cursors). --- - /** - * 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() + /** Recomputes the display flows from the active scope's cursors. */ + fun updateStatus() { + val c = cursors + val relays = relaysFor() ?: 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) } + 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 = pager.reachedUntilFor(key, relay, floor), - done = pager.isDone(key, relay), - stalled = relay in stalled && !pager.isDone(key, relay), + reachedUntil = c?.reachedUntilFor(relay, floor) ?: floor, + done = c?.isDone(relay) ?: false, + stalled = relay in stalledRelays && c?.isDone(relay) != true, ) } } // 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 + private fun recomputeExhausted() { + val c = cursors ?: return + val relays = relaysFor() ?: return if (relays.isEmpty()) return - val stalled = stalledRelays[key] ?: emptySet() - val pending = relays.any { !pager.isDone(key, it) && it !in stalled } + val pending = relays.any { !c.isDone(it) && it !in stalledRelays } 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 } + if (ex && !_exhausted.value) { + 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" } } - if (activeKey == key) _exhausted.value = ex + _exhausted.value = ex } companion object { diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/BackwardRelayPagerTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/BackwardRelayPagerTest.kt index 8e233a956f..2f8b9c9126 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/BackwardRelayPagerTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/BackwardRelayPagerTest.kt @@ -36,12 +36,16 @@ import kotlin.test.assertTrue * assert the cursor / done / stalled / exhausted bookkeeping — the logic that backed the "All caught up * while messages missing" and the stalled-vs-done bugs. The relay's own `until`+`limit`+EOSE wire * behaviour is covered separately against the in-process relay in `UntilLimitPagingRelayTest`. + * + * The pager is the **single-active orchestrator**: its per-relay cursors live on a separate + * [UntilLimitPager] (in production, on a `Chatroom` / `ChatroomList`), bound in via [bind]. These tests + * supply their own cursor object so they can rebind a previously-paged scope and assert what persists + * (the cursors) versus what is transient and recomputed (the stalled set, the live flows). */ class BackwardRelayPagerTest { private val r1 = NormalizedRelayUrl("wss://r1.example/") private val r2 = NormalizedRelayUrl("wss://r2.example/") private val r3 = NormalizedRelayUrl("wss://r3.example/") - private val key = "acct" private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) @@ -50,19 +54,25 @@ class BackwardRelayPagerTest { scope.cancel() } - private fun pagerOf(vararg relays: NormalizedRelayUrl): BackwardRelayPager = BackwardRelayPager("test") { relays.toList() }.also { it.activate(key) } + // A pager bound to a fresh scope of [relays]; returns both so tests can read the pinned cursor floor. + private fun pagerOf(vararg relays: NormalizedRelayUrl): Pair { + val cursors = UntilLimitPager() + val p = BackwardRelayPager("test") + p.bind(cursors, scope) { relays.toList() } + return p to cursors + } @Test fun firstPageRequestsTheFloorAndAnEmptyPageIsCaughtUp() { - val p = pagerOf(r1) + val (p, cursors) = pagerOf(r1) assertFalse(p.exhausted.value) - assertTrue(p.advance(key, r1, scope)) - // The very first page asks `until = floor`. - assertEquals(p.floorFor(key), p.requestedUntilFor(key, r1)) + assertTrue(p.advance(r1)) + // The very first page asks `until = floor` (pinned on the bound cursors). + assertEquals(cursors.floor, p.requestedUntilFor(r1)) // Empty page + EOSE → that relay is done; the only relay is done → genuinely caught up. - assertTrue(p.onEose(key, r1)) + assertTrue(p.onEose(r1)) assertTrue( p.relayProgress.value .getValue(r1) @@ -74,14 +84,14 @@ class BackwardRelayPagerTest { @Test fun nonEmptyPageMovesTheCursorThenBottomsOut() { - val p = pagerOf(r1) - p.advance(key, r1, scope) + val (p, _) = pagerOf(r1) + p.advance(r1) // A page of three events; the oldest is 80, so the reached cursor drops to 80 (not done). - p.onEvent(key, r1, 100) - p.onEvent(key, r1, 80) - p.onEvent(key, r1, 90) - assertFalse(p.onEose(key, r1)) + p.onEvent(r1, 100) + p.onEvent(r1, 80) + p.onEvent(r1, 90) + assertFalse(p.onEose(r1)) assertFalse( p.relayProgress.value .getValue(r1) @@ -91,27 +101,27 @@ class BackwardRelayPagerTest { assertFalse(p.exhausted.value) // The next page must start strictly below the oldest reached (80 → until 79). - assertTrue(p.advance(key, r1, scope)) - assertEquals(79L, p.requestedUntilFor(key, r1)) + assertTrue(p.advance(r1)) + assertEquals(79L, p.requestedUntilFor(r1)) // Empty page now → done → caught up. - assertTrue(p.onEose(key, r1)) + assertTrue(p.onEose(r1)) assertTrue(p.exhausted.value) assertEquals(0, p.stalledCount.value) } @Test fun aStalledRelayMakesExhaustionIncompleteNotCaughtUp() { - val p = pagerOf(r1, r2) - p.advance(key, r1, scope) - p.advance(key, r2, scope) + val (p, _) = pagerOf(r1, r2) + p.advance(r1) + p.advance(r2) // r1 genuinely bottoms out; r2 is still pending, so not exhausted yet. - p.onEose(key, r1) + p.onEose(r1) assertFalse(p.exhausted.value) // r2 auth-walls the REQ → stalled (kept, not done). - p.onClosed(key, r2, "auth-required") + p.onClosed(r2, "auth-required") assertTrue( p.relayProgress.value .getValue(r2) @@ -130,9 +140,9 @@ class BackwardRelayPagerTest { @Test fun cannotConnectAlsoStalls() { - val p = pagerOf(r1) - p.advance(key, r1, scope) - p.onCannotConnect(key, r1, "offline") + val (p, _) = pagerOf(r1) + p.advance(r1) + p.onCannotConnect(r1, "offline") assertTrue( p.relayProgress.value .getValue(r1) @@ -144,14 +154,14 @@ class BackwardRelayPagerTest { @Test fun reAdvancingAStalledRelayClearsTheStallAndUnExhausts() { - val p = pagerOf(r1) - p.advance(key, r1, scope) - p.onClosed(key, r1, "auth-required") + val (p, _) = pagerOf(r1) + p.advance(r1) + p.onClosed(r1, "auth-required") assertTrue(p.exhausted.value) assertEquals(1, p.stalledCount.value) // Retrying it re-arms the relay: no longer stalled, no longer exhausted. - assertTrue(p.advance(key, r1, scope)) + assertTrue(p.advance(r1)) assertFalse( p.relayProgress.value .getValue(r1) @@ -163,15 +173,15 @@ class BackwardRelayPagerTest { @Test fun reachedBackIsTheDeepestCursorAcrossRelays() { - val p = pagerOf(r1, r2) - p.advance(key, r1, scope) - p.advance(key, r2, scope) + val (p, _) = pagerOf(r1, r2) + p.advance(r1) + p.advance(r2) - p.onEvent(key, r1, 500) - p.onEose(key, r1) // r1 reached 500 + p.onEvent(r1, 500) + p.onEose(r1) // r1 reached 500 - p.onEvent(key, r2, 300) - p.onEose(key, r2) // r2 reached 300 + p.onEvent(r2, 300) + p.onEose(r2) // r2 reached 300 // Deepest = the oldest point any relay has reached. assertEquals(300L, p.reachedBack.value) @@ -179,46 +189,54 @@ class BackwardRelayPagerTest { @Test fun aDoneRelayWillNotAdvanceAgain() { - val p = pagerOf(r1) - p.advance(key, r1, scope) - p.onEose(key, r1) // empty → done - assertFalse(p.advance(key, r1, scope)) + val (p, _) = pagerOf(r1) + p.advance(r1) + p.onEose(r1) // empty → done + assertFalse(p.advance(r1)) } @Test fun advanceAllArmsEveryNotDoneRelay() { - val p = pagerOf(r1, r2, r3) + val (p, _) = pagerOf(r1, r2, r3) // r2 already finished; advanceAll should arm only r1 and r3. - p.advance(key, r2, scope) - p.onEose(key, r2) + p.advance(r2) + p.onEose(r2) - assertTrue(p.advanceAll(key, scope)) - assertEquals(setOf(r1, r3), p.armedRelays(key, listOf(r1, r2, r3)).toSet()) + assertTrue(p.advanceAll()) + assertEquals(setOf(r1, r3), p.armedRelays(listOf(r1, r2, r3)).toSet()) } @Test - fun switchingActiveKeyRepointsTheDisplayFlows() { - val keyA = "a" - val keyB = "b" - val relaysByKey = mapOf(keyA to listOf(r1), keyB to listOf(r2)) - val p = BackwardRelayPager("test") { relaysByKey[it] } + fun rebindingRepointsFlowsKeepingDoneCursorsButDroppingTransientStalls() { + val cursorsA = UntilLimitPager() + val cursorsB = UntilLimitPager() + val p = BackwardRelayPager("test") - p.activate(keyA) - p.advance(keyA, r1, scope) - p.onClosed(keyA, r1, "auth-required") // A: exhausted + 1 stalled + // Scope A: r1 bottoms out (done — a persistent cursor fact); r2 auth-walls (stalled — transient). + p.bind(cursorsA, scope) { listOf(r1, r2) } + p.advance(r1) + p.advance(r2) + p.onEose(r1) + p.onClosed(r2, "auth-required") assertTrue(p.exhausted.value) assertEquals(1, p.stalledCount.value) - // Switching to a fresh key B repoints the flows to B's own state: nothing stalled, and its - // reach sits at B's floor (no history fetched yet — the markers start at the live-tail boundary). - p.activate(keyB) + // 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(p.floorFor(keyB), p.reachedBack.value) + assertEquals(cursorsB.floor, p.reachedBack.value) - // Switching back to A restores its remembered terminal state. - p.activate(keyA) - assertTrue(p.exhausted.value) - assertEquals(1, p.stalledCount.value) + // 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 + .getValue(r1) + .done, + ) + assertEquals(0, p.stalledCount.value) + assertFalse(p.exhausted.value) } } From b37f1a6e56e2f68aa524d627384e59d9a85bc697 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 5 Jun 2026 18:57:41 -0400 Subject: [PATCH 088/103] fix(dm): make the commons DM feed UI compile for iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DM history widgets extracted into commons were never compiled for the commons iOS target, which hid two Kotlin/Native-only breaks: - RelayReachMarker: `toSortedMap(compareBy { it.ordinal })` + a destructured `(state, list)` Map.Entry inside an inline @Composable lambda don't type-infer on Native. Rewrite as `.entries.sortedBy { it.key.ordinal }` with explicit `entry.key` / `entry.value`. - DmHistoryLoadingCard referenced RelayPagingProgress, which sat in quartz's jvmAndroid source set — visible to commonMain only when building JVM/Android, not iOS. It's a pure data class, so move it to quartz commonMain. commons:compileKotlinIosArm64 now succeeds; JVM/Android unaffected and the DM test suite is still green (26/26). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../amethyst/commons/ui/feeds/RelayReachMarker.kt | 10 ++++++++-- .../relay/client/paging/RelayPagingProgress.kt | 0 2 files changed, 8 insertions(+), 2 deletions(-) rename quartz/src/{jvmAndroid => commonMain}/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayPagingProgress.kt (100%) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt index a136bdbd09..c955c154b5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt @@ -212,11 +212,17 @@ private fun RelayReachMarker(entries: List) { fontWeight = FontWeight.Medium, maxLines = 1, ) + // Present states in enum order. Written with an explicit `sortedBy` + `entry.key`/`entry.value` + // (not `toSortedMap(compareBy { it.ordinal })` + a destructured `(state, list)`) because + // Kotlin/Native's Compose compiler can't infer those inside this inline @Composable lambda + // (commons iOS). entries .groupBy { it.state } - .toSortedMap(compareBy { it.ordinal }) .entries - .forEachIndexed { index, (state, list) -> + .sortedBy { it.key.ordinal } + .forEachIndexed { index, entry -> + val state = entry.key + val list = entry.value if (index > 0) { Text("·", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 11.sp) } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayPagingProgress.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayPagingProgress.kt similarity index 100% rename from quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayPagingProgress.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayPagingProgress.kt From c512bd39c4531777042bcb6bbedf8a4b6d9fbb27 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 5 Jun 2026 19:07:30 -0400 Subject: [PATCH 089/103] refactor(dm): rename UntilLimitPager to RelayLoadingCursors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once the pager was split into the orchestrator (BackwardRelayPager) and the pure per-relay cursor state that lives on the model, "UntilLimitPager" no longer described the latter — it pages nothing, it just records how far each relay has loaded. Rename it (and its test) to RelayLoadingCursors. The geode wire-contract test keeps its name (UntilLimitPagingRelayTest): it pins the relay-side `until`+`limit` paging behaviour, not the class. Pure rename — no behaviour change. Design doc updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-06-01-dm-live-tail-and-history-slices.md | 12 +-- ...agerTest.kt => RelayLoadingCursorsTest.kt} | 82 +++++++++---------- .../commons/model/privateChats/Chatroom.kt | 4 +- .../model/privateChats/ChatroomList.kt | 6 +- ...ilLimitPager.kt => RelayLoadingCursors.kt} | 2 +- .../relay/client/paging/BackwardRelayPager.kt | 10 +-- .../client/paging/BackwardRelayPagerTest.kt | 10 +-- .../paging/UntilLimitPagingRelayTest.kt | 2 +- 8 files changed, 64 insertions(+), 64 deletions(-) rename amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/{UntilLimitPagerTest.kt => RelayLoadingCursorsTest.kt} (54%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/{UntilLimitPager.kt => RelayLoadingCursors.kt} (99%) diff --git a/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md b/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md index 2f51b44378..aff6779402 100644 --- a/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md +++ b/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md @@ -41,7 +41,7 @@ Each DM protocol — **NIP-17** gift wraps (kind 1059) and **NIP-04** legacy DMs when all settle, drives the boot spinner. 2. **History** — everything *older* than the week floor, paged **backward by `until`+`limit`, per relay, on demand**. Backed by the **per-relay model** - (`UntilLimitPager` + `PerRelayLoadTracker`), driven by on-screen markers. + (`RelayLoadingCursors` + `PerRelayLoadTracker`), driven by on-screen markers. The two are disjoint in time, so re-issuing a history page never re-streams the live tail, and consecutive history pages never re-stream each other. @@ -56,7 +56,7 @@ Accessed from the UI via `accountViewModel.dataSources()` as `.account.giftWrapsHistory`, `.chatroom.nip04History`, `.chatroomList.nip04History`. -### The history paging primitive: `UntilLimitPager` +### The history paging primitive: `RelayLoadingCursors` The time-window model can't tell "this relay is empty" from "this is a gap" — a `since`/`until` slice that returns nothing might just be a quiet stretch above @@ -76,7 +76,7 @@ Stop signals: an empty page marks the relay **`done`**. A relay returning fewer than `limit` is treated as its own cap, **not** exhaustion. A misbehaving relay that returns events but none older than already reached (echoing its newest events) is also treated as the bottom, so its marker can't re-request the same -window forever. Tested in `UntilLimitPagerTest.kt`. +window forever. Tested in `RelayLoadingCursorsTest.kt`. ### The two completion models (and where each lives) @@ -92,7 +92,7 @@ the one-shot fixed-window backfill the live tail does. > in current use only the settle / idle / cap paths ever fire. The REQ-aware > machinery is dormant in production — see "Things to scrutinize". -**Per-relay model — `UntilLimitPager` + `PerRelayLoadTracker` (all history).** +**Per-relay model — `RelayLoadingCursors` + `PerRelayLoadTracker` (all history).** Each relay advances to its next page the instant *it* EOSEs, independent of the others; the subscription layer diffs per relay, so re-issuing only re-REQs the relay whose cursor moved. `loading` starts **`false`** (a `true` start would @@ -231,7 +231,7 @@ pagination, but it lived here because unreachable relays were part of the same out of amethyst so desktop / CLI / any feed can reuse it; in the `jvmAndroid` source set (uses `java.util.concurrent`), visible to amethyst + desktop + quartz's `jvmAndroidTest` (geode in-process relay). -- `UntilLimitPager.kt` — per-relay `until`+`limit` cursor. *(+ `UntilLimitPagerTest` in amethyst)* +- `RelayLoadingCursors.kt` — per-relay `until`+`limit` cursor. *(+ `RelayLoadingCursorsTest` in amethyst)* - `PerRelayLoadTracker.kt` — per-relay in-flight tracker + silence watchdog. - `WindowLoadTracker.kt` — round/barrier completion tracker (live tail). *(+ silence test in amethyst)* - `RelayPagingProgress.kt` — `(reachedUntil, done, stalled)` per relay. @@ -291,7 +291,7 @@ strings, no app-theme / `java.time` deps. These sections describe earlier iterations, kept for context. The code has moved past all of them. -### v1 — time-slice history (superseded by `UntilLimitPager`) +### v1 — time-slice history (superseded by `RelayLoadingCursors`) History was first loaded in bounded `since`+`until` **time slices** (`TimeWindowPagination`, now deleted): `loadMore` fetched only the new band 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/RelayLoadingCursorsTest.kt similarity index 54% rename from amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPagerTest.kt rename to amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/RelayLoadingCursorsTest.kt index 597e783493..7f5237e7dc 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/RelayLoadingCursorsTest.kt @@ -20,90 +20,90 @@ */ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers -import com.vitorpamplona.quartz.nip01Core.relay.client.paging.UntilLimitPager +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -class UntilLimitPagerTest { +class RelayLoadingCursorsTest { private val relayA = RelayUrlNormalizer.normalizeOrNull("wss://a.relay")!! private val relayB = RelayUrlNormalizer.normalizeOrNull("wss://b.relay")!! private val start = 1_000L @Test fun unarmedRelayIsNotRequestedAndSitsAtTheFloor() { - val pager = UntilLimitPager() + val cursors = RelayLoadingCursors() // never advanced, so it carries no REQ - assertEquals(emptyList(), pager.armedRelays(listOf(relayA))) + assertEquals(emptyList(), cursors.armedRelays(listOf(relayA))) // marker sits at the floor until it delivers - assertEquals(start, pager.reachedUntilFor(relayA, start)) + assertEquals(start, cursors.reachedUntilFor(relayA, start)) } @Test fun firstAdvanceRequestsTheFloorThenSubsequentPagesStepBelowReached() { - val pager = UntilLimitPager() + val cursors = RelayLoadingCursors() - assertTrue(pager.advance(relayA, start)) - assertEquals(start, pager.requestedUntilFor(relayA)) + assertTrue(cursors.advance(relayA, start)) + assertEquals(start, cursors.requestedUntilFor(relayA)) // page returns events; oldest seen = 800 - pager.onEvent(relayA, 900) - pager.onEvent(relayA, 800) - pager.onEose(relayA) - assertEquals(800L, pager.reachedUntilFor(relayA, start)) + cursors.onEvent(relayA, 900) + cursors.onEvent(relayA, 800) + cursors.onEose(relayA) + assertEquals(800L, cursors.reachedUntilFor(relayA, start)) // EOSE does NOT move the requested cursor — the relay parks at the same filter - assertEquals(start, pager.requestedUntilFor(relayA)) + assertEquals(start, cursors.requestedUntilFor(relayA)) // next advance steps to reached - 1 - assertTrue(pager.advance(relayA, start)) - assertEquals(799L, pager.requestedUntilFor(relayA)) + assertTrue(cursors.advance(relayA, start)) + assertEquals(799L, cursors.requestedUntilFor(relayA)) } @Test fun emptyPageMarksRelayDoneAndBlocksFurtherAdvance() { - val pager = UntilLimitPager() - pager.advance(relayA, start) - pager.onEose(relayA) // no events - assertTrue(pager.isDone(relayA)) - assertFalse(pager.advance(relayA, start)) - assertEquals(emptyList(), pager.armedRelays(listOf(relayA))) + val cursors = RelayLoadingCursors() + cursors.advance(relayA, start) + cursors.onEose(relayA) // no events + assertTrue(cursors.isDone(relayA)) + assertFalse(cursors.advance(relayA, start)) + assertEquals(emptyList(), cursors.armedRelays(listOf(relayA))) } @Test fun aPageThatDoesNotStepOlderEndsTheRelayInsteadOfLooping() { - val pager = UntilLimitPager() - pager.advance(relayA, start) - pager.onEvent(relayA, 800) - pager.onEose(relayA) - assertEquals(800L, pager.reachedUntilFor(relayA, start)) + val cursors = RelayLoadingCursors() + cursors.advance(relayA, start) + cursors.onEvent(relayA, 800) + cursors.onEose(relayA) + assertEquals(800L, cursors.reachedUntilFor(relayA, start)) // misbehaving relay: next page echoes an event no older than what we already reached - pager.advance(relayA, start) // requested = 799 - pager.onEvent(relayA, 900) // newer than reached(800) — not strictly older - pager.onEose(relayA) - assertTrue("a non-advancing page should end the relay, not re-loop", pager.isDone(relayA)) - assertEquals(800L, pager.reachedUntilFor(relayA, start)) + cursors.advance(relayA, start) // requested = 799 + cursors.onEvent(relayA, 900) // newer than reached(800) — not strictly older + cursors.onEose(relayA) + assertTrue("a non-advancing page should end the relay, not re-loop", cursors.isDone(relayA)) + assertEquals(800L, cursors.reachedUntilFor(relayA, start)) } @Test fun relaysAreTrackedIndependently() { - val pager = UntilLimitPager() - pager.advance(relayA, start) - pager.onEvent(relayA, 500) - pager.onEose(relayA) + val cursors = RelayLoadingCursors() + cursors.advance(relayA, start) + cursors.onEvent(relayA, 500) + cursors.onEose(relayA) // B never advanced - assertEquals(listOf(relayA), pager.armedRelays(listOf(relayA, relayB))) - assertEquals(500L, pager.reachedUntilFor(relayA, start)) - assertEquals(start, pager.reachedUntilFor(relayB, start)) + assertEquals(listOf(relayA), cursors.armedRelays(listOf(relayA, relayB))) + assertEquals(500L, cursors.reachedUntilFor(relayA, start)) + assertEquals(start, cursors.reachedUntilFor(relayB, start)) // deepest reached across both = A's 500 (B counts as the floor) - assertEquals(500L, pager.deepestReached(listOf(relayA, relayB), start)) + assertEquals(500L, cursors.deepestReached(listOf(relayA, relayB), start)) } @Test fun deepestReachedIsNullWhenNoRelays() { - val pager = UntilLimitPager() - assertEquals(null, pager.deepestReached(emptyList(), start)) + val cursors = RelayLoadingCursors() + assertEquals(null, cursors.deepestReached(emptyList(), start)) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt index 0379f030ae..995e5ae27c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt @@ -30,7 +30,7 @@ import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.WeakReference import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.relay.client.paging.UntilLimitPager +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip14Subject.subject import com.vitorpamplona.quartz.utils.TimeUtils @@ -51,7 +51,7 @@ class Chatroom : NotesGatherer { // progress and the cursors share the lifetime of the cached messages. The conversation history // loader binds its (single-active) orchestrator to this. Lazy — most rooms in the rooms list are // never opened for history paging, so they never allocate it. - val nip04History by lazy { UntilLimitPager() } + val nip04History by lazy { RelayLoadingCursors() } // Per-instance lock shared by previously @Synchronized methods. private val syncLock = KmpLock() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatroomList.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatroomList.kt index 6585317fa5..ccb294281c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatroomList.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatroomList.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.commons.model.privateChats import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.relay.client.paging.UntilLimitPager +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.utils.cache.LargeCache @@ -39,8 +39,8 @@ class ChatroomList( // lifetime of the cached messages and are dropped when the cache prunes them. The account-level // history loaders bind their orchestrator to these. (Per-conversation NIP-04 cursors live on the // individual [Chatroom] instead.) - val giftWrapHistory = UntilLimitPager() - val nip04History = UntilLimitPager() + val giftWrapHistory = RelayLoadingCursors() + val nip04History = RelayLoadingCursors() private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom = rooms.getOrCreate(key) { Chatroom() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayLoadingCursors.kt similarity index 99% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPager.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayLoadingCursors.kt index 8e0431d2db..37453b1779 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayLoadingCursors.kt @@ -57,7 +57,7 @@ import kotlin.concurrent.Volatile * thread-safe [LargeCache]. Keyed only by [NormalizedRelayUrl], which is `Comparable` and consistent * with `equals`, so the sorted cache identifies relays correctly. */ -class UntilLimitPager { +class RelayLoadingCursors { private class RelayCursor { // The `until` the REQ carries; null until the relay is first advanced. Moves only in advance(). @Volatile var requestedUntil: Long? = null 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 index 29578c236f..32656295cf 100644 --- 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 @@ -31,11 +31,11 @@ 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 + * **one page at a time, per relay, on demand**, by `until`+`limit` ([RelayLoadingCursors]) — with each * relay advancing independently the moment *it* settles, never paced by the slowest one. * * This is the **single-active orchestrator** around the paging state. The state itself (the per-relay - * [UntilLimitPager] cursors) does NOT live here — it lives on the owning domain object (a `Chatroom` + * [RelayLoadingCursors] cursors) does NOT live here — it lives on the owning domain object (a `Chatroom` * for a conversation, a `ChatroomList` for the account-level feeds), so its lifetime matches the cached * messages it describes. One orchestrator drives whichever scope is on screen; calling [bind] repoints * it at that scope's cursors. This is safe because history relays only ever arm while their on-screen @@ -44,7 +44,7 @@ import java.util.concurrent.ConcurrentHashMap * 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 - * history floor live on the bound [UntilLimitPager]. + * history floor live on the bound [RelayLoadingCursors]. * * 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 @@ -81,7 +81,7 @@ class BackwardRelayPager( // The active scope, set by [bind]: its persistent per-relay cursors (which live on the owning domain // object) and the lookup for the relay set it fans out to. @Volatile - private var cursors: UntilLimitPager? = null + private var cursors: RelayLoadingCursors? = null @Volatile private var relaysFor: () -> Collection? = { null } @@ -133,7 +133,7 @@ class BackwardRelayPager( * instead of restarting. */ fun bind( - scopeCursors: UntilLimitPager, + scopeCursors: RelayLoadingCursors, scope: CoroutineScope, relaysForScope: () -> Collection?, ) { diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/BackwardRelayPagerTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/BackwardRelayPagerTest.kt index 2f8b9c9126..74e0bb4428 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/BackwardRelayPagerTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/BackwardRelayPagerTest.kt @@ -38,7 +38,7 @@ import kotlin.test.assertTrue * behaviour is covered separately against the in-process relay in `UntilLimitPagingRelayTest`. * * The pager is the **single-active orchestrator**: its per-relay cursors live on a separate - * [UntilLimitPager] (in production, on a `Chatroom` / `ChatroomList`), bound in via [bind]. These tests + * [RelayLoadingCursors] (in production, on a `Chatroom` / `ChatroomList`), bound in via [bind]. These tests * supply their own cursor object so they can rebind a previously-paged scope and assert what persists * (the cursors) versus what is transient and recomputed (the stalled set, the live flows). */ @@ -55,8 +55,8 @@ class BackwardRelayPagerTest { } // A pager bound to a fresh scope of [relays]; returns both so tests can read the pinned cursor floor. - private fun pagerOf(vararg relays: NormalizedRelayUrl): Pair { - val cursors = UntilLimitPager() + private fun pagerOf(vararg relays: NormalizedRelayUrl): Pair { + val cursors = RelayLoadingCursors() val p = BackwardRelayPager("test") p.bind(cursors, scope) { relays.toList() } return p to cursors @@ -208,8 +208,8 @@ class BackwardRelayPagerTest { @Test fun rebindingRepointsFlowsKeepingDoneCursorsButDroppingTransientStalls() { - val cursorsA = UntilLimitPager() - val cursorsB = UntilLimitPager() + val cursorsA = RelayLoadingCursors() + val cursorsB = RelayLoadingCursors() val p = BackwardRelayPager("test") // Scope A: r1 bottoms out (done — a persistent cursor fact); r2 auth-walls (stalled — transient). diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPagingRelayTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPagingRelayTest.kt index d422ec580a..676872671d 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPagingRelayTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPagingRelayTest.kt @@ -32,7 +32,7 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue /** - * Pins down the relay-side contract the whole [UntilLimitPager] / [BackwardRelayPager] design rests on, + * Pins down the relay-side contract the whole [RelayLoadingCursors] / [BackwardRelayPager] design rests on, * against the in-process relay: a backward `until`+`limit` walk returns each event **exactly once** * (no re-download), in **newest-first** capped pages, and an **empty page + EOSE** is the gap-proof * stop. If a relay ever stopped honouring this (e.g. oldest-first, or ignoring `until`), these break — From 50676bcc64087f1c0aac19a317b5e8379ab6819d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 5 Jun 2026 19:11:51 -0400 Subject: [PATCH 090/103] docs(dm): fix stale KDoc links on RelayLoadingCursors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the class moved to commonMain and was renamed, two doc nits remained: - [BackwardRelayPager] is a jvmAndroid type, so the KDoc link can't resolve from commonMain — demote it to a plain mention. - the bare [done] links pointed at the private RelayCursor.done, not a member of this class — repoint them to the public [isDone]. Also reword "Not internally synchronized" (it leans on the thread-safe LargeCache + serialized per-relay callbacks) to not read as a contradiction. Comment-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../relay/client/paging/RelayLoadingCursors.kt | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayLoadingCursors.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayLoadingCursors.kt index 37453b1779..5e0789aa55 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayLoadingCursors.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayLoadingCursors.kt @@ -30,7 +30,7 @@ import kotlin.concurrent.Volatile * owner calls [advance]. * * This is pure per-relay paging *state*, with no orchestration (no spinner / stall / exhaustion / live - * flows — those are [BackwardRelayPager]'s job). It is meant to live on the owning domain object (a + * flows — those are the orchestrator's job; see `BackwardRelayPager`). It is meant to live on the owning domain object (a * `Chatroom` for a conversation, a `ChatroomList` for the account-level feeds), so the "how far each * relay has paged" it records shares the lifetime of the cached messages it describes and is dropped * with them. That is why it carries no key: the object graph *is* the partition. @@ -52,10 +52,11 @@ import kotlin.concurrent.Volatile * relay [done][isDone]. A relay that returns anything — even fewer than the requested limit, since a * relay may cap results below what we asked — is not done. * - * Not internally synchronized: per-relay counters are touched on the relay IO threads (one relay's - * callbacks are serialized) and read on the owning scope; fields are volatile and the relay map is a - * thread-safe [LargeCache]. Keyed only by [NormalizedRelayUrl], which is `Comparable` and consistent - * with `equals`, so the sorted cache identifies relays correctly. + * No locking of its own — it leans on cheaper guarantees instead: a cursor's per-page counters are + * mutated on the relay IO threads, where one relay's callbacks are serialized (so its read-modify-write + * can't race itself), and read on the owning scope; the cursor fields are `@Volatile`, and the relay + * map is the thread-safe [LargeCache]. That map is keyed only by [NormalizedRelayUrl], which is + * `Comparable` and consistent with `equals`, so the sorted cache identifies relays correctly. */ class RelayLoadingCursors { private class RelayCursor { @@ -103,7 +104,7 @@ class RelayLoadingCursors { /** * Steps [relay] to its next, older page: points its REQ just below the oldest event it has delivered * (or [start] for its very first page) and clears the page tally. No-op (returns false) if the relay - * has already paged to the bottom ([done]). The owner re-issues the REQ after this (invalidateFilters). + * has already paged to the bottom ([isDone]). The owner re-issues the REQ after this (invalidateFilters). */ fun advance( relay: NormalizedRelayUrl, @@ -133,7 +134,7 @@ class RelayLoadingCursors { } /** - * Finalizes [relay] for the page on its EOSE: an empty page marks it [done]; otherwise the reached + * Finalizes [relay] for the page on its EOSE: an empty page marks it [isDone]; otherwise the reached * cursor drops to the oldest event the page returned. The requested cursor is left alone so the relay * parks until [advance] is called again. */ @@ -155,7 +156,7 @@ class RelayLoadingCursors { } } - /** Relays from [all] that have been armed (advanced at least once) and are not yet [done]. */ + /** Relays from [all] that have been armed (advanced at least once) and are not yet [isDone]. */ fun armedRelays(all: Collection): List = all.filter { val c = cursor(it) From d836c067725a032e0eae296bb073e57aa16c7141 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 5 Jun 2026 19:14:19 -0400 Subject: [PATCH 091/103] docs(dm): tighten the RelayLoadingCursors KDoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same content, ~⅓ shorter: fold the per-relay/on-demand intro into the asked-vs-delivered framing, compress the time-window rationale and the thread-safety note, keep the two-cursor explanation and the short-page caveat. Comment-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/paging/RelayLoadingCursors.kt | 53 ++++++++----------- 1 file changed, 21 insertions(+), 32 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayLoadingCursors.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayLoadingCursors.kt index 5e0789aa55..f25be82bf9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayLoadingCursors.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayLoadingCursors.kt @@ -25,38 +25,28 @@ import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlin.concurrent.Volatile /** - * Backward `until`+`limit` pagination cursors for **one scope** (one account, or one conversation), - * tracked **independently per relay** and advanced **on demand** — one page at a time, only when the - * owner calls [advance]. + * Per-relay `until`+`limit` pagination cursors for **one scope** (one account, or one conversation): + * how far back each relay has been *asked* to load, and how far it has actually *delivered*. * - * This is pure per-relay paging *state*, with no orchestration (no spinner / stall / exhaustion / live - * flows — those are the orchestrator's job; see `BackwardRelayPager`). It is meant to live on the owning domain object (a - * `Chatroom` for a conversation, a `ChatroomList` for the account-level feeds), so the "how far each - * relay has paged" it records shares the lifetime of the cached messages it describes and is dropped - * with them. That is why it carries no key: the object graph *is* the partition. + * Pure state, no orchestration — the spinner / stall / exhaustion / live flows are the orchestrator's + * job (`BackwardRelayPager`). It lives on the owning domain object (a `Chatroom`, or a `ChatroomList` + * for the account-level feeds), so it shares the lifetime of the cached messages it describes and needs + * no key: the object graph *is* the partition. * - * The time-window model can't tell "this relay is empty" from "this is a gap" — a `since`/`until` - * slice that returns nothing might just be a quiet stretch with older messages beneath it. Paging by - * `until`+`limit` removes that ambiguity: a relay returns its N newest events older than `until`, - * **skipping gaps**, so an empty page can only mean there is nothing older. + * Why `until`+`limit` and not a time window: a `since`/`until` slice that comes back empty can't tell + * "nothing older here" from "just a quiet gap". `until`+`limit` returns the N newest events older than + * `until`, skipping gaps — so an **empty page + EOSE is the gap-proof stop** ([isDone]). (A short page, + * fewer than the limit, is the relay capping us, not the bottom.) * - * Two cursors are kept per relay, deliberately decoupled so a relay never pages further than it was - * asked to: - * - [requestedUntilFor] — the `until` the relay's REQ currently carries. Moves **only** in [advance]. - * Leaving it untouched on EOSE is what makes paging demand-driven: a relay that finished a page just - * parks at the same filter (no re-REQ) until the owner advances it again. - * - reached (see [reachedUntilFor]) — the oldest `created_at` the relay has actually delivered. Moves - * on EOSE. This is what the in-stream markers sit at; [advance] starts the next page just below it. + * Two cursors per relay, kept apart so a relay never pages past what it was asked: + * - [requestedUntilFor] — the `until` its REQ carries; moves only in [advance]. Untouched on EOSE, so a + * finished page just parks (no re-REQ) until advanced again — this is what makes paging demand-driven. + * - reached ([reachedUntilFor]) — the oldest `created_at` it has delivered; moves on EOSE. The in-stream + * marker sits here, and the next [advance] starts just below it. * - * Stop signal (per relay): an **empty page followed by EOSE** ([onEose] with no events) marks that - * relay [done][isDone]. A relay that returns anything — even fewer than the requested limit, since a - * relay may cap results below what we asked — is not done. - * - * No locking of its own — it leans on cheaper guarantees instead: a cursor's per-page counters are - * mutated on the relay IO threads, where one relay's callbacks are serialized (so its read-modify-write - * can't race itself), and read on the owning scope; the cursor fields are `@Volatile`, and the relay - * map is the thread-safe [LargeCache]. That map is keyed only by [NormalizedRelayUrl], which is - * `Comparable` and consistent with `equals`, so the sorted cache identifies relays correctly. + * No locks of its own: each relay's callbacks are serialized, the cursor fields are `@Volatile`, and the + * relay map is the thread-safe [LargeCache] (keyed by [NormalizedRelayUrl], which orders consistently + * with `equals`). */ class RelayLoadingCursors { private class RelayCursor { @@ -79,10 +69,9 @@ class RelayLoadingCursors { private val cursors = LargeCache() /** - * The session-pinned history floor this scope pages down from (typically `now − liveTail`, set by the - * owner on first advance). Kept here so it persists with the scope and does not drift forward on - * recompute — an undelivered relay's marker sits at this floor, and a moving floor would re-trigger - * its on-screen sentinel. + * The history floor this scope pages down from (`now − liveTail`, pinned by the owner on first + * advance). Kept here so it persists with the scope and doesn't drift on recompute — an undelivered + * relay's marker sits at this floor, and a moving floor would re-fire its sentinel. */ @Volatile var floor: Long? = null From 3fde69c3a982259232add6a73d23a89ceaa34d82 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 5 Jun 2026 19:18:23 -0400 Subject: [PATCH 092/103] docs(dm): keep RelayLoadingCursors KDoc free of downstream-module concepts It's a reusable quartz class, so it shouldn't document itself in terms of Amethyst's Chatroom / ChatroomList / feeds / on-screen markers / sentinels / invalidateFilters(). Reword generically: "the caller holds one instance per scope on whatever object owns it", "a demand-driven loader", "the loaded-back-to point", "the owner re-issues the relay's REQ". BackwardRelayPager demoted to a backtick mention (it's jvmAndroid, so the KDoc link can't resolve from commonMain anyway). Comment-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/paging/RelayLoadingCursors.kt | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayLoadingCursors.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayLoadingCursors.kt index f25be82bf9..90ea6be155 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayLoadingCursors.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayLoadingCursors.kt @@ -28,21 +28,22 @@ import kotlin.concurrent.Volatile * Per-relay `until`+`limit` pagination cursors for **one scope** (one account, or one conversation): * how far back each relay has been *asked* to load, and how far it has actually *delivered*. * - * Pure state, no orchestration — the spinner / stall / exhaustion / live flows are the orchestrator's - * job (`BackwardRelayPager`). It lives on the owning domain object (a `Chatroom`, or a `ChatroomList` - * for the account-level feeds), so it shares the lifetime of the cached messages it describes and needs - * no key: the object graph *is* the partition. + * Pure paging state, no orchestration — the loading / stall / exhaustion / live status a caller shows + * around paging is a separate concern, layered on top by a driver (in this library, `BackwardRelayPager`). + * It carries no key on purpose: the caller holds one instance per scope on whatever object owns that + * scope, so the cursors share that object's lifetime and the object graph is the partition — not a map + * kept in here. * * Why `until`+`limit` and not a time window: a `since`/`until` slice that comes back empty can't tell * "nothing older here" from "just a quiet gap". `until`+`limit` returns the N newest events older than * `until`, skipping gaps — so an **empty page + EOSE is the gap-proof stop** ([isDone]). (A short page, - * fewer than the limit, is the relay capping us, not the bottom.) + * fewer than the limit, is the relay capping the response, not the bottom.) * * Two cursors per relay, kept apart so a relay never pages past what it was asked: * - [requestedUntilFor] — the `until` its REQ carries; moves only in [advance]. Untouched on EOSE, so a * finished page just parks (no re-REQ) until advanced again — this is what makes paging demand-driven. - * - reached ([reachedUntilFor]) — the oldest `created_at` it has delivered; moves on EOSE. The in-stream - * marker sits here, and the next [advance] starts just below it. + * - reached ([reachedUntilFor]) — the oldest `created_at` it has delivered; moves on EOSE. This is the + * "loaded back to here" point, and the next [advance] starts just below it. * * No locks of its own: each relay's callbacks are serialized, the cursor fields are `@Volatile`, and the * relay map is the thread-safe [LargeCache] (keyed by [NormalizedRelayUrl], which orders consistently @@ -54,7 +55,7 @@ class RelayLoadingCursors { @Volatile var requestedUntil: Long? = null // The oldest created_at this relay has delivered; null until its first non-empty page. Moves on - // EOSE. The marker sits here and the next page starts just below it. + // EOSE. This is the "loaded back to here" point; the next page starts just below it. @Volatile var reachedUntil: Long? = null // Set once the relay answered an empty page with EOSE: there is nothing older on it. @@ -69,9 +70,10 @@ class RelayLoadingCursors { private val cursors = LargeCache() /** - * The history floor this scope pages down from (`now − liveTail`, pinned by the owner on first - * advance). Kept here so it persists with the scope and doesn't drift on recompute — an undelivered - * relay's marker sits at this floor, and a moving floor would re-fire its sentinel. + * The history floor this scope pages down from (e.g. `now − liveTail`, pinned by the owner on first + * advance). Kept here so it persists with the scope and doesn't drift on recompute — a relay that + * hasn't delivered yet reports this floor as its reached point, and a moving floor would make a + * demand-driven loader re-fire on it. */ @Volatile var floor: Long? = null @@ -81,7 +83,7 @@ class RelayLoadingCursors { /** The `until` [relay]'s REQ currently carries. Only meaningful once it has been [advance]d. */ fun requestedUntilFor(relay: NormalizedRelayUrl): Long? = cursor(relay).requestedUntil - /** The oldest point [relay] has reached (its marker depth), or [start] if it hasn't delivered yet. */ + /** The oldest point [relay] has reached, or [start] if it hasn't delivered yet. */ fun reachedUntilFor( relay: NormalizedRelayUrl, start: Long, @@ -93,7 +95,7 @@ class RelayLoadingCursors { /** * Steps [relay] to its next, older page: points its REQ just below the oldest event it has delivered * (or [start] for its very first page) and clears the page tally. No-op (returns false) if the relay - * has already paged to the bottom ([isDone]). The owner re-issues the REQ after this (invalidateFilters). + * has already paged to the bottom ([isDone]). The owner re-issues the relay's REQ after this. */ fun advance( relay: NormalizedRelayUrl, @@ -134,8 +136,8 @@ class RelayLoadingCursors { } else { // The reached cursor must move strictly older every page (the next page asks `until = // reached - 1`). A relay that returns events but none older than we already have — a - // misbehaving relay echoing the same newest events — would otherwise pin the cursor and the - // on-screen sentinel would re-request the same window forever. Treat that as the bottom. + // misbehaving relay echoing the same newest events — would otherwise pin the cursor and a + // demand-driven loader would re-request the same window forever. Treat that as the bottom. val prev = c.reachedUntil if (prev == null || c.pageOldest < prev) { c.reachedUntil = c.pageOldest From 1751f41a68edcaa2d430a016281861e616406cf1 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 5 Jun 2026 19:22:45 -0400 Subject: [PATCH 093/103] docs(dm): keep the rest of the quartz paging package module-neutral MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same principle as RelayLoadingCursors: BackwardRelayPager, PerRelayLoadTracker, WindowLoadTracker and RelayPagingProgress are reusable quartz classes, so their docs shouldn't lean on Amethyst's Chatroom / ChatroomList / feeds / loading card / on-screen markers / sentinels / "decrypted into rooms" / invalidateFilters(). Reworded to generic library terms ("the caller", "the bound scope", "a demand-driven loader", "a per-relay progress display", "the owning object"). Comment-only. (The DMPagination log tag stays — it's an established log key, not doc prose.) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/paging/RelayPagingProgress.kt | 6 +++--- .../relay/client/paging/BackwardRelayPager.kt | 21 +++++++++---------- .../client/paging/PerRelayLoadTracker.kt | 13 ++++++------ .../relay/client/paging/WindowLoadTracker.kt | 8 +++---- 4 files changed, 23 insertions(+), 25 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayPagingProgress.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayPagingProgress.kt index 584a0eeeec..125a3da2ba 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayPagingProgress.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayPagingProgress.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.client.paging -/** How far back one relay has paged a DM history, for the per-relay progress markers. */ +/** How far back one relay has paged through its history, for a per-relay progress display. */ data class RelayPagingProgress( - // The oldest createdAt this relay has loaded down to (its `until` cursor). The marker sits here and - // slides down (older) as the relay pages further back. + // The oldest createdAt this relay has loaded down to (its `until` cursor). This is the "loaded back + // to" point; it slides down (older) as the relay pages further back. val reachedUntil: Long, // The relay answered an empty page: it has nothing older, it has reached the bottom of its window. val done: Boolean, 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 index 32656295cf..e1fee8ab48 100644 --- 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 @@ -35,11 +35,10 @@ import java.util.concurrent.ConcurrentHashMap * relay advancing independently the moment *it* settles, never paced by the slowest one. * * This is the **single-active orchestrator** around the paging state. The state itself (the per-relay - * [RelayLoadingCursors] cursors) does NOT live here — it lives on the owning domain object (a `Chatroom` - * for a conversation, a `ChatroomList` for the account-level feeds), so its lifetime matches the cached - * messages it describes. One orchestrator drives whichever scope is on screen; calling [bind] repoints - * it at that scope's cursors. This is safe because history relays only ever arm while their on-screen - * markers are visible, so a backgrounded scope produces no callbacks to mis-route. + * [RelayLoadingCursors]) does NOT live here — the caller holds it on whatever object owns the scope, so + * its lifetime matches that object. One orchestrator drives whichever scope is currently bound; calling + * [bind] repoints it at that scope's cursors. This is safe as long as the caller only advances the bound + * 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], @@ -48,10 +47,10 @@ import java.util.concurrent.ConcurrentHashMap * * 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). + * assembles its own `RelayBasedFilter`s (the kinds / authors / `#p` tags differ per query). * - **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. + * its filter after [advance] / [advanceAll] return true. * - **Which scope's cursors + which relays.** Supplied together by [bind]. * * ### Done vs stalled (read [exhausted] with care) @@ -115,19 +114,19 @@ class BackwardRelayPager( private val _relayProgress = MutableStateFlow>(emptyMap()) - /** Per-relay window position (reached / done / stalled) — the data on-screen reach markers render. */ + /** Per-relay window position (reached / done / stalled) — what a caller's per-relay progress UI renders. */ val relayProgress: StateFlow> = _relayProgress.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 sentinel). + // and does not drift forward on recompute (which would re-trigger an undelivered relay's loader). private fun floor(): Long { val c = cursors ?: return TimeUtils.now() - liveTailSeconds return c.floor ?: (TimeUtils.now() - liveTailSeconds).also { c.floor = it } } /** - * Repoints to a scope (call on subscribe / when the on-screen scope changes): its persistent - * [scopeCursors] (held on the owning model object), the [scope] for the silence watchdog, and the + * Repoints to a scope (call on subscribe / when the active scope changes): its persistent + * [scopeCursors] (held on the caller's scope object), the [scope] for the silence watchdog, and the * [relaysForScope] lookup. Resets the transient orchestration (in-flight, stalled) and recomputes * the display flows from the bound cursors — so a previously-paged scope restores its progress * instead of restarting. diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/PerRelayLoadTracker.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/PerRelayLoadTracker.kt index 12333caf38..27f431c912 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/PerRelayLoadTracker.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/PerRelayLoadTracker.kt @@ -34,11 +34,10 @@ import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap /** - * Tracks which relays currently have a demand-driven history page **in flight**, so the loading card - * can show a spinner while any relay is fetching and clear it the moment they've all answered (or - * parked). Unlike [WindowLoadTracker] this has no notion of a "window" or "round" — relays are advanced - * one page at a time, independently, by their on-screen markers, so completion is simply "nothing in - * flight." + * Tracks which relays currently have a demand-driven history page **in flight**, so a caller can show a + * spinner while any relay is fetching and clear it the moment they've all answered (or parked). Unlike + * [WindowLoadTracker] this has no notion of a "window" or "round" — relays are advanced one page at a + * time, independently, on demand by the caller, so completion is simply "nothing in flight." * * A single backstop covers a relay that accepts a REQ and then goes silent (auth-walled / dead): if * nothing has been heard from ANY in-flight relay for [silenceMs], the still-pending relays are dropped @@ -96,7 +95,7 @@ class PerRelayLoadTracker( /** * A relay answered (EOSE / CLOSED / cannot-connect). Drops it from in-flight. When the last one * settles, the spinner is dropped after a short linger rather than immediately, so a relay paging - * page-after-page (each page settles then the marker fires the next) keeps a steady spinner instead + * page-after-page (each page settles, then the caller advances the next) keeps a steady spinner instead * of flickering it off for the few ms between pages. The linger is cancelled the moment a new page * starts ([onAdvance]). */ @@ -122,7 +121,7 @@ class PerRelayLoadTracker( } } - /** Drops everything (e.g. account/conversation switched). */ + /** Drops everything (e.g. the bound scope switched). */ @Synchronized fun reset() { inFlight.clear() diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/WindowLoadTracker.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/WindowLoadTracker.kt index c5d99df778..56493e6854 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/WindowLoadTracker.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/WindowLoadTracker.kt @@ -40,15 +40,15 @@ import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds /** - * Tracks when one relay-subscription "window" has finished loading, so callers (the rooms-screen - * auto-fill loop) can wait for the WHOLE response instead of declaring victory on the first EOSE. + * Tracks when one relay-subscription "window" has finished loading, so a caller (e.g. an auto-fill / + * pagination loop) can wait for the WHOLE response instead of declaring victory on the first EOSE. * * A subscription fans a single REQ out to several relays. The first EOSE is a misleading "done" * signal: a fast but near-empty relay can EOSE in milliseconds while the relay that actually holds * the data is still connecting, stuck in an auth handshake, or busy streaming thousands of stored * events. An auto-fill loop driven by the first EOSE — or by a fixed wall-clock timeout — would - * widen the window again mid-stream, before the events were even decrypted into rooms, re-issuing - * an ever-wider REQ that re-downloads the whole history over and over. + * widen the window again mid-stream, before the events were even processed, re-issuing an ever-wider + * REQ that re-downloads the whole history over and over. * * Completion is therefore **per-relay terminal-state** based, not wall-clock based. A relay is * *settled* once it answers with a terminal signal — an EOSE (stored backfill done), a CLOSED (it From 26c0ae7f69db85282044d5bac2960231a0dff0ed Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 5 Jun 2026 19:52:58 -0400 Subject: [PATCH 094/103] refactor(dm): move the paging orchestrators from quartz to commons/relayClient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per commons/ARCHITECTURE.md, quartz is protocol/NIPs/crypto/relay framing while commons owns the relay-subscription client and StateFlow state holders. The paging *orchestrators* are exactly that — StateFlow-backed, subscription-loading state — so they belong in commons, not quartz: - BackwardRelayPager, PerRelayLoadTracker, WindowLoadTracker (+ trackingListener) -> commons/relayClient/paging (jvmAndroid source set, same as before). - BackwardRelayPagerTest -> commons jvmTest. The pure protocol-paging primitives stay in quartz commonMain: - RelayLoadingCursors (the until+limit cursor mechanics) and RelayPagingProgress. They had no upward deps, so the move is downhill (commons -> quartz): the orchestrators now import RelayLoadingCursors / RelayPagingProgress from quartz. Consumers (the six DM managers/assemblers + WindowLoadTrackerIdleTest) repoint their imports to the commons package. The quartz geode wire test keeps testing the relay contract; its lone BackwardRelayPager KDoc link is demoted to a backtick (no longer reachable from quartz). No behaviour change. DM suite green (26/26); quartz + commons compile on iOS. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt | 4 ++-- .../nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt | 2 +- .../privateDM/datasource/ChatroomNip04HistorySubAssembler.kt | 2 +- .../chats/privateDM/datasource/ChatroomNip04SubAssembler.kt | 4 ++-- .../rooms/datasource/ChatroomListNip04HistorySubAssembler.kt | 2 +- .../chats/rooms/datasource/ChatroomListNip04SubAssembler.kt | 4 ++-- .../relayClient/eoseManagers/WindowLoadTrackerIdleTest.kt | 2 +- .../commons/relayClient}/paging/BackwardRelayPager.kt | 4 +++- .../commons/relayClient}/paging/PerRelayLoadTracker.kt | 2 +- .../amethyst/commons/relayClient}/paging/WindowLoadTracker.kt | 2 +- .../commons/relayClient}/paging/BackwardRelayPagerTest.kt | 3 ++- .../relay/client/paging/UntilLimitPagingRelayTest.kt | 2 +- 12 files changed, 18 insertions(+), 15 deletions(-) rename {quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client => commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient}/paging/BackwardRelayPager.kt (98%) rename {quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client => commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient}/paging/PerRelayLoadTracker.kt (99%) rename {quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client => commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient}/paging/WindowLoadTracker.kt (99%) rename {quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client => commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient}/paging/BackwardRelayPagerTest.kt (98%) 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 a211e94111..5a9633e210 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 @@ -21,14 +21,14 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey +import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker +import com.vitorpamplona.amethyst.commons.relayClient.paging.trackingListener 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.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 ea32227c49..69ae9de98a 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 @@ -21,13 +21,13 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey +import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager 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.BackwardRelayPager 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 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 09fe595fb9..484621e230 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 @@ -20,12 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource +import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager 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.BackwardRelayPager 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 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 dad1896efa..f8b233f7cd 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 @@ -20,13 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource +import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker +import com.vitorpamplona.amethyst.commons.relayClient.paging.trackingListener import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager 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 7c62a8d2ad..88b8af03c4 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 @@ -20,13 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource +import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager 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.BackwardRelayPager 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 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 b0aead3399..09e2910591 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 @@ -20,14 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource +import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker +import com.vitorpamplona.amethyst.commons.relayClient.paging.trackingListener 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.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/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTrackerIdleTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTrackerIdleTest.kt index 7179fed63c..d78a1d0d6c 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTrackerIdleTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/WindowLoadTrackerIdleTest.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.eoseManagers -import com.vitorpamplona.quartz.nip01Core.relay.client.paging.WindowLoadTracker +import com.vitorpamplona.amethyst.commons.relayClient.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/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/BackwardRelayPager.kt similarity index 98% rename from quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/BackwardRelayPager.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/BackwardRelayPager.kt index e1fee8ab48..40806c2c7f 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/BackwardRelayPager.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/BackwardRelayPager.kt @@ -18,8 +18,10 @@ * 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 +package com.vitorpamplona.amethyst.commons.relayClient.paging +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/PerRelayLoadTracker.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/PerRelayLoadTracker.kt similarity index 99% rename from quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/PerRelayLoadTracker.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/PerRelayLoadTracker.kt index 27f431c912..8203f3bca3 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/PerRelayLoadTracker.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/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.quartz.nip01Core.relay.client.paging +package com.vitorpamplona.amethyst.commons.relayClient.paging import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.Log diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/WindowLoadTracker.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/WindowLoadTracker.kt similarity index 99% rename from quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/WindowLoadTracker.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/WindowLoadTracker.kt index 56493e6854..345fe58f18 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/WindowLoadTracker.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/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.quartz.nip01Core.relay.client.paging +package com.vitorpamplona.amethyst.commons.relayClient.paging import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/BackwardRelayPagerTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/BackwardRelayPagerTest.kt similarity index 98% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/BackwardRelayPagerTest.kt rename to commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/BackwardRelayPagerTest.kt index 74e0bb4428..a4f0f72df0 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/BackwardRelayPagerTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/BackwardRelayPagerTest.kt @@ -18,8 +18,9 @@ * 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 +package com.vitorpamplona.amethyst.commons.relayClient.paging +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPagingRelayTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPagingRelayTest.kt index 676872671d..1c1a43f1e1 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPagingRelayTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/UntilLimitPagingRelayTest.kt @@ -32,7 +32,7 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue /** - * Pins down the relay-side contract the whole [RelayLoadingCursors] / [BackwardRelayPager] design rests on, + * Pins down the relay-side contract the whole [RelayLoadingCursors] / `BackwardRelayPager` design rests on, * against the in-process relay: a backward `until`+`limit` walk returns each event **exactly once** * (no re-download), in **newest-first** capped pages, and an **empty page + EOSE** is the gap-proof * stop. If a relay ever stopped honouring this (e.g. oldest-first, or ignoring `until`), these break — From 0f915d6d68e56804f3c249469a863accbea9eaee Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 5 Jun 2026 20:53:41 -0400 Subject: [PATCH 095/103] =?UTF-8?q?docs(dm):=20reflect=20the=20quartz?= =?UTF-8?q?=E2=86=92commons=20split=20in=20the=20DM=20design=20doc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The design doc still described the pre-keyless engine ("BackwardRelayPager... owns the cursors... in quartz"). Update it to current reality: - the engine paragraph: keyless single-active orchestrator that does NOT hold the cursors (RelayLoadingCursors live on the Chatroom / ChatroomList), binds per scope. - the component map: split into "Paging primitives (quartz commonMain)" — RelayLoadingCursors, RelayPagingProgress — and "Paging orchestrators (commons relayClient/paging, jvmAndroid)" — BackwardRelayPager, PerRelayLoadTracker, WindowLoadTracker. Fixed stale test notes (the deleted silence test; BackwardRelayPagerTest now in commons jvmTest; diagnostics are amethyst-side). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-06-01-dm-live-tail-and-history-slices.md | 51 ++++++++++++------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md b/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md index aff6779402..b93c13e2ec 100644 --- a/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md +++ b/amethyst/plans/2026-06-01-dm-live-tail-and-history-slices.md @@ -110,14 +110,17 @@ to load, just not advancing) keeps it false. > All three history managers (`AccountGiftWrapsHistoryEoseManager`, > `ChatroomNip04HistorySubAssembler`, `ChatroomListNip04HistorySubAssembler`) > were structurally the same per-relay loader, so that bookkeeping is now a -> single reusable engine — **`BackwardRelayPager`** (in quartz, -> `nip01Core/relay/client/paging/`). It owns the cursors, in-flight + silence -> tracking, stalled set, pinned floor, and the display flows; each manager -> supplies only its REQ-filter builder, a `relaysFor(key)` lookup, and the -> subscription wiring (it forwards relay callbacks via -> `onEvent`/`onEose`/`onClosed`/`onCannotConnect` and re-issues filters after -> `advance`/`advanceAll`). The earlier round-model history (and the rooms-list -> "stall-gate") was fully removed — see Design evolution. +> single reusable engine — **`BackwardRelayPager`** (keyless, single-active). It +> does **not** hold the cursors: the per-relay `RelayLoadingCursors` live on the +> scope's own domain object (a `Chatroom` per conversation, a `ChatroomList` per +> account), so they share the cached messages' lifetime and survive an account +> switch. The orchestrator owns only the transient bits — in-flight + silence +> tracking, the stalled set, and the display flows — and +> `bind(cursors, scope, relaysFor)`s to whichever scope is active. Each manager +> supplies its REQ-filter builder, a `relaysFor` lookup, and the subscription +> wiring (forwards relay callbacks via `onEvent`/`onEose`/`onClosed`/`onCannotConnect`, +> re-issues filters after `advance`/`advanceAll`). The earlier round-model history +> (and the rooms-list "stall-gate") was fully removed — see Design evolution. ### What drives `advance()`: on-screen markers, off viewport visibility @@ -227,18 +230,28 @@ pagination, but it lived here because unreachable relays were part of the same ## Component map (vs `origin/main`) -**Reusable paging toolkit (quartz, `nip01Core/relay/client/paging/`)** — moved -out of amethyst so desktop / CLI / any feed can reuse it; in the `jvmAndroid` -source set (uses `java.util.concurrent`), visible to amethyst + desktop + quartz's -`jvmAndroidTest` (geode in-process relay). -- `RelayLoadingCursors.kt` — per-relay `until`+`limit` cursor. *(+ `RelayLoadingCursorsTest` in amethyst)* -- `PerRelayLoadTracker.kt` — per-relay in-flight tracker + silence watchdog. -- `WindowLoadTracker.kt` — round/barrier completion tracker (live tail). *(+ silence test in amethyst)* +**Paging primitives (quartz, `nip01Core/relay/client/paging/`, `commonMain`)** — +the pure, protocol-level paging *state*; iOS-clean, reusable by any KMP target. +- `RelayLoadingCursors.kt` — per-relay `until`+`limit` cursor state + pinned floor; + held on the scope's domain object. *(+ `RelayLoadingCursorsTest` in amethyst; the + `until`+`limit` wire contract is covered by `UntilLimitPagingRelayTest` against + the quartz `jvmAndroidTest` geode relay)* - `RelayPagingProgress.kt` — `(reachedUntil, done, stalled)` per relay. -- `BackwardRelayPager.kt` — the generic per-relay backward-pagination engine the - three history managers delegate to. *(+ `BackwardRelayPagerTest` state-machine - + `UntilLimitPagingRelayTest` geode wire-contract test)* -- `DmRelayLog.kt`, diagnostics/`DmRelayDiagnosticsLogger.kt` — `DMPagination` logs. + +**Paging orchestrators (commons, `relayClient/paging/`, `jvmAndroid`)** — the +StateFlow-backed, subscription-loading state holders. Moved out of amethyst **and** +out of quartz (per `commons/ARCHITECTURE.md`: the relay-subscription client + +`StateFlow` state holders live in commons) so desktop / CLI / any feed can reuse +them; `jvmAndroid` (uses `java.util.concurrent` + `@Synchronized`) → Android + +Desktop, not iOS. +- `BackwardRelayPager.kt` — the keyless single-active orchestrator the three + history managers `bind` to. *(+ `BackwardRelayPagerTest` state-machine in commons + `jvmTest`)* +- `PerRelayLoadTracker.kt` — per-relay in-flight tracker + silence watchdog. +- `WindowLoadTracker.kt` — round/barrier completion tracker (live tail). *(+ `WindowLoadTrackerIdleTest` in amethyst)* + +**Diagnostics (amethyst)** — `service/relayClient/eoseManagers/DmRelayLog.kt`, +`service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt` — the `DMPagination` logs. **Managers / assemblers** - `AccountGiftWrapsEoseManager.kt` (live tail) + `AccountGiftWrapsHistoryEoseManager.kt` (new, history). From fd8dd80172766970d2d19204311a5589436a87b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 6 Jun 2026 23:00:03 +0000 Subject: [PATCH 096/103] fix(dm): show parked relays on the paused history card + make the sync marker tappable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reported chatroom-screen issues. Bug 1 — the NIP-04 card's `⋯` paused state showed a bare protocol tag with no relay count, even though tapping it listed 5 relays. historySubtitle only counted relays that were *in-flight* (relayCount) or *stalled*; a relay that returned a page and parked (the paused state) is neither, so it fell through to the bare tag. The card now derives a "reaching" count from relayProgress (not done && not stalled) — covering both fetching and parked relays — so the subtitle reads "N relays · back to ", matching the popup. Bug 2 — the in-stream "Relay sync: ✓ 5" divider was a non-interactive dead end and didn't say which protocol it meant (it mixes NIP-17 + NIP-04). Give each RelayReachCursor a protocol tag, make RelayReachMarkers tap-through (optional onShowDetail callback), and add RelayReachDetailDialog listing the relays at that point in the stream with protocol · state glyph · reach-back date. The conversation view hoists the dialog state and wires the tap; the marker stays a passive divider wherever onShowDetail isn't supplied (rooms list unchanged). Compiles: commons (JVM) + amethyst. iOS not buildable in this sandbox (toolchain download blocked) but avoids the destructuring-in-composable pattern the file guards against. --- .../loggedIn/chats/privateDM/ChatroomView.kt | 14 +++- .../commons/ui/feeds/DmHistoryLoadingCard.kt | 82 ++++++++++++++++++- .../commons/ui/feeds/RelayReachMarker.kt | 24 ++++-- 3 files changed, 106 insertions(+), 14 deletions(-) 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 48c7947194..6a5354ce73 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 @@ -29,6 +29,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -43,6 +44,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachDetailDialog import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachSentinels import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState @@ -237,12 +239,12 @@ fun ChatroomViewUI( buildList { if (!giftWrapsExhausted) { giftWrapsProgress.forEach { (relay, p) -> - add(RelayReachCursor("17:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p)) { giftWrapsHistory.advance(relay) }) + add(RelayReachCursor("17:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p), "NIP-17") { giftWrapsHistory.advance(relay) }) } } if (!nip04Exhausted) { nip04Progress.forEach { (relay, p) -> - add(RelayReachCursor("04:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p)) { nip04History.advance(relay) }) + add(RelayReachCursor("04:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p), "NIP-04") { nip04History.advance(relay) }) } } } @@ -250,6 +252,12 @@ fun ChatroomViewUI( val nip17Name = stringResource(R.string.chats_history_proto_nip17) val nip04Name = stringResource(R.string.chats_history_proto_nip04) + // The relays behind a tapped in-stream "Relay sync" marker; non-null shows the detail popup. + var syncDetail by remember { mutableStateOf?>(null) } + syncDetail?.let { detail -> + RelayReachDetailDialog(detail, ::formatHistoryReachDate) { syncDetail = null } + } + BootstrapHistoryWhenEmpty(feedViewModel.feedState, accountViewModel) Column(Modifier.fillMaxHeight()) { @@ -284,7 +292,7 @@ fun ChatroomViewUI( if (limits.isEmpty()) { null } else { - { newer, older -> RelayReachMarkers(limits, newer, older) } + { newer, older -> RelayReachMarkers(limits, newer, older) { syncDetail = it } } }, // The hoisted load driver that pulls each relay's next page while its marker is on screen, // off viewport visibility (see RelayReachSentinels) so feed reorders don't re-page. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DmHistoryLoadingCard.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DmHistoryLoadingCard.kt index 8388088669..d9992dd167 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DmHistoryLoadingCard.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DmHistoryLoadingCard.kt @@ -65,6 +65,7 @@ import com.vitorpamplona.amethyst.commons.resources.chats_history_incomplete_sub import com.vitorpamplona.amethyst.commons.resources.chats_history_older import com.vitorpamplona.amethyst.commons.resources.chats_history_reached_start import com.vitorpamplona.amethyst.commons.resources.chats_history_relay_back +import com.vitorpamplona.amethyst.commons.resources.chats_history_relay_sync import com.vitorpamplona.amethyst.commons.resources.chats_history_relays import com.vitorpamplona.amethyst.commons.resources.chats_history_relays_title import com.vitorpamplona.amethyst.commons.resources.chats_history_subtitle @@ -117,6 +118,11 @@ fun DmHistoryLoadingCard( val caughtUp = exhausted && stalledCount <= 0 val incomplete = exhausted && stalledCount > 0 + // Relays that still have older history to pull: not done and not stalled. Unlike [relayCount] (only + // those fetching a page *right now*), this also counts relays that returned a page and PARKED — the + // `⋯` paused state — so the subtitle doesn't read as a bare tag while the tap-popup lists N relays. + val reaching = remember(relayProgress) { relayProgress.values.count { !it.done && !it.stalled } } + // Only the genuine caught-up state lingers then collapses; an incomplete window stays so it can be acted on. var collapsed by remember { mutableStateOf(false) } LaunchedEffect(caughtUp) { @@ -215,7 +221,7 @@ fun DmHistoryLoadingCard( when (state) { HistoryPhase.CaughtUp -> stringResource(Res.string.chats_history_reached_start, protocolName) HistoryPhase.Incomplete -> incompleteSubtitle(stalledCount) - HistoryPhase.Loading -> historySubtitle(protocolTag, relayCount, stalledCount, reachedBack, formatReachDate) + HistoryPhase.Loading -> historySubtitle(protocolTag, reaching, stalledCount, reachedBack, formatReachDate) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -249,9 +255,9 @@ fun historySubtitle( formatReachDate: (epochSeconds: Long) -> String, ): String { val backLabel = remember(reachedBack) { reachedBack?.let(formatReachDate) } - // Middle segment: the relays actively fetching ("N relays"), or — when none are in flight but some - // can't be reached — what we're waiting on ("waiting on N relays"). With neither, just the tag, since - // a paged-out-but-parked protocol isn't waiting on anything (it resumes on scroll). + // Middle segment: relays still working on it — fetching a page OR parked with more to pull ("N relays") + // — or, when none are reaching but some can't be reached, what we're waiting on ("waiting on N relays"). + // With neither (every relay done), just the tag. [relayCount] here is the reaching count, not in-flight. val middle = when { relayCount > 0 -> pluralStringResource(Res.plurals.chats_history_relays, relayCount, relayCount) @@ -347,3 +353,71 @@ private fun relayShortName(relay: NormalizedRelayUrl): String = .substringAfter("://") .trimEnd('/') .substringBefore('/') + +/** + * Popup shown when an in-stream "Relay sync" marker is tapped: the relays whose window sits at that point + * in the stream, each with its protocol tag, state glyph (✓ done · … stalled · ↓ reaching) and how far + * back it has paged — so the otherwise-terse `Relay sync: ✓ N` divider stops being a dead end and its + * meaning is explorable. Deepest-reaching first. + */ +@Composable +fun RelayReachDetailDialog( + cursors: List, + formatReachDate: (epochSeconds: Long) -> String, + onDismiss: () -> Unit, +) { + val rows = remember(cursors) { cursors.sortedBy { it.reachedUntil } } + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(Res.string.action_dismiss)) } + }, + title = { Text(stringResource(Res.string.chats_history_relay_sync)) }, + text = { + Column( + Modifier + .heightIn(max = 360.dp) + .verticalScroll(rememberScrollState()), + ) { + rows.forEach { c -> + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = reachGlyph(c.state), + color = reachColor(c.state), + fontWeight = FontWeight.Bold, + modifier = Modifier.width(22.dp), + ) + if (c.protocol.isNotEmpty()) { + Text( + text = c.protocol, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + Spacer(Modifier.width(6.dp)) + } + Text( + text = c.name, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(Res.string.chats_history_relay_back, formatReachDate(c.reachedUntil)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } + } + }, + ) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt index c955c154b5..a9f565b7c8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.ui.feeds +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding @@ -97,6 +98,9 @@ data class RelayReachCursor( val name: String, val reachedUntil: Long, val state: RelayReachState, + // Short protocol tag (e.g. "NIP-17" / "NIP-04") shown in the tap-through detail popup, so a marker + // that mixes protocols in one gap isn't ambiguous. Empty when the feed has a single (implicit) kind. + val protocol: String = "", val advance: () -> Unit, ) @@ -173,6 +177,9 @@ fun RelayReachMarkers( limits: List, newerCreatedAt: Long?, olderCreatedAt: Long?, + // Tapped with the relays in this gap, so the caller can open a detail popup (which relays, how far + // back, per protocol). Null leaves the marker a passive, non-interactive divider. + onShowDetail: ((List) -> Unit)? = null, ) { val here = remember(limits, newerCreatedAt, olderCreatedAt) { @@ -180,7 +187,7 @@ fun RelayReachMarkers( } if (here.isEmpty()) return - RelayReachMarker(here.map { RelayReach(it.name, it.state) }) + RelayReachMarker(here.map { RelayReach(it.name, it.state) }, onClick = onShowDetail?.let { cb -> { cb(here) } }) } /** @@ -196,13 +203,16 @@ fun RelayReachMarkers( * Reads e.g. "Relay sync: ✓ 8 · ↓ 1" or "Relay sync: ↓ nostr.wine". */ @Composable -private fun RelayReachMarker(entries: List) { +private fun RelayReachMarker( + entries: List, + onClick: (() -> Unit)? = null, +) { if (entries.isEmpty()) return Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.padding(5.dp), + modifier = Modifier.padding(5.dp).then(if (onClick != null) Modifier.clickable { onClick() } else Modifier), ) { HorizontalDivider(modifier = Modifier.weight(1f), thickness = DividerThickness) Text( @@ -227,8 +237,8 @@ private fun RelayReachMarker(entries: List) { Text("·", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 11.sp) } Text( - text = glyph(state) + " " + if (list.size == 1) list.first().name else list.size.toString(), - color = color(state), + text = reachGlyph(state) + " " + if (list.size == 1) list.first().name else list.size.toString(), + color = reachColor(state), fontSize = 11.sp, fontWeight = FontWeight.Medium, maxLines = 1, @@ -239,7 +249,7 @@ private fun RelayReachMarker(entries: List) { } } -private fun glyph(state: RelayReachState) = +internal fun reachGlyph(state: RelayReachState) = when (state) { RelayReachState.REACHING -> "↓" RelayReachState.STALLED -> "…" @@ -247,7 +257,7 @@ private fun glyph(state: RelayReachState) = } @Composable -private fun color(state: RelayReachState): Color = +internal fun reachColor(state: RelayReachState): Color = when (state) { RelayReachState.REACHING -> MaterialTheme.colorScheme.onSurfaceVariant RelayReachState.STALLED -> MaterialTheme.colorScheme.error From 6aaed71eea2849afc181da76629d8867d7165776 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 14:48:38 +0000 Subject: [PATCH 097/103] fix(dm): widen the per-relay silence window so a slow Tor connect isn't "stalled" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PerRelayLoadTracker silenced (→ stalled) any in-flight relay after 15 s of total cohort dead air. Over Tor, REQs queue on a not-yet-connected socket and circuits routinely take 20–80 s to come up, so relays — including the user's primary — were being flagged "stalled" before they ever connected (visible in the Messages trace: vitor's history REQ went out at +0 s, was silenced at +15 s, and only actually hit the wire at +77 s). Bump the window to 60 s. lastActivityMs is global, so any relay delivering keeps it fresh for the whole cohort — this only fires on total dead air, and a genuinely dead relay still settles via CLOSED / cannot-connect, not this watchdog. --- .../commons/relayClient/paging/PerRelayLoadTracker.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/PerRelayLoadTracker.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/PerRelayLoadTracker.kt index 8203f3bca3..732b6d1917 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/PerRelayLoadTracker.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayClient/paging/PerRelayLoadTracker.kt @@ -47,7 +47,12 @@ import java.util.concurrent.ConcurrentHashMap */ class PerRelayLoadTracker( private val name: String, - private val silenceMs: Long = 15_000L, + // How long the whole in-flight cohort can go without ANY signal before the still-pending relays are + // dropped + reported stalled. lastActivityMs is global, so any relay delivering keeps it fresh for all + // — this only fires on total dead air. Set well above mobile-over-Tor connect times (which run tens of + // seconds, occasionally past a minute): at 15 s a relay was being flagged "stalled" before its circuit + // even finished connecting. A genuinely dead relay still settles via CLOSED/cannot-connect, not this. + private val silenceMs: Long = 60_000L, private val onSilenced: (Set) -> Unit = {}, ) { private val _loading = MutableStateFlow(false) From e9ff46ac4646b292dab321eb1d361f3aa29dafa8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 14:48:40 +0000 Subject: [PATCH 098/103] fix(dm): gate forwarded callbacks on the bound scope (single-active orchestrator) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-active BackwardRelayPager applies forwarded relay callbacks to whichever scope is currently bound. Its doc already states this is "safe as long as the caller only advances the bound scope", but a subscription for a *just-backgrounded* scope (conversation navigation overlap, account switch, a second pane) can still deliver a late onEvent/onEose/onClosed — which would move the newly-bound scope's cursors instead. Now that those cursors persist on the Chatroom/ChatroomList model, that corruption would stick. Add BackwardRelayPager.isBoundTo(cursors) (cursor identity == scope identity) and gate each manager's forwarded callbacks on it, so a stray callback from a non-bound scope is dropped, not mis-applied. The framework's own newEose bookkeeping still runs. No-op on the happy single-scope path. --- .../AccountGiftWrapsHistoryEoseManager.kt | 17 +++++++++++------ .../ChatroomNip04HistorySubAssembler.kt | 17 +++++++++++------ .../ChatroomListNip04HistorySubAssembler.kt | 17 +++++++++++------ .../relayClient/paging/BackwardRelayPager.kt | 9 +++++++++ 4 files changed, 42 insertions(+), 18 deletions(-) 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 69ae9de98a..8668f8a920 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 @@ -111,22 +111,26 @@ class AccountGiftWrapsHistoryEoseManager( return requestNewSubscription(historyListener(key)) } - private fun historyListener(key: AccountQueryState): SubscriptionListener = - object : SubscriptionListener { + private fun historyListener(key: AccountQueryState): SubscriptionListener { + // A just-backgrounded account's subscription can still deliver after the orchestrator rebinds to + // another account; gate the pager (single-active) on whether it's still bound to THIS account's + // cursors so a late callback can't move another account's cursors. newEose runs regardless. + val myCursors = key.account.chatroomList.giftWrapHistory + return object : SubscriptionListener { override fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, forFilters: List?, ) { - pager.onEvent(relay, event.createdAt) + if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt) } override fun onEose( relay: NormalizedRelayUrl, forFilters: List?, ) { - if (pager.onEose(relay)) { + if (pager.isBoundTo(myCursors) && pager.onEose(relay)) { Log.d(TAG) { "[giftwrap.history] ${relay.url} reached the bottom (done)" } } // No auto-advance: the relay parks here until its marker asks for the next page. @@ -138,7 +142,7 @@ class AccountGiftWrapsHistoryEoseManager( relay: NormalizedRelayUrl, forFilters: List?, ) { - pager.onClosed(relay, message) + if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message) } override fun onCannotConnect( @@ -146,9 +150,10 @@ class AccountGiftWrapsHistoryEoseManager( message: String, forFilters: List?, ) { - pager.onCannotConnect(relay, message) + if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message) } } + } companion object { private const val TAG = "DMPagination" 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 484621e230..e087790fcf 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 @@ -114,22 +114,26 @@ class ChatroomNip04HistorySubAssembler( return requestNewSubscription(historyListener(key)) } - private fun historyListener(key: ChatroomQueryState): SubscriptionListener = - object : SubscriptionListener { + private fun historyListener(key: ChatroomQueryState): SubscriptionListener { + // A just-backgrounded room's subscription can still deliver after the orchestrator rebinds to + // another room; gate the pager (single-active) on whether it's still bound to THIS room's cursors + // so a late callback can't move another room's cursors. newEose (framework bookkeeping) runs anyway. + val myCursors = cursorsFor(key) + return object : SubscriptionListener { override fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, forFilters: List?, ) { - pager.onEvent(relay, event.createdAt) + if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt) } override fun onEose( relay: NormalizedRelayUrl, forFilters: List?, ) { - if (pager.onEose(relay)) { + if (pager.isBoundTo(myCursors) && pager.onEose(relay)) { Log.d("DMPagination") { "[convo.nip04.history] ${relay.url} reached the bottom (done)" } } newEose(key, relay, TimeUtils.now(), forFilters) @@ -140,7 +144,7 @@ class ChatroomNip04HistorySubAssembler( relay: NormalizedRelayUrl, forFilters: List?, ) { - pager.onClosed(relay, message) + if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message) } override fun onCannotConnect( @@ -148,7 +152,8 @@ class ChatroomNip04HistorySubAssembler( message: String, forFilters: List?, ) { - pager.onCannotConnect(relay, message) + if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message) } } + } } 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 88b8af03c4..ffd84d0290 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 @@ -104,22 +104,26 @@ class ChatroomListNip04HistorySubAssembler( return requestNewSubscription(historyListener(key)) } - private fun historyListener(key: ChatroomListState): SubscriptionListener = - object : SubscriptionListener { + private fun historyListener(key: ChatroomListState): SubscriptionListener { + // A just-backgrounded account's subscription can still deliver after the orchestrator rebinds to + // another account; gate the pager (single-active) on whether it's still bound to THIS account's + // cursors so a late callback can't move another account's cursors. newEose runs regardless. + val myCursors = key.account.chatroomList.nip04History + return object : SubscriptionListener { override fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, forFilters: List?, ) { - pager.onEvent(relay, event.createdAt) + if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt) } override fun onEose( relay: NormalizedRelayUrl, forFilters: List?, ) { - if (pager.onEose(relay)) { + if (pager.isBoundTo(myCursors) && pager.onEose(relay)) { Log.d("DMPagination") { "[rooms.nip04.history] ${relay.url} reached the bottom (done)" } } newEose(key, relay, TimeUtils.now(), forFilters) @@ -130,7 +134,7 @@ class ChatroomListNip04HistorySubAssembler( relay: NormalizedRelayUrl, forFilters: List?, ) { - pager.onClosed(relay, message) + if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message) } override fun onCannotConnect( @@ -138,7 +142,8 @@ class ChatroomListNip04HistorySubAssembler( message: String, forFilters: List?, ) { - pager.onCannotConnect(relay, message) + if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message) } } + } } 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 40806c2c7f..b8b45ad107 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 @@ -147,6 +147,15 @@ class BackwardRelayPager( recomputeExhausted() } + /** + * Whether [c] is the currently-bound scope's cursor object. The orchestrator is single-active: it can + * only correctly process callbacks for the bound scope. A caller whose subscription may still be alive + * for a *just-backgrounded* scope (navigation overlap, a second pane) must gate its forwarded callbacks + * on this — otherwise a late EOSE from scope A would move scope B's cursors. The cursor object is the + * scope identity (one per `Chatroom`/`ChatroomList`), so reference identity is the check. + */ + fun isBoundTo(c: RelayLoadingCursors): Boolean = c === cursors + // --- Filter building support: the caller assembles the actual REQ from these. --- /** Relays of the active scope that have been advanced (armed) and aren't done — i.e. carry a REQ. */ From 96ff5316dc32410c38577c8eb5640180746c1b2c Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 8 Jun 2026 16:03:50 -0400 Subject: [PATCH 099/103] fix(dm): realign the per-relay download window when DMs are pruned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Memory pruning drops DM messages out of the cache but left the per-relay paging cursors untouched, so a relay still claimed to have delivered the dropped band (reachedUntil deep, or done) and the demand-driven loader never re-requested it — a silent hole until app restart. - Prune NIP-17 too: pruneMessagesToTheLatestOnly now reaps both NIP-04 (PrivateDmEvent) and NIP-17 (WrappedEvent rumors) on one merged top-N cut, so a conversation is cut at a single time point (no NIP-04-without -NIP-17 holes). NIP-17 is the actual memory-pressure driver. - HostStub carries the host's createdAt, so a decrypted rumor self- describes its outer gift-wrap time (the time the cursor pages by; the rumor's own time is the message time, not the wrap time). - RelayLoadingCursors.rewindTo() pulls a relay's reached cursor up past the pruned band, clears done, and un-arms it (demand-driven re-fetch); advance() now resumes from the rewound reached point instead of the floor. - LocalCache.pruneOldMessages accumulates the newest pruned created_at per relay (outer-wrap time for gift wraps, event time for NIP-04), filtered below each cursor's floor, then rewinds giftWrapHistory + rooms-list nip04History (account-wide) and the per-conversation nip04History. The gift-wrap window is account-global, so pruning one room rewinds the shared sweep; the interference is bounded (already-held wraps short- circuit in consumeRegularEvent, re-fetch is demand-gated). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../amethyst/model/LocalCache.kt | 46 ++++++++- .../eoseManagers/RelayLoadingCursorsTest.kt | 97 +++++++++++++++++++ .../commons/model/privateChats/Chatroom.kt | 7 +- .../client/paging/RelayLoadingCursors.kt | 54 ++++++++++- .../quartz/nip59Giftwrap/HostStub.kt | 11 +++ .../nip59Giftwrap/seals/SealedRumorEvent.kt | 2 +- .../nip59Giftwrap/wraps/GiftWrapEvent.kt | 2 +- 7 files changed, 208 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index b316174452..920631cf99 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -2645,20 +2645,58 @@ object LocalCache : ILocalCache, ICacheProvider { } chatroomList.forEach { userHex, room -> + // History floors are pinned per scope on first advance; null means that window never paged + // history, so its cursors hold no position to misalign and nothing needs rewinding. Only the + // bands strictly BELOW a floor are this window's responsibility — a pruned message newer than + // the floor is the always-on live tail's concern, and rewinding history for it would needlessly + // re-page (and, for a busy room straddling the floor, mis-set the boundary). Hence the per-floor + // filter when accumulating below. + val giftWrapFloor = room.giftWrapHistory.floor + val accountNip04Floor = room.nip04History.floor + room.rooms.map { key, chatroom -> val toBeRemoved = chatroom.pruneMessagesToTheLatestOnly() val childrenToBeRemoved = mutableListOf() - toBeRemoved.forEach { - childrenToBeRemoved.addAll(removeIfWrap(it)) - unlinkAndRemove(it) + // Newest pruned `created_at` per relay, in each window's cursor space, capped at < floor. + // Gift wraps page by the OUTER wrap time (from the rumor's host stub); NIP-04 by the event's + // own time, and a kind:4 belongs to BOTH the account (rooms-list) and per-conversation cursor. + val giftWrapPruned = HashMap() + val accountNip04Pruned = HashMap() + val roomNip04Pruned = HashMap() + // chatroom.nip04History is lazy — only touch (allocate) it when this room actually drops a + // kind:4 message, so rooms that never paged conversation history pay nothing. + val roomNip04Floor = if (toBeRemoved.any { it.event is PrivateDmEvent }) chatroom.nip04History.floor else null - childrenToBeRemoved.addAll(it.clearChildLinks()) + toBeRemoved.forEach { note -> + when (val ev = note.event) { + is WrappedEvent -> + if (giftWrapFloor != null) { + val outerUntil = ev.host?.createdAt ?: ev.createdAt + if (outerUntil < giftWrapFloor) note.relays.forEach { giftWrapPruned.merge(it, outerUntil, ::maxOf) } + } + is PrivateDmEvent -> { + val until = ev.createdAt + if (accountNip04Floor != null && until < accountNip04Floor) note.relays.forEach { accountNip04Pruned.merge(it, until, ::maxOf) } + if (roomNip04Floor != null && until < roomNip04Floor) note.relays.forEach { roomNip04Pruned.merge(it, until, ::maxOf) } + } + } + + childrenToBeRemoved.addAll(removeIfWrap(note)) + unlinkAndRemove(note) + + childrenToBeRemoved.addAll(note.clearChildLinks()) } unlinkAndRemove(childrenToBeRemoved) + // Realign the windows so a relay that already paged past (or `done` below) the dropped band + // re-requests it on the next demand-advance instead of skipping the hole. + if (giftWrapPruned.isNotEmpty()) room.giftWrapHistory.rewindTo(giftWrapPruned) + if (accountNip04Pruned.isNotEmpty()) room.nip04History.rewindTo(accountNip04Pruned) + if (roomNip04Pruned.isNotEmpty()) chatroom.nip04History.rewindTo(roomNip04Pruned) + if (toBeRemoved.size > 1) { println( "PRUNE: ${toBeRemoved.size} private messages from $userHex to ${key.users.joinToString()} removed. ${chatroom.messages.size} kept", diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/RelayLoadingCursorsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/RelayLoadingCursorsTest.kt index 7f5237e7dc..b297dabc9f 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/RelayLoadingCursorsTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/RelayLoadingCursorsTest.kt @@ -106,4 +106,101 @@ class RelayLoadingCursorsTest { val cursors = RelayLoadingCursors() assertEquals(null, cursors.deepestReached(emptyList(), start)) } + + // ── rewindTo: realign the window after the cache prunes messages out of it ── + + @Test + fun rewindReopensThePrunedBandAndResumesFromItOnNextAdvance() { + val cursors = RelayLoadingCursors() + cursors.floor = start + + // page deep: floor 1000 → reached 200 + cursors.advance(relayA, start) + cursors.onEvent(relayA, 900) + cursors.onEvent(relayA, 200) + cursors.onEose(relayA) + assertEquals(200L, cursors.reachedUntilFor(relayA, start)) + + // prune drops everything older than 700 (newest pruned = 700) + cursors.rewindTo(mapOf(relayA to 700L)) + + // reached pulled up to just above the pruned band, not done, and un-armed (demand-driven) + assertEquals(701L, cursors.reachedUntilFor(relayA, start)) + assertFalse(cursors.isDone(relayA)) + assertEquals(emptyList(), cursors.armedRelays(listOf(relayA))) + + // the next advance resumes at the boundary and re-requests the pruned band (until = 700), + // NOT from the floor (which would re-stream the still-held tail above 700) + assertTrue(cursors.advance(relayA, start)) + assertEquals(700L, cursors.requestedUntilFor(relayA)) + } + + @Test + fun rewindClearsDoneSoAnExhaustedRelayCanReFetch() { + val cursors = RelayLoadingCursors() + cursors.floor = start + + cursors.advance(relayA, start) + cursors.onEvent(relayA, 300) + cursors.onEose(relayA) // reached 300 + cursors.advance(relayA, start) + cursors.onEose(relayA) // empty page → done + assertTrue(cursors.isDone(relayA)) + + cursors.rewindTo(mapOf(relayA to 500L)) + + assertFalse("a pruned relay must be re-fetchable even after it reached the bottom", cursors.isDone(relayA)) + assertEquals(501L, cursors.reachedUntilFor(relayA, start)) + assertTrue(cursors.advance(relayA, start)) + assertEquals(500L, cursors.requestedUntilFor(relayA)) + } + + @Test + fun rewindNeverClimbsAboveTheFloor() { + val cursors = RelayLoadingCursors() + cursors.floor = start + + cursors.advance(relayA, start) + cursors.onEvent(relayA, 300) + cursors.onEose(relayA) // reached 300 + + // a boundary at/above the floor clamps to the floor (history lives strictly below it) + cursors.rewindTo(mapOf(relayA to start)) + assertEquals(start, cursors.reachedUntilFor(relayA, start)) + } + + @Test + fun rewindSkipsRelaysWithoutACursorOrShallowerThanThePrunedBand() { + val cursors = RelayLoadingCursors() + cursors.floor = start + + // A delivered to 200; B never paged + cursors.advance(relayA, start) + cursors.onEvent(relayA, 200) + cursors.onEose(relayA) + + // B has no cursor (never paged) → skipped, no entry minted; A's reach (200) is already shallower + // than a boundary of 150 (target 151), so it needs no rewind either. + cursors.rewindTo(mapOf(relayB to 500L, relayA to 150L)) + + // B: untouched (still at the floor, unarmed) + assertEquals(start, cursors.reachedUntilFor(relayB, start)) + assertEquals(emptyList(), cursors.armedRelays(listOf(relayB))) + // A: unchanged + assertEquals(200L, cursors.reachedUntilFor(relayA, start)) + } + + @Test + fun rewindIsANoOpWhenTheWindowNeverPagedHistory() { + val cursors = RelayLoadingCursors() + // floor is null (never advanced any history page) + cursors.advance(relayA, start) + cursors.onEvent(relayA, 200) + cursors.onEose(relayA) + + cursors.rewindTo(mapOf(relayA to 150L)) + + // unchanged: with no pinned floor there is no history window to realign + assertEquals(200L, cursors.reachedUntilFor(relayA, start)) + } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt index 995e5ae27c..13d604208d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip14Subject.subject +import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow @@ -145,7 +146,11 @@ class Chatroom : NotesGatherer { } else { // Old messages, keep the last one. sorted.take(1).toSet() - } + sorted.filter { it.flowSet?.isInUse() ?: false } + sorted.filter { it.event !is PrivateDmEvent } + } + sorted.filter { it.flowSet?.isInUse() ?: false } + sorted.filter { it.event !is PrivateDmEvent && it.event !is WrappedEvent } + // Both DM protocols are pruned by the recency rule above: NIP-04 (PrivateDmEvent) and NIP-17 + // (WrappedEvent rumors — ChatMessageEvent / file headers). Anything else that ever lands in a + // room is kept. The caller realigns the per-relay download window for the dropped messages so + // they can be paged again later (see LocalCache.pruneOldMessages + RelayLoadingCursors.rewindTo). val toRemove = messages.minus(toKeep) messages = toKeep diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayLoadingCursors.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayLoadingCursors.kt index 90ea6be155..cb720f48f9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayLoadingCursors.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayLoadingCursors.kt @@ -103,11 +103,18 @@ class RelayLoadingCursors { ): Boolean { val c = cursor(relay) if (c.done) return false + val reached = c.reachedUntil c.requestedUntil = - if (c.requestedUntil == null) { - start - } else { - (c.reachedUntil ?: start) - 1 + when { + // Resume just below the oldest event already delivered. Covers both normal page-to-page + // advance and a post-[rewindTo] resume (which un-arms the relay — requestedUntil back to + // null — but keeps the rewound reached point, so the next page picks up at the boundary + // instead of restarting at the floor and re-streaming the still-held tail). + reached != null -> reached - 1 + // Very first page for this relay (nothing delivered, nothing requested yet). + c.requestedUntil == null -> start + // Armed but still mid-page (no EOSE yet) — keep asking from the same top. + else -> start } c.pageCount = 0 c.pageOldest = Long.MAX_VALUE @@ -147,6 +154,45 @@ class RelayLoadingCursors { } } + /** + * Realigns the window after the cache prunes messages out of it: for each `relay → newestPrunedUntil` + * entry, rewinds that relay so it no longer claims to hold anything at or below [newestPrunedUntil] + * (the newest cursor-space `created_at` among the messages pruned from that relay — for gift wraps the + * **outer-wrap** time, recovered from the rumor's [host][com.vitorpamplona.quartz.nip59Giftwrap.HostStub]). + * + * Without this, a relay that already paged past the pruned band — or reached `done` — would never + * re-request the dropped messages: its [reachedUntil] still points below them, so the next [advance] + * starts even older and skips the hole entirely. + * + * The rewind pulls [reachedUntil] back up to just above [newestPrunedUntil] (so the next page's + * `until` re-includes it), clears [done] (there *is* older data to re-fetch again), and un-arms the + * relay (requested cursor back to null) so paging stays demand-driven — the dropped band comes back + * only when the on-screen marker advances the relay again, not eagerly on the next re-subscribe. + * + * Bounds: + * - A relay with no cursor yet (never paged) is skipped — there is no window position to misalign. + * - The rewind never moves [reachedUntil] above the pinned [floor] (history lives strictly below it; + * a pruned message newer than the floor is the live tail's concern, not this window's). + * - A relay whose reached point is already shallower than the pruned band needs no rewind. + */ + fun rewindTo(newestPrunedUntil: Map) { + val floorAt = floor ?: return + newestPrunedUntil.forEach { (relay, prunedUntil) -> + val c = cursors.get(relay) ?: return@forEach + val reached = c.reachedUntil ?: return@forEach + // Re-include the newest pruned event: the next page asks `until = reached - 1`, so reached must + // sit one tick above it. Never climb above the floor. + val target = minOf(prunedUntil + 1, floorAt) + if (reached < target) { + c.reachedUntil = target + c.requestedUntil = null + c.done = false + c.pageCount = 0 + c.pageOldest = Long.MAX_VALUE + } + } + } + /** Relays from [all] that have been armed (advanced at least once) and are not yet [isDone]. */ fun armedRelays(all: Collection): List = all.filter { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/HostStub.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/HostStub.kt index b971711e4b..8fdd3c92d8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/HostStub.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/HostStub.kt @@ -22,8 +22,19 @@ package com.vitorpamplona.quartz.nip59Giftwrap import com.vitorpamplona.quartz.nip01Core.core.HexKey +/** + * A lightweight reference to the host event a [WrappedEvent] was extracted from — kept on the inner + * event so callers can broadcast / delete / locate the outer wrap without holding the full event. + * + * [createdAt] is the host's own `created_at` (e.g. the kind:1059 gift-wrap timestamp, randomized per + * NIP-59), carried here so a decrypted rumor self-describes its outer-wrap time. The history pager + * cursors page gift wraps by that outer time, so the prune path uses it to realign the per-relay + * download window when a wrapped message is pruned (the chatroom only keeps the inner rumor, whose + * `created_at` is the real message time, not the wrap time). + */ class HostStub( val id: HexKey, val pubKey: HexKey, val kind: Int, + val createdAt: Long, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt index 3591a5d7a1..ad2c9cf873 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt @@ -70,7 +70,7 @@ class SealedRumorEvent( val event = rumor.mergeWith(this) if (event is WrappedEvent) { - event.host = host ?: HostStub(this.id, this.pubKey, this.kind) + event.host = host ?: HostStub(this.id, this.pubKey, this.kind, this.createdAt) } innerEventId = event.id diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt index af7298adf8..7d19424ddc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt @@ -74,7 +74,7 @@ open class GiftWrapEvent( val gift = fromJson(giftStr) if (gift is WrappedEvent) { - gift.host = HostStub(this.id, this.pubKey, this.kind) + gift.host = HostStub(this.id, this.pubKey, this.kind, this.createdAt) } innerEventId = gift.id From aa6b9bc53eb06622a87fd25d3192f85ccce16e9b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 8 Jun 2026 17:59:04 -0400 Subject: [PATCH 100/103] refactor(dm): centralize the DM history-window boundary + log prune rewinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "where the live tail ends and paged history begins" boundary (one week) was copy-pasted across the gift-wrap + NIP-04 live tails, the backward history pager floor, and the prune's recent/old split — easy to drift out of lockstep (overlap = double-load, gap = missed messages). - Add DmHistoryTuning: one place for liveTailSeconds + recentKeepCount, read by AccountGiftWrapsEoseManager, both NIP-04 SubAssemblers, BackwardRelayPager, and Chatroom.pruneMessagesToTheLatestOnly. Drops the duplicated LIVE_TAIL_SECONDS / DEFAULT_LIVE_TAIL_SECONDS constants. - Log the prune-time window realignment under DMPagination, per scope ([giftwrap] / [rooms.nip04] / [convo.nip04]): relay count + newest pruned timestamp, so the rewind is observable in logcat. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../amethyst/model/LocalCache.kt | 15 ++++-- .../AccountGiftWrapsEoseManager.kt | 8 ++- .../datasource/ChatroomNip04SubAssembler.kt | 6 +-- .../ChatroomListNip04SubAssembler.kt | 6 +-- .../commons/model/privateChats/Chatroom.kt | 9 ++-- .../model/privateChats/DmHistoryTuning.kt | 49 +++++++++++++++++++ .../relayClient/paging/BackwardRelayPager.kt | 8 ++- 7 files changed, 77 insertions(+), 24 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/DmHistoryTuning.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 920631cf99..a2bf434c66 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -2693,9 +2693,18 @@ object LocalCache : ILocalCache, ICacheProvider { // Realign the windows so a relay that already paged past (or `done` below) the dropped band // re-requests it on the next demand-advance instead of skipping the hole. - if (giftWrapPruned.isNotEmpty()) room.giftWrapHistory.rewindTo(giftWrapPruned) - if (accountNip04Pruned.isNotEmpty()) room.nip04History.rewindTo(accountNip04Pruned) - if (roomNip04Pruned.isNotEmpty()) chatroom.nip04History.rewindTo(roomNip04Pruned) + if (giftWrapPruned.isNotEmpty()) { + room.giftWrapHistory.rewindTo(giftWrapPruned) + Log.d("DMPagination") { "[giftwrap] window rewound after prune: ${giftWrapPruned.size} relay(s), newest pruned wrap @${giftWrapPruned.values.max()}" } + } + if (accountNip04Pruned.isNotEmpty()) { + room.nip04History.rewindTo(accountNip04Pruned) + Log.d("DMPagination") { "[rooms.nip04] window rewound after prune: ${accountNip04Pruned.size} relay(s), newest pruned @${accountNip04Pruned.values.max()}" } + } + if (roomNip04Pruned.isNotEmpty()) { + chatroom.nip04History.rewindTo(roomNip04Pruned) + Log.d("DMPagination") { "[convo.nip04] window rewound after prune of ${key.users.joinToString()}: ${roomNip04Pruned.size} relay(s), newest pruned @${roomNip04Pruned.values.max()}" } + } if (toBeRemoved.size > 1) { println( 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 5a9633e210..1ddf8e8028 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 @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps +import com.vitorpamplona.amethyst.commons.model.privateChats.DmHistoryTuning import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker import com.vitorpamplona.amethyst.commons.relayClient.paging.trackingListener @@ -68,9 +69,9 @@ class AccountGiftWrapsEoseManager( } val relays = key.account.dmRelays.flow.value windowLoad.setExpectedRelays(relays.toSet()) - val sinceTime = TimeUtils.now() - LIVE_TAIL_SECONDS + val sinceTime = DmHistoryTuning.recentBoundary() DmRelayLog.log("giftwrap.live", key.account) - Log.d(TAG) { "[giftwrap.live] REQ since=$sinceTime (7d, no until) on ${relays.size} relay(s): ${relays.map { it.url }}" } + Log.d(TAG) { "[giftwrap.live] REQ since=$sinceTime (no until) on ${relays.size} relay(s): ${relays.map { it.url }}" } return relays.flatMap { relay -> filterGiftWrapsToPubkey(relay = relay, pubkey = user(key).pubkeyHex, since = sinceTime) } @@ -106,8 +107,5 @@ class AccountGiftWrapsEoseManager( companion object { private const val TAG = "DMPagination" - - // The live tail's fixed lower bound: one week of recent history, always open at the top. - val LIVE_TAIL_SECONDS = 7L * TimeUtils.ONE_DAY } } 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 f8b233f7cd..f7c8bfb686 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 @@ -20,11 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource +import com.vitorpamplona.amethyst.commons.model.privateChats.DmHistoryTuning import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker import com.vitorpamplona.amethyst.commons.relayClient.paging.trackingListener import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager -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.pool.RelayBasedFilter @@ -50,11 +50,11 @@ class ChatroomNip04SubAssembler( since: SincePerRelayMap?, ): List? = if (key.account.isWriteable()) { - val sinceTime = TimeUtils.now() - AccountGiftWrapsEoseManager.LIVE_TAIL_SECONDS + val sinceTime = DmHistoryTuning.recentBoundary() val filters = filterNip04DMs(key.room.users, key.account, sinceTime) windowLoad.setExpectedRelays(filters?.mapTo(mutableSetOf()) { it.relay } ?: emptySet()) DmRelayLog.log("convo.nip04.live", key.account) - Log.d("DMPagination") { "[convo.nip04.live] REQ since=$sinceTime (7d, no until) on ${filters?.size ?: 0} relay-filter(s): ${filters?.map { it.relay.url }?.distinct()}" } + Log.d("DMPagination") { "[convo.nip04.live] REQ since=$sinceTime (no until) on ${filters?.size ?: 0} relay-filter(s): ${filters?.map { it.relay.url }?.distinct()}" } filters } else { windowLoad.setExpectedRelays(emptySet()) 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 09e2910591..cd712abcaa 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 @@ -20,12 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource +import com.vitorpamplona.amethyst.commons.model.privateChats.DmHistoryTuning import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker import com.vitorpamplona.amethyst.commons.relayClient.paging.trackingListener 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.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.pool.RelayBasedFilter @@ -59,9 +59,9 @@ class ChatroomListNip04SubAssembler( val homeRelays = key.account.homeRelays.flow.value val dmRelays = key.account.dmRelays.flow.value windowLoad.setExpectedRelays((homeRelays + dmRelays).toSet()) - val sinceTime = TimeUtils.now() - AccountGiftWrapsEoseManager.LIVE_TAIL_SECONDS + val sinceTime = DmHistoryTuning.recentBoundary() DmRelayLog.log("rooms.nip04.live", key.account) - Log.d("DMPagination") { "[rooms.nip04.live] REQ since=$sinceTime (7d, no until) fromMe(outbox)=${homeRelays.map { it.url }} toMe(inbox)=${dmRelays.map { it.url }}" } + Log.d("DMPagination") { "[rooms.nip04.live] REQ since=$sinceTime (no until) fromMe(outbox)=${homeRelays.map { it.url }} toMe(inbox)=${dmRelays.map { it.url }}" } homeRelays.map { filterNip04DMsFromMe(key.account.userProfile(), it, sinceTime) } + dmRelays.map { filterNip04DMsToMe(key.account.userProfile(), it, sinceTime) } } else { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt index 13d604208d..5c9f6cc7b3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt @@ -34,7 +34,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursor import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip14Subject.subject import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent -import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -140,11 +139,11 @@ class Chatroom : NotesGatherer { val sorted = messages.sortedWith(DefaultFeedOrder) val toKeep = - if ((sorted.firstOrNull()?.createdAt() ?: 0L) > TimeUtils.oneWeekAgo()) { - // Recent messages, keep last 100 - sorted.take(100).toSet() + if ((sorted.firstOrNull()?.createdAt() ?: 0L) > DmHistoryTuning.recentBoundary()) { + // Recent conversation, keep its newest N + sorted.take(DmHistoryTuning.recentKeepCount).toSet() } else { - // Old messages, keep the last one. + // Old conversation, keep the last one. sorted.take(1).toSet() } + sorted.filter { it.flowSet?.isInUse() ?: false } + sorted.filter { it.event !is PrivateDmEvent && it.event !is WrappedEvent } // Both DM protocols are pruned by the recency rule above: NIP-04 (PrivateDmEvent) and NIP-17 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/DmHistoryTuning.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/DmHistoryTuning.kt new file mode 100644 index 0000000000..b2c1dae11c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/DmHistoryTuning.kt @@ -0,0 +1,49 @@ +/* + * 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.amethyst.commons.model.privateChats + +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.concurrent.Volatile + +/** + * One shared boundary for the DM history window, so the three things that must agree on "where the + * live tail ends and paged history begins" actually read the same number: + * - the live-tail subscriptions' `since` floor (e.g. `AccountGiftWrapsEoseManager`), + * - the backward history pager's pinned floor (`BackwardRelayPager`), + * - the memory prune's recent/old split + retention cap (`Chatroom.pruneMessagesToTheLatestOnly`). + * + * Keeping them in one place avoids the live tail and history overlapping (double-loading) or leaving a + * gap when the boundary is tuned. The knobs are plain `@Volatile` vars (overridable once at startup — + * e.g. shrinking the window in a test to exercise pruning + the per-relay download-window realignment + * without needing week-old threads); they are not meant to change mid-session. + */ +object DmHistoryTuning { + /** Seconds below "now" where the live tail ends and paged history begins. Production: one week. */ + @Volatile + var liveTailSeconds: Long = 7L * TimeUtils.ONE_DAY + + /** How many newest messages a still-recent conversation keeps on a prune. Production: 100. */ + @Volatile + var recentKeepCount: Int = 100 + + /** The epoch-seconds boundary `now − [liveTailSeconds]` (recomputed each call against the clock). */ + fun recentBoundary(): Long = TimeUtils.now() - liveTailSeconds +} 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 b8b45ad107..62e675e244 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 @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.relayClient.paging +import com.vitorpamplona.amethyst.commons.model.privateChats.DmHistoryTuning import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -74,8 +75,8 @@ class BackwardRelayPager( // 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 shared live-tail boundary ([DmHistoryTuning]): everything newer is the always-on tail's job. + private val liveTailSeconds: Long = DmHistoryTuning.liveTailSeconds, ) { private val loadTracker = PerRelayLoadTracker(name, onSilenced = ::onSilenced) @@ -300,8 +301,5 @@ class BackwardRelayPager( 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 } } From b21aecdfcae96c7158088fa9d889051c72c7bef1 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 9 Jun 2026 13:47:46 -0400 Subject: [PATCH 101/103] Improves markers --- .../chats/rooms/feed/ChatroomListFeedView.kt | 17 ++- .../composeResources/values/strings.xml | 10 +- .../commons/ui/feeds/DmHistoryLoadingCard.kt | 137 ++++++++++-------- .../commons/ui/feeds/RelayReachMarker.kt | 76 ++++++++-- 4 files changed, 162 insertions(+), 78 deletions(-) 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 9301863e9f..84efb0f6c6 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 @@ -31,7 +31,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -41,6 +43,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachDetailDialog import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachSentinels import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState @@ -193,12 +196,12 @@ private fun FeedLoaded( buildList { if (!giftWrapsExhausted) { giftWrapsProgress.forEach { (relay, p) -> - add(RelayReachCursor("17:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p)) { giftWrapsHistory.advance(relay) }) + add(RelayReachCursor("17:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p), "NIP-17") { giftWrapsHistory.advance(relay) }) } } if (!nip04Exhausted) { nip04Progress.forEach { (relay, p) -> - add(RelayReachCursor("04:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p)) { nip04History.advance(relay) }) + add(RelayReachCursor("04:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p), "NIP-04") { nip04History.advance(relay) }) } } } @@ -208,6 +211,14 @@ private fun FeedLoaded( // (a live DM bumping a room) no longer re-fire paging. The markers below are pure UI. RelayReachSentinels(limits, listState) { index -> items.list.getOrNull(index)?.createdAt() } + // The relays behind a tapped in-stream "Relay sync" marker; non-null shows the per-relay popup so the + // terse divider isn't a dead end — every count/name is one tap from the full breakdown (which relays, + // protocol, how far back each paged). + var syncDetail by remember { mutableStateOf?>(null) } + syncDetail?.let { detail -> + RelayReachDetailDialog(detail, ::formatHistoryReachDate) { syncDetail = null } + } + LazyColumn( contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, @@ -244,7 +255,7 @@ private fun FeedLoaded( limits, item.createdAt(), items.list.getOrNull(index + 1)?.createdAt(), - ) + ) { syncDetail = it } } } } diff --git a/commons/src/commonMain/composeResources/values/strings.xml b/commons/src/commonMain/composeResources/values/strings.xml index 72c32fa254..9924ada856 100644 --- a/commons/src/commonMain/composeResources/values/strings.xml +++ b/commons/src/commonMain/composeResources/values/strings.xml @@ -56,17 +56,21 @@ Navigate - Relay sync: + Loading: + Fully loaded: + (fully loaded) + History by relay + Will retry when you reopen this screen Older %1$s messages All caught up Reached the start of your %1$s messages - %1$s · %2$s · back to %3$s + %1$s · %2$s · loaded since %3$s %1$s · %2$s waiting on %1$s Some relays didn\'t respond %1$s unreachable · tap to see which %1$s · history by relay - back to %1$s + since %1$s Dismiss %1$d relay diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DmHistoryLoadingCard.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DmHistoryLoadingCard.kt index d9992dd167..e32444f27c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DmHistoryLoadingCard.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DmHistoryLoadingCard.kt @@ -60,14 +60,15 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.action_dismiss import com.vitorpamplona.amethyst.commons.resources.chats_history_all_caught_up +import com.vitorpamplona.amethyst.commons.resources.chats_history_by_relay import com.vitorpamplona.amethyst.commons.resources.chats_history_incomplete import com.vitorpamplona.amethyst.commons.resources.chats_history_incomplete_sub import com.vitorpamplona.amethyst.commons.resources.chats_history_older import com.vitorpamplona.amethyst.commons.resources.chats_history_reached_start -import com.vitorpamplona.amethyst.commons.resources.chats_history_relay_back -import com.vitorpamplona.amethyst.commons.resources.chats_history_relay_sync +import com.vitorpamplona.amethyst.commons.resources.chats_history_relay_since import com.vitorpamplona.amethyst.commons.resources.chats_history_relays import com.vitorpamplona.amethyst.commons.resources.chats_history_relays_title +import com.vitorpamplona.amethyst.commons.resources.chats_history_stalled_retry import com.vitorpamplona.amethyst.commons.resources.chats_history_subtitle import com.vitorpamplona.amethyst.commons.resources.chats_history_subtitle_no_date import com.vitorpamplona.amethyst.commons.resources.chats_history_waiting @@ -277,7 +278,8 @@ fun historySubtitle( /** * Popup shown when the history card is tapped: one row per relay with its state glyph (✓ done, … stalled, - * ↓ still reaching) and how far back it has paged ("back to "), deepest-reaching first. + * ↓ still reaching) and how far back it has paged ("since "), deepest-reaching first. A stalled relay + * also gets a one-line hint that it retries when the screen is reopened. */ @Composable fun DmHistoryRelayDialog( @@ -300,32 +302,30 @@ fun DmHistoryRelayDialog( .verticalScroll(rememberScrollState()), ) { rows.forEach { (relay, p) -> - Row( - Modifier - .fillMaxWidth() - .padding(vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = relayStateGlyph(p), - color = relayStateColor(p), - fontWeight = FontWeight.Bold, - modifier = Modifier.width(22.dp), - ) - Text( - text = relayShortName(relay), - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(Modifier.width(8.dp)) - Text( - text = stringResource(Res.string.chats_history_relay_back, formatReachDate(p.reachedUntil)), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - ) + Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = relayStateGlyph(p), + color = relayStateColor(p), + fontWeight = FontWeight.Bold, + modifier = Modifier.width(22.dp), + ) + Text( + text = relayShortName(relay), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(Res.string.chats_history_relay_since, formatReachDate(p.reachedUntil)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + if (p.stalled) StalledRetryHint() } } } @@ -333,6 +333,19 @@ fun DmHistoryRelayDialog( ) } +/** The "stopped early, retries on reopen" caption shown under a stalled relay in the per-relay popups. + * Retry is demand-driven (no timer): reopening the screen re-binds and clears the stalled set, which + * retries it — so that's what we tell the user rather than a countdown we can't honour. */ +@Composable +private fun StalledRetryHint() { + Text( + text = stringResource(Res.string.chats_history_stalled_retry), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(start = 22.dp, top = 2.dp), + ) +} + private fun relayStateGlyph(p: RelayPagingProgress) = when { p.done -> "✓" @@ -355,10 +368,10 @@ private fun relayShortName(relay: NormalizedRelayUrl): String = .substringBefore('/') /** - * Popup shown when an in-stream "Relay sync" marker is tapped: the relays whose window sits at that point + * Popup shown when an in-stream "Loading" marker is tapped: the relays whose window sits at that point * in the stream, each with its protocol tag, state glyph (✓ done · … stalled · ↓ reaching) and how far - * back it has paged — so the otherwise-terse `Relay sync: ✓ N` divider stops being a dead end and its - * meaning is explorable. Deepest-reaching first. + * back it has paged — so the otherwise-terse `Loading: ↓ N` divider stops being a dead end and its + * meaning is explorable. A stalled relay also gets the retry-on-reopen hint. Deepest-reaching first. */ @Composable fun RelayReachDetailDialog( @@ -372,7 +385,7 @@ fun RelayReachDetailDialog( confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.action_dismiss)) } }, - title = { Text(stringResource(Res.string.chats_history_relay_sync)) }, + title = { Text(stringResource(Res.string.chats_history_by_relay)) }, text = { Column( Modifier @@ -380,41 +393,39 @@ fun RelayReachDetailDialog( .verticalScroll(rememberScrollState()), ) { rows.forEach { c -> - Row( - Modifier - .fillMaxWidth() - .padding(vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = reachGlyph(c.state), - color = reachColor(c.state), - fontWeight = FontWeight.Bold, - modifier = Modifier.width(22.dp), - ) - if (c.protocol.isNotEmpty()) { + Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = c.protocol, - style = MaterialTheme.typography.labelSmall, + text = reachGlyph(c.state), + color = reachColor(c.state), + fontWeight = FontWeight.Bold, + modifier = Modifier.width(22.dp), + ) + if (c.protocol.isNotEmpty()) { + Text( + text = c.protocol, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + Spacer(Modifier.width(6.dp)) + } + Text( + text = c.name, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(Res.string.chats_history_relay_since, formatReachDate(c.reachedUntil)), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, ) - Spacer(Modifier.width(6.dp)) } - Text( - text = c.name, - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(Modifier.width(8.dp)) - Text( - text = stringResource(Res.string.chats_history_relay_back, formatReachDate(c.reachedUntil)), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - ) + if (c.state == RelayReachState.STALLED) StalledRetryHint() } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt index a9f565b7c8..117c6323fe 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt @@ -42,10 +42,14 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.commons.resources.Res -import com.vitorpamplona.amethyst.commons.resources.chats_history_relay_sync +import com.vitorpamplona.amethyst.commons.resources.chats_history_fully_loaded +import com.vitorpamplona.amethyst.commons.resources.chats_history_fully_loaded_label +import com.vitorpamplona.amethyst.commons.resources.chats_history_loading_label +import com.vitorpamplona.amethyst.commons.resources.chats_history_relays import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.distinctUntilChanged +import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource // A relay-reach divider is hair-thin; inlined here so the shared component carries no app-theme dep. @@ -171,6 +175,13 @@ fun RelayReachSentinels( * (at [newerCreatedAt]) and its next-older neighbour (at [olderCreatedAt], null at the oldest end). Pure * UI: the load driving lives in [RelayReachSentinels], so this can be (re)placed freely per row on * every feed reorder without triggering any paging. + * + * A [DONE][RelayReachState.DONE] relay has no incompleteness frontier — it has loaded everything it has — + * so it does NOT mark its own history bottom mid-stream (which would read like a false "incomplete below + * here" line). Instead every done relay sinks to the **oldest-end gap** ([olderCreatedAt] null), where it + * renders as one "fully loaded" marker. Only [REACHING][RelayReachState.REACHING] / + * [STALLED][RelayReachState.STALLED] relays — the genuine "below here may still be incomplete" frontiers — + * are placed at their reached cursor. */ @Composable fun RelayReachMarkers( @@ -183,7 +194,14 @@ fun RelayReachMarkers( ) { val here = remember(limits, newerCreatedAt, olderCreatedAt) { - limits.filter { reachedFallsInGap(it.reachedUntil, newerCreatedAt, olderCreatedAt) } + limits.filter { + if (it.state == RelayReachState.DONE) { + // Fully loaded → sink to the oldest end rather than mark a frontier it doesn't have. + newerCreatedAt != null && olderCreatedAt == null + } else { + reachedFallsInGap(it.reachedUntil, newerCreatedAt, olderCreatedAt) + } + } } if (here.isEmpty()) return @@ -196,11 +214,16 @@ fun RelayReachMarkers( * down (older) in the stream — relays that race ahead leave their marker deep while slower relays' * markers trail higher up, converging as they catch up. * - * A leading "Relay sync:" label gives the glyphs context; then each state renders one compact label: - * a relay's host name when it is the only one of its state there (the usual converged case, where each - * relay sits at its own depth), or just a count when several pile up at the same depth (e.g. all nine - * clustered at the live-tail floor on first open) so the line can't grow into an unreadable comma list. - * Reads e.g. "Relay sync: ✓ 8 · ↓ 1" or "Relay sync: ↓ nostr.wine". + * The line is always captioned so it's never a bare glyph cluster: the live frontiers + * ([REACHING][RelayReachState.REACHING] / [STALLED][RelayReachState.STALLED]) read "Loading:"; the + * oldest-end pile of [DONE][RelayReachState.DONE] relays reads "Fully loaded:". Each state then renders + * one compact label: the host name(s) when one — or two short-named — relays sit at that state (the usual + * converged case, where each relay rests at its own depth), or just a count when several pile up at the + * same depth (e.g. all nine clustered at the oldest-end floor) so the line can't grow into an unreadable + * comma list. In the rare mixed line (an active frontier sharing the oldest-end gap with done relays) the + * caption is "Loading:", so the done chip is suffixed "(fully loaded)" to keep its meaning clear. Either + * way the whole marker is tappable for the full per-relay breakdown. Reads e.g. "Loading: ↓ nostr.wine" + * or "Fully loaded: ✓ 8". */ @Composable private fun RelayReachMarker( @@ -209,14 +232,21 @@ private fun RelayReachMarker( ) { if (entries.isEmpty()) return + val hasActiveFrontier = entries.any { it.state != RelayReachState.DONE } + Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(5.dp).then(if (onClick != null) Modifier.clickable { onClick() } else Modifier), ) { HorizontalDivider(modifier = Modifier.weight(1f), thickness = DividerThickness) + // Always caption the line so it's never a bare glyph cluster: live frontiers are "Loading:"; the + // oldest-end pile of only-done relays is "Fully loaded:". Text( - text = stringResource(Res.string.chats_history_relay_sync), + text = + stringResource( + if (hasActiveFrontier) Res.string.chats_history_loading_label else Res.string.chats_history_fully_loaded_label, + ), color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 11.sp, fontWeight = FontWeight.Medium, @@ -236,8 +266,21 @@ private fun RelayReachMarker( if (index > 0) { Text("·", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 11.sp) } + // Spell out 1–2 short host names; otherwise a count. Done relays count as "N relays" so the + // fully-loaded floor reads as a sentence ("✓ 8 relays"), not a bare number; active frontiers + // stay terse ("↓ 1"). Only a mixed line (caption "Loading:") needs the done chip tagged + // "(fully loaded)" — a pure-done line already says so in its "Fully loaded:" caption. + val names = list.map { it.name } + val inlineNames = reachInlineNames(names) + val label = + when { + inlineNames != null -> inlineNames + state == RelayReachState.DONE -> pluralStringResource(Res.plurals.chats_history_relays, names.size, names.size) + else -> names.size.toString() + } + val chip = reachGlyph(state) + " " + label Text( - text = reachGlyph(state) + " " + if (list.size == 1) list.first().name else list.size.toString(), + text = if (state == RelayReachState.DONE && hasActiveFrontier) chip + " " + stringResource(Res.string.chats_history_fully_loaded) else chip, color = reachColor(state), fontSize = 11.sp, fontWeight = FontWeight.Medium, @@ -249,6 +292,21 @@ private fun RelayReachMarker( } } +// Host names short enough to spell out inline on the single-line divider instead of collapsing to a +// bare count: a name up to [INLINE_NAME_MAX] when it's the lone relay of its state, or two names each +// up to [INLINE_TWO_NAMES_MAX] when a pair shares it. Longer hosts, or 3+ relays at one state, return +// null so the caller renders a count instead and the line can't grow unbounded — the tap-through dialog +// always lists them all. +private const val INLINE_NAME_MAX = 16 +private const val INLINE_TWO_NAMES_MAX = 12 + +internal fun reachInlineNames(names: List): String? = + when { + names.size == 1 && names[0].length <= INLINE_NAME_MAX -> names[0] + names.size == 2 && names.all { it.length <= INLINE_TWO_NAMES_MAX } -> names.joinToString(", ") + else -> null + } + internal fun reachGlyph(state: RelayReachState) = when (state) { RelayReachState.REACHING -> "↓" From 75315095ee45ecdb5f94edc2a1786d10f45a754c Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 9 Jun 2026 14:13:26 -0400 Subject: [PATCH 102/103] 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) --- .../AccountGiftWrapsHistoryEoseManager.kt | 8 +- .../loggedIn/chats/feed/LoadingReplyNote.kt | 45 ++---- .../loggedIn/chats/privateDM/ChatroomView.kt | 32 ++-- .../ChatroomNip04HistorySubAssembler.kt | 8 +- .../ChatroomListNip04HistorySubAssembler.kt | 8 +- .../chats/rooms/feed/ChatroomListFeedView.kt | 49 +++---- .../relayClient/paging/BackwardRelayPager.kt | 138 +++++++++--------- .../paging/BackwardRelayPagerTest.kt | 62 ++++---- 8 files changed, 154 insertions(+), 196 deletions(-) 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) } } From 39eb25bc175741461c1843f591c5576d470bf532 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 9 Jun 2026 14:17:07 -0400 Subject: [PATCH 103/103] fix(commons): show "N relays" on every history marker count chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-stream loading marker spelled out "N relays" only for the fully-loaded (done) chip; active frontiers showed a bare count ("Loading: ↓ 8"). Use the relays plural for the count fallback on every state so a count chip always reads as a sentence ("Loading: ↓ 8 relays"). 1–2 short host names still spell out. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../commons/ui/feeds/RelayReachMarker.kt | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt index 117c6323fe..6e25e26c2c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/RelayReachMarker.kt @@ -222,8 +222,8 @@ fun RelayReachMarkers( * same depth (e.g. all nine clustered at the oldest-end floor) so the line can't grow into an unreadable * comma list. In the rare mixed line (an active frontier sharing the oldest-end gap with done relays) the * caption is "Loading:", so the done chip is suffixed "(fully loaded)" to keep its meaning clear. Either - * way the whole marker is tappable for the full per-relay breakdown. Reads e.g. "Loading: ↓ nostr.wine" - * or "Fully loaded: ✓ 8". + * way the whole marker is tappable for the full per-relay breakdown. Reads e.g. "Loading: ↓ nostr.wine", + * "Loading: ↓ 8 relays" or "Fully loaded: ✓ 8 relays". */ @Composable private fun RelayReachMarker( @@ -266,18 +266,12 @@ private fun RelayReachMarker( if (index > 0) { Text("·", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 11.sp) } - // Spell out 1–2 short host names; otherwise a count. Done relays count as "N relays" so the - // fully-loaded floor reads as a sentence ("✓ 8 relays"), not a bare number; active frontiers - // stay terse ("↓ 1"). Only a mixed line (caption "Loading:") needs the done chip tagged - // "(fully loaded)" — a pure-done line already says so in its "Fully loaded:" caption. + // Spell out 1–2 short host names; otherwise "N relays" for every state, so a count chip + // always reads as a sentence ("↓ 8 relays", "✓ 8 relays") rather than a bare number. Only a + // mixed line (caption "Loading:") needs the done chip tagged "(fully loaded)" — a pure-done + // line already says so in its "Fully loaded:" caption. val names = list.map { it.name } - val inlineNames = reachInlineNames(names) - val label = - when { - inlineNames != null -> inlineNames - state == RelayReachState.DONE -> pluralStringResource(Res.plurals.chats_history_relays, names.size, names.size) - else -> names.size.toString() - } + val label = reachInlineNames(names) ?: pluralStringResource(Res.plurals.chats_history_relays, names.size, names.size) val chip = reachGlyph(state) + " " + label Text( text = if (state == RelayReachState.DONE && hasActiveFrontier) chip + " " + stringResource(Res.string.chats_history_fully_loaded) else chip,