refactor(notifications): extract history paging out of CardFeedView

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<RelayReachCursor> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
This commit is contained in:
Claude
2026-07-20 21:15:06 +00:00
parent 05426d8c66
commit 77908b2be0
2 changed files with 209 additions and 152 deletions
@@ -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 (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. 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<List<RelayReachCursor>?>(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<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('/')
@Composable
private fun RenderCardItem(
item: Card,
@@ -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('/')