From 7fe7111b112bf0b3e456efbcfc0d44ffc25ddac2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 05:32:11 +0000 Subject: [PATCH 1/2] feat: modernize Buzz community screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesign the Buzz workspace community view to feel closer to the Concord server view — richer, more informative channel and DM rows, with actions tucked behind overflow menus so the list reads cleanly. - Title bar now uses a middle ellipsis on the community name so both ends stay visible instead of truncating the tail. - Channel cards (BuzzImportRow) now show a last-message preview (author + snippet, or the Buzz activity summary for system/diff/job rows), a recent-posters facepile, an unread-count badge, and the last-activity time — reusing the Concord facepile/unread-badge composables. Each card warms its recent messages while visible so previews fill in ahead of a tap. - Pin/Unpin and Add-to-my-list move off the channel row into a per-channel 3-dot overflow menu. - DM rows gain the same last-message preview line. - Add-all and Agent Console move from the inline header button / footer card into the community's top-bar 3-dot overflow menu. - Add relay-group timeline helpers (newestTimelineNote, recentAuthorHexes, relayGroupChannelUnreadCountFlow) mirroring the Concord ones. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FHnm6G9YytnLfs1ycYfK89 --- .../ui/screen/loggedIn/buzz/BuzzImportRow.kt | 302 +++++++++++++----- .../buzz/BuzzWorkspaceOverflowMenu.kt | 39 ++- .../relayGroup/RelayGroupChannelListScreen.kt | 116 ++++--- .../relayGroup/RelayGroupUnread.kt | 80 ++++- 4 files changed, 406 insertions(+), 131 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt index 3c65271fcd..fba902c9a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -31,17 +32,22 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Card +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -49,19 +55,38 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.Note 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.service.relayClient.reqCommand.user.observeUserName +import com.vitorpamplona.amethyst.ui.note.timeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupChannelHasUnreadFlow +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.buzzTimelinePreviewSummary +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordAuthorFacepile +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordUnreadBadge +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.newestTimelineNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.recentAuthorHexes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupChannelUnreadCountFlow import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +/** How many recent-poster avatars a channel row's facepile shows at most. */ +private const val FACEPILE_MAX = 4 + +/** A first screen's worth of recent messages to prefetch per visible card, so previews fill in. */ +private const val CARD_WARMUP_LIMIT = 10 + /** - * One row in the "your channels on this workspace" section: a Buzz workspace channel the user is a - * member of (via kind-44100). Tapping the card opens the channel ([onOpen]); the trailing Add - * affordance appends it to the kind-10009 list so it surfaces in Messages / Relay Groups. Reused by - * the relay group-list screen where Buzz membership discovery is folded in. + * One channel row in a Buzz workspace's community view: a channel the user is a member of (via + * kind-44100), rendered like the Concord server view — a colored monogram, the channel name with a + * recent-posters facepile, a preview of the last message (author + snippet, or the Buzz activity + * summary for system/diff/job rows), the relative time of that message, and an unread-count badge. + * Tapping the card opens the channel ([onOpen]); the trailing overflow (3-dot) menu holds the + * per-channel actions — Pin/Unpin and Add-to-my-list — so the row stays clean. + * + * Reused by the relay group-list screen where Buzz membership discovery is folded in. */ @Composable fun BuzzImportRow( @@ -73,21 +98,57 @@ fun BuzzImportRow( isStarred: Boolean = false, onToggleStar: (() -> Unit)? = null, ) { + val account = accountViewModel.account val baseChannel = remember(groupId) { LocalCache.getOrCreateRelayGroupChannel(groupId) } + + // Warm a first screen's worth of recent messages while this card is visible (content only — the + // directory subscription already streams metadata), so the preview + facepile fill in ahead of a + // tap instead of staying blank until the channel is opened. Bounded to visible rows by the + // LazyColumn and released as they scroll off. + RelayGroupCardWarmupSubscription( + baseChannel, + accountViewModel.dataSources().relayGroupCardWarmup, + accountViewModel, + contentOnly = true, + contentLimit = CARD_WARMUP_LIMIT, + ) + val channelState by observeChannel(baseChannel, accountViewModel) val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel + // The channel's own notes flow drives the preview/facepile so they update the moment a message + // folds in, independent of the metadata-scoped [observeChannel] above. + val notesState by channel + .flow() + .notes.stateFlow + .collectAsStateWithLifecycle() val name = channel.toBestDisplayName() val memberCount = channel.memberCount() - - // An unread dot when this group has chat newer than the last time this account opened it. - val hasUnread by remember(groupId) { - relayGroupChannelHasUnreadFlow(accountViewModel.account, groupId) - }.collectAsStateWithLifecycle(false) + val isPrivate = channel.isPrivate() + val lastNote = remember(notesState) { channel.newestTimelineNote(account) } + val faceAuthors = remember(notesState) { channel.recentAuthorHexes(account, FACEPILE_MAX) } + val unread by + remember(groupId) { relayGroupChannelUnreadCountFlow(account, groupId) } + .collectAsStateWithLifecycle(0) + val hasUnread = unread > 0 val content = @Composable { - BuzzImportRowContent(name, groupId.id, memberCount, isAdded, hasUnread, isStarred, onToggleStar, onAdd) + BuzzImportRowContent( + name = name, + seed = groupId.id, + isPrivate = isPrivate, + memberCount = memberCount, + lastNote = lastNote, + faceAuthors = faceAuthors, + unread = unread, + hasUnread = hasUnread, + isAdded = isAdded, + isStarred = isStarred, + onToggleStar = onToggleStar, + onAdd = onAdd, + accountViewModel = accountViewModel, + ) } if (onOpen != null) { Card(onClick = onOpen, modifier = Modifier.fillMaxWidth()) { content() } @@ -100,83 +161,172 @@ fun BuzzImportRow( private fun BuzzImportRowContent( name: String, seed: String, + isPrivate: Boolean, memberCount: Int, - isAdded: Boolean, + lastNote: Note?, + faceAuthors: List, + unread: Int, hasUnread: Boolean, + isAdded: Boolean, isStarred: Boolean, onToggleStar: (() -> Unit)?, onAdd: () -> Unit, + accountViewModel: AccountViewModel, ) { Row( - modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 8.dp, end = 4.dp), + modifier = Modifier.padding(start = 12.dp, top = 10.dp, bottom = 10.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { - Box { - BuzzImportAvatar(name = name, seed = seed) - if (hasUnread) { - Box( - Modifier - .align(Alignment.TopEnd) - .size(11.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surface) - .padding(2.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary), - ) - } - } + BuzzImportAvatar(name = name, seed = seed) Spacer(Modifier.width(12.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = name, - style = MaterialTheme.typography.titleSmall, - fontWeight = if (hasUnread) FontWeight.Bold else FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + // Line 1: a lock (private), the channel name, a pin marker (starred), and the recent- + // posters facepile pushed to the right. + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + if (isPrivate) { + Icon( + symbol = MaterialSymbols.Lock, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(14.dp), + ) + } + Text( + text = name, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.titleSmall, + fontWeight = if (hasUnread) FontWeight.Bold else FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (isStarred) { + Icon( + symbol = MaterialSymbols.PushPin, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(14.dp), + ) + } + ConcordAuthorFacepile(faceAuthors, accountViewModel) + } + // Line 2: the last-message preview, then the time + unread badge. + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box(Modifier.weight(1f)) { + BuzzChannelPreviewLine(lastNote, memberCount, accountViewModel) + } + lastNote?.createdAt()?.let { ts -> + Text( + timeAgo(ts, LocalContext.current, prefix = ""), + style = MaterialTheme.typography.labelSmall, + color = if (hasUnread) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + ConcordUnreadBadge(unread) + } + } + BuzzChannelRowMenu( + isAdded = isAdded, + onAdd = onAdd, + isStarred = isStarred, + onToggleStar = onToggleStar, + ) + } +} + +/** + * The line under a channel name: the last message's author + a snippet ("author: hello"), the Buzz + * activity summary for a system/diff/job row, or — before anything has folded in — the member count + * (or a muted "No messages yet"). Author names resolve reactively (hex → profile name). + */ +@Composable +private fun BuzzChannelPreviewLine( + lastNote: Note?, + memberCount: Int, + accountViewModel: AccountViewModel, +) { + val event = lastNote?.event + val author = lastNote?.author + // A Buzz timeline row (system line, huddle/job activity, diff) carries JSON/diff in its content, + // so show its human-readable summary — the same text the in-chat row renders — rather than + // "author: {json}". A plain chat message falls through to the usual "author: message" framing. + val summary = remember(event) { event?.let { buzzTimelinePreviewSummary(it) } } + val preview: String = + when { + summary != null -> summary + event != null && author != null -> { + val authorName by observeUserName(author, accountViewModel) + val body = event.content.take(80) + if (body.isBlank()) authorName else "$authorName: $body" + } + event != null -> event.content.take(80) + memberCount > 0 -> pluralStringResource(R.plurals.relay_group_member_count, memberCount, memberCount) + else -> stringRes(R.string.relay_group_no_messages_yet) + } + Text( + preview, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +/** + * The per-channel overflow (3-dot) menu: Pin/Unpin and Add-to-my-list. Moved off the row itself so a + * channel card reads as a clean Concord-style row, with its actions one tap behind the kebab. + */ +@Composable +private fun BuzzChannelRowMenu( + isAdded: Boolean, + onAdd: () -> Unit, + isStarred: Boolean, + onToggleStar: (() -> Unit)?, +) { + var expanded by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { expanded = true }) { + Icon( + symbol = MaterialSymbols.MoreVert, + contentDescription = stringRes(R.string.more_options), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), ) - if (memberCount > 0) { - Text( - text = "$memberCount", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } } - if (onToggleStar != null) { - IconButton(onClick = onToggleStar) { - Icon( - symbol = MaterialSymbols.PushPin, - contentDescription = stringRes(if (isStarred) R.string.buzz_unpin else R.string.buzz_pin), - tint = if (isStarred) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + if (onToggleStar != null) { + DropdownMenuItem( + leadingIcon = { + Icon( + symbol = MaterialSymbols.PushPin, + contentDescription = null, + tint = if (isStarred) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + }, + text = { Text(stringRes(if (isStarred) R.string.buzz_unpin else R.string.buzz_pin)) }, + onClick = { + expanded = false + onToggleStar() + }, ) } - } - if (isAdded) { - // Match the OutlinedButton's trailing content padding so the label doesn't jam against - // the row edge (and doesn't jump horizontally) when Add flips to Added. - Row( - modifier = Modifier.padding(end = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - symbol = MaterialSymbols.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(4.dp)) - Text( - text = stringRes(R.string.buzz_import_added), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - ) - } - } else { - OutlinedButton(onClick = onAdd) { - Text(stringRes(R.string.buzz_import_add)) - } + DropdownMenuItem( + leadingIcon = { + Icon( + symbol = if (isAdded) MaterialSymbols.Check else MaterialSymbols.Add, + contentDescription = null, + tint = if (isAdded) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + }, + text = { Text(stringRes(if (isAdded) R.string.buzz_import_added else R.string.buzz_import_add)) }, + enabled = !isAdded, + onClick = { + expanded = false + onAdd() + }, + ) } } } @@ -198,7 +348,7 @@ private fun BuzzImportAvatar( ?.toString() ?: "#" } Box( - modifier = Modifier.size(40.dp).clip(CircleShape).background(color), + modifier = Modifier.size(44.dp).clip(CircleShape).background(color), contentAlignment = Alignment.Center, ) { Text(text = initial, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = Color.White) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzWorkspaceOverflowMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzWorkspaceOverflowMenu.kt index 02772811db..eabc05d9bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzWorkspaceOverflowMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzWorkspaceOverflowMenu.kt @@ -54,17 +54,24 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch /** - * The Buzz workspace top-bar overflow (3-dot) menu. Holds the two workspace-owner actions that used - * to sit inline above the channel list: "Add people to this workspace" (kind-9030, delegated to the - * screen's [onAddPeople] dialog) and "Create invite link" (mints via the relay's `/api/invites` - * endpoint — see [BuzzInviteMinter]). Any member sees both, but the relay only serves owners/admins, - * so a rejection surfaces as the error dialog. + * The Buzz workspace top-bar overflow (3-dot) menu. Holds the community-wide actions that used to sit + * inline in the channel list: + * - "Add all channels" ([onAddAll], shown only when some channels aren't in the user's list yet) — + * appends every discovered channel to the kind-10009 list at once; + * - "Agent Console" ([onOpenAgentConsole]) — the owner's per-community fleet console; + * - "Add people to this workspace" (kind-9030, delegated to the screen's [onAddPeople] dialog); + * - "Create invite link" (mints via the relay's `/api/invites` endpoint — see [BuzzInviteMinter]). + * + * Any member sees all of them, but the relay only serves the owner/admin ones, so a rejection + * surfaces as the error dialog. */ @Composable fun BuzzWorkspaceOverflowMenu( relay: NormalizedRelayUrl, accountViewModel: AccountViewModel, onAddPeople: () -> Unit, + onOpenAgentConsole: () -> Unit, + onAddAll: (() -> Unit)? = null, ) { var menuOpen by remember { mutableStateOf(false) } var minting by remember { mutableStateOf(false) } @@ -83,6 +90,28 @@ fun BuzzWorkspaceOverflowMenu( } DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + if (onAddAll != null) { + DropdownMenuItem( + leadingIcon = { + Icon(symbol = MaterialSymbols.DoneAll, contentDescription = null, modifier = Modifier.size(20.dp)) + }, + text = { Text(stringRes(R.string.buzz_import_add_all)) }, + onClick = { + menuOpen = false + onAddAll() + }, + ) + } + DropdownMenuItem( + leadingIcon = { + Icon(symbol = MaterialSymbols.AutoAwesome, contentDescription = null, modifier = Modifier.size(20.dp)) + }, + text = { Text(stringRes(R.string.buzz_console_card_title)) }, + onClick = { + menuOpen = false + onOpenAgentConsole() + }, + ) DropdownMenuItem( leadingIcon = { Icon(symbol = MaterialSymbols.PersonAdd, contentDescription = null, modifier = Modifier.size(20.dp)) 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..ffc0a54bb4 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 @@ -65,6 +65,7 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect @@ -90,6 +91,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzImportRow import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzRelayImportViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzWorkspaceOverflowMenu import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.PresenceDot +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.buzzTimelinePreviewSummary import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelaySubscription import com.vitorpamplona.amethyst.ui.stringRes @@ -271,7 +273,7 @@ fun RelayGroupChannelListScreen( text = relay.displayUrl(), fontWeight = FontWeight.Bold, maxLines = 1, - overflow = TextOverflow.Ellipsis, + overflow = TextOverflow.MiddleEllipsis, ) }, showBackButton = canPop, @@ -282,6 +284,10 @@ fun RelayGroupChannelListScreen( relay = relay, accountViewModel = accountViewModel, onAddPeople = { showAddPeople = true }, + onOpenAgentConsole = { nav.nav(Route.AgentConsole(relay.url)) }, + // Only offer "Add all" when some discovered channel isn't in the user's + // list yet — hidden once everything is already added. + onAddAll = if (buzzChatChannels.any { it.id !in buzzAdded }) ({ buzzVm.addAll() }) else null, ) } }, @@ -360,7 +366,7 @@ fun RelayGroupChannelListScreen( } } - // -- CHANNELS -- + // -- CHANNELS -- (Add-all now lives in the community's top-bar overflow menu) if (buzzChatChannels.isNotEmpty()) { val channelsCollapsed = "channels" in collapsedSections item(key = "sec-channels") { @@ -368,13 +374,7 @@ fun RelayGroupChannelListScreen( title = stringRes(R.string.relay_group_section_channels), collapsed = channelsCollapsed, onToggle = { toggleSection("channels") }, - ) { - if (buzzChatChannels.any { it.id !in buzzAdded }) { - FilledTonalButton(onClick = { buzzVm.addAll() }) { - Text(stringRes(R.string.buzz_import_add_all)) - } - } - } + ) } if (!channelsCollapsed) { items(buzzChatChannels, key = { "chat-${it.id}" }) { groupId -> @@ -456,11 +456,7 @@ fun RelayGroupChannelListScreen( } } } - - // -- AGENT CONSOLE -- (owner's per-community fleet console, footer) - item(key = "sec-console") { - AgentConsoleFooter { nav.nav(Route.AgentConsole(relay.url)) } - } + // Agent Console now lives in the community's top-bar overflow menu, not a footer card. } else { // Vanilla NIP-29 relay: flat channel directory (no forums/DMs/console). itemsIndexed(channels, key = { _, channel -> channel.groupId.id }) { index, channel -> @@ -565,8 +561,9 @@ private fun RelayGroupSectionHeader( /** * One inline Direct-Message conversation row inside the community view: the counterpart's avatar + - * name (or a "+N" cluster label for a group DM) and a compact last-activity time. Tapping opens the - * DM as its relay-group chat. + * name (or a "+N" cluster label for a group DM), a preview of the last message, and a compact + * last-activity time. The channel's recent content is warmed while the row is visible so the preview + * fills in ahead of a tap. Tapping opens the DM as its relay-group chat. */ @Composable private fun BuzzDmInlineRow( @@ -582,6 +579,23 @@ private fun BuzzDmInlineRow( val leadName by observeUserName(leadUser, accountViewModel) val label = if (others.size > 1) "$leadName +${others.size - 1}" else leadName + val account = accountViewModel.account + val groupId = remember(row.channelId, row.relayUrl) { GroupId(row.channelId, row.relayUrl) } + val channel = remember(groupId) { LocalCache.getOrCreateRelayGroupChannel(groupId) } + // Warm a screen's worth of recent DM messages while visible, so the preview isn't blank until opened. + RelayGroupCardWarmupSubscription( + channel, + accountViewModel.dataSources().relayGroupCardWarmup, + accountViewModel, + contentOnly = true, + contentLimit = CHANNEL_LIST_WARMUP_LIMIT, + ) + val notesState by channel + .flow() + .notes.stateFlow + .collectAsStateWithLifecycle() + val lastNote = remember(notesState) { channel.newestTimelineNote(account) } + Row( modifier = Modifier @@ -593,19 +607,21 @@ private fun BuzzDmInlineRow( ) { // Only show a presence dot for a 1:1 DM — a cluster avatar can't carry one peer's status. Box { - UserPicture(leadHex, 40.dp, accountViewModel = accountViewModel, nav = nav) + UserPicture(leadHex, 44.dp, accountViewModel = accountViewModel, nav = nav) if (others.size == 1) { PresenceDot(leadHex, Modifier.align(Alignment.BottomEnd), ringColor = MaterialTheme.colorScheme.surface) } } - Text( - text = label, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + BuzzDmPreviewLine(lastNote, accountViewModel) + } if (row.lastActivity > 0) { Text( text = timeAgoShort(row.lastActivity, stringRes(R.string.now)), @@ -616,6 +632,34 @@ private fun BuzzDmInlineRow( } } +/** The last-message preview under a DM row: "author: snippet", or nothing before any message loads. */ +@Composable +private fun BuzzDmPreviewLine( + lastNote: Note?, + accountViewModel: AccountViewModel, +) { + val event = lastNote?.event ?: return + val author = lastNote.author + val summary = remember(event) { buzzTimelinePreviewSummary(event) } + val preview: String = + if (summary != null) { + summary + } else if (author != null) { + val authorName by observeUserName(author, accountViewModel) + val body = event.content.take(80) + if (body.isBlank()) authorName else "$authorName: $body" + } else { + event.content.take(80) + } + Text( + preview, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + /** The "See all N conversations" row that opens the full per-community DM inbox. */ @Composable private fun SeeAllRow( @@ -632,28 +676,6 @@ private fun SeeAllRow( } } -/** The owner's per-community Agent Console entry, pinned as the footer of a Buzz community view. */ -@Composable -private fun AgentConsoleFooter(onClick: () -> Unit) { - Card(onClick = onClick, modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 12.dp)) { - Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) { - Icon(symbol = MaterialSymbols.AutoAwesome, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(22.dp)) - Spacer(Modifier.size(12.dp)) - Column(Modifier.weight(1f)) { - Text(stringRes(R.string.buzz_console_card_title), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) - Text( - stringRes(R.string.buzz_console_card_subtitle), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - Icon(symbol = MaterialSymbols.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(20.dp)) - } - } -} - @Composable private fun RelayGroupChannelRow( channel: RelayGroupChannel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt index 75659f454e..cab481765a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt @@ -20,9 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup +import com.vitorpamplona.amethyst.commons.model.Channel +import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip29RelayGroups.GroupId @@ -82,7 +85,78 @@ fun relayGroupServerHasUnreadFlow( private fun RelayGroupChannel.hasChatNewerThan( account: Account, sinceSecs: Long, -): Boolean = +): Boolean = newMessagesSince(account, sinceSecs) > 0 + +/** + * True for a note the group's chat *timeline* actually renders — an acceptable (not muted/blocked) + * message whose event is real group-chat content ([isGroupChatContent], which also folds in the Buzz + * dialect's stream/system/activity rows). Every list-row surface that summarizes a channel — the + * unread badge ([relayGroupChannelUnreadCountFlow]), the last-message preview + time + * ([newestTimelineNote]) and the recent-posters facepile ([recentAuthorHexes]) — reuses this so none + * of them can disagree with the open channel's feed or with the unread dot. + */ +private fun isRelayGroupTimelineMessage( + note: Note, + account: Account, +): Boolean = note.event?.isGroupChatContent() == true && account.isAcceptable(note) + +/** + * The newest timeline message in this group (see [isRelayGroupTimelineMessage]), or null if none — + * the note a list row shows as the channel's "last message". Skips reactions/metadata and hidden + * authors so the preview matches what the channel feed renders and the unread badge counts. + */ +fun RelayGroupChannel.newestTimelineNote(account: Account): Note? = + notes + .filter { _, note -> isRelayGroupTimelineMessage(note, account) } + .minWithOrNull(Channel.DefaultFeedOrder) + +/** + * The pubkeys of the [limit] most-recent distinct posters in this group, newest first — the facepile + * shown on a channel row. One O(notes) pass keeps each author's latest post time, so a chatty author + * counts once (at their newest message) rather than crowding out quieter voices. + */ +fun RelayGroupChannel.recentAuthorHexes( + account: Account, + limit: Int, +): List { + val latestByAuthor = HashMap() + for (note in notes.values()) { + if (!isRelayGroupTimelineMessage(note, account)) continue + val author = note.author?.pubkeyHex ?: continue + val at = note.createdAt() ?: continue + val prev = latestByAuthor[author] + if (prev == null || at > prev) latestByAuthor[author] = at + } + return latestByAuthor.entries + .sortedByDescending { it.value } + .take(limit) + .map { it.key } +} + +/** The number of this group's timeline messages created strictly after [sinceSecs] (0 if none). */ +private fun RelayGroupChannel.newMessagesSince( + account: Account, + sinceSecs: Long, +): Int = notes.count { _, note -> - (note.createdAt() ?: 0L) > sinceSecs && account.isAcceptable(note) && note.event?.isGroupChatContent() == true - } > 0 + (note.createdAt() ?: 0L) > sinceSecs && isRelayGroupTimelineMessage(note, account) + } + +/** + * The count of chat messages in [groupId] newer than the timestamp this account last read it — the + * number the channel-row unread badge shows. Reactive: it recombines both when a fresh message folds + * in (the channel's notes flow ticks) and when the user opens the group (which advances the last-read + * marker), so opening a channel clears its badge. Mirrors [relayGroupChannelHasUnreadFlow]. + */ +fun relayGroupChannelUnreadCountFlow( + account: Account, + groupId: GroupId, +): Flow { + val channel = LocalCache.getOrCreateRelayGroupChannel(groupId) + return combine( + account.loadLastReadFlow(relayGroupChannelLastReadRoute(groupId)), + channel.flow().notes.stateFlow, + ) { lastRead, _ -> + channel.newMessagesSince(account, lastRead) + } +} From eff0a30a392dff890fa5b02a70edd0d1d4a6e0ae Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 22:57:49 +0000 Subject: [PATCH 2/2] perf: skip chat warmup + activity preview for Buzz forum rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forum channels store their posts as threads (a separate store), not in the chat notes the activity preview reads — so a forum row was opening a kind-9 chat warmup subscription that returns nothing and could never render a last-message preview, facepile, or unread badge. Add a `showActivityPreview` flag (default true) to BuzzImportRow that gates the warmup, the notes-flow collection, and the preview/facepile/unread reads. The forum section passes false, so a forum row skips the useless subscription and shows a member-count summary instead. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FHnm6G9YytnLfs1ycYfK89 --- .../ui/screen/loggedIn/buzz/BuzzImportRow.kt | 59 +++++++++++++------ .../relayGroup/RelayGroupChannelListScreen.kt | 3 + 2 files changed, 43 insertions(+), 19 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt index 83faebeeff..06486b8b0d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt @@ -87,6 +87,12 @@ private const val CARD_WARMUP_LIMIT = 10 * per-channel actions — Pin/Unpin and Add-to-my-list — so the row stays clean. * * Reused by the relay group-list screen where Buzz membership discovery is folded in. + * + * [showActivityPreview] gates the chat-activity machinery — the recent-message warmup, the + * last-message preview, the recent-posters facepile and the unread badge. Enable it for **chat** + * channels (whose content lives in [RelayGroupChannel.notes]); leave it off for **forum** channels, + * whose posts are threads (a separate store), so the row doesn't open a kind-9 chat subscription that + * would return nothing and drives a member-count summary instead. */ @Composable fun BuzzImportRow( @@ -97,6 +103,7 @@ fun BuzzImportRow( onOpen: (() -> Unit)? = null, isStarred: Boolean = false, onToggleStar: (() -> Unit)? = null, + showActivityPreview: Boolean = true, ) { val account = accountViewModel.account val baseChannel = remember(groupId) { LocalCache.getOrCreateRelayGroupChannel(groupId) } @@ -104,32 +111,46 @@ fun BuzzImportRow( // Warm a first screen's worth of recent messages while this card is visible (content only — the // directory subscription already streams metadata), so the preview + facepile fill in ahead of a // tap instead of staying blank until the channel is opened. Bounded to visible rows by the - // LazyColumn and released as they scroll off. - RelayGroupCardWarmupSubscription( - baseChannel, - accountViewModel.dataSources().relayGroupCardWarmup, - accountViewModel, - contentOnly = true, - contentLimit = CARD_WARMUP_LIMIT, - ) + // LazyColumn and released as they scroll off. Skipped for forum channels (no chat to warm). + if (showActivityPreview) { + RelayGroupCardWarmupSubscription( + baseChannel, + accountViewModel.dataSources().relayGroupCardWarmup, + accountViewModel, + contentOnly = true, + contentLimit = CARD_WARMUP_LIMIT, + ) + } val channelState by observeChannel(baseChannel, accountViewModel) val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel - // The channel's own notes flow drives the preview/facepile so they update the moment a message - // folds in, independent of the metadata-scoped [observeChannel] above. - val notesState by channel - .flow() - .notes.stateFlow - .collectAsStateWithLifecycle() val name = channel.toBestDisplayName() val memberCount = channel.memberCount() val isPrivate = channel.isPrivate() - val lastNote = remember(notesState) { channel.newestTimelineNote(account) } - val faceAuthors = remember(notesState) { channel.recentAuthorHexes(account, FACEPILE_MAX) } - val unread by - remember(groupId) { relayGroupChannelUnreadCountFlow(account, groupId) } - .collectAsStateWithLifecycle(0) + + // The channel's own notes flow drives the preview/facepile so they update the moment a message + // folds in, independent of the metadata-scoped [observeChannel] above. Only collected for chat + // channels; a forum row shows a member-count summary with no facepile/unread. + val lastNote: Note? + val faceAuthors: List + val unread: Int + if (showActivityPreview) { + val notesState by channel + .flow() + .notes.stateFlow + .collectAsStateWithLifecycle() + lastNote = remember(notesState) { channel.newestTimelineNote(account) } + faceAuthors = remember(notesState) { channel.recentAuthorHexes(account, FACEPILE_MAX) } + unread = + remember(groupId) { relayGroupChannelUnreadCountFlow(account, groupId) } + .collectAsStateWithLifecycle(0) + .value + } else { + lastNote = null + faceAuthors = emptyList() + unread = 0 + } val hasUnread = unread > 0 val content = 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 c762bc06be..e57252f94c 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 @@ -413,6 +413,9 @@ fun RelayGroupChannelListScreen( onOpen = { nav.nav(Route.RelayGroupThreads(groupId.id, relay.url)) }, isStarred = groupId.id in starred, onToggleStar = { BuzzChannelStars.toggle(groupId.id) }, + // Forum posts live in a separate thread store, not the chat notes the + // activity preview reads — so don't warm a kind-9 sub that returns nothing. + showActivityPreview = false, ) } }