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 9ffa1c6f1c..4207c80def 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -209,6 +209,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 @@ -660,6 +661,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/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..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 @@ -53,6 +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.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.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.commons.ui.notifications.Card import com.vitorpamplona.amethyst.commons.ui.notifications.CardFeedState @@ -70,6 +73,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 @@ -89,9 +93,21 @@ 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), 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 } + 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 // one when refreshes (e.g. double-tap on the Notifications tab) bounce the @@ -116,6 +132,7 @@ fun RenderCardFeed( nav = nav, scrollToEventId = scrollToEventId, headerContent = headerContent, + drivesPaging = drivesPaging, ) } @@ -149,10 +166,30 @@ 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() + // 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 } + // 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 + 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) } + syncDetail?.let { detail -> + // 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()) // Track which card is highlighted (will auto-clear after animation) @@ -233,7 +270,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,6 +294,18 @@ private fun FeedLoaded( HorizontalDivider( thickness = DividerThickness, ) + + // 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, + item.createdAt(), + items.list.getOrNull(index + 1)?.createdAt(), + ) { syncDetail = it } + } } } } 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('/') 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, ) } } 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()) + } +} 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() } } }