From 43de099e7ecf9a927534cf9aa888ac8bbbf019aa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 22:41:08 +0000 Subject: [PATCH] feat: scroll-driven NIP-17 loading in conversations (drop eager load-all) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the eager full gift-wrap load on conversation open with the same scroll-driven widening the rooms list uses. As the user scrolls a thread toward older messages (reverse-laid-out, so older = higher indices), the account-wide gift-wrap window widens one step at a time — prefetching at the midpoint so older messages land before the top is reached — and stops once the window is exhausted. A thread that already fills the viewport doesn't load anything extra until you actually scroll back. The shared chat feed (used by public channels, ephemeral chats, live activities, marmot groups too) stays generic: it gains an opt-in listStateObserver slot, and only the private-DM screen attaches the gift-wrap loader through it. NIP-04 in a conversation is still loaded in full (it was already, and a single room's kind:4 is cheap), so only the windowed NIP-17 side is scroll-driven; the thread is time-sorted so the two merge without reordering. loadEverything stays for the rooms-list button. --- .../loggedIn/chats/feed/ChatFeedView.kt | 4 ++ .../loggedIn/chats/privateDM/ChatroomView.kt | 52 +++++++++++++++---- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt index 9fc8f3aa96..af35f579b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt @@ -58,8 +58,12 @@ fun RefreshingChatroomFeedView( onWantsToEditDraft: (Note) -> Unit, avoidDraft: DraftTagState? = null, scrollStateKey: String? = null, + // Opt-in hook handed the feed's scroll state, so a specific screen (e.g. private DMs) can + // attach scroll-driven loading. No-op for the public-chat / channel callers that don't paginate. + listStateObserver: @Composable (LazyListState) -> Unit = {}, ) { SaveableFeedState(feedContentState, scrollStateKey) { listState -> + listStateObserver(listState) RenderChatFeedView( feedContentState, accountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt index 33b6d933e1..eaee6ad314 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt @@ -24,10 +24,13 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -46,6 +49,9 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch @Composable @@ -123,19 +129,41 @@ fun ChatroomView( } /** - * Pulls the account-wide gift-wrap (NIP-17) history all the way back when a conversation opens, so - * a thread always shows its full history regardless of how narrow the rooms-list window currently is. + * Scroll-driven NIP-17 history loader for a conversation. The thread is reverse-laid-out (newest at + * the bottom, index 0), so older messages live at higher indices; this widens the account-wide + * gift-wrap window one step whenever nothing is loaded yet or the oldest visible row has crossed the + * midpoint of what's loaded — prefetching older gift wraps before the user reaches the top — and + * stops once the window is exhausted. * - * Gift wraps are addressed to us (not to the conversation partner), so a relay cannot filter them - * per-room — the only lever is the shared account window. [AccountGiftWrapsEoseManager.loadEverything] - * is idempotent: once the window has reached the maximum lookback this is a no-op, so opening or - * reopening conversations after the first full load costs nothing. + * Gift wraps are addressed to us (not the partner), so a relay cannot filter them per-room: the only + * lever is the shared account window. NIP-04 in a conversation is already loaded in full, so only the + * windowed NIP-17 side needs this. The combined [AccountGiftWrapsEoseManager.loadingMore] guard gates + * each step on the previous window finishing, so it advances one step at a time, not in a burst. */ @Composable -private fun EnsureFullGiftWrapHistory(accountViewModel: AccountViewModel) { - LaunchedEffect(accountViewModel) { - val giftWraps = accountViewModel.dataSources().account.giftWraps - giftWraps.loadEverything(accountViewModel.userProfile()) +private fun LoadOlderGiftWrapsWhenScrolling( + listState: LazyListState, + accountViewModel: AccountViewModel, +) { + val giftWraps = remember(accountViewModel) { accountViewModel.dataSources().account.giftWraps } + + LaunchedEffect(listState, giftWraps) { + combine( + snapshotFlow { + val info = listState.layoutInfo + val total = info.totalItemsCount + val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1 + total == 0 || lastVisible >= total / 2 + }, + giftWraps.loadingMore, + giftWraps.exhausted, + ) { wantMore, loadingMore, exhausted -> + wantMore && !loadingMore && !exhausted + }.distinctUntilChanged() + .filter { it } + .collect { + giftWraps.loadMore(accountViewModel.userProfile()) + } } } @@ -149,7 +177,6 @@ fun ChatroomViewUI( ) { WatchLifecycleAndUpdateModel(feedViewModel) ChatroomFilterAssemblerSubscription(room, accountViewModel.dataSources().chatroom, accountViewModel) - EnsureFullGiftWrapHistory(accountViewModel) Column(Modifier.fillMaxHeight()) { ObserveRelayListForDMsAndDisplayIfNotFound(accountViewModel, nav) @@ -169,6 +196,9 @@ fun ChatroomViewUI( avoidDraft = newPostModel.draftTag, onWantsToReply = newPostModel::reply, onWantsToEditDraft = newPostModel::editFromDraft, + listStateObserver = { listState -> + LoadOlderGiftWrapsWhenScrolling(listState, accountViewModel) + }, ) }