feat: scroll-driven NIP-17 loading in conversations (drop eager load-all)

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.
This commit is contained in:
Claude
2026-05-30 22:41:08 +00:00
parent 58973701d0
commit 43de099e7e
2 changed files with 45 additions and 11 deletions
@@ -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,
@@ -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)
},
)
}