From ba8de9ba21914c0bf885583331b8679ff45ea936 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 15:53:32 +0000 Subject: [PATCH 1/6] feat(notifications): paginate history by time with in-feed load markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notifications were pinned to the recent week with no way to scroll further back. Mirror the NIP-04 / gift-wrap DM approach: a backward, per-relay until+limit pager driven by in-feed window-limit markers that pull the next older page only while visible. - Add Account.notificationHistory (RelayLoadingCursors) and AccountNotificationsHistoryEoseManager, a BackwardRelayPager over the inbox + NIP-29 group-host relays, registered always-on in AccountFilterAssembler. It parks until a marker advances a relay. - Add filterNotificationsHistoryToPubkey / filterGroupNotificationsHistoryToPubkey and AllNotificationKinds: one combined-kinds filter per relay so the single per-relay cursor stays gap-proof (empty page + EOSE = nothing older). - Wire RelayReachMarkers + RelayReachSentinels into the notifications card feed (CardFeedView), with an empty-feed bootstrap, so scrolling a relay's marker into view loads more. - Fix the two live notification loaders to a fixed one-week tail (drop the fullness-driven `since` drift), letting the marker-driven pager cleanly own everything older — the same live-tail/history split DMs use. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f --- .../vitorpamplona/amethyst/model/Account.kt | 6 + .../account/AccountFilterAssembler.kt | 10 +- ...NotificationsEoseFromInboxRelaysManager.kt | 10 +- ...otificationsEoseFromRandomRelaysManager.kt | 11 +- .../AccountNotificationsHistoryEoseManager.kt | 222 ++++++++++++++++++ .../FilterNotificationsToPubkey.kt | 64 +++++ .../loggedIn/notifications/CardFeedView.kt | 104 +++++++- .../FilterNotificationsHistoryTest.kt | 86 +++++++ 8 files changed, 497 insertions(+), 16 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsHistoryEoseManager.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsHistoryTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 9ea8909821..04d9e3c3b8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -208,6 +208,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @@ -650,6 +651,11 @@ class Account( val dmRelays = DmInboxRelayState(dmRelayList, nip65RelayList, privateStorageRelayList, localRelayList, scope) val notificationRelays = NotificationInboxRelayState(nip65RelayList, localRelayList, scope) + // Account-level notification history paging cursors (one scope per account): how far back each + // notification relay has been paged by until+limit. Held here so they share the account's lifetime; + // the history loader ([AccountNotificationsHistoryEoseManager]) binds its orchestrator to these. + val notificationHistory = RelayLoadingCursors() + val cashuWalletState = com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState( pubKey = signer.pubKey, 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 e654e9df0c..7220fc739d 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 @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.drafts. import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.marmot.MarmotGroupEventsEoseManager 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.nip01Notifications.AccountNotificationsHistoryEoseManager 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 @@ -56,13 +57,20 @@ class AccountFilterAssembler( // History: older gift wraps, loaded on demand in bounded one-shot slices. val giftWrapsHistory = AccountGiftWrapsHistoryEoseManager(client, ::allKeys) + // Live tail: the recent week of notifications from the inbox + group host relays. + val notifications = AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys) + + // History: older notifications, paged backward by until+limit per relay, driven by the feed's markers. + val notificationsHistory = AccountNotificationsHistoryEoseManager(client, ::allKeys) + val group = listOf( AccountMetadataEoseManager(client, ::allKeys), giftWraps, giftWrapsHistory, AccountDraftsEoseManager(client, ::allKeys), - AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys), + notifications, + notificationsHistory, MarmotGroupEventsEoseManager(client, ::allKeys), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt index c8f58630ce..33e6e67ebf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt @@ -61,7 +61,10 @@ class AccountNotificationsEoseFromInboxRelaysManager( filterNotificationsToPubkey( relay = it, pubkey = user(key).pubkeyHex, - since = since?.get(it)?.time ?: key.feedContentStates.notifications.lastNoteCreatedAtIfFilled() ?: TimeUtils.oneWeekAgo(), + // Fixed one-week live tail. Everything older is paged on demand by the marker-driven + // [AccountNotificationsHistoryEoseManager] (until+limit, per relay) — the NIP-04 model — + // so this filter no longer drifts its `since` back as the feed fills. + since = since?.get(it)?.time ?: TimeUtils.oneWeekAgo(), ) } @@ -103,11 +106,6 @@ class AccountNotificationsEoseFromInboxRelaysManager( invalidateFilters() } }, - key.account.scope.launch(Dispatchers.IO) { - key.feedContentStates.notifications.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { - invalidateFilters() - } - }, ) return super.newSub(key) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt index 9aa0acc87d..3ada70531f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt @@ -33,7 +33,6 @@ import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.sample import kotlinx.coroutines.launch class AccountNotificationsEoseFromRandomRelaysManager( @@ -51,8 +50,9 @@ class AccountNotificationsEoseFromRandomRelaysManager( key: AccountQueryState, since: SincePerRelayMap?, ): List { - // only loads this after the feed is built - val defaultSince = key.feedContentStates.notifications.lastNoteCreatedAtIfFilled() ?: TimeUtils.oneWeekAgo() + // Fixed one-week live tail of stragglers from follow relays. Backward history is the marker-driven + // [AccountNotificationsHistoryEoseManager]'s job (on inbox + group relays), so this no longer drifts. + val defaultSince = TimeUtils.oneWeekAgo() return (key.account.followsPerRelay.value.keys - key.account.notificationRelays.flow.value).flatMap { val since = since?.get(it)?.time ?: defaultSince filterJustTheLatestNotificationsToPubkeyFromRandomRelays(it, user(key).pubkeyHex, since) @@ -73,11 +73,6 @@ class AccountNotificationsEoseFromRandomRelaysManager( invalidateFilters() } }, - key.account.scope.launch(Dispatchers.IO) { - key.feedContentStates.notifications.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { - invalidateFilters() - } - }, ) return super.newSub(key) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsHistoryEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsHistoryEoseManager.kt new file mode 100644 index 0000000000..32dcb9d932 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsHistoryEoseManager.kt @@ -0,0 +1,222 @@ +/* + * 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.nip01Notifications + +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.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.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.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +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.flow.sample +import kotlinx.coroutines.launch + +/** + * Loads the account's notification **history** — everything older than the one-week live tail + * ([AccountNotificationsEoseFromInboxRelaysManager]) — by **`until`+`limit` paging, per relay, on + * demand**, so the notifications feed can be scrolled back in time instead of being pinned to the + * recent week. + * + * There is no proactive walk: each relay advances exactly one page when the feed's on-screen + * window-limit marker for that relay asks ([advance]), then **parks** at its window limit. The markers + * are the drivers — a relay pages only while its marker is visible, and keeps paging as long as it + * stays visible (see the notifications card feed). So a spam-dense relay never floods: the user has to + * scroll through its notifications to pull more, and nothing is fetched while its marker is off screen. + * + * Relays paged: the same set the live inbox loader covers — the user's inbox relays (all notification + * kinds tagging me) plus each joined NIP-29 group's host relay (group-activity kinds scoped by `#h`). + * The foreground "random follows" straggler query ([AccountNotificationsEoseFromRandomRelaysManager], + * tiny latest-N limits) is deliberately live-tail only and is NOT paged here. + * + * The per-relay cursors live on the [Account] (so they share the account's lifetime); this class binds + * the single-active [BackwardRelayPager] orchestrator to them on [newSub], builds the notification 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 AccountNotificationsHistoryEoseManager( + client: INostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun user(key: AccountQueryState) = key.account.userProfile() + + // A modest page: each marker-triggered advance pulls ~500 older notifications, digestible to render + // and enough to fill a scroll, rather than the gift-wrap default (a whole encrypted-blob band at once). + private val pager = BackwardRelayPager("notifications.history", pageLimit = 500) + + val loadingMore: StateFlow = pager.loadingMore + val status: StateFlow = pager.status + + // Each joined group's id, bucketed by the normalized host relay it lives on. Used both to route the + // group filter and (its keys) to add group host relays to the paged relay set. + private fun groupsByRelay(account: Account): Map> = + account.relayGroupList.liveRelayGroupList.value + .groupBy({ RelayUrlNormalizer.normalizeOrNull(it.relayUrl) }, { it.groupId }) + .mapNotNull { (relay, ids) -> relay?.let { it to ids.distinct() } } + .toMap() + + // The full relay set this account pages notifications back through: inbox relays + group host relays. + private fun notificationRelaySet(account: Account): Set = account.notificationRelays.flow.value + groupsByRelay(account).keys + + override fun updateFilter( + key: AccountQueryState, + since: SincePerRelayMap?, + ): List { + if (!key.account.isWriteable()) return emptyList() + + val pubkey = user(key).pubkeyHex + val inbox = key.account.notificationRelays.flow.value + val groups = groupsByRelay(key.account) + + // 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 marker advances it again. + val armed = pager.armedRelays(inbox + groups.keys) + if (armed.isEmpty()) return emptyList() + + return armed.flatMap { relay -> + val until = pager.requestedUntilFor(relay) ?: return@flatMap emptyList() + Log.d(TAG) { "[notifications.history] REQ ${relay.url} until=$until limit=${pager.pageLimit}" } + buildList { + if (relay in inbox) { + addAll(filterNotificationsHistoryToPubkey(relay, pubkey, until, pager.pageLimit)) + } + groups[relay]?.let { groupIds -> + addAll(filterGroupNotificationsHistoryToPubkey(relay, pubkey, groupIds, until, pager.pageLimit)) + } + } + } + } + + /** Steps a single [relay] to its next, older page. Driven by that relay's on-screen window-limit marker. */ + 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() { + if (pager.advanceAll()) { + Log.d(TAG) { "[notifications.history] advanceAll (empty-feed bootstrap)" } + invalidateFilters() + } + } + + private val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: AccountQueryState): Subscription { + // Repoint the single-active orchestrator at this account's notification cursors and the relay set + // it fans out to, refreshing the display flows from the restored progress. + pager.bind(key.account.notificationHistory, key.account.scope) { notificationRelaySet(key.account) } + + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + // A relay joining/leaving the paged set (inbox change, group join/leave) re-issues the REQ + // so a newly-added relay can be armed and a removed one drops out. + key.account.scope.launch(Dispatchers.IO) { + key.account.notificationRelays.flow + .sample(1000) + .collectLatest { invalidateFilters() } + }, + key.account.scope.launch(Dispatchers.IO) { + key.account.relayGroupList.liveRelayGroupList + .sample(1000) + .collectLatest { invalidateFilters() } + }, + ) + + return requestNewSubscription(historyListener(key)) + } + + 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.notificationHistory + return object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors) && pager.onEose(relay)) { + Log.d(TAG) { "[notifications.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) + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message) + } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message) + } + } + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } + + companion object { + private const val TAG = "NotificationPagination" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt index 05f95c095d..579eb13aa5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt @@ -124,6 +124,70 @@ val NotificationsPerKeyKinds3 = AttestorRecommendationEvent.KIND, ) +/** + * Every kind the notifications feed cares about on the user's inbox relays, flattened into one list. + * The live-tail query ([filterNotificationsToPubkey] / [filterSummaryNotificationsToPubkey]) splits these + * across several filters with different per-kind limits; backward history paging instead asks ONE filter + * per relay so the single until+limit cursor stays gap-proof (an empty page truly means "nothing older"). + */ +val AllNotificationKinds = + (SummaryKinds + NotificationsPerKeyKinds + NotificationsPerKeyKinds2 + NotificationsPerKeyKinds3).distinct() + +/** + * One backward-paging page of notifications on an inbox relay: the N newest events tagging me + * ([AllNotificationKinds], `#p` = me) strictly older than [until]. A single filter (not the live query's + * split) so the [BackwardRelayPager][com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager] + * cursor tracking the oldest delivered `created_at` can't skip a band that a per-kind sub-limit capped. + */ +fun filterNotificationsHistoryToPubkey( + relay: NormalizedRelayUrl, + pubkey: HexKey?, + until: Long, + limit: Int, +): List { + if (pubkey.isNullOrEmpty()) return emptyList() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = AllNotificationKinds, + tags = mapOf("p" to listOf(pubkey)), + limit = limit, + until = until, + ), + ), + ) +} + +/** + * One backward-paging page of NIP-29 group-activity notifications on a group's host relay: + * [GroupNotificationKinds] tagging me (`#p`) inside my joined groups (`#h`), strictly older than [until]. + */ +fun filterGroupNotificationsHistoryToPubkey( + relay: NormalizedRelayUrl, + pubkey: HexKey?, + groupIds: List, + until: Long, + limit: Int, +): List { + if (pubkey.isNullOrEmpty() || groupIds.isEmpty()) return emptyList() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = GroupNotificationKinds, + tags = mapOf("p" to listOf(pubkey), "h" to groupIds), + limit = limit, + until = until, + ), + ), + ) +} + fun filterSummaryNotificationsToPubkey( relay: NormalizedRelayUrl, pubkey: HexKey?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt index 1dcbdcfa52..57c5b3a306 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt @@ -53,6 +53,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color 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.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 import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.commons.ui.notifications.Card import com.vitorpamplona.amethyst.commons.ui.notifications.CardFeedState @@ -70,6 +76,7 @@ import com.vitorpamplona.amethyst.ui.note.NutzapUserSetCompose import com.vitorpamplona.amethyst.ui.note.ZapUserSetCompose import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.formatHistoryReachDate import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.donations.ShowDonationCard import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -77,7 +84,13 @@ import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.imageModifier +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter @Composable fun RenderCardFeed( @@ -92,6 +105,12 @@ fun RenderCardFeed( ) { val feedState by feedContent.feedContent.collectAsStateWithLifecycle() + // A genuinely empty feed has no card rows to host the per-relay window-limit markers, so step every + // relay one page at a time to hunt for the first notifications. Once cards appear the markers take + // over, demand-driven. Gated on Empty only (never the transient Loading navigation flashes through). + val history = remember(accountViewModel) { accountViewModel.dataSources().account.notificationsHistory } + BootstrapNotificationHistoryWhenEmpty(feedState is CardFeedState.Empty, history.loadingMore, history.status) { history.advanceAll() } + // Direct switch instead of CrossfadeIfEnabled: the crossfade's `currentlyVisible` // accumulator can leave a previous `Loaded` instance composed alongside the new // one when refreshes (e.g. double-tap on the Notifications tab) bounce the @@ -153,6 +172,37 @@ private fun FeedLoaded( val items by loaded.feed.collectAsStateWithLifecycle() val openPolls by polls.flow.collectAsStateWithLifecycle() + // Backward time-pagination of the notifications feed: each notification relay carries a window-limit + // marker placed at the oldest point it has paged to. Scrolling that marker into view pulls the relay's + // next, older page (see RelayReachSentinels) — the same demand-driven mechanism the DM history uses. + val history = remember(accountViewModel) { accountViewModel.dataSources().account.notificationsHistory } + val historyStatus by history.status.collectAsStateWithLifecycle() + + // One cursor per relay: its reached depth, state (reaching / stalled / done) and the advance() that + // pulls its next page. A done relay's marker sinks to the oldest end reading "fully loaded". + val limits = + remember(historyStatus) { + historyStatus.relayProgress.map { (relay, p) -> + RelayReachCursor(relay.url, relayShortName(relay), p.reachedUntil, reachState(p)) { history.advance(relay) } + } + } + + // Count of items above the notification cards in the LazyColumn (scaffold header + donation card + + // open-poll cards), so the hoisted sentinel can map a visible LazyColumn index back to a card. + val leadingItemCount = (if (headerContent != null) 1 else 0) + 1 + openPolls.size + + // Hoisted load driver (above the LazyColumn): pages each relay off viewport visibility, so feed + // reorders don't re-fire paging. The per-gap markers below are pure UI. + if (limits.isNotEmpty()) { + RelayReachSentinels(limits, listState) { index -> items.list.getOrNull(index - leadingItemCount)?.createdAt() } + } + + // The relays behind a tapped in-stream marker; non-null shows the per-relay breakdown popup. + var syncDetail by remember { mutableStateOf?>(null) } + syncDetail?.let { detail -> + RelayReachDetailDialog(detail, ::formatHistoryReachDate) { syncDetail = null } + } + StickToTopOnPrepend(listState, items.list.firstOrNull()?.id()) // Track which card is highlighted (will auto-clear after animation) @@ -233,7 +283,7 @@ private fun FeedLoaded( items = items.list, key = { _, item -> item.id() }, contentType = { _, item -> item.javaClass.simpleName }, - ) { _, item -> + ) { index, item -> val isHighlighted = highlightedCardId == item.id() val highlightColor by animateColorAsState( targetValue = if (isHighlighted) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) else Color.Transparent, @@ -257,10 +307,62 @@ private fun FeedLoaded( HorizontalDivider( thickness = DividerThickness, ) + + // Per-relay window-limit markers in the gap toward the next-older card: each pulls its relay's + // next page while on screen. olderCreatedAt is null past the oldest loaded card, so relays that + // reached the bottom of the list sit there as "fully loaded". + if (limits.isNotEmpty()) { + RelayReachMarkers( + limits, + item.createdAt(), + items.list.getOrNull(index + 1)?.createdAt(), + ) { syncDetail = it } + } } } } +/** + * Bootstraps notification history while the feed is genuinely empty: steps every relay one page at a + * time, gated on its own loader, until notifications appear or every relay exhausts. Once cards load this + * stops and the per-relay window-limit markers drive paging on demand. + * + * Leads with a debounce so the brief Empty/Loading flash navigation passes through does NOT trigger a + * hunt; if [active] drops before it elapses (cards loaded) the effect cancels and nothing pages. + */ +@Composable +private fun BootstrapNotificationHistoryWhenEmpty( + active: Boolean, + loadingMore: StateFlow, + status: StateFlow, + advanceAll: () -> Unit, +) { + LaunchedEffect(active, loadingMore, status) { + if (!active) return@LaunchedEffect + delay(BOOTSTRAP_DEBOUNCE_MS) + combine(loadingMore, status) { loading, s -> !loading && !s.exhausted } + .distinctUntilChanged() + .filter { it } + .collect { advanceAll() } + } +} + +// Ignore the transient empty feed that navigation flashes through before notifications re-appear. +private const val BOOTSTRAP_DEBOUNCE_MS = 1200L + +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 + .substringAfter("://") + .trimEnd('/') + .substringBefore('/') + @Composable private fun RenderCardItem( item: Card, diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsHistoryTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsHistoryTest.kt new file mode 100644 index 0000000000..32a887a94d --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsHistoryTest.kt @@ -0,0 +1,86 @@ +/* + * 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.nip01Notifications + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pins the backward-paging notification history filters: they must ask for the N newest events strictly + * OLDER than a cursor (`until`+`limit`, no `since`), so the single per-relay cursor the + * [BackwardRelayPager][com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager] tracks + * can't skip a band and an empty page truly means "nothing older" (see RelayLoadingCursors). + */ +class FilterNotificationsHistoryTest { + private val relay = RelayUrlNormalizer.normalize("wss://inbox.example.com") + private val pubkey = "aa".repeat(32) + private val until = 1_700_000_000L + + @Test + fun `history filter asks one until+limit page tagging me, no since`() { + val filters = filterNotificationsHistoryToPubkey(relay, pubkey, until, 500) + + // A single combined-kinds filter, not the live query's split — one cursor stays gap-proof. + assertEquals(1, filters.size) + val f = filters.first().filter + assertEquals(relay, filters.first().relay) + assertEquals(until, f.until) + assertEquals(500, f.limit) + assertNull("history pages by until, never since", f.since) + assertEquals(listOf(pubkey), f.tags?.get("p")) + assertEquals(AllNotificationKinds, f.kinds) + } + + @Test + fun `combined kinds cover every live-query notification kind`() { + listOf(SummaryKinds, NotificationsPerKeyKinds, NotificationsPerKeyKinds2, NotificationsPerKeyKinds3) + .flatten() + .forEach { kind -> + assertTrue("AllNotificationKinds must include live kind $kind", kind in AllNotificationKinds) + } + // Flattened + de-duplicated: no kind appears twice. + assertEquals(AllNotificationKinds.size, AllNotificationKinds.toSet().size) + } + + @Test + fun `group history filter scopes to my groups by h tag`() { + val groupIds = listOf("group-a", "group-b") + val filters = filterGroupNotificationsHistoryToPubkey(relay, pubkey, groupIds, until, 500) + + assertEquals(1, filters.size) + val f = filters.first().filter + assertEquals(until, f.until) + assertNull(f.since) + assertEquals(listOf(pubkey), f.tags?.get("p")) + assertEquals(groupIds, f.tags?.get("h")) + assertEquals(GroupNotificationKinds, f.kinds) + } + + @Test + fun `empty pubkey or groups yields no filter`() { + assertTrue(filterNotificationsHistoryToPubkey(relay, null, until, 500).isEmpty()) + assertTrue(filterNotificationsHistoryToPubkey(relay, "", until, 500).isEmpty()) + assertTrue(filterGroupNotificationsHistoryToPubkey(relay, pubkey, emptyList(), until, 500).isEmpty()) + } +} From ad75344c3e7eca4d799d2231ad726e6fe621fa68 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 19:48:27 +0000 Subject: [PATCH 2/6] feat(notifications): infinite-scroll paging via a look-ahead buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch the notifications feed from marker-visibility paging (load only when the bottom marker is on screen) to infinite scroll: keep ~100 already-loaded rows below the viewport so the user practically never reaches the end. - Replace RelayReachSentinels with a buffer-depth driver: when fewer than NOTIFICATION_LOOKAHEAD_BUFFER (100) rows remain ahead of the last visible one, step every not-done relay one older page (advanceAll). It re-fires as each page settles until the buffer refills or all relays run dry — the wallet's lastVisibleIndex >= totalItems - N pattern with a large N. - Keep the per-relay BackwardRelayPager engine, cursors and filters unchanged. - Keep the in-feed per-relay progress markers + tap-through detail dialog; they are now purely visual (loading is driven by the buffer, not by them). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f --- .../loggedIn/notifications/CardFeedView.kt | 63 ++++++++++++------- 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt index 57c5b3a306..afafdc267b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt @@ -44,6 +44,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -57,7 +58,6 @@ import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus 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 import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.commons.ui.notifications.Card @@ -105,9 +105,9 @@ fun RenderCardFeed( ) { val feedState by feedContent.feedContent.collectAsStateWithLifecycle() - // A genuinely empty feed has no card rows to host the per-relay window-limit markers, so step every - // relay one page at a time to hunt for the first notifications. Once cards appear the markers take - // over, demand-driven. Gated on Empty only (never the transient Loading navigation flashes through). + // A genuinely empty feed has no rows for the look-ahead buffer driver to measure, so step every relay + // one page at a time to hunt for the first notifications. Once cards appear the buffer driver takes + // over. Gated on Empty only (never the transient Loading navigation flashes through). val history = remember(accountViewModel) { accountViewModel.dataSources().account.notificationsHistory } BootstrapNotificationHistoryWhenEmpty(feedState is CardFeedState.Empty, history.loadingMore, history.status) { history.advanceAll() } @@ -172,14 +172,37 @@ private fun FeedLoaded( val items by loaded.feed.collectAsStateWithLifecycle() val openPolls by polls.flow.collectAsStateWithLifecycle() - // Backward time-pagination of the notifications feed: each notification relay carries a window-limit - // marker placed at the oldest point it has paged to. Scrolling that marker into view pulls the relay's - // next, older page (see RelayReachSentinels) — the same demand-driven mechanism the DM history uses. + // Infinite-scroll backward pagination: the notifications feed keeps a fat look-ahead buffer of older + // notifications loaded below the viewport. When fewer than [NOTIFICATION_LOOKAHEAD_BUFFER] rows remain + // ahead of the last visible one, every not-done relay is stepped one older page (until+limit, gap-proof) + // — refilling eagerly and repeating until the buffer is full again or all relays run dry. The per-relay + // [BackwardRelayPager] engine is the same one the DM history uses; only the trigger differs (buffer depth + // here vs. marker visibility there). val history = remember(accountViewModel) { accountViewModel.dataSources().account.notificationsHistory } val historyStatus by history.status.collectAsStateWithLifecycle() - // One cursor per relay: its reached depth, state (reaching / stalled / done) and the advance() that - // pulls its next page. A done relay's marker sinks to the oldest end reading "fully loaded". + // Keep a big runway of already-loaded rows below the fold so the user effectively never reaches the end. + val exhausted = historyStatus.exhausted + val loadingMore by history.loadingMore.collectAsStateWithLifecycle() + val shouldLoadMore by remember { + derivedStateOf { + val lastVisibleIndex = + listState.layoutInfo.visibleItemsInfo + .lastOrNull() + ?.index ?: 0 + val totalItems = listState.layoutInfo.totalItemsCount + totalItems > 0 && lastVisibleIndex >= totalItems - NOTIFICATION_LOOKAHEAD_BUFFER + } + } + // Re-evaluated when the buffer runs low, a page settles (loadingMore falls), or paging exhausts — so a + // single page that doesn't refill the whole buffer keeps pulling the next until it does or relays run dry. + LaunchedEffect(shouldLoadMore, loadingMore, exhausted) { + if (shouldLoadMore && !loadingMore && !exhausted) history.advanceAll() + } + + // One cursor per relay: its reached depth, state (reaching / stalled / done) and the advance() that pulls + // its next page (also usable to retry a stalled relay by tapping). A done relay's marker sinks to the + // oldest end reading "fully loaded". val limits = remember(historyStatus) { historyStatus.relayProgress.map { (relay, p) -> @@ -187,16 +210,6 @@ private fun FeedLoaded( } } - // Count of items above the notification cards in the LazyColumn (scaffold header + donation card + - // open-poll cards), so the hoisted sentinel can map a visible LazyColumn index back to a card. - val leadingItemCount = (if (headerContent != null) 1 else 0) + 1 + openPolls.size - - // Hoisted load driver (above the LazyColumn): pages each relay off viewport visibility, so feed - // reorders don't re-fire paging. The per-gap markers below are pure UI. - if (limits.isNotEmpty()) { - RelayReachSentinels(limits, listState) { index -> items.list.getOrNull(index - leadingItemCount)?.createdAt() } - } - // The relays behind a tapped in-stream marker; non-null shows the per-relay breakdown popup. var syncDetail by remember { mutableStateOf?>(null) } syncDetail?.let { detail -> @@ -308,9 +321,9 @@ private fun FeedLoaded( thickness = DividerThickness, ) - // Per-relay window-limit markers in the gap toward the next-older card: each pulls its relay's - // next page while on screen. olderCreatedAt is null past the oldest loaded card, so relays that - // reached the bottom of the list sit there as "fully loaded". + // Per-relay progress markers in the gap toward the next-older card, at the depth each relay has + // paged to (loading is driven by the look-ahead buffer above, not by these). olderCreatedAt is + // null past the oldest loaded card, so relays that reached the bottom sit there as "fully loaded". if (limits.isNotEmpty()) { RelayReachMarkers( limits, @@ -325,7 +338,7 @@ private fun FeedLoaded( /** * Bootstraps notification history while the feed is genuinely empty: steps every relay one page at a * time, gated on its own loader, until notifications appear or every relay exhausts. Once cards load this - * stops and the per-relay window-limit markers drive paging on demand. + * stops and the look-ahead buffer driver takes over, keeping older pages loaded ahead of the viewport. * * Leads with a debounce so the brief Empty/Loading flash navigation passes through does NOT trigger a * hunt; if [active] drops before it elapses (cards loaded) the effect cancels and nothing pages. @@ -350,6 +363,10 @@ private fun BootstrapNotificationHistoryWhenEmpty( // Ignore the transient empty feed that navigation flashes through before notifications re-appear. private const val BOOTSTRAP_DEBOUNCE_MS = 1200L +// How many already-loaded rows to keep below the last visible one before pulling the next older page. +// Large on purpose: the feed reads as infinite scroll, the user practically never reaches the bottom. +private const val NOTIFICATION_LOOKAHEAD_BUFFER = 100 + private fun reachState(p: RelayPagingProgress): RelayReachState = when { p.done -> RelayReachState.DONE From cc94ef91036290fcc18e5b2bfe809c3ba8b86d35 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 20:14:30 +0000 Subject: [PATCH 3/6] fix(notifications): retry stalled relays via per-relay sentinels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The buffer-only driver stopped paging once every relay was done-or-stalled (exhausted), so a transient all-relays blip halted history until re-navigation — bad for faulty relays with different datasets, exactly when we'd miss data. Restore the per-relay RelayReachSentinels alongside the look-ahead buffer: - the buffer driver (advanceAll) keeps the runway full from healthy relays; - the sentinels retry an individual relay when its frontier marker scrolls into view — the recovery path once the buffer can't keep the frontier ahead (relays stalled/exhausted), naturally rate-limited by scrolling. The buffer keeps the frontier ~a screen below the fold, so the sentinels stay quiet during normal scrolling and only fire on stall/end. This also makes the kept per-relay markers functional again (they drive the retry) instead of purely decorative. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f --- .../loggedIn/notifications/CardFeedView.kt | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt index afafdc267b..6f43b4624a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt @@ -58,6 +58,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus 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 import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.commons.ui.notifications.Card @@ -172,12 +173,15 @@ private fun FeedLoaded( val items by loaded.feed.collectAsStateWithLifecycle() val openPolls by polls.flow.collectAsStateWithLifecycle() - // Infinite-scroll backward pagination: the notifications feed keeps a fat look-ahead buffer of older - // notifications loaded below the viewport. When fewer than [NOTIFICATION_LOOKAHEAD_BUFFER] rows remain - // ahead of the last visible one, every not-done relay is stepped one older page (until+limit, gap-proof) - // — refilling eagerly and repeating until the buffer is full again or all relays run dry. The per-relay - // [BackwardRelayPager] engine is the same one the DM history uses; only the trigger differs (buffer depth - // here vs. marker visibility there). + // Infinite-scroll backward pagination over the per-relay [BackwardRelayPager] (the same engine the DM + // history uses — each relay keeps its own until+limit cursor so faulty relays with different datasets + // page independently and can't gap each other). Two drivers cooperate: + // 1. the look-ahead BUFFER below keeps a fat runway of older notifications loaded ahead of the viewport + // (advanceAll), so healthy relays fill the feed and the user practically never reaches the end; + // 2. the per-relay MARKERS/SENTINELS below retry an INDIVIDUAL relay when its frontier marker scrolls + // into view — the recovery path for a stalled/faulty relay, naturally rate-limited by scrolling. + // The buffer keeps the frontier ~a screen-full below the fold, so the sentinels stay quiet during normal + // scrolling and only fire when the buffer can't keep up (relays stalled/exhausted) — exactly a retry. val history = remember(accountViewModel) { accountViewModel.dataSources().account.notificationsHistory } val historyStatus by history.status.collectAsStateWithLifecycle() @@ -201,8 +205,7 @@ private fun FeedLoaded( } // One cursor per relay: its reached depth, state (reaching / stalled / done) and the advance() that pulls - // its next page (also usable to retry a stalled relay by tapping). A done relay's marker sinks to the - // oldest end reading "fully loaded". + // its next page. A done relay's marker sinks to the oldest end reading "fully loaded". val limits = remember(historyStatus) { historyStatus.relayProgress.map { (relay, p) -> @@ -210,6 +213,18 @@ private fun FeedLoaded( } } + // Count of items above the notification cards in the LazyColumn (scaffold header + donation card + open + // polls), so the hoisted sentinel can map a visible LazyColumn index back to a card. + val leadingItemCount = (if (headerContent != null) 1 else 0) + 1 + openPolls.size + + // Per-relay retry driver: when a relay's frontier marker is on screen (the buffer couldn't keep the + // frontier ahead, i.e. that relay stalled or the feed is genuinely at its end), step that one relay. + // A done relay drives nothing. This is the recovery path the buffer driver above can't cover once every + // relay is stalled (exhausted) — scrolling to the stalled marker retries it, no hammering. + if (limits.isNotEmpty()) { + RelayReachSentinels(limits, listState) { index -> items.list.getOrNull(index - leadingItemCount)?.createdAt() } + } + // The relays behind a tapped in-stream marker; non-null shows the per-relay breakdown popup. var syncDetail by remember { mutableStateOf?>(null) } syncDetail?.let { detail -> @@ -321,9 +336,10 @@ private fun FeedLoaded( thickness = DividerThickness, ) - // Per-relay progress markers in the gap toward the next-older card, at the depth each relay has - // paged to (loading is driven by the look-ahead buffer above, not by these). olderCreatedAt is - // null past the oldest loaded card, so relays that reached the bottom sit there as "fully loaded". + // Per-relay markers in the gap toward the next-older card, at the depth each relay has paged to. + // The bulk load is buffer-driven (above); these mark each relay's frontier and drive the + // stalled-relay retry when scrolled into view (see the sentinel above). olderCreatedAt is null + // past the oldest loaded card, so relays that reached the bottom sit there as "fully loaded". if (limits.isNotEmpty()) { RelayReachMarkers( limits, From c8f43a1bd4eb54fadbfb79cac795cd434c5d11d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 20:36:43 +0000 Subject: [PATCH 4/6] feat(notifications): auto-retry faulty relays + actionable relay detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improve the notifications history UX around slow/unreachable relays, keeping the per-relay markers (they let users notice their own bad relays) but making recovery automatic and the tap-through actionable. - Auto-retry stalled relays with backoff (~3s→30s): once the buffer driver stops (every relay done-or-stalled) but some are merely stalled, keep re-advancing them so recovery no longer depends on the user scrolling to the marker or reopening the screen. One non-restarting effect so the backoff survives the transient in-flight blips each retry causes; cancels on leave. - Add a "Try Again" action to RelayReachDetailDialog (shared): when a caller passes onRetry and a relay is stalled, the tapped marker's detail popup offers an active retry and drops the now-inaccurate "retries on reopen" hint. Notifications wire it to advanceAll; DM callers pass nothing (unchanged). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f --- .../loggedIn/notifications/CardFeedView.kt | 29 ++++++++++++++++++- .../commons/ui/feeds/DmHistoryLoadingCard.kt | 25 ++++++++++++++-- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt index 6f43b4624a..605cade9b8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt @@ -92,6 +92,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first @Composable fun RenderCardFeed( @@ -204,6 +205,26 @@ private fun FeedLoaded( if (shouldLoadMore && !loadingMore && !exhausted) history.advanceAll() } + // Auto-retry faulty relays with backoff. The buffer driver above stops once every relay is done-or- + // stalled (exhausted); when some are merely stalled (a slow/unreachable relay, not a real end) this + // keeps re-advancing them so recovery doesn't depend on the user scrolling to the marker or reopening. + // A single non-restarting effect so the backoff survives the transient in-flight blips each retry causes. + LaunchedEffect(history) { + var backoffMs = STALLED_RETRY_MIN_MS + while (true) { + history.status.first { it.exhausted && it.stalledCount > 0 } // park until stuck on a stalled relay + while (true) { + delay(backoffMs) + val s = history.status.value + if (!(s.exhausted && s.stalledCount > 0)) break // recovered (a relay answered, or scroll retried) + history.advanceAll() + history.loadingMore.first { !it } // let the retry settle before escalating + backoffMs = (backoffMs * 2).coerceAtMost(STALLED_RETRY_MAX_MS) + } + backoffMs = STALLED_RETRY_MIN_MS // reset for the next stall + } + } + // One cursor per relay: its reached depth, state (reaching / stalled / done) and the advance() that pulls // its next page. A done relay's marker sinks to the oldest end reading "fully loaded". val limits = @@ -228,7 +249,8 @@ private fun FeedLoaded( // The relays behind a tapped in-stream marker; non-null shows the per-relay breakdown popup. var syncDetail by remember { mutableStateOf?>(null) } syncDetail?.let { detail -> - RelayReachDetailDialog(detail, ::formatHistoryReachDate) { syncDetail = null } + // Tap-through offers a Try Again on stalled relays, so a user who sees a bad relay can act on it. + RelayReachDetailDialog(detail, ::formatHistoryReachDate, onRetry = { history.advanceAll() }) { syncDetail = null } } StickToTopOnPrepend(listState, items.list.firstOrNull()?.id()) @@ -383,6 +405,11 @@ private const val BOOTSTRAP_DEBOUNCE_MS = 1200L // Large on purpose: the feed reads as infinite scroll, the user practically never reaches the bottom. private const val NOTIFICATION_LOOKAHEAD_BUFFER = 100 +// Backoff bounds for auto-retrying stalled (slow/unreachable) relays: first retry ~3s after a stall, +// doubling up to ~30s, so a faulty relay is retried gently but keeps a chance to recover on its own. +private const val STALLED_RETRY_MIN_MS = 3_000L +private const val STALLED_RETRY_MAX_MS = 30_000L + private fun reachState(p: RelayPagingProgress): RelayReachState = when { p.done -> RelayReachState.DONE 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 e32444f27c..bc308cc97b 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 @@ -59,6 +59,7 @@ import androidx.compose.ui.text.style.TextOverflow 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.action_try_again 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 @@ -377,14 +378,32 @@ private fun relayShortName(relay: NormalizedRelayUrl): String = fun RelayReachDetailDialog( cursors: List, formatReachDate: (epochSeconds: Long) -> String, + // When provided and at least one relay is stalled, the dialog offers a "Try Again" action that + // re-advances the stalled relays on demand (and suppresses the "retries on reopen" hint, since the + // caller retries actively). Null keeps the passive, dismiss-only dialog (the DM callers). + onRetry: (() -> Unit)? = null, onDismiss: () -> Unit, ) { val rows = remember(cursors) { cursors.sortedBy { it.reachedUntil } } + val showRetry = onRetry != null && rows.any { it.state == RelayReachState.STALLED } AlertDialog( onDismissRequest = onDismiss, confirmButton = { - TextButton(onClick = onDismiss) { Text(stringResource(Res.string.action_dismiss)) } + if (showRetry) { + TextButton(onClick = { + onRetry?.invoke() + onDismiss() + }) { Text(stringResource(Res.string.action_try_again)) } + } else { + TextButton(onClick = onDismiss) { Text(stringResource(Res.string.action_dismiss)) } + } }, + dismissButton = + if (showRetry) { + { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.action_dismiss)) } } + } else { + null + }, title = { Text(stringResource(Res.string.chats_history_by_relay)) }, text = { Column( @@ -425,7 +444,9 @@ fun RelayReachDetailDialog( maxLines = 1, ) } - if (c.state == RelayReachState.STALLED) StalledRetryHint() + // The passive "retries on reopen" caption only applies without an active Try Again + // action; with one, the button (and the caller's auto-retry) covers it. + if (c.state == RelayReachState.STALLED && onRetry == null) StalledRetryHint() } } } From 05426d8c662b0239f02b84cd389588bdd9634a41 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 21:07:30 +0000 Subject: [PATCH 5/6] fix(notifications): bound eager fill + only the active feed drives paging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the two audit findings on the infinite-scroll driver. - Bound the eager fill (#1): the buffer targets 100 rows but pages are pulled in events and notifications collapse heavily into cards, so on a dense account a fill could keep pulling until it downloaded the whole history to reach the row target. Cap consecutive pages pulled WITHOUT scrolling (NOTIFICATION_MAX_PAGES_PER_BURST); scrolling resets the budget, so paging resumes as the buffer is consumed. Appended older cards don't move firstVisibleItemIndex, so the from-top preload still fills the full look-ahead on open — only a dense whale is bounded. - Only the active feed drives (#2): add drivesPaging (default true); the split screen passes page == pagerState.currentPage so an off-screen tab composed during a swipe no longer drives the shared account pager, and its buffer driver / auto-retry loop / sentinels stay idle. Single screen and side panel keep driving. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f --- .../loggedIn/notifications/CardFeedView.kt | 56 +++++++++++++++---- .../notifications/NotificationScreen.kt | 7 +++ 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt index 605cade9b8..3a986fad92 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt @@ -46,6 +46,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -104,14 +105,20 @@ fun RenderCardFeed( routeForLastRead: String, scrollToEventId: String? = null, headerContent: (@Composable () -> Unit)? = null, + // Whether THIS feed drives the shared account history pager. False for an off-screen split tab / second + // pane, so only the feed the user is actually looking at pages the pager (see #2 in the audit). + drivesPaging: Boolean = true, ) { val feedState by feedContent.feedContent.collectAsStateWithLifecycle() // A genuinely empty feed has no rows for the look-ahead buffer driver to measure, so step every relay // one page at a time to hunt for the first notifications. Once cards appear the buffer driver takes - // over. Gated on Empty only (never the transient Loading navigation flashes through). + // over. Gated on Empty only (never the transient Loading navigation flashes through), and only for the + // active feed so an off-screen tab doesn't hunt on the shared pager. val history = remember(accountViewModel) { accountViewModel.dataSources().account.notificationsHistory } - BootstrapNotificationHistoryWhenEmpty(feedState is CardFeedState.Empty, history.loadingMore, history.status) { history.advanceAll() } + if (drivesPaging) { + BootstrapNotificationHistoryWhenEmpty(feedState is CardFeedState.Empty, history.loadingMore, history.status) { history.advanceAll() } + } // Direct switch instead of CrossfadeIfEnabled: the crossfade's `currentlyVisible` // accumulator can leave a previous `Loaded` instance composed alongside the new @@ -137,6 +144,7 @@ fun RenderCardFeed( nav = nav, scrollToEventId = scrollToEventId, headerContent = headerContent, + drivesPaging = drivesPaging, ) } @@ -170,6 +178,7 @@ private fun FeedLoaded( nav: INav, scrollToEventId: String? = null, headerContent: (@Composable () -> Unit)? = null, + drivesPaging: Boolean = true, ) { val items by loaded.feed.collectAsStateWithLifecycle() val openPolls by polls.flow.collectAsStateWithLifecycle() @@ -199,17 +208,35 @@ private fun FeedLoaded( totalItems > 0 && lastVisibleIndex >= totalItems - NOTIFICATION_LOOKAHEAD_BUFFER } } - // Re-evaluated when the buffer runs low, a page settles (loadingMore falls), or paging exhausts — so a - // single page that doesn't refill the whole buffer keeps pulling the next until it does or relays run dry. - LaunchedEffect(shouldLoadMore, loadingMore, exhausted) { - if (shouldLoadMore && !loadingMore && !exhausted) history.advanceAll() + + // Bound the eager fill. Pages are pulled in events but the buffer is counted in rows, and notifications + // collapse heavily into cards — so on a dense account a page can add very few rows, and an uncapped fill + // would keep pulling until it downloaded the whole history to reach the row target. Cap the consecutive + // pages pulled WITHOUT the user scrolling; scrolling (firstVisibleItemIndex moving) resets the budget so + // paging resumes as the buffer is consumed. From position 0 this still preloads the full look-ahead for a + // normal account (1–2 pages), yet a dense whale can't burst-download everything on open. + val firstVisibleIndex by remember { derivedStateOf { listState.firstVisibleItemIndex } } + var pagesThisBurst by remember { mutableIntStateOf(0) } + LaunchedEffect(firstVisibleIndex) { pagesThisBurst = 0 } + + // Re-evaluated when the buffer runs low, a page settles (loadingMore falls), paging exhausts, this feed + // (de)activates, or the burst budget changes — so a page that doesn't refill the buffer keeps pulling the + // next (up to the burst cap) until the buffer is full or relays run dry. Only the active feed drives, so + // an off-screen tab / second pane doesn't page the shared account pager the user isn't looking at. + LaunchedEffect(drivesPaging, shouldLoadMore, loadingMore, exhausted, pagesThisBurst) { + if (drivesPaging && shouldLoadMore && !loadingMore && !exhausted && pagesThisBurst < NOTIFICATION_MAX_PAGES_PER_BURST) { + history.advanceAll() + pagesThisBurst++ + } } - // Auto-retry faulty relays with backoff. The buffer driver above stops once every relay is done-or- - // stalled (exhausted); when some are merely stalled (a slow/unreachable relay, not a real end) this - // keeps re-advancing them so recovery doesn't depend on the user scrolling to the marker or reopening. - // A single non-restarting effect so the backoff survives the transient in-flight blips each retry causes. - LaunchedEffect(history) { + // Auto-retry faulty relays with backoff, only while this feed drives paging. The buffer driver above + // stops once every relay is done-or-stalled (exhausted); when some are merely stalled (a slow/unreachable + // relay, not a real end) this keeps re-advancing them so recovery doesn't depend on the user scrolling to + // the marker or reopening. A single non-restarting loop so the backoff survives the transient in-flight + // blips each retry causes. + LaunchedEffect(history, drivesPaging) { + if (!drivesPaging) return@LaunchedEffect var backoffMs = STALLED_RETRY_MIN_MS while (true) { history.status.first { it.exhausted && it.stalledCount > 0 } // park until stuck on a stalled relay @@ -242,7 +269,7 @@ private fun FeedLoaded( // frontier ahead, i.e. that relay stalled or the feed is genuinely at its end), step that one relay. // A done relay drives nothing. This is the recovery path the buffer driver above can't cover once every // relay is stalled (exhausted) — scrolling to the stalled marker retries it, no hammering. - if (limits.isNotEmpty()) { + if (drivesPaging && limits.isNotEmpty()) { RelayReachSentinels(limits, listState) { index -> items.list.getOrNull(index - leadingItemCount)?.createdAt() } } @@ -405,6 +432,11 @@ private const val BOOTSTRAP_DEBOUNCE_MS = 1200L // Large on purpose: the feed reads as infinite scroll, the user practically never reaches the bottom. private const val NOTIFICATION_LOOKAHEAD_BUFFER = 100 +// Cap on consecutive pages pulled to fill the buffer WITHOUT the user scrolling (the budget resets on +// scroll). Generous so a normal account preloads the full look-ahead from the top in 1–2 pages, while a +// dense account whose events collapse into few cards is bounded instead of burst-downloading everything. +private const val NOTIFICATION_MAX_PAGES_PER_BURST = 6 + // Backoff bounds for auto-retrying stalled (slow/unreachable) relays: first retry ~3s after a stall, // doubling up to ~30s, so a faulty relay is retried gently but keeps a chance to recover on its own. private const val STALLED_RETRY_MIN_MS = 3_000L diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt index d9ad7b2826..b226f9d3df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt @@ -258,6 +258,9 @@ private fun SplitNotificationsBody( nav: INav, ) { HorizontalPager(state = pagerState) { page -> + // Only the settled, on-screen tab drives the shared account history pager, so the off-screen tab + // (composed during a swipe) doesn't page notifications the user isn't looking at. + val drivesPaging = page == pagerState.currentPage when (page) { 0 -> { NotificationPagerPage( @@ -265,6 +268,7 @@ private fun SplitNotificationsBody( pollContent = notifPolls, scrollStateKey = ScrollStateKeys.NOTIFICATION_FOLLOWING, scrollToEventId = scrollToEventId, + drivesPaging = drivesPaging, accountViewModel = accountViewModel, nav = nav, ) @@ -278,6 +282,7 @@ private fun SplitNotificationsBody( // Only the Following tab honors the deep-link scroll target so users // aren't bounced when they swipe across to Everyone. scrollToEventId = null, + drivesPaging = drivesPaging, accountViewModel = accountViewModel, nav = nav, ) @@ -294,6 +299,7 @@ private fun NotificationPagerPage( scrollToEventId: String?, accountViewModel: AccountViewModel, nav: INav, + drivesPaging: Boolean = true, ) { RefresheableBox(state, true) { val listState = rememberForeverLazyListState(scrollStateKey) @@ -309,6 +315,7 @@ private fun NotificationPagerPage( routeForLastRead = NOTIFICATION_LAST_READ_KEY, scrollToEventId = scrollToEventId, headerContent = { ObserveInboxRelayListAndDisplayIfNotFound(accountViewModel, nav) }, + drivesPaging = drivesPaging, ) } } From 77908b2be0d5482dde76b00cfb73393923b8e0ff Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 21:15:06 +0000 Subject: [PATCH 6/6] refactor(notifications): extract history paging out of CardFeedView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CardFeedView had grown a ~90-line paging block plus an auto-retry loop, constants and helpers that aren't about rendering cards. Move all of it into a dedicated NotificationHistoryPaging.kt: - rememberNotificationHistoryPaging(): the look-ahead buffer driver (with the per-burst cap), the stalled-relay auto-retry loop, cursor building, and the per-relay sentinels — returns the List the feed draws. - BootstrapNotificationHistoryWhenEmpty(): the empty-feed hunt. - The five tuning constants and the reachState / relayShortName helpers. CardFeedView.FeedLoaded now just fetches the pager, calls the helper for the cursors, and renders the detail dialog; the in-gap RelayReachMarkers stay inline (they're per-row). No behavior change. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f --- .../loggedIn/notifications/CardFeedView.kt | 159 +------------- .../NotificationHistoryPaging.kt | 202 ++++++++++++++++++ 2 files changed, 209 insertions(+), 152 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationHistoryPaging.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt index 3a986fad92..f52eae40e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt @@ -44,9 +44,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -55,12 +53,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color 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.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 import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.commons.ui.notifications.Card import com.vitorpamplona.amethyst.commons.ui.notifications.CardFeedState @@ -86,14 +81,7 @@ import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.imageModifier -import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.first @Composable fun RenderCardFeed( @@ -183,95 +171,17 @@ private fun FeedLoaded( val items by loaded.feed.collectAsStateWithLifecycle() val openPolls by polls.flow.collectAsStateWithLifecycle() - // Infinite-scroll backward pagination over the per-relay [BackwardRelayPager] (the same engine the DM - // history uses — each relay keeps its own until+limit cursor so faulty relays with different datasets - // page independently and can't gap each other). Two drivers cooperate: - // 1. the look-ahead BUFFER below keeps a fat runway of older notifications loaded ahead of the viewport - // (advanceAll), so healthy relays fill the feed and the user practically never reaches the end; - // 2. the per-relay MARKERS/SENTINELS below retry an INDIVIDUAL relay when its frontier marker scrolls - // into view — the recovery path for a stalled/faulty relay, naturally rate-limited by scrolling. - // The buffer keeps the frontier ~a screen-full below the fold, so the sentinels stay quiet during normal - // scrolling and only fire when the buffer can't keep up (relays stalled/exhausted) — exactly a retry. + // Infinite-scroll backward pagination of the notifications history. All the driving (look-ahead buffer, + // auto-retry, per-relay sentinels) lives in [rememberNotificationHistoryPaging]; here we just get back + // the per-relay cursors to draw as frontier markers, and keep [history] for the detail dialog's retry. val history = remember(accountViewModel) { accountViewModel.dataSources().account.notificationsHistory } - val historyStatus by history.status.collectAsStateWithLifecycle() - - // Keep a big runway of already-loaded rows below the fold so the user effectively never reaches the end. - val exhausted = historyStatus.exhausted - val loadingMore by history.loadingMore.collectAsStateWithLifecycle() - val shouldLoadMore by remember { - derivedStateOf { - val lastVisibleIndex = - listState.layoutInfo.visibleItemsInfo - .lastOrNull() - ?.index ?: 0 - val totalItems = listState.layoutInfo.totalItemsCount - totalItems > 0 && lastVisibleIndex >= totalItems - NOTIFICATION_LOOKAHEAD_BUFFER - } - } - - // Bound the eager fill. Pages are pulled in events but the buffer is counted in rows, and notifications - // collapse heavily into cards — so on a dense account a page can add very few rows, and an uncapped fill - // would keep pulling until it downloaded the whole history to reach the row target. Cap the consecutive - // pages pulled WITHOUT the user scrolling; scrolling (firstVisibleItemIndex moving) resets the budget so - // paging resumes as the buffer is consumed. From position 0 this still preloads the full look-ahead for a - // normal account (1–2 pages), yet a dense whale can't burst-download everything on open. - val firstVisibleIndex by remember { derivedStateOf { listState.firstVisibleItemIndex } } - var pagesThisBurst by remember { mutableIntStateOf(0) } - LaunchedEffect(firstVisibleIndex) { pagesThisBurst = 0 } - - // Re-evaluated when the buffer runs low, a page settles (loadingMore falls), paging exhausts, this feed - // (de)activates, or the burst budget changes — so a page that doesn't refill the buffer keeps pulling the - // next (up to the burst cap) until the buffer is full or relays run dry. Only the active feed drives, so - // an off-screen tab / second pane doesn't page the shared account pager the user isn't looking at. - LaunchedEffect(drivesPaging, shouldLoadMore, loadingMore, exhausted, pagesThisBurst) { - if (drivesPaging && shouldLoadMore && !loadingMore && !exhausted && pagesThisBurst < NOTIFICATION_MAX_PAGES_PER_BURST) { - history.advanceAll() - pagesThisBurst++ - } - } - - // Auto-retry faulty relays with backoff, only while this feed drives paging. The buffer driver above - // stops once every relay is done-or-stalled (exhausted); when some are merely stalled (a slow/unreachable - // relay, not a real end) this keeps re-advancing them so recovery doesn't depend on the user scrolling to - // the marker or reopening. A single non-restarting loop so the backoff survives the transient in-flight - // blips each retry causes. - LaunchedEffect(history, drivesPaging) { - if (!drivesPaging) return@LaunchedEffect - var backoffMs = STALLED_RETRY_MIN_MS - while (true) { - history.status.first { it.exhausted && it.stalledCount > 0 } // park until stuck on a stalled relay - while (true) { - delay(backoffMs) - val s = history.status.value - if (!(s.exhausted && s.stalledCount > 0)) break // recovered (a relay answered, or scroll retried) - history.advanceAll() - history.loadingMore.first { !it } // let the retry settle before escalating - backoffMs = (backoffMs * 2).coerceAtMost(STALLED_RETRY_MAX_MS) - } - backoffMs = STALLED_RETRY_MIN_MS // reset for the next stall - } - } - - // One cursor per relay: its reached depth, state (reaching / stalled / done) and the advance() that pulls - // its next page. A done relay's marker sinks to the oldest end reading "fully loaded". - val limits = - remember(historyStatus) { - historyStatus.relayProgress.map { (relay, p) -> - RelayReachCursor(relay.url, relayShortName(relay), p.reachedUntil, reachState(p)) { history.advance(relay) } - } - } - // Count of items above the notification cards in the LazyColumn (scaffold header + donation card + open // polls), so the hoisted sentinel can map a visible LazyColumn index back to a card. val leadingItemCount = (if (headerContent != null) 1 else 0) + 1 + openPolls.size - - // Per-relay retry driver: when a relay's frontier marker is on screen (the buffer couldn't keep the - // frontier ahead, i.e. that relay stalled or the feed is genuinely at its end), step that one relay. - // A done relay drives nothing. This is the recovery path the buffer driver above can't cover once every - // relay is stalled (exhausted) — scrolling to the stalled marker retries it, no hammering. - if (drivesPaging && limits.isNotEmpty()) { - RelayReachSentinels(limits, listState) { index -> items.list.getOrNull(index - leadingItemCount)?.createdAt() } - } + val limits = + rememberNotificationHistoryPaging(history, listState, drivesPaging) { index -> + items.list.getOrNull(index - leadingItemCount)?.createdAt() + } // The relays behind a tapped in-stream marker; non-null shows the per-relay breakdown popup. var syncDetail by remember { mutableStateOf?>(null) } @@ -400,61 +310,6 @@ private fun FeedLoaded( } } -/** - * Bootstraps notification history while the feed is genuinely empty: steps every relay one page at a - * time, gated on its own loader, until notifications appear or every relay exhausts. Once cards load this - * stops and the look-ahead buffer driver takes over, keeping older pages loaded ahead of the viewport. - * - * Leads with a debounce so the brief Empty/Loading flash navigation passes through does NOT trigger a - * hunt; if [active] drops before it elapses (cards loaded) the effect cancels and nothing pages. - */ -@Composable -private fun BootstrapNotificationHistoryWhenEmpty( - active: Boolean, - loadingMore: StateFlow, - status: StateFlow, - advanceAll: () -> Unit, -) { - LaunchedEffect(active, loadingMore, status) { - if (!active) return@LaunchedEffect - delay(BOOTSTRAP_DEBOUNCE_MS) - combine(loadingMore, status) { loading, s -> !loading && !s.exhausted } - .distinctUntilChanged() - .filter { it } - .collect { advanceAll() } - } -} - -// Ignore the transient empty feed that navigation flashes through before notifications re-appear. -private const val BOOTSTRAP_DEBOUNCE_MS = 1200L - -// How many already-loaded rows to keep below the last visible one before pulling the next older page. -// Large on purpose: the feed reads as infinite scroll, the user practically never reaches the bottom. -private const val NOTIFICATION_LOOKAHEAD_BUFFER = 100 - -// Cap on consecutive pages pulled to fill the buffer WITHOUT the user scrolling (the budget resets on -// scroll). Generous so a normal account preloads the full look-ahead from the top in 1–2 pages, while a -// dense account whose events collapse into few cards is bounded instead of burst-downloading everything. -private const val NOTIFICATION_MAX_PAGES_PER_BURST = 6 - -// Backoff bounds for auto-retrying stalled (slow/unreachable) relays: first retry ~3s after a stall, -// doubling up to ~30s, so a faulty relay is retried gently but keeps a chance to recover on its own. -private const val STALLED_RETRY_MIN_MS = 3_000L -private const val STALLED_RETRY_MAX_MS = 30_000L - -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 - .substringAfter("://") - .trimEnd('/') - .substringBefore('/') - @Composable private fun RenderCardItem( item: Card, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationHistoryPaging.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationHistoryPaging.kt new file mode 100644 index 0000000000..4dee72c0ab --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationHistoryPaging.kt @@ -0,0 +1,202 @@ +/* + * 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.notifications + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachSentinels +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsHistoryEoseManager +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first + +/** + * Drives the notifications feed's infinite-scroll backward pagination over the account's per-relay + * [AccountNotificationsHistoryEoseManager] (a [BackwardRelayPager][com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager]: + * each relay keeps its own until+limit cursor so faulty relays with different datasets page independently + * and can't gap each other) and returns the per-relay [RelayReachCursor]s the feed draws as frontier + * markers. + * + * Three cooperating drivers, all gated on [drivesPaging] so only the on-screen feed pages the shared + * account pager (an off-screen split tab / second pane stays idle): + * 1. **Look-ahead buffer** — keeps a fat runway of older notifications loaded ahead of the viewport, so + * healthy relays fill the feed and the user practically never reaches the end. Bounded per burst + * ([NOTIFICATION_MAX_PAGES_PER_BURST]) and reset by scrolling, so a dense account whose events collapse + * into few cards can't burst-download its whole history to hit the row target, while a normal account + * still preloads the full look-ahead from the top. + * 2. **Auto-retry** — when every relay is done-or-stalled (exhausted) but some are merely stalled (a + * slow/unreachable relay, not a real end), re-advances them on a backoff so recovery doesn't depend on + * the user scrolling to the marker or reopening. + * 3. **Per-relay sentinels** — retry an individual relay the moment its frontier marker scrolls into view; + * the buffer keeps the frontier below the fold, so these stay quiet unless the buffer can't keep up. + * + * @param createdAtAt createdAt of the card at a LazyColumn index (null past the ends / non-card rows), so + * the hoisted sentinel can test which inter-card gap a relay's cursor sits in against the visible rows. + */ +@Composable +fun rememberNotificationHistoryPaging( + history: AccountNotificationsHistoryEoseManager, + listState: LazyListState, + drivesPaging: Boolean, + createdAtAt: (index: Int) -> Long?, +): List { + val historyStatus by history.status.collectAsStateWithLifecycle() + val exhausted = historyStatus.exhausted + val loadingMore by history.loadingMore.collectAsStateWithLifecycle() + + // Keep a big runway of already-loaded rows below the fold so the user effectively never reaches the end. + val shouldLoadMore by remember { + derivedStateOf { + val lastVisibleIndex = + listState.layoutInfo.visibleItemsInfo + .lastOrNull() + ?.index ?: 0 + val totalItems = listState.layoutInfo.totalItemsCount + totalItems > 0 && lastVisibleIndex >= totalItems - NOTIFICATION_LOOKAHEAD_BUFFER + } + } + + // Bound the eager fill. Pages are pulled in events but the buffer is counted in rows, and notifications + // collapse heavily into cards — so on a dense account a page can add very few rows, and an uncapped fill + // would keep pulling until it downloaded the whole history to reach the row target. Cap the consecutive + // pages pulled WITHOUT the user scrolling; scrolling (firstVisibleItemIndex moving) resets the budget so + // paging resumes as the buffer is consumed. From position 0 this still preloads the full look-ahead for a + // normal account (1–2 pages), yet a dense whale can't burst-download everything on open. + val firstVisibleIndex by remember { derivedStateOf { listState.firstVisibleItemIndex } } + var pagesThisBurst by remember { mutableIntStateOf(0) } + LaunchedEffect(firstVisibleIndex) { pagesThisBurst = 0 } + + // Re-evaluated when the buffer runs low, a page settles (loadingMore falls), paging exhausts, this feed + // (de)activates, or the burst budget changes — so a page that doesn't refill the buffer keeps pulling the + // next (up to the burst cap) until the buffer is full or relays run dry. + LaunchedEffect(drivesPaging, shouldLoadMore, loadingMore, exhausted, pagesThisBurst) { + if (drivesPaging && shouldLoadMore && !loadingMore && !exhausted && pagesThisBurst < NOTIFICATION_MAX_PAGES_PER_BURST) { + history.advanceAll() + pagesThisBurst++ + } + } + + // Auto-retry faulty relays with backoff, only while this feed drives paging. A single non-restarting + // loop so the backoff survives the transient in-flight blips each retry causes. + LaunchedEffect(history, drivesPaging) { + if (!drivesPaging) return@LaunchedEffect + var backoffMs = STALLED_RETRY_MIN_MS + while (true) { + history.status.first { it.exhausted && it.stalledCount > 0 } // park until stuck on a stalled relay + while (true) { + delay(backoffMs) + val s = history.status.value + if (!(s.exhausted && s.stalledCount > 0)) break // recovered (a relay answered, or scroll retried) + history.advanceAll() + history.loadingMore.first { !it } // let the retry settle before escalating + backoffMs = (backoffMs * 2).coerceAtMost(STALLED_RETRY_MAX_MS) + } + backoffMs = STALLED_RETRY_MIN_MS // reset for the next stall + } + } + + // One cursor per relay: its reached depth, state (reaching / stalled / done) and the advance() that pulls + // its next page. A done relay's marker sinks to the oldest end reading "fully loaded". + val limits = + remember(historyStatus) { + historyStatus.relayProgress.map { (relay, p) -> + RelayReachCursor(relay.url, relayShortName(relay), p.reachedUntil, reachState(p)) { history.advance(relay) } + } + } + + // Per-relay retry driver: when a relay's frontier marker is on screen (the buffer couldn't keep the + // frontier ahead, i.e. that relay stalled or the feed is genuinely at its end), step that one relay. + if (drivesPaging && limits.isNotEmpty()) { + RelayReachSentinels(limits, listState, createdAtAt) + } + + return limits +} + +/** + * Bootstraps notification history while the feed is genuinely empty: steps every relay one page at a + * time, gated on its own loader, until notifications appear or every relay exhausts. Once cards load this + * stops and the look-ahead buffer driver takes over, keeping older pages loaded ahead of the viewport. + * + * Leads with a debounce so the brief Empty/Loading flash navigation passes through does NOT trigger a + * hunt; if [active] drops before it elapses (cards loaded) the effect cancels and nothing pages. + */ +@Composable +fun BootstrapNotificationHistoryWhenEmpty( + active: Boolean, + loadingMore: StateFlow, + status: StateFlow, + advanceAll: () -> Unit, +) { + LaunchedEffect(active, loadingMore, status) { + if (!active) return@LaunchedEffect + delay(BOOTSTRAP_DEBOUNCE_MS) + combine(loadingMore, status) { loading, s -> !loading && !s.exhausted } + .distinctUntilChanged() + .filter { it } + .collect { advanceAll() } + } +} + +// Ignore the transient empty feed that navigation flashes through before notifications re-appear. +private const val BOOTSTRAP_DEBOUNCE_MS = 1200L + +// How many already-loaded rows to keep below the last visible one before pulling the next older page. +// Large on purpose: the feed reads as infinite scroll, the user practically never reaches the bottom. +private const val NOTIFICATION_LOOKAHEAD_BUFFER = 100 + +// Cap on consecutive pages pulled to fill the buffer WITHOUT the user scrolling (the budget resets on +// scroll). Generous so a normal account preloads the full look-ahead from the top in 1–2 pages, while a +// dense account whose events collapse into few cards is bounded instead of burst-downloading everything. +private const val NOTIFICATION_MAX_PAGES_PER_BURST = 6 + +// Backoff bounds for auto-retrying stalled (slow/unreachable) relays: first retry ~3s after a stall, +// doubling up to ~30s, so a faulty relay is retried gently but keeps a chance to recover on its own. +private const val STALLED_RETRY_MIN_MS = 3_000L +private const val STALLED_RETRY_MAX_MS = 30_000L + +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 + .substringAfter("://") + .trimEnd('/') + .substringBefore('/')