Merge pull request #3736 from vitorpamplona/claude/buzz-community-redesign-vrovav

Redesign Buzz channel rows to match Concord style with activity previews
This commit is contained in:
Vitor Pamplona
2026-07-26 22:48:40 -04:00
committed by GitHub
4 changed files with 409 additions and 129 deletions
@@ -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,44 @@ 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.
*
* [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(
@@ -72,22 +103,73 @@ 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) }
// 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. 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
val name = channel.toBestDisplayName()
val memberCount = channel.memberCount()
val isPrivate = channel.isPrivate()
// 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)
// 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<String>
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 =
@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,84 +182,179 @@ fun BuzzImportRow(
private fun BuzzImportRowContent(
name: String,
seed: String,
isPrivate: Boolean,
memberCount: Int,
isAdded: Boolean,
lastNote: Note?,
faceAuthors: List<String>,
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
val preview: String =
if (event == null) {
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),
)
}
}
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,
)
pluralStringResource(R.plurals.relay_group_member_count, memberCount, memberCount)
} else {
stringRes(R.string.relay_group_no_messages_yet)
}
} else {
OutlinedButton(onClick = onAdd) {
Text(stringRes(R.string.buzz_import_add))
// 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 "author: message".
val summary = buzzTimelinePreviewSummary(event, accountViewModel)
when {
summary != null -> summary
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 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),
)
}
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()
},
)
}
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 +375,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)
@@ -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))
@@ -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 ->
@@ -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,
)
}
}
@@ -456,11 +459,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 +564,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 +582,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 +610,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 +635,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 = buzzTimelinePreviewSummary(event, accountViewModel)
val preview: String =
when {
summary != null -> summary
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 +679,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,
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.isMinichatReply
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
@@ -111,11 +112,59 @@ fun RelayGroupChannel.newestTimelineNote(account: Account): Note? =
.sortedByDefaultFeedOrder()
.firstOrNull()
/**
* 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<HexKey> {
val latestByAuthor = HashMap<HexKey, Long>()
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 }
}
/** Whether this group's message store holds any acceptable timeline message created after [sinceSecs]. */
private fun RelayGroupChannel.hasChatNewerThan(
account: Account,
sinceSecs: Long,
): Boolean =
): Boolean = newMessagesSince(account, sinceSecs) > 0
/** 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 && isRelayGroupTimelineMessage(note, account)
} > 0
}
/**
* 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<Int> {
val channel = LocalCache.getOrCreateRelayGroupChannel(groupId)
return combine(
account.loadLastReadFlow(relayGroupChannelLastReadRoute(groupId)),
channel.flow().notes.stateFlow,
) { lastRead, _ ->
channel.newMessagesSince(account, lastRead)
}
}