Merge pull request #3653 from vitorpamplona/claude/notifications-pagination-n0qahe

Add infinite-scroll backward pagination for notification history
This commit is contained in:
Vitor Pamplona
2026-07-20 20:10:05 -04:00
committed by GitHub
9 changed files with 669 additions and 4 deletions
@@ -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,
@@ -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),
)
@@ -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<AccountQueryState>,
) : PerUserEoseManager<AccountQueryState>(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<Boolean> = pager.loadingMore
val status: StateFlow<PagingStatus> = 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<NormalizedRelayUrl, List<String>> =
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<NormalizedRelayUrl> = account.notificationRelays.flow.value + groupsByRelay(account).keys
override fun updateFilter(
key: AccountQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
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<User, List<Job>>()
@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<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
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<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message)
}
override fun onCannotConnect(
relay: NormalizedRelayUrl,
message: String,
forFilters: List<Filter>?,
) {
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"
}
}
@@ -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<RelayBasedFilter> {
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<String>,
until: Long,
limit: Int,
): List<RelayBasedFilter> {
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?,
@@ -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<List<RelayReachCursor>?>(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 }
}
}
}
}
@@ -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<RelayReachCursor> {
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 (12 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<Boolean>,
status: StateFlow<PagingStatus>,
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 12 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('/')
@@ -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,
)
}
}
@@ -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())
}
}
@@ -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<RelayReachCursor>,
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()
}
}
}