feat: window NIP-04 DMs in lockstep with gift wraps in the rooms list

The rooms list merges NIP-04 (kind 4) and NIP-17 (gift wrap) conversations into
one time-sorted list, but only the gift-wrap loader was windowed — NIP-04
(`DMsFromUserFilterSubAssembler`) still used an EOSE-only `since` with no limit,
so it loaded all kind-4 history at boot.

That asymmetry broke scroll-to-load-more: gift wraps filled only the recent top
of the list while NIP-04 filled the whole tail, so reaching the list end (deep in
the NIP-04 tail) fired `giftWraps.loadMore()`, and the newly fetched 7-14d gift
wraps inserted in the *middle* of the feed instead of extending the end — and
could re-fire step after step while the user sat in the NIP-04 tail.

Apply the same TimeWindowPagination to the NIP-04 rooms-list loader and advance
both windows together from the scroll handler, so the merged list is bounded
uniformly and reaching the end extends the actual end. The loading footer now
reflects either protocol still loading.

https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
This commit is contained in:
Claude
2026-05-30 20:42:06 +00:00
parent 976c650b2f
commit e930cc6ef0
3 changed files with 60 additions and 10 deletions
@@ -35,9 +35,11 @@ class ChatroomListState(
class ChatroomListFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<ChatroomListState>() {
val nip04Dms = DMsFromUserFilterSubAssembler(client, ::allKeys)
val group =
listOf(
DMsFromUserFilterSubAssembler(client, ::allKeys),
nip04Dms,
FollowingPublicChatSubAssembler(client, ::allKeys),
FollowingEphemeralChatSubAssembler(client, ::allKeys),
)
@@ -20,15 +20,22 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource
import com.vitorpamplona.amethyst.commons.relayClient.pagination.TimeWindowPagination
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@@ -36,21 +43,50 @@ class DMsFromUserFilterSubAssembler(
client: INostrClient,
allKeys: () -> Set<ChatroomListState>,
) : PerUserEoseManager<ChatroomListState>(client, allKeys) {
// Same moving time window as the gift-wrap (NIP-17) loader, so the merged rooms list is
// bounded uniformly across both DM protocols. Without this, NIP-04 loaded all history while
// NIP-17 only loaded the recent window, so scroll-to-end (which widens the windows) landed
// new NIP-17 rooms in the middle of the NIP-04 tail instead of extending the list end.
private val windows = mutableMapOf<HexKey, TimeWindowPagination>()
private fun windowFor(user: User) = windows.getOrPut(user.pubkeyHex) { TimeWindowPagination() }
private val _loadingMore = MutableStateFlow(false)
val loadingMore: StateFlow<Boolean> = _loadingMore.asStateFlow()
override fun updateFilter(
key: ChatroomListState,
since: SincePerRelayMap?,
): List<RelayBasedFilter>? =
if (key.account.isWriteable()) {
val windowSince = windowFor(user(key)).since
key.account.homeRelays.flow.value.map {
filterNip04DMsFromMe(key.account.userProfile(), it, since?.get(it)?.time)
filterNip04DMsFromMe(key.account.userProfile(), it, windowSince)
} +
key.account.dmRelays.flow.value.map {
filterNip04DMsToMe(key.account.userProfile(), it, since?.get(it)?.time)
filterNip04DMsToMe(key.account.userProfile(), it, windowSince)
}
} else {
emptyList()
}
/** Widens the NIP-04 time window for [user] one step back. Kept in lockstep with the gift-wrap window. */
fun loadMore(user: User) {
windowFor(user).loadMore()
_loadingMore.value = true
invalidateFilters()
}
override fun newEose(
key: ChatroomListState,
relay: NormalizedRelayUrl,
time: Long,
filters: List<Filter>?,
) {
if (_loadingMore.value) _loadingMore.value = false
super.newEose(key, relay, time, filters)
}
override fun user(key: ChatroomListState) = key.account.userProfile()
val userJobMap = mutableMapOf<User, List<Job>>()
@@ -127,7 +127,10 @@ private fun FeedLoaded(
val myPubKey = accountViewModel.userProfile().pubkeyHex
val giftWraps = remember(accountViewModel) { accountViewModel.dataSources().account.giftWraps }
val loadingMore by giftWraps.loadingMore.collectAsStateWithLifecycle()
val nip04Dms = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04Dms }
val loadingGiftWraps by giftWraps.loadingMore.collectAsStateWithLifecycle()
val loadingNip04 by nip04Dms.loadingMore.collectAsStateWithLifecycle()
val loadingMore = loadingGiftWraps || loadingNip04
LoadMoreWhenReachingEnd(listState, items.list.size, accountViewModel)
@@ -170,10 +173,16 @@ private fun FeedLoaded(
private const val LOAD_MORE_THRESHOLD = 5
/**
* Widens the DM time window when the messages list is scrolled near its end, so
* Widens the DM time windows when the messages list is scrolled near its end, so
* older conversations stream in on demand instead of all at boot. Re-evaluates as
* the list grows so a near-empty screen keeps filling; the per-account
* [AccountGiftWrapsEoseManager.loadingMore] guard prevents overlapping requests.
* the list grows so a near-empty screen keeps filling.
*
* Both DM protocols are advanced in lockstep: NIP-17 gift wraps (always-on account
* loader) and NIP-04 (this screen's loader). They must move together — if only one
* were windowed, the merged time-sorted list would mix a deep tail of one protocol
* with a shallow window of the other, and reaching the list end would pull rooms
* that land in the middle of the feed instead of extending the end. The combined
* loadingMore guard prevents overlapping requests.
*/
@Composable
private fun LoadMoreWhenReachingEnd(
@@ -190,11 +199,14 @@ private fun LoadMoreWhenReachingEnd(
.filter { it && itemCount > 0 }
.collect {
val giftWraps = accountViewModel.dataSources().account.giftWraps
if (giftWraps.loadingMore.value) {
val nip04Dms = accountViewModel.dataSources().chatroomList.nip04Dms
if (giftWraps.loadingMore.value || nip04Dms.loadingMore.value) {
Log.d("DMPagination") { "rooms list near end ($itemCount items) but a window load is already in flight, skipping" }
} else {
Log.d("DMPagination") { "rooms list scrolled near end ($itemCount items), requesting an older window of conversations" }
giftWraps.loadMore(accountViewModel.userProfile())
Log.d("DMPagination") { "rooms list scrolled near end ($itemCount items), widening NIP-17 + NIP-04 windows" }
val user = accountViewModel.userProfile()
giftWraps.loadMore(user)
nip04Dms.loadMore(user)
}
}
}