Compare commits

...

1 Commits

Author SHA1 Message Date
Claude
4435b8c1bd feat: paginate gift wrap loading for DM chat list
Loads gift wraps in batches of 100 using limit/until filters instead of
fetching all at once. Auto-paginates until 50 distinct chatrooms are
found, then switches to live EOSE-based tracking. Additional pages load
on scroll in the DM chat list via a load-more trigger near the bottom.

https://claude.ai/code/session_01SFTkZZcmdf3WVL9ZCpSY3H
2026-03-29 04:46:54 +00:00
4 changed files with 206 additions and 8 deletions

View File

@@ -56,11 +56,13 @@ class AccountFilterAssembler(
failureTracker: RelayOfflineTracker,
scope: CoroutineScope,
) : ComposeSubscriptionManager<AccountQueryState>() {
val giftWraps = AccountGiftWrapsEoseManager(client, ::allKeys)
val group =
listOf(
AccountMetadataEoseManager(client, ::allKeys),
AccountFollowsLoaderSubAssembler(client, cache, scope, authenticator, failureTracker, ::allKeys),
AccountGiftWrapsEoseManager(client, ::allKeys),
giftWraps,
AccountDraftsEoseManager(client, ::allKeys),
AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys),
AccountNotificationsEoseFromRandomRelaysManager(client, ::allKeys),

View File

@@ -26,10 +26,16 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.Account
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
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.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@@ -37,23 +43,90 @@ class AccountGiftWrapsEoseManager(
client: INostrClient,
allKeys: () -> Set<AccountQueryState>,
) : PerUserEoseManager<AccountQueryState>(client, allKeys) {
companion object {
const val TARGET_CHATROOMS = 50
const val BATCH_SIZE = 100
}
override fun user(key: AccountQueryState) = key.account.userProfile()
// Pagination state
private var paginationCursor: Long? = null
private var paginationStartTime: Long = TimeUtils.now()
private var initialLoadComplete = false
private var oldestEventInBatch: Long? = null
private var eventsInBatch = 0
private var batchEoseReceived = false
private val _hasMore = MutableStateFlow(true)
val hasMore = _hasMore.asStateFlow()
private val _isLoadingMore = MutableStateFlow(false)
val isLoadingMore = _isLoadingMore.asStateFlow()
private var currentKey: AccountQueryState? = null
override fun updateFilter(
key: AccountQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
// Only loads DMs if the account is writeable
return if (key.account.isWriteable()) {
key.account.dmRelays.flow.value.flatMap { relay ->
if (!key.account.isWriteable()) return emptyList()
currentKey = key
val pubkey = user(key).pubkeyHex
if (initialLoadComplete && !_isLoadingMore.value) {
// Live mode: normal since-based tracking
return key.account.dmRelays.flow.value.flatMap { relay ->
filterGiftWrapsToPubkey(
relay = relay,
pubkey = user(key).pubkeyHex,
pubkey = pubkey,
since = since?.get(relay)?.time,
)
}
} else {
emptyList()
}
// Pagination mode (initial load or load-more)
if (batchEoseReceived) {
val roomCount =
key.account.chatroomList.rooms
.size()
if (roomCount >= TARGET_CHATROOMS || eventsInBatch == 0) {
// Pagination phase done
if (!initialLoadComplete) {
initialLoadComplete = true
}
_hasMore.value = eventsInBatch > 0
_isLoadingMore.value = false
batchEoseReceived = false
// Switch to live mode
return key.account.dmRelays.flow.value.flatMap { relay ->
filterGiftWrapsToPubkey(
relay = relay,
pubkey = pubkey,
since = paginationStartTime.minus(TimeUtils.twoDays()),
)
}
} else {
// Need more: advance cursor
paginationCursor = oldestEventInBatch?.minus(1)
}
}
// Reset batch tracking for new page
batchEoseReceived = false
eventsInBatch = 0
oldestEventInBatch = null
return key.account.dmRelays.flow.value.flatMap { relay ->
filterGiftWrapsToPubkeyPaginated(
relay = relay,
pubkey = pubkey,
until = paginationCursor,
limit = BATCH_SIZE,
)
}
}
@@ -72,7 +145,50 @@ class AccountGiftWrapsEoseManager(
},
)
return super.newSub(key)
currentKey = key
paginationStartTime = TimeUtils.now()
initialLoadComplete = false
paginationCursor = null
batchEoseReceived = false
eventsInBatch = 0
oldestEventInBatch = null
_hasMore.value = true
_isLoadingMore.value = false
return requestNewSubscription(
object : SubscriptionListener {
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (initialLoadComplete && !_isLoadingMore.value) {
newEose(key, relay, TimeUtils.now(), forFilters)
} else {
batchEoseReceived = true
invalidateFilters()
}
}
override fun onEvent(
event: com.vitorpamplona.quartz.nip01Core.core.Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (!initialLoadComplete || _isLoadingMore.value) {
eventsInBatch++
val eventTime = event.createdAt
val oldest = oldestEventInBatch
if (oldest == null || eventTime < oldest) {
oldestEventInBatch = eventTime
}
}
if (isLive) {
newEose(key, relay, TimeUtils.now(), forFilters)
}
}
},
)
}
override fun endSub(
@@ -82,4 +198,13 @@ class AccountGiftWrapsEoseManager(
super.endSub(key, subId)
userJobMap[key]?.forEach { it.cancel() }
}
fun loadMore() {
if (!initialLoadComplete || !_hasMore.value || _isLoadingMore.value) return
_isLoadingMore.value = true
batchEoseReceived = false
eventsInBatch = 0
oldestEventInBatch = null
invalidateFilters()
}
}

View File

@@ -46,3 +46,25 @@ fun filterGiftWrapsToPubkey(
),
)
}
fun filterGiftWrapsToPubkeyPaginated(
relay: NormalizedRelayUrl,
pubkey: HexKey?,
until: Long?,
limit: Int,
): List<RelayBasedFilter> {
if (pubkey.isNullOrEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(GiftWrapEvent.KIND),
tags = mapOf("p" to listOf(pubkey)),
until = until,
limit = limit,
),
),
)
}

View File

@@ -21,15 +21,25 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
@@ -102,6 +112,27 @@ private fun FeedLoaded(
) {
val items by loaded.feed.collectAsStateWithLifecycle()
val giftWrapsManager = accountViewModel.dataSources().account.giftWraps
val isLoadingMore by giftWrapsManager.isLoadingMore.collectAsStateWithLifecycle()
val hasMore by giftWrapsManager.hasMore.collectAsStateWithLifecycle()
val shouldLoadMore by remember {
derivedStateOf {
val lastVisibleIndex =
listState.layoutInfo.visibleItemsInfo
.lastOrNull()
?.index ?: 0
val totalItems = listState.layoutInfo.totalItemsCount
lastVisibleIndex >= totalItems - 5 && !isLoadingMore && hasMore && items.list.isNotEmpty()
}
}
LaunchedEffect(shouldLoadMore) {
if (shouldLoadMore) {
giftWrapsManager.loadMore()
}
}
LazyColumn(
contentPadding = FeedPadding,
state = listState,
@@ -122,5 +153,23 @@ private fun FeedLoaded(
thickness = DividerThickness,
)
}
if (isLoadingMore) {
item(key = "loading_more") {
Box(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.secondary,
strokeWidth = 2.dp,
)
}
}
}
}
}