Merge pull request #3735 from vitorpamplona/fix/community-tab-first-frame

fix(chats): render the community tab correct on its first frame
This commit is contained in:
Vitor Pamplona
2026-07-26 22:48:56 -04:00
committed by GitHub
3 changed files with 52 additions and 4 deletions
@@ -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.
@@ -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) {
@@ -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<GroupId> { 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<GroupId> { it.id in starred }.thenBy { buzzSortKey(it) })
}
// Which sections the user has collapsed (session-scoped). Keyed by section id below.