From 5b357cd2fc40ec86bd5914ca20ca20b153b1c518 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 01:16:29 +0000 Subject: [PATCH] perf: trim the channel-invite row's per-item cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from auditing the previous commit. observeChannel registered a ChannelFinder query per invite row that could never produce a filter: every assembler under ChannelFinderFilterAssemblyGroup is gated on `is PublicChatChannel` / `is LiveActivitiesChannel`, so a RelayGroupChannel contributes nothing and the registration only churned the app-wide key set (an allKeys() Set copy per bundled invalidation) on every mount and unmount. Collect the channel's metadata stateFlow directly instead — same live name updates when the group's kind-39000 lands, none of the churn. TimeAgo used the default Dotted style inside a row that already spaces its children, so the variant's own leading " • " doubled the gap. DottedTight is what the note header uses in exactly this position. Key each row by channel id. The list is sorted newest-first, so an arriving invite shifts every row below it; without a key Compose matches children by call-site position and each shifted row recomposed against a different invite, re-resolving the actor and reloading their avatar. Also remember the row modifier rather than rebuilding the chain on every recomposition. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LGB1z4VcNfDw5cMt9V5NCG --- .../notifications/ChannelInvitesSection.kt | 37 ++++++++++++++----- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/ChannelInvitesSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/ChannelInvitesSection.kt index 68c1ab9b9e..c10fdc654f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/ChannelInvitesSection.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/ChannelInvitesSection.kt @@ -32,6 +32,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -44,13 +45,13 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvite import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel import com.vitorpamplona.amethyst.ui.layouts.NoteComposeLayout import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.DisplayBlankAuthor import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -87,8 +88,13 @@ fun ChannelInvitesSection( Column(modifier) { invites.forEach { invite -> - ChannelInviteCard(invite, accountViewModel, nav) - HorizontalDivider(thickness = DividerThickness) + // Keyed by channel: the list is sorted newest-first, so an arriving invite shifts every row + // below it. Without a key Compose matches children by position and each shifted row would + // recompose against a different invite — re-resolving the actor and reloading their avatar. + key(invite.channelId) { + ChannelInviteCard(invite, accountViewModel, nav) + HorizontalDivider(thickness = DividerThickness) + } } } } @@ -110,11 +116,17 @@ fun ChannelInviteCard( LocalCache.getOrCreateRelayGroupChannel(GroupId(invite.channelId, invite.relay)) } - // Metadata only — the same observer the channel rows use. It resolves the name a stranger's add - // arrives without, and never opens the channel's message subscription (which is the whole point of - // holding the invite back until the viewer answers). - val channelState by observeChannel(baseChannel, accountViewModel) - val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel + // The channel's own metadata flow, collected directly rather than through `observeChannel`. That + // helper also registers a ChannelFinder query, and every assembler under it is gated on + // `is PublicChatChannel` / `is LiveActivitiesChannel` — a RelayGroupChannel yields no filter at all, + // so the registration buys nothing and only churns the app-wide key set on mount/unmount. The flow + // still fills the name in when the group's kind-39000 lands from the directory subscription, and + // nothing here opens the channel's *message* subscription — holding that back until the viewer + // answers is the whole point of the prompt. + val channelState by + remember(baseChannel) { baseChannel.flow().metadata.stateFlow } + .collectAsStateWithLifecycle() + val channel = channelState.channel as? RelayGroupChannel ?: baseChannel val actorUser = remember(invite.actor) { invite.actor?.let { LocalCache.getOrCreateUser(it) } } @@ -125,7 +137,10 @@ fun ChannelInviteCard( .compositeOver(MaterialTheme.colorScheme.background) NoteComposeLayout( - modifier = Modifier.drawBehind { drawRect(backgroundColor) }.fillMaxWidth(), + modifier = + remember(backgroundColor) { + Modifier.drawBehind { drawRect(backgroundColor) }.fillMaxWidth() + }, authorPicture = { Box(Size55Modifier, contentAlignment = Alignment.BottomEnd) { if (actorUser != null) { @@ -155,7 +170,9 @@ fun ChannelInviteCard( ) } - TimeAgo(invite.createdAt) + // DottedTight, not Dotted: the row's `spacedBy` already supplies the gap, so the + // dotted variant's own leading space would double it. Same choice the note header makes. + TimeAgo(invite.createdAt, style = TimeAgoStyle.DottedTight) } }, secondRow = {},