mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
Merge pull request #3653 from vitorpamplona/claude/notifications-pagination-n0qahe
Add infinite-scroll backward pagination for notification history
This commit is contained in:
@@ -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,
|
||||
|
||||
+9
-1
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
+222
@@ -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"
|
||||
}
|
||||
}
|
||||
+64
@@ -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?,
|
||||
|
||||
+50
-1
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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('/')
|
||||
+7
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+86
@@ -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())
|
||||
}
|
||||
}
|
||||
+23
-2
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user