fix(buzz): make the Messages toggle read the list it claims to change

"Remove from Messages" was hardcoded on every surface, so it never showed
an "Add to Messages" counterpart for a channel that was already off the
list, and the Buzz workspace rows read a session-local snapshot of the
kind-10009 list that was seeded once and only ever grew — a channel taken
off Messages still rendered as a disabled "Added", leaving no way back.

Removal itself always worked (verified on-device: the kind-10009
republished and the row left Messages); what was missing was any read of
that list on the way back.

- RelayGroupListState: expose liveRelayGroupIds, the joined groups as
  normalized GroupIds, so the UI can ask whether a channel is on Messages
  without string-matching a raw relay url another client may not have
  normalized the way we do.
- RelayGroupTopBar / BuzzImportRow: one Add/Remove toggle driven by that
  flow. Remove no longer pops back — you stay a member reading the
  channel, and staying is what makes the entry flip so the action is
  visibly undoable. Leave still pops.
- BuzzRelayImportViewModel: track "added" against the live list instead of
  a one-shot seed, and add remove(); add() now also clears the dismissal
  so a relay's kind-44100 re-announcement isn't filtered back out.
- AccountViewModel: addRelayGroupToMessages() as the counterpart to
  removeRelayGroupFromMessages(); acceptChannelInvite() delegates to it.

Buzz DMs had the same one-way shape for a different reason: hiding is a
relay-side per-viewer flag (kind-41012 -> the kind-30622 snapshot), and
rebuildRows dropped hidden DMs on the floor, so a hidden conversation was
gone for good. There is no unhide command — re-opening is the unhide, a
kind-41010 with the same participants resolving to the same canonical
channel. Hidden DMs are now projected into their own list behind a
collapsible "Hidden (N)" header, faded but still openable, each offering
"Add to Messages". Also added to the community view's inline DM rows,
which had no menu at all and are where DMs actually live — the full inbox
sits behind a "see all" row that only appears above six DMs, so in a small
workspace the hidden section would have been unreachable.

Both list screens now leave bottom room for the FAB, which the Scaffold's
padding deliberately doesn't account for; the last row's overflow menu was
sitting underneath it.

