feat(buzz): move per-row channel actions into the opened screen's top bar

The community list rows (channels, forums, DMs) each carried a 3-dot overflow —
Pin/Unpin and Add/Remove-from-Messages. Those are gone; each row is now a clean
tap-to-open target and its actions live in the top-bar overflow of the screen it
opens:

- Channels & DMs open RelayGroupChatScreen (RelayGroupTopBar): add Pin/Unpin
  (Buzz, non-DM) alongside the existing Messages toggle. A DM has no kind-10009
  entry, so its Messages toggle drives the DM-specific hide/unhide (kind-41012 /
  re-open) read from the per-viewer 30622 snapshot; the DM overflow is always
  shown so that action stays reachable.
- Forums open RelayGroupThreadsScreen: give its top bar an overflow with Pin/Unpin
  and Add/Remove-from-Messages.

Shared BuzzPinDropdownItem / RelayGroupMessagesDropdownItem keep the two bars
consistent. AccountViewModel gains hideBuzzDm/unhideBuzzDm wrappers. The
community-list top-bar overflow (Add people, Invite, Agent Console, Add all) is
unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
This commit is contained in:
Claude
2026-07-29 14:36:42 +00:00
parent 34fa103dbd
commit 97ff971542
6 changed files with 190 additions and 139 deletions
@@ -1708,6 +1708,22 @@ class AccountViewModel(
*/
fun acceptChannelInvite(channel: RelayGroupChannel) = addRelayGroupToMessages(channel)
/**
* Hide a Buzz DM from Messages (kind-41012). DM-specific — a DM has no kind-10009 entry; the relay
* republishes my per-viewer 30622 hidden snapshot, dropping it from the inbox until I re-open it.
*/
fun hideBuzzDm(channel: RelayGroupChannel) = launchSigner { account.hideBuzzDm(channel) }
/**
* Bring a hidden Buzz DM back to Messages: Buzz has no "unhide", so re-open the conversation with
* the same [participants] (a kind-41010 resolving to the same canonical channel), which drops it
* from the 30622 hidden snapshot.
*/
fun unhideBuzzDm(
relay: NormalizedRelayUrl,
participants: List<HexKey>,
) = launchSigner { account.openBuzzDm(relay, participants) }
/**
* Keep the channel off Messages without touching membership. Local and reversible — I stay in the
* roster and can still open and post; [leaveChannelInvite] is the one that actually removes me.
@@ -32,16 +32,11 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
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
@@ -83,9 +78,9 @@ private const val CARD_WARMUP_LIMIT = 10
* 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 the Add/Remove-from-Messages toggle ([isAdded] says which half
* to show, and it must come from the live kind-10009 list) — so the row stays clean.
* Tapping the card opens the channel ([onOpen]); the row itself is a clean tap-to-open target — its
* per-channel actions (Pin/Unpin, Add/Remove-from-Messages) live in the opened channel's/forum's
* top-bar overflow, not on the row. A pinned channel still shows a pin marker here ([isStarred]).
*
* Reused by the relay group-list screen where Buzz membership discovery is folded in.
*
@@ -98,13 +93,9 @@ private const val CARD_WARMUP_LIMIT = 10
@Composable
fun BuzzImportRow(
groupId: GroupId,
isAdded: Boolean,
onAdd: () -> Unit,
onRemove: () -> Unit,
accountViewModel: AccountViewModel,
onOpen: (() -> Unit)? = null,
isStarred: Boolean = false,
onToggleStar: (() -> Unit)? = null,
showActivityPreview: Boolean = true,
) {
val account = accountViewModel.account
@@ -166,11 +157,7 @@ fun BuzzImportRow(
faceAuthors = faceAuthors,
unread = unread,
hasUnread = hasUnread,
isAdded = isAdded,
isStarred = isStarred,
onToggleStar = onToggleStar,
onAdd = onAdd,
onRemove = onRemove,
accountViewModel = accountViewModel,
)
}
@@ -195,15 +182,11 @@ private fun BuzzImportRowContent(
faceAuthors: List<String>,
unread: Int,
hasUnread: Boolean,
isAdded: Boolean,
isStarred: Boolean,
onToggleStar: (() -> Unit)?,
onAdd: () -> Unit,
onRemove: () -> Unit,
accountViewModel: AccountViewModel,
) {
Row(
modifier = Modifier.padding(start = 12.dp, top = 10.dp, bottom = 10.dp, end = 4.dp),
modifier = Modifier.padding(start = 12.dp, top = 10.dp, bottom = 10.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
BuzzImportAvatar(name = name, seed = seed)
@@ -254,13 +237,6 @@ private fun BuzzImportRowContent(
ConcordUnreadBadge(unread)
}
}
BuzzChannelRowMenu(
isAdded = isAdded,
onAdd = onAdd,
onRemove = onRemove,
isStarred = isStarred,
onToggleStar = onToggleStar,
)
}
}
@@ -308,68 +284,6 @@ private fun BuzzChannelPreviewLine(
)
}
/**
* 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,
onRemove: () -> 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()
},
)
}
// A toggle, not a one-way "Added" badge: a channel already on the kind-10009 list offers
// the way back off it. Neither half touches the relay roster, so the channel stays in this
// list (and readable) either way — only whether it shows on Messages changes.
DropdownMenuItem(
leadingIcon = {
Icon(
symbol = if (isAdded) MaterialSymbols.VisibilityOff else MaterialSymbols.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
},
text = { Text(stringRes(if (isAdded) R.string.remove_from_messages else R.string.add_to_messages)) },
onClick = {
expanded = false
if (isAdded) onRemove() else onAdd()
},
)
}
}
}
/** A round monogram whose color is derived deterministically from the channel id. */
@Composable
private fun BuzzImportAvatar(
@@ -0,0 +1,104 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
import androidx.compose.foundation.layout.size
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
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.buzz.BuzzChannelStars
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
/**
* Pin/Unpin a Buzz channel (a device-local favorite — [BuzzChannelStars]). Moved off the per-channel
* list row into the opened channel's/forum's top-bar overflow, so the list row stays a clean
* tap-to-open target. Reads the live starred set so the label + icon reflect the current state.
*/
@Composable
fun BuzzPinDropdownItem(
groupId: GroupId,
closeMenu: () -> Unit,
) {
val starred by BuzzChannelStars.flow.collectAsStateWithLifecycle()
val isStarred = groupId.id in starred
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 = {
closeMenu()
BuzzChannelStars.toggle(groupId.id)
},
)
}
/**
* Add/Remove this relay-group [channel] from my kind-10009 list (whether it shows in Messages). A
* reversible toggle that never touches my relay membership — same split as "Leave" — so it stays
* readable either way. Reads the live kind-10009 list so it flips as the change lands. Moved off the
* per-channel list row into the opened screen's top-bar overflow.
*/
@Composable
fun RelayGroupMessagesDropdownItem(
channel: RelayGroupChannel,
accountViewModel: AccountViewModel,
closeMenu: () -> Unit,
) {
val joinedGroupIds by accountViewModel.account.relayGroupList.liveRelayGroupIds
.collectAsStateWithLifecycle()
val onMyList = channel.groupId in joinedGroupIds
DropdownMenuItem(
leadingIcon = {
Icon(
symbol = if (onMyList) MaterialSymbols.VisibilityOff else MaterialSymbols.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
},
text = { Text(stringRes(if (onMyList) R.string.remove_from_messages else R.string.add_to_messages)) },
onClick = {
closeMenu()
if (onMyList) {
accountViewModel.removeRelayGroupFromMessages(channel)
} else {
accountViewModel.addRelayGroupToMessages(channel)
}
},
)
}
@@ -36,8 +36,6 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
@@ -463,13 +461,9 @@ fun RelayGroupChannelListScreen(
RowHairline(index)
BuzzImportRow(
groupId = groupId,
isAdded = groupId.id in buzzAdded,
onAdd = { buzzVm.add(groupId) },
onRemove = { buzzVm.remove(groupId) },
accountViewModel = accountViewModel,
onOpen = { nav.nav(Route.RelayGroup(groupId.id, relay.url)) },
isStarred = groupId.id in starred,
onToggleStar = { BuzzChannelStars.toggle(groupId.id) },
)
}
}
@@ -501,15 +495,11 @@ fun RelayGroupChannelListScreen(
RowHairline(index)
BuzzImportRow(
groupId = groupId,
isAdded = groupId.id in buzzAdded,
onAdd = { buzzVm.add(groupId) },
onRemove = { buzzVm.remove(groupId) },
accountViewModel = accountViewModel,
// A forum channel's primary content is its threads (kind-45001 posts), not a
// kind-9 chat, so open the forum/threads view directly instead of the chat.
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,
@@ -548,7 +538,6 @@ fun RelayGroupChannelListScreen(
row = row,
myPubkey = myPubkey,
isHidden = false,
onToggleMessages = { dmVm.removeFromMessages(row) },
accountViewModel = accountViewModel,
nav = nav,
) {
@@ -580,7 +569,6 @@ fun RelayGroupChannelListScreen(
row = row,
myPubkey = myPubkey,
isHidden = true,
onToggleMessages = { dmVm.addToMessages(row) },
accountViewModel = accountViewModel,
nav = nav,
) {
@@ -721,25 +709,23 @@ private fun SectionAddButton(
/**
* One inline Direct-Message conversation row inside the community view: the counterpart's avatar +
* name (or a "+N" cluster label for a group DM), a preview of the last message, a compact
* last-activity time, and an overflow holding the Add/Remove-from-Messages toggle. 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.
* 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; the Add/Remove-from-Messages
* (hide/unhide) action lives in that chat screen's top-bar overflow, not on this row.
*
* [isHidden] renders the row faded and flips the overflow to "Add to Messages" — a hidden DM is a
* live conversation the viewer merely parked, so it stays openable and reversible.
* [isHidden] renders the row faded — a hidden DM is a live conversation the viewer merely parked, so
* it stays openable and reversible from the opened conversation.
*/
@Composable
private fun BuzzDmInlineRow(
row: BuzzDmListViewModel.DmRow,
myPubkey: HexKey,
isHidden: Boolean,
onToggleMessages: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
onClick: () -> Unit,
) {
var menuOpen by remember { mutableStateOf(false) }
val others = row.others.ifEmpty { listOf(myPubkey) }
val leadHex = others.first()
val leadUser = remember(leadHex) { LocalCache.getOrCreateUser(leadHex) }
@@ -768,7 +754,7 @@ private fun BuzzDmInlineRow(
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(start = 16.dp, end = 4.dp, top = 10.dp, bottom = 10.dp)
.padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 10.dp)
.alpha(if (isHidden) 0.55f else 1f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
@@ -797,33 +783,6 @@ private fun BuzzDmInlineRow(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Box {
IconButton(onClick = { menuOpen = true }) {
Icon(
symbol = MaterialSymbols.MoreVert,
contentDescription = stringRes(R.string.more_options),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
leadingIcon = {
Icon(
symbol = if (isHidden) MaterialSymbols.Add else MaterialSymbols.VisibilityOff,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
},
text = { Text(stringRes(if (isHidden) R.string.add_to_messages else R.string.remove_from_messages)) },
onClick = {
menuOpen = false
onToggleMessages()
},
)
}
}
}
}
@@ -34,15 +34,19 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -166,6 +170,25 @@ private fun RelayGroupThreads(
)
}
},
actions = {
// The forum's per-item actions, moved off the community-list row into this screen's
// top-bar overflow: Pin/Unpin and the Add/Remove-from-Messages toggle. Buzz-only,
// which every forum channel is.
if (isBuzz) {
var menuOpen by remember { mutableStateOf(false) }
IconButton(onClick = { menuOpen = true }) {
Icon(
symbol = MaterialSymbols.MoreVert,
contentDescription = stringRes(R.string.more_options),
modifier = Modifier.size(22.dp),
)
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
BuzzPinDropdownItem(channel.groupId) { menuOpen = false }
RelayGroupMessagesDropdownItem(channel, accountViewModel) { menuOpen = false }
}
}
},
popBack = nav::popBack,
)
},
@@ -53,6 +53,7 @@ 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.buzz.BuzzDmRegistry
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
@@ -117,6 +118,9 @@ fun RelayGroupTopBar(
// My kind-10009 list, live: drives the Add/Remove-from-Messages toggle in the overflow below.
val joinedGroupIds by accountViewModel.account.relayGroupList.liveRelayGroupIds
.collectAsStateWithLifecycle()
// A Buzz DM is hidden via its own per-viewer 30622 snapshot, not the kind-10009 list, so the
// Messages toggle branches on this for DMs.
val hiddenDms by BuzzDmRegistry.hidden.collectAsStateWithLifecycle()
// Read once here (nav.canPop() is @Composable) so the post-action navigation can pop from a menu
// callback — leaving a group shouldn't strand the user on the screen of a group they left.
val canPop = nav.canPop()
@@ -237,7 +241,9 @@ fun RelayGroupTopBar(
// Buzz `t=stream` channel it is always empty (forum posts live in `t=forum` channels, which
// the relay's channel list already surfaces in their own section), so it read as a broken
// feature on every chat. Demoted to the overflow, where the frequency of use actually is.
if (!isDm || naddr != null || showMembershipActions) {
// A Buzz DM always gets the overflow too — its hide/unhide (below) is the DM row's old
// action, and it must be reachable even where the membership actions aren't offered.
if (!isDm || naddr != null || showMembershipActions || (isDm && isBuzzRelay)) {
IconButton(onClick = { menuOpen = true }) {
Icon(
symbol = MaterialSymbols.MoreVert,
@@ -246,6 +252,33 @@ fun RelayGroupTopBar(
)
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
// Pin/Unpin moved here off the community-list row. A local favorite, so it's offered
// for any Buzz channel/forum regardless of membership; DMs are never pinned.
if (isBuzzRelay && !isDm) {
BuzzPinDropdownItem(channel.groupId) { menuOpen = false }
}
// A DM's Add/Remove-from-Messages, moved off the DM list row. It rides the per-viewer
// 30622 hide snapshot (kind-41012 hide / re-open), not the kind-10009 list, and is
// shown regardless of the membership gate below.
if (isBuzzRelay && isDm) {
val dmHidden = channel.groupId.id in (hiddenDms[myPubkey] ?: emptySet())
DropdownMenuItem(
text = { Text(stringRes(if (dmHidden) R.string.add_to_messages else R.string.remove_from_messages)) },
onClick = {
menuOpen = false
if (dmHidden) {
val participants =
channel.event
?.buzzParticipants()
?.filter { it != myPubkey }
.orEmpty()
accountViewModel.unhideBuzzDm(channel.groupId.relayUrl, participants.ifEmpty { listOf(myPubkey) })
} else {
accountViewModel.hideBuzzDm(channel)
}
},
)
}
if (!isDm) {
DropdownMenuItem(
text = { Text(stringRes(R.string.relay_group_threads_title)) },
@@ -308,6 +341,8 @@ fun RelayGroupTopBar(
// Two distinct actions, never conflated: the Messages toggle adds/drops the group
// on my kind-10009 list but keeps my relay membership either way; "Leave" sends
// the kind-9022 that actually removes me. Same split as the channel-invite card.
// (A DM's Messages toggle is the hide/unhide item above — it rides a different
// mechanism and must show even when these membership actions don't.)
//
// Reads the live kind-10009 list rather than assuming the group is on it: this
// bar also opens for channels reached from the workspace browse (a Buzz relay