mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 16:33:27 +00:00
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:
+7
-152
@@ -44,9 +44,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -55,12 +53,9 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachDetailDialog
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachSentinels
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState
|
||||
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
|
||||
import com.vitorpamplona.amethyst.commons.ui.notifications.Card
|
||||
import com.vitorpamplona.amethyst.commons.ui.notifications.CardFeedState
|
||||
@@ -86,14 +81,7 @@ import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.imageModifier
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
@Composable
|
||||
fun RenderCardFeed(
|
||||
@@ -183,95 +171,17 @@ private fun FeedLoaded(
|
||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||
val openPolls by polls.flow.collectAsStateWithLifecycle()
|
||||
|
||||
// Infinite-scroll backward pagination over the per-relay [BackwardRelayPager] (the same engine the DM
|
||||
// history uses — each relay keeps its own until+limit cursor so faulty relays with different datasets
|
||||
// page independently and can't gap each other). Two drivers cooperate:
|
||||
// 1. the look-ahead BUFFER below keeps a fat runway of older notifications loaded ahead of the viewport
|
||||
// (advanceAll), so healthy relays fill the feed and the user practically never reaches the end;
|
||||
// 2. the per-relay MARKERS/SENTINELS below retry an INDIVIDUAL relay when its frontier marker scrolls
|
||||
// into view — the recovery path for a stalled/faulty relay, naturally rate-limited by scrolling.
|
||||
// The buffer keeps the frontier ~a screen-full below the fold, so the sentinels stay quiet during normal
|
||||
// scrolling and only fire when the buffer can't keep up (relays stalled/exhausted) — exactly a retry.
|
||||
// Infinite-scroll backward pagination of the notifications history. All the driving (look-ahead buffer,
|
||||
// auto-retry, per-relay sentinels) lives in [rememberNotificationHistoryPaging]; here we just get back
|
||||
// the per-relay cursors to draw as frontier markers, and keep [history] for the detail dialog's retry.
|
||||
val history = remember(accountViewModel) { accountViewModel.dataSources().account.notificationsHistory }
|
||||
val historyStatus by history.status.collectAsStateWithLifecycle()
|
||||
|
||||
// Keep a big runway of already-loaded rows below the fold so the user effectively never reaches the end.
|
||||
val exhausted = historyStatus.exhausted
|
||||
val loadingMore by history.loadingMore.collectAsStateWithLifecycle()
|
||||
val shouldLoadMore by remember {
|
||||
derivedStateOf {
|
||||
val lastVisibleIndex =
|
||||
listState.layoutInfo.visibleItemsInfo
|
||||
.lastOrNull()
|
||||
?.index ?: 0
|
||||
val totalItems = listState.layoutInfo.totalItemsCount
|
||||
totalItems > 0 && lastVisibleIndex >= totalItems - NOTIFICATION_LOOKAHEAD_BUFFER
|
||||
}
|
||||
}
|
||||
|
||||
// Bound the eager fill. Pages are pulled in events but the buffer is counted in rows, and notifications
|
||||
// collapse heavily into cards — so on a dense account a page can add very few rows, and an uncapped fill
|
||||
// would keep pulling until it downloaded the whole history to reach the row target. Cap the consecutive
|
||||
// pages pulled WITHOUT the user scrolling; scrolling (firstVisibleItemIndex moving) resets the budget so
|
||||
// paging resumes as the buffer is consumed. From position 0 this still preloads the full look-ahead for a
|
||||
// normal account (1–2 pages), yet a dense whale can't burst-download everything on open.
|
||||
val firstVisibleIndex by remember { derivedStateOf { listState.firstVisibleItemIndex } }
|
||||
var pagesThisBurst by remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(firstVisibleIndex) { pagesThisBurst = 0 }
|
||||
|
||||
// Re-evaluated when the buffer runs low, a page settles (loadingMore falls), paging exhausts, this feed
|
||||
// (de)activates, or the burst budget changes — so a page that doesn't refill the buffer keeps pulling the
|
||||
// next (up to the burst cap) until the buffer is full or relays run dry. Only the active feed drives, so
|
||||
// an off-screen tab / second pane doesn't page the shared account pager the user isn't looking at.
|
||||
LaunchedEffect(drivesPaging, shouldLoadMore, loadingMore, exhausted, pagesThisBurst) {
|
||||
if (drivesPaging && shouldLoadMore && !loadingMore && !exhausted && pagesThisBurst < NOTIFICATION_MAX_PAGES_PER_BURST) {
|
||||
history.advanceAll()
|
||||
pagesThisBurst++
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-retry faulty relays with backoff, only while this feed drives paging. The buffer driver above
|
||||
// stops once every relay is done-or-stalled (exhausted); when some are merely stalled (a slow/unreachable
|
||||
// relay, not a real end) this keeps re-advancing them so recovery doesn't depend on the user scrolling to
|
||||
// the marker or reopening. A single non-restarting loop so the backoff survives the transient in-flight
|
||||
// blips each retry causes.
|
||||
LaunchedEffect(history, drivesPaging) {
|
||||
if (!drivesPaging) return@LaunchedEffect
|
||||
var backoffMs = STALLED_RETRY_MIN_MS
|
||||
while (true) {
|
||||
history.status.first { it.exhausted && it.stalledCount > 0 } // park until stuck on a stalled relay
|
||||
while (true) {
|
||||
delay(backoffMs)
|
||||
val s = history.status.value
|
||||
if (!(s.exhausted && s.stalledCount > 0)) break // recovered (a relay answered, or scroll retried)
|
||||
history.advanceAll()
|
||||
history.loadingMore.first { !it } // let the retry settle before escalating
|
||||
backoffMs = (backoffMs * 2).coerceAtMost(STALLED_RETRY_MAX_MS)
|
||||
}
|
||||
backoffMs = STALLED_RETRY_MIN_MS // reset for the next stall
|
||||
}
|
||||
}
|
||||
|
||||
// One cursor per relay: its reached depth, state (reaching / stalled / done) and the advance() that pulls
|
||||
// its next page. A done relay's marker sinks to the oldest end reading "fully loaded".
|
||||
val limits =
|
||||
remember(historyStatus) {
|
||||
historyStatus.relayProgress.map { (relay, p) ->
|
||||
RelayReachCursor(relay.url, relayShortName(relay), p.reachedUntil, reachState(p)) { history.advance(relay) }
|
||||
}
|
||||
}
|
||||
|
||||
// Count of items above the notification cards in the LazyColumn (scaffold header + donation card + open
|
||||
// polls), so the hoisted sentinel can map a visible LazyColumn index back to a card.
|
||||
val leadingItemCount = (if (headerContent != null) 1 else 0) + 1 + openPolls.size
|
||||
|
||||
// Per-relay retry driver: when a relay's frontier marker is on screen (the buffer couldn't keep the
|
||||
// frontier ahead, i.e. that relay stalled or the feed is genuinely at its end), step that one relay.
|
||||
// A done relay drives nothing. This is the recovery path the buffer driver above can't cover once every
|
||||
// relay is stalled (exhausted) — scrolling to the stalled marker retries it, no hammering.
|
||||
if (drivesPaging && limits.isNotEmpty()) {
|
||||
RelayReachSentinels(limits, listState) { index -> items.list.getOrNull(index - leadingItemCount)?.createdAt() }
|
||||
}
|
||||
val limits =
|
||||
rememberNotificationHistoryPaging(history, listState, drivesPaging) { index ->
|
||||
items.list.getOrNull(index - leadingItemCount)?.createdAt()
|
||||
}
|
||||
|
||||
// The relays behind a tapped in-stream marker; non-null shows the per-relay breakdown popup.
|
||||
var syncDetail by remember { mutableStateOf<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 1–2 pages, while a
|
||||
// dense account whose events collapse into few cards is bounded instead of burst-downloading everything.
|
||||
private const val NOTIFICATION_MAX_PAGES_PER_BURST = 6
|
||||
|
||||
// Backoff bounds for auto-retrying stalled (slow/unreachable) relays: first retry ~3s after a stall,
|
||||
// doubling up to ~30s, so a faulty relay is retried gently but keeps a chance to recover on its own.
|
||||
private const val STALLED_RETRY_MIN_MS = 3_000L
|
||||
private const val STALLED_RETRY_MAX_MS = 30_000L
|
||||
|
||||
private fun reachState(p: RelayPagingProgress): RelayReachState =
|
||||
when {
|
||||
p.done -> RelayReachState.DONE
|
||||
p.stalled -> RelayReachState.STALLED
|
||||
else -> RelayReachState.REACHING
|
||||
}
|
||||
|
||||
private fun relayShortName(relay: NormalizedRelayUrl): String =
|
||||
relay.url
|
||||
.substringAfter("://")
|
||||
.trimEnd('/')
|
||||
.substringBefore('/')
|
||||
|
||||
@Composable
|
||||
private fun RenderCardItem(
|
||||
item: Card,
|
||||
|
||||
+202
@@ -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 (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<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 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('/')
|
||||
Reference in New Issue
Block a user