fix: stop scroll-to-end widening from cascading the DM window

Re-key the rooms list edge-detector on listState only, instead of
(listState, itemCount). Keying on item count re-armed the detector on
every widen: loadMore pulled older conversations, the list grew,
LaunchedEffect restarted, the edge-detector reset, and it fired again —
walking the window 7->14->21->...->112 days back in a few seconds on a
slow connection. Now distinctUntilChanged fires once per reach-the-end
gesture and does not re-fire while parked at the end.

Also surface initialLoadInFlight from both DM loaders (gift wraps +
NIP-04) and keep a spinner up on the rooms screen until the first relay
answers, so cold boot no longer flashes the empty state before the DMs
land.
This commit is contained in:
Claude
2026-05-30 21:11:43 +00:00
parent e930cc6ef0
commit 80dc2f2d91
3 changed files with 40 additions and 8 deletions
@@ -66,6 +66,12 @@ class AccountGiftWrapsEoseManager(
private val _loadingMore = MutableStateFlow(false)
val loadingMore: StateFlow<Boolean> = _loadingMore.asStateFlow()
// True from cold boot until the first EOSE arrives. Lets the rooms screen keep
// showing a spinner during the (Tor-slow) initial load instead of flashing the
// empty state before any relay has answered.
private val _initialLoadInFlight = MutableStateFlow(true)
val initialLoadInFlight: StateFlow<Boolean> = _initialLoadInFlight.asStateFlow()
override fun updateFilter(
key: AccountQueryState,
since: SincePerRelayMap?,
@@ -152,6 +158,7 @@ class AccountGiftWrapsEoseManager(
bootStartMs[pubkey] = System.currentTimeMillis()
bootEventCount[pubkey] = 0
bootEoseLogged.remove(pubkey)
_initialLoadInFlight.value = true
Log.d(TAG) { "cold boot: pubkey=${pubkey.take(8)}… opening gift-wrap subscription, starting to load messages" }
// Custom listener so we can tell a real EOSE (load finished) apart from live
@@ -163,6 +170,7 @@ class AccountGiftWrapsEoseManager(
forFilters: List<Filter>?,
) {
if (bootEoseLogged.add(pubkey)) {
_initialLoadInFlight.value = false
val elapsed = System.currentTimeMillis() - (bootStartMs[pubkey] ?: System.currentTimeMillis())
val count = bootEventCount[pubkey] ?: 0
Log.d(TAG) {
@@ -54,6 +54,11 @@ class DMsFromUserFilterSubAssembler(
private val _loadingMore = MutableStateFlow(false)
val loadingMore: StateFlow<Boolean> = _loadingMore.asStateFlow()
// True from (re)subscribe until the first relay response, so the rooms screen can
// keep a spinner up during the initial load instead of flashing the empty state.
private val _initialLoadInFlight = MutableStateFlow(true)
val initialLoadInFlight: StateFlow<Boolean> = _initialLoadInFlight.asStateFlow()
override fun updateFilter(
key: ChatroomListState,
since: SincePerRelayMap?,
@@ -84,6 +89,7 @@ class DMsFromUserFilterSubAssembler(
filters: List<Filter>?,
) {
if (_loadingMore.value) _loadingMore.value = false
_initialLoadInFlight.value = false
super.newEose(key, relay, time, filters)
}
@@ -90,6 +90,15 @@ private fun CrossFadeState(
) {
val feedState by feedContentState.feedContent.collectAsStateWithLifecycle()
// While the first gift-wrap / NIP-04 window is still being fetched and decrypted, an
// empty feed means "not loaded yet", not "no conversations". Keep the spinner up until
// both initial loads answer so cold boot doesn't flash the empty state before the DMs land.
val giftWraps = remember(accountViewModel) { accountViewModel.dataSources().account.giftWraps }
val nip04Dms = remember(accountViewModel) { accountViewModel.dataSources().chatroomList.nip04Dms }
val giftWrapsInitialLoad by giftWraps.initialLoadInFlight.collectAsStateWithLifecycle()
val nip04InitialLoad by nip04Dms.initialLoadInFlight.collectAsStateWithLifecycle()
val initialLoadInFlight = giftWrapsInitialLoad || nip04InitialLoad
CrossfadeIfEnabled(
targetState = feedState,
animationSpec = tween(durationMillis = 100),
@@ -97,7 +106,11 @@ private fun CrossFadeState(
) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty { feedContentState.invalidateData() }
if (initialLoadInFlight) {
LoadingFeed()
} else {
FeedEmpty { feedContentState.invalidateData() }
}
}
is FeedState.FeedError -> {
@@ -132,7 +145,7 @@ private fun FeedLoaded(
val loadingNip04 by nip04Dms.loadingMore.collectAsStateWithLifecycle()
val loadingMore = loadingGiftWraps || loadingNip04
LoadMoreWhenReachingEnd(listState, items.list.size, accountViewModel)
LoadMoreWhenReachingEnd(listState, accountViewModel)
LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding),
@@ -187,23 +200,28 @@ private const val LOAD_MORE_THRESHOLD = 5
@Composable
private fun LoadMoreWhenReachingEnd(
listState: LazyListState,
itemCount: Int,
accountViewModel: AccountViewModel,
) {
LaunchedEffect(listState, itemCount) {
// Keyed only on listState so the edge-detector is NOT restarted when a widen adds
// rooms. distinctUntilChanged then fires exactly once per reach-the-end gesture:
// staying at the end does not re-fire, and scrolling back up (nearEnd -> false)
// stops further loads. Keying on item count instead would re-arm on every item
// growth and cascade the window back for minutes over a slow connection.
LaunchedEffect(listState) {
snapshotFlow {
val info = listState.layoutInfo
val total = info.totalItemsCount
val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1
lastVisible >= info.totalItemsCount - LOAD_MORE_THRESHOLD
total > 0 && lastVisible >= total - LOAD_MORE_THRESHOLD
}.distinctUntilChanged()
.filter { it && itemCount > 0 }
.filter { it }
.collect {
val giftWraps = accountViewModel.dataSources().account.giftWraps
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" }
Log.d("DMPagination") { "rooms list reached end but a window load is already in flight, skipping" }
} else {
Log.d("DMPagination") { "rooms list scrolled near end ($itemCount items), widening NIP-17 + NIP-04 windows" }
Log.d("DMPagination") { "rooms list reached end, widening NIP-17 + NIP-04 windows one step" }
val user = accountViewModel.userProfile()
giftWraps.loadMore(user)
nip04Dms.loadMore(user)