From 654c33fee81c881f9cd992b9c07b04e028df23d8 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 26 Jul 2026 22:45:16 -0400 Subject: [PATCH] fix(chats): render the community tab correct on its first frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leaving the Buzz community tab and coming back rebuilt the screen: Direct Messages sat empty for about a second, and the channel list settled into a different order than it had a moment earlier — every visit, and a different order each time. Three causes, all fixed here. **The tab was destroyed, not left.** navBottomBar popped siblings without `saveState`, so the entry — and its ViewModelStore — was thrown away on every tab switch. Returning built new BuzzRelayImportViewModel / BuzzDmListViewModel instances, whose bind() self-guard could not help because the guard lives on an object that no longer existed. Adding saveState/restoreState keeps each tab's state; other tabs get their scroll position back as a side effect. **The DM inbox waited on the network to show what it already had.** bind() called refresh() → discoverMemberChannels(), an 8s-timeout relay round-trip, before anything could render — even though rebuildRows() reads nothing but LocalCache and an in-memory map, and the always-on BuzzDmDiscovery has already recorded those channel ids process-wide in BuzzDmChannels. Seed memberChannels from that registry and project the rows before any network work; refresh still runs behind it and corrects anything stale. **The channel order was arrival order.** buzzGroupIds is "membership ids as the ViewModel emitted them, then directory ids", and the only sort was `sortedByDescending { it.id in starred }` — a stable sort over a boolean, which preserves whatever landed first. Order by (starred, name) so the first frame is the final order; a channel whose 39000 hasn't arrived sorts by id until its name lands. Verified on emulator-5554, capturing ~0.45s after the tab switch: channels in alphabetical order and both DMs present, with no reshuffle in later frames. Previously the same capture showed an empty Direct Messages section and a list that reordered within the second. Co-Authored-By: Claude Opus 5 (1M context) --- .../amethyst/ui/navigation/navs/Nav.kt | 8 +++++ .../loggedIn/buzz/BuzzDmListViewModel.kt | 30 +++++++++++++++++++ .../relayGroup/RelayGroupChannelListScreen.kt | 18 ++++++++--- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt index 1578cbc853..c8081a2cd1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt @@ -94,10 +94,18 @@ class Nav( // Clear sibling bottom-nav entries but keep Home (the start // destination) below, so back-swipe from any tab returns to // Home and back-swipe from Home leaves the app. + // + // saveState/restoreState is what makes a tab survive being left. Without them the + // popped entry is DESTROYED, taking its ViewModelStore with it — so every return to + // a tab rebuilt its screen-scoped ViewModels from nothing and re-fetched. On the + // Buzz community tab that is a visible ~1s of empty Direct Messages plus a channel + // list that reshuffles as data lands; other tabs pay it as lost scroll position. popUpTo(Route.Home) { inclusive = false + saveState = true } launchSingleTop = true + restoreState = true } // Mark this entry as a tab root: hides the back arrow in canPop // and skips the horizontal slide in composableFromEnd. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt index 9c15d9d7bd..2c07d8ccf5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmChannels import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces @@ -126,10 +127,39 @@ class BuzzDmListViewModel : ViewModel() { // challenge was spent unauthenticated, so reconnect to re-challenge and authenticate. if (newlyJoined) account.client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true) + // Paint from cache BEFORE any network work. [discoverMemberChannels] learns the channel ids + // from a relay round-trip, so waiting on it left the Direct Messages section visibly empty + // for about a second on every visit — even though the always-on [BuzzDmDiscovery] already + // recorded those ids process-wide and [rebuildRows] reads nothing but caches. Seeding from + // that registry makes the first frame the right frame; the refresh below still runs and + // corrects anything stale. + seedFromDiscovery(account, relay) + refresh() startLive() } + /** + * Fills [memberChannels] from the app-wide [BuzzDmChannels] registry (scoped to this community's + * relay) and projects the rows straight away, so the inbox renders from cache instead of after a + * fetch. A no-op the first time a viewer ever opens a Buzz relay, when discovery genuinely has + * nothing yet. + */ + private fun seedFromDiscovery( + account: Account, + relay: NormalizedRelayUrl, + ) { + val known = BuzzDmChannels.channelsFor(account.userProfile().pubkeyHex) + var seeded = false + known.forEach { (channelId, discoveredOn) -> + if (discoveredOn == relay) { + memberChannels[channelId] = discoveredOn + seeded = true + } + } + if (seeded) rebuildRows(account) + } + fun refresh() { val account = account ?: return viewModelScope.launch(Dispatchers.IO) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index c26cbd3390..d9a6cea737 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -214,20 +214,30 @@ fun RelayGroupChannelListScreen( } fun buzzTypeOf(groupId: GroupId): String? = channelsById[groupId.id]?.event?.buzzChannelType() - // Starred channels float to the top of their section (stable sort keeps the alphabetical order - // within the starred and unstarred buckets). + + // Starred channels float to the top of their section, then alphabetical. + // + // The name is the tie-break on purpose: [buzzGroupIds] is in *arrival* order (membership ids as + // the ViewModel emitted them, then directory ids), so sorting on `starred` alone — a stable sort + // over a boolean — left the underlying order at the mercy of whatever landed first. The list + // visibly reshuffled in the second after opening, and came back differently each visit. Ordering + // by a property of the channel instead makes the first frame the final order; a channel whose + // 39000 hasn't arrived sorts by its id until the name lands. val starred by BuzzChannelStars.flow.collectAsStateWithLifecycle() + + fun buzzSortKey(groupId: GroupId): String = channelsById[groupId.id]?.toBestDisplayName()?.lowercase() ?: groupId.id + val buzzChatChannels = remember(buzzGroupIds, channelsById, starred) { buzzGroupIds .filter { buzzTypeOf(it).let { t -> t != BUZZ_CHANNEL_TYPE_FORUM && t != BUZZ_CHANNEL_TYPE_DM } } - .sortedByDescending { it.id in starred } + .sortedWith(compareByDescending { it.id in starred }.thenBy { buzzSortKey(it) }) } val buzzForumChannels = remember(buzzGroupIds, channelsById, starred) { buzzGroupIds .filter { buzzTypeOf(it) == BUZZ_CHANNEL_TYPE_FORUM } - .sortedByDescending { it.id in starred } + .sortedWith(compareByDescending { it.id in starred }.thenBy { buzzSortKey(it) }) } // Which sections the user has collapsed (session-scoped). Keyed by section id below.