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 e57252f94c..8feb4ef174 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 @@ -216,20 +216,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.