From e930cc6ef04e6fca4d2c27b3cdc2374ca13bcb77 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 20:42:06 +0000 Subject: [PATCH] feat: window NIP-04 DMs in lockstep with gift wraps in the rooms list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../datasource/ChatroomListFilterAssembler.kt | 4 +- .../DMsFromUserFilterSubAssembler.kt | 40 ++++++++++++++++++- .../chats/rooms/feed/ChatroomListFeedView.kt | 26 ++++++++---- 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt index 2b9c534502..d4876752b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt @@ -35,9 +35,11 @@ class ChatroomListState( class ChatroomListFilterAssembler( client: INostrClient, ) : ComposeSubscriptionManager() { + val nip04Dms = DMsFromUserFilterSubAssembler(client, ::allKeys) + val group = listOf( - DMsFromUserFilterSubAssembler(client, ::allKeys), + nip04Dms, FollowingPublicChatSubAssembler(client, ::allKeys), FollowingEphemeralChatSubAssembler(client, ::allKeys), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt index 410e03d59b..803e97a04d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/DMsFromUserFilterSubAssembler.kt @@ -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, ) : PerUserEoseManager(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() + + private fun windowFor(user: User) = windows.getOrPut(user.pubkeyHex) { TimeWindowPagination() } + + private val _loadingMore = MutableStateFlow(false) + val loadingMore: StateFlow = _loadingMore.asStateFlow() + override fun updateFilter( key: ChatroomListState, since: SincePerRelayMap?, ): List? = 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?, + ) { + if (_loadingMore.value) _loadingMore.value = false + super.newEose(key, relay, time, filters) + } + override fun user(key: ChatroomListState) = key.account.userProfile() val userJobMap = mutableMapOf>() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt index 43a1c4e01a..3a962a1217 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt @@ -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) } } }