feat: modernize Buzz community screens

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHnm6G9YytnLfs1ycYfK89
This commit is contained in:
Claude
2026-07-25 05:32:11 +00:00
parent c64d4eacd2
commit 7fe7111b11
4 changed files with 406 additions and 131 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,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<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
// 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)
@@ -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 ->
@@ -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,
@@ -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<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 }
}
/** 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<Int> {
val channel = LocalCache.getOrCreateRelayGroupChannel(groupId)
return combine(
account.loadLastReadFlow(relayGroupChannelLastReadRoute(groupId)),
channel.flow().notes.stateFlow,
) { lastRead, _ ->
channel.newMessagesSince(account, lastRead)
}
}