Adds SimpleGroupListEventTest covering the removal path, including that a
renamed channel still matches (removal keys on group id + relay only).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-07-28 16:50:49 -04:00
co-authored by Claude Opus 5
parent 311b3ec7f0
commit 618ef66e75
10 changed files with 412 additions and 48 deletions
@@ -1689,17 +1689,25 @@ class AccountViewModel(
}
/**
* Accept a channel somebody added me to: write it into my kind-10009 so it shows on Messages and
* follows me to other devices. No kind-9021 join — the relay already put me in the roster, which is
* why the channel opens and accepts posts today; this only records *my* decision to surface it.
* Put a relay group (back) on Messages: write it into my kind-10009 so it shows and follows me to
* other devices, and clear the dismissal [removeRelayGroupFromMessages] left behind so a Buzz relay
* re-announcing my membership isn't filtered out. No kind-9021 join — the relay roster is untouched
* by both halves of this toggle; this only records *my* decision to surface the channel.
*/
fun acceptChannelInvite(channel: RelayGroupChannel) =
fun addRelayGroupToMessages(channel: RelayGroupChannel) =
launchSigner {
account.settings.undismissChannelInvite(channel.groupId.id)
account.follow(channel)
BuzzChannelInvites.remove(account.userProfile().pubkeyHex, channel.groupId.id)
}
/**
* Accept a channel somebody added me to. Identical to [addRelayGroupToMessages] — accepting an
* invite *is* surfacing the channel, since the relay already put me in the roster (which is why
* the channel opens and accepts posts today).
*/
fun acceptChannelInvite(channel: RelayGroupChannel) = addRelayGroupToMessages(channel)
/**
* 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.
@@ -56,7 +56,9 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
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
@@ -82,6 +84,12 @@ import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import kotlinx.coroutines.launch
/**
* Bottom room the inbox leaves for its extended FAB — same clearance the community view uses, and
* wide enough for the taller extended variant.
*/
private val FAB_CLEARANCE = 96.dp
/**
* The **Buzz Direct Messages** inbox. A Buzz DM is a relay-authoritative NIP-29 group
* whose id is a UUID, so tapping a row opens the very same [Route.RelayGroup] chat screen
@@ -99,6 +107,11 @@ fun BuzzDmListScreen(
viewModel.bind(accountViewModel.account, relayUrl)
val rows by viewModel.rows.collectAsStateWithLifecycle()
val hiddenRows by viewModel.hiddenRows.collectAsStateWithLifecycle()
// Hidden conversations stay collapsed behind a header — they are off Messages by the user's own
// choice, so they must not compete with the live inbox; they only need to be *reachable* again.
var showHidden by remember { mutableStateOf(false) }
Scaffold(
topBar = { TopBarWithBackButton(stringRes(R.string.buzz_dm_title), nav) },
@@ -110,26 +123,79 @@ fun BuzzDmListScreen(
)
},
) { padding ->
if (rows.isEmpty()) {
if (rows.isEmpty() && hiddenRows.isEmpty()) {
EmptyDmInbox(modifier = Modifier.padding(padding))
} else {
LazyColumn(
modifier = Modifier.padding(padding).fillMaxSize(),
contentPadding = PaddingValues(16.dp),
// Extra room at the bottom so the last row's overflow clears the FAB, which the
// Scaffold's padding deliberately doesn't account for (a FAB overlays content).
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = FAB_CLEARANCE),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(rows, key = { it.channelId }) { row ->
DmRowCard(row, accountViewModel, nav)
DmRowCard(row, isHidden = false, viewModel = viewModel, accountViewModel = accountViewModel, nav = nav)
}
if (hiddenRows.isNotEmpty()) {
item(key = "hidden-header") {
HiddenDmHeader(
count = hiddenRows.size,
expanded = showHidden,
onToggle = { showHidden = !showHidden },
)
}
if (showHidden) {
items(hiddenRows, key = { "hidden-${it.channelId}" }) { row ->
DmRowCard(row, isHidden = true, viewModel = viewModel, accountViewModel = accountViewModel, nav = nav)
}
}
}
}
}
}
}
/**
* The collapsible "Hidden (N)" divider between the live inbox and the conversations I took off it.
* Shared with the community view's inline Direct Messages section, so a hidden DM is reachable from
* wherever the user's DMs are — this inbox screen is only reachable behind a "see all" row.
*/
@Composable
fun HiddenDmHeader(
count: Int,
expanded: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier =
modifier
.fillMaxWidth()
.clickable(onClick = onToggle)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(
symbol = if (expanded) MaterialSymbols.ExpandMore else MaterialSymbols.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
Text(
text = pluralStringResource(R.plurals.buzz_dm_hidden_count, count, count),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/** One conversation: a participant avatar (stacked for group DMs), names, host, last-seen, kebab. */
@Composable
private fun DmRowCard(
row: BuzzDmListViewModel.DmRow,
isHidden: Boolean,
viewModel: BuzzDmListViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -156,7 +222,9 @@ private fun DmRowCard(
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(12.dp),
// A hidden DM is still openable (it's a live conversation I merely took off the list), so
// it renders faded rather than disabled — visibly parked, not broken.
modifier = Modifier.padding(12.dp).alpha(if (isHidden) 0.55f else 1f),
verticalAlignment = Alignment.CenterVertically,
) {
DmAvatars(row.others, accountViewModel, nav)
@@ -204,21 +272,21 @@ private fun DmRowCard(
addMemberOpen = true
},
)
// A toggle, like the channel rows: hiding a DM is a per-viewer, reversible
// relay-side flag (kind-41012 / the 30622 snapshot), never a departure — so a
// hidden conversation must offer its own way back rather than vanishing for good.
DropdownMenuItem(
text = { Text(stringRes(R.string.remove_from_messages)) },
text = { Text(stringRes(if (isHidden) R.string.add_to_messages else R.string.remove_from_messages)) },
leadingIcon = {
Icon(
symbol = MaterialSymbols.VisibilityOff,
symbol = if (isHidden) MaterialSymbols.Add else MaterialSymbols.VisibilityOff,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
},
onClick = {
menuOpen = false
scope.launch {
val channel = LocalCache.getOrCreateRelayGroupChannel(groupId)
accountViewModel.account.hideBuzzDm(channel)
}
if (isHidden) viewModel.addToMessages(row) else viewModel.removeFromMessages(row)
},
)
}
@@ -71,8 +71,11 @@ import java.util.concurrent.ConcurrentHashMap
* `t` = `dm` — that same 39000 also carries the roster the shared chat composer's member gate
* needs, and the DM participants;
* - subscribes the per-viewer [DmVisibilityEvent] (`kind:30622`) so a hidden DM (tracked in
* [BuzzDmRegistry]) drops out;
* - projects the visible DMs into [rows], sorted by last message time.
* [BuzzDmRegistry]) moves from [rows] to [hiddenRows];
* - projects the visible DMs into [rows] and the hidden ones into [hiddenRows], both sorted by
* last message time. Hidden DMs stay projected (rather than being dropped on the floor) so the
* inbox can offer them back — hiding is reversible, and a conversation with no way back is a
* conversation the user has lost.
*/
class BuzzDmListViewModel : ViewModel() {
@Volatile private var account: Account? = null
@@ -88,6 +91,10 @@ class BuzzDmListViewModel : ViewModel() {
private val _rows = MutableStateFlow<List<DmRow>>(emptyList())
val rows: StateFlow<List<DmRow>> = _rows.asStateFlow()
/** The DMs I hid (per the relay's 30622 snapshot), newest-first — offered back under "Hidden". */
private val _hiddenRows = MutableStateFlow<List<DmRow>>(emptyList())
val hiddenRows: StateFlow<List<DmRow>> = _hiddenRows.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
@@ -211,14 +218,17 @@ class BuzzDmListViewModel : ViewModel() {
account.client.fetchAllWithHooks(filters = byRelay, timeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false }
}
/** Project the discovered DM channels (metadata `t` = `dm`), minus my hidden set, newest-first. */
/**
* Project the discovered DM channels (metadata `t` = `dm`) newest-first, split by my hidden set
* (the relay's 30622 snapshot) into [rows] and [hiddenRows]. Both halves come from one pass so a
* DM can only ever be in one of them.
*/
private fun rebuildRows(account: Account) {
val myPubkey = account.userProfile().pubkeyHex
val hidden = BuzzDmRegistry.hiddenFor(myPubkey)
_rows.value =
val (hiddenDms, visibleDms) =
memberChannels.entries
.mapNotNull { (channelId, relay) ->
if (channelId in hidden) return@mapNotNull null
val channel = LocalCache.getOrCreateRelayGroupChannel(GroupId(channelId, relay))
val metadata = channel.event ?: return@mapNotNull null
if (!metadata.isBuzzDm()) return@mapNotNull null
@@ -231,6 +241,38 @@ class BuzzDmListViewModel : ViewModel() {
lastActivity = lastActivityFor(channelId),
)
}.sortedByDescending { it.lastActivity }
.partition { it.channelId in hidden }
_rows.value = visibleDms
_hiddenRows.value = hiddenDms
}
/**
* Take [row] off Messages with a kind-41012 hide command. Server-side and per-viewer: the relay
* republishes my 30622 snapshot with this channel in it, which moves the row to [hiddenRows].
* Membership is untouched — nobody else's inbox changes, and [addToMessages] brings it back.
*/
fun removeFromMessages(row: DmRow) {
val account = account ?: return
viewModelScope.launch(Dispatchers.IO) {
account.hideBuzzDm(LocalCache.getOrCreateRelayGroupChannel(GroupId(row.channelId, row.relayUrl)))
}
}
/**
* Put a hidden DM back on Messages. Buzz has no "unhide" command — re-opening the conversation is
* the un-hide: a kind-41010 with the same participants resolves to the same canonical channel and
* drops it from the 30622 hidden snapshot. A self-DM has no `others`, so send myself, which is
* what the relay derived that channel from (and satisfies kind-41010's 1-8 participant rule).
*/
fun addToMessages(row: DmRow) {
val account = account ?: return
viewModelScope.launch(Dispatchers.IO) {
val me = account.userProfile().pubkeyHex
account.openBuzzDm(row.relayUrl, row.others.ifEmpty { listOf(me) })
// The relay's new 30622 normally arrives on the live subscription; refresh anyway so the
// row returns even if this screen's socket missed the snapshot.
refresh()
}
}
/** Newest message `created_at` for [channelId] from [LocalCache], or 0 when the DM is empty. */
@@ -84,7 +84,8 @@ private const val CARD_WARMUP_LIMIT = 10
* 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.
* 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.
*
* Reused by the relay group-list screen where Buzz membership discovery is folded in.
*
@@ -99,6 +100,7 @@ fun BuzzImportRow(
groupId: GroupId,
isAdded: Boolean,
onAdd: () -> Unit,
onRemove: () -> Unit,
accountViewModel: AccountViewModel,
onOpen: (() -> Unit)? = null,
isStarred: Boolean = false,
@@ -168,6 +170,7 @@ fun BuzzImportRow(
isStarred = isStarred,
onToggleStar = onToggleStar,
onAdd = onAdd,
onRemove = onRemove,
accountViewModel = accountViewModel,
)
}
@@ -192,6 +195,7 @@ private fun BuzzImportRowContent(
isStarred: Boolean,
onToggleStar: (() -> Unit)?,
onAdd: () -> Unit,
onRemove: () -> Unit,
accountViewModel: AccountViewModel,
) {
Row(
@@ -249,6 +253,7 @@ private fun BuzzImportRowContent(
BuzzChannelRowMenu(
isAdded = isAdded,
onAdd = onAdd,
onRemove = onRemove,
isStarred = isStarred,
onToggleStar = onToggleStar,
)
@@ -307,6 +312,7 @@ private fun BuzzChannelPreviewLine(
private fun BuzzChannelRowMenu(
isAdded: Boolean,
onAdd: () -> Unit,
onRemove: () -> Unit,
isStarred: Boolean,
onToggleStar: (() -> Unit)?,
) {
@@ -338,20 +344,22 @@ private fun BuzzChannelRowMenu(
},
)
}
// 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.Check else MaterialSymbols.Add,
symbol = if (isAdded) MaterialSymbols.VisibilityOff else MaterialSymbols.Add,
contentDescription = null,
tint = if (isAdded) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
tint = 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,
text = { Text(stringRes(if (isAdded) R.string.remove_from_messages else R.string.add_to_messages)) },
onClick = {
expanded = false
onAdd()
if (isAdded) onRemove() else onAdd()
},
)
}
@@ -39,7 +39,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.util.Collections
@@ -79,7 +78,12 @@ class BuzzRelayImportViewModel : ViewModel() {
private val _channels = MutableStateFlow<List<GroupId>>(emptyList())
val channels: StateFlow<List<GroupId>> = _channels.asStateFlow()
/** Channel ids already present in the user's kind-10009 list (seeded + updated as they add). */
/**
* Channel ids on this relay that are currently in the user's kind-10009 list. Mirrors the live
* list rather than snapshotting it: it used to be seeded once at [bind] and only ever grow, so a
* channel taken off Messages anywhere else still rendered as "Added" — with the Add action
* disabled, leaving no way back.
*/
private val _added = MutableStateFlow<Set<String>>(emptySet())
val added: StateFlow<Set<String>> = _added.asStateFlow()
@@ -110,12 +114,13 @@ class BuzzRelayImportViewModel : ViewModel() {
// every other `#p=me`-gated read on the shared socket.
if (newlyJoined) account.client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true)
// Seed "already added" from the current kind-10009 list so channels the user already has
// render as added rather than offering a duplicate Add.
_added.value =
account.relayGroupList.liveRelayGroupList.value
.filter { RelayUrlNormalizer.normalizeOrNull(it.relayUrl) == normalized }
.mapTo(mutableSetOf()) { it.groupId }
// Track "already added" against the live kind-10009 list, scoped to this relay, so the rows
// follow every add/remove — from here, from the channel's top bar, or from another device.
viewModelScope.launch(Dispatchers.IO) {
account.relayGroupList.liveRelayGroupIds.collect { groups ->
_added.value = groups.filter { it.relayUrl == normalized }.mapTo(mutableSetOf()) { it.id }
}
}
discover(account, normalized)
}
@@ -176,13 +181,32 @@ class BuzzRelayImportViewModel : ViewModel() {
}
}
/** Append [groupId] to the user's kind-10009 list (public group tag), so it shows in Messages. */
/**
* Append [groupId] to the user's kind-10009 list (public group tag), so it shows in Messages, and
* clear any earlier dismissal so the relay's kind-44100 re-announcement isn't filtered back out.
* [_added] is not touched here — the live-list collector in [bind] reflects the new event.
*/
fun add(groupId: GroupId) {
val account = account ?: return
viewModelScope.launch(Dispatchers.IO) {
val channel = LocalCache.getOrCreateRelayGroupChannel(groupId)
account.settings.undismissChannelInvite(groupId.id)
account.follow(channel)
_added.update { it + groupId.id }
}
}
/**
* Take [groupId] off the kind-10009 list without leaving the channel: no kind-9022, so the relay
* roster (and therefore this very list of channels) is untouched and it can be added back. The
* dismissal keeps a Buzz relay's kind-44100 re-announcement from bouncing it back as an invite —
* mirrors `AccountViewModel.removeRelayGroupFromMessages`.
*/
fun remove(groupId: GroupId) {
val account = account ?: return
viewModelScope.launch(Dispatchers.IO) {
val channel = LocalCache.getOrCreateRelayGroupChannel(groupId)
account.settings.dismissChannelInvite(groupId.id)
account.unfollow(channel)
}
}
@@ -24,6 +24,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -36,6 +37,8 @@ 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
@@ -53,6 +56,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.pluralStringResource
@@ -90,6 +94,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzDmListViewModel
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.HiddenDmHeader
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
@@ -113,6 +118,13 @@ private const val CHANNEL_LIST_WARMUP_LIMIT = 10
/** Grace period before offering the Tor→clearnet escape hatch, so a slow-but-working relay isn't nagged. */
private const val TOR_CLEARNET_HINT_DELAY_MS = 6_000L
/**
* Bottom room the list leaves for the floating action button: a 56dp FAB + the Scaffold's 16dp margin
* + slack, so the last row's overflow menu stays tappable instead of sitting under the FAB. Matches
* the value `JobBoardScreen` already uses.
*/
private val FAB_CLEARANCE = 96.dp
/**
* Lists every channel a relay hosts (its kind 39000-39003 directory), so the user
* can browse and open channels on that relay. The relay's directory is streamed by
@@ -200,6 +212,12 @@ fun RelayGroupChannelListScreen(
val dmVm: BuzzDmListViewModel = viewModel(key = "BuzzDmInline-${relay.url}")
LaunchedEffect(relay, isBuzz) { if (isBuzz) dmVm.bind(accountViewModel.account, relay.url) }
val dmRows by dmVm.rows.collectAsStateWithLifecycle()
val hiddenDmRows by dmVm.hiddenRows.collectAsStateWithLifecycle()
// DMs I took off Messages, parked behind a collapsed "Hidden (N)" tail below the section. They
// live here and not only on the full inbox screen because that screen sits behind a "see all"
// row that never appears until a community has more DMs than fit inline — so without this, a
// hidden DM in a small workspace would have no way back at all.
var showHiddenDms by remember { mutableStateOf(false) }
// A Buzz workspace's channels come in three flavours, distinguished by the relay-signed 39000
// `channel_type`: chat "stream" channels, "forum" channels (threaded posts), and "dm" channels
@@ -345,7 +363,14 @@ fun RelayGroupChannelListScreen(
}
}
} else {
LazyColumn(modifier = Modifier.padding(padding)) {
// The Scaffold's `padding` carries the top/bottom bars but deliberately not the FAB — a FAB
// overlays content by design, so clearing it is the list's job. As contentPadding (not a
// modifier) so rows scroll *through* that strip and only come to rest clear of it; the
// modifier form would shrink the viewport and leave the FAB floating over dead space.
LazyColumn(
modifier = Modifier.padding(padding),
contentPadding = PaddingValues(bottom = FAB_CLEARANCE),
) {
if (showTorHint) {
item(key = "tor-hint") {
TorClearnetBanner(
@@ -391,6 +416,7 @@ fun RelayGroupChannelListScreen(
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,
@@ -416,6 +442,7 @@ fun RelayGroupChannelListScreen(
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.
@@ -455,7 +482,14 @@ fun RelayGroupChannelListScreen(
} else {
val shown = dmRows.take(INLINE_DM_LIMIT)
items(shown, key = { "dm-${it.channelId}" }) { row ->
BuzzDmInlineRow(row, myPubkey, accountViewModel, nav) {
BuzzDmInlineRow(
row = row,
myPubkey = myPubkey,
isHidden = false,
onToggleMessages = { dmVm.removeFromMessages(row) },
accountViewModel = accountViewModel,
nav = nav,
) {
nav.nav(Route.RelayGroup(row.channelId, row.relayUrl.url))
}
}
@@ -468,6 +502,30 @@ fun RelayGroupChannelListScreen(
}
}
}
if (hiddenDmRows.isNotEmpty()) {
item(key = "dm-hidden-header") {
HiddenDmHeader(
count = hiddenDmRows.size,
expanded = showHiddenDms,
onToggle = { showHiddenDms = !showHiddenDms },
modifier = Modifier.padding(horizontal = 16.dp),
)
}
if (showHiddenDms) {
items(hiddenDmRows, key = { "dm-hidden-${it.channelId}" }) { row ->
BuzzDmInlineRow(
row = row,
myPubkey = myPubkey,
isHidden = true,
onToggleMessages = { dmVm.addToMessages(row) },
accountViewModel = accountViewModel,
nav = nav,
) {
nav.nav(Route.RelayGroup(row.channelId, row.relayUrl.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).
@@ -572,18 +630,25 @@ 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), 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.
* 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.
*
* [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.
*/
@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) }
@@ -612,7 +677,8 @@ private fun BuzzDmInlineRow(
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
.padding(start = 16.dp, end = 4.dp, top = 10.dp, bottom = 10.dp)
.alpha(if (isHidden) 0.55f else 1f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
@@ -640,6 +706,33 @@ 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()
},
)
}
}
}
}
@@ -114,8 +114,11 @@ fun RelayGroupTopBar(
val dmOther = if (isDm) channel.event?.buzzParticipants()?.firstOrNull { it != myPubkey } else null
var menuOpen by remember { mutableStateOf(false) }
// My kind-10009 list, live: drives the Add/Remove-from-Messages toggle in the overflow below.
val joinedGroupIds by accountViewModel.account.relayGroupList.liveRelayGroupIds
.collectAsStateWithLifecycle()
// Read once here (nav.canPop() is @Composable) so the post-action navigation can pop from a menu
// callback — leaving/removing a group shouldn't strand the user on the screen of a group they left.
// callback — leaving a group shouldn't strand the user on the screen of a group they left.
val canPop = nav.canPop()
var showInvite by remember { mutableStateOf(false) }
var showJoinCode by remember { mutableStateOf(false) }
@@ -302,15 +305,26 @@ fun RelayGroupTopBar(
},
)
}
// Two distinct actions, never conflated: "Remove from Messages" drops the group
// from my kind-10009 list but keeps my relay membership; "Leave" sends the
// kind-9022 that actually removes me. Same split as the channel-invite card.
// 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.
//
// 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
// lists every channel you're a member of, joined or not) and for ones you removed
// earlier — both need the "Add" half. And because it's a reversible toggle, remove
// does NOT pop back: you're still a member reading the channel, and staying is
// what makes the entry flip so the action is visibly undoable. Leave still pops.
val onMyList = channel.groupId in joinedGroupIds
DropdownMenuItem(
text = { Text(stringRes(R.string.remove_from_messages)) },
text = { Text(stringRes(if (onMyList) R.string.remove_from_messages else R.string.add_to_messages)) },
onClick = {
menuOpen = false
accountViewModel.removeRelayGroupFromMessages(channel)
if (canPop) nav.popBack()
if (onMyList) {
accountViewModel.removeRelayGroupFromMessages(channel)
} else {
accountViewModel.addRelayGroupToMessages(channel)
}
},
)
DropdownMenuItem(
+5
View File
@@ -450,6 +450,7 @@
<string name="changed_chat_profile_to">New chat profile:</string>
<string name="leave">Leave</string>
<string name="remove_from_messages">Remove from Messages</string>
<string name="add_to_messages">Add to Messages</string>
<string name="unfollow">Unfollow</string>
<string name="channel_created">Channel created</string>
<string name="channel_information_changed_to">"Channel Information changed to"</string>
@@ -3586,6 +3587,10 @@
<string name="buzz_pin">Pin channel</string>
<string name="buzz_unpin">Unpin channel</string>
<string name="buzz_dm_section_empty">No conversations yet</string>
<plurals name="buzz_dm_hidden_count">
<item quantity="one">%1$d hidden conversation</item>
<item quantity="other">%1$d hidden conversations</item>
</plurals>
<plurals name="buzz_dm_see_all_count">
<item quantity="one">See %1$d more conversation</item>
<item quantity="other">See all %1$d conversations</item>
@@ -23,7 +23,9 @@ package com.vitorpamplona.amethyst.commons.model.nip29RelayGroups
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
import com.vitorpamplona.quartz.utils.Log
@@ -97,6 +99,27 @@ class RelayGroupListState(
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, emptySet())
/**
* The joined groups as normalized [GroupId]s — what the UI asks when it needs to know whether a
* channel is on my Messages list ("Remove from Messages" vs "Add to Messages"). The stored tag
* carries a raw relay url string (another client may not have normalized it the way we do), so
* normalize before comparing instead of matching [GroupTag] strings.
*/
@OptIn(ExperimentalCoroutinesApi::class)
val liveRelayGroupIds: StateFlow<Set<GroupId>> =
liveRelayGroupList
.transformLatest { groups ->
emit(
groups.mapNotNullTo(mutableSetOf()) { tag ->
RelayUrlNormalizer.normalizeOrNull(tag.relayUrl)?.let { GroupId(tag.groupId, it) }
},
)
}.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, emptySet())
/** Is [groupId] currently on my kind-10009 list (i.e. does it show on Messages)? */
fun isOnMyList(groupId: GroupId) = groupId in liveRelayGroupIds.value
private fun RelayGroupChannel.toGroupTag() = GroupTag(groupId.id, groupId.relayUrl.url, event?.name())
suspend fun follow(channel: RelayGroupChannel): SimpleGroupListEvent {
@@ -0,0 +1,79 @@
/*
* 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.quartz.nip51Lists.simpleGroupList
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.utils.nsecToKeyPair
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
class SimpleGroupListEventTest {
private val signer = NostrSignerInternal("nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair())
private val group = GroupTag("abc123", "wss://relay.example.com/", "My Group")
@Test
fun removeFromListCreatedWithNoPrivateGroups() =
runTest {
// Exactly what RelayGroupListState.follow() does for the very first group.
val created =
SimpleGroupListEvent.create(
publicGroups = listOf(group),
signer = signer,
createdAt = 1740669816,
)
assertEquals(1, created.publicGroups().size)
val removed =
SimpleGroupListEvent.remove(
earlierVersion = created,
group = group,
signer = signer,
createdAt = 1740669817,
)
assertEquals(0, removed.publicGroups().size)
}
@Test
fun removeIgnoresTheCosmeticNameOnTheStoredTag() =
runTest {
val created =
SimpleGroupListEvent.create(
publicGroups = listOf(group),
signer = signer,
createdAt = 1740669816,
)
// The channel metadata may have changed names since the tag was written.
val removed =
SimpleGroupListEvent.remove(
earlierVersion = created,
group = GroupTag(group.groupId, group.relayUrl, "Renamed"),
signer = signer,
createdAt = 1740669817,
)
assertEquals(0, removed.publicGroups().size)
}
}