feat(buzz): archive/unarchive channels + fix visibility edits on Buzz

Two channel-management gaps vs the Buzz interface (both ride kind-9002 tags; no
new protocol), verified against block/buzz:

Archive/Unarchive — the reversible hide-from-the-sidebar the Buzz client has,
distinct from delete. EditMetadataEvent gains an `archived` tag; Account/
AccountViewModel expose archiveRelayGroup; the channel and forum top bars offer
Archive/Unarchive to admins (no confirm — it's reversible). The relay stamps
`["archived","true"]` on the 39000, so GroupMetadataEvent.isArchived() /
RelayGroupChannel.isArchived() read it directly; the community list drops archived
channels out of Channels/Forums into a collapsed "Archived" tail from which they
can be reopened and unarchived.

Visibility-on-edit — a Buzz relay reads its own `visibility` (open/private) tag,
not the NIP-29 `private` status flag, so the edit screen's private toggle was a
silent no-op on Buzz. editRelayGroupMetadata now sends the `visibility` tag on
Buzz relays (status flag still sent for vanilla NIP-29).

Not gaps (checked): topic/purpose/TTL are in the relay's system-message
vocabulary but not extracted on 9002/9007, so there's nothing to mirror.

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 15:05:59 +00:00
parent 97ff971542
commit f723310dc7
10 changed files with 206 additions and 6 deletions
@@ -3676,6 +3676,10 @@ class Account(
parent: String? = channel.parentGroupId(),
children: List<String> = channel.childGroupIds(),
) {
// On a Buzz relay, visibility rides a `visibility` ("open"/"private") tag — the relay does NOT
// read NIP-29's `private` status flag — so a Buzz channel's visibility only actually changes on
// edit when we send that tag. A plain NIP-29 relay ignores it and honours the status flag.
val isBuzz = BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)
val template =
EditMetadataEvent.build(
channel.groupId.id,
@@ -3687,10 +3691,24 @@ class Account(
geohashes = geohashes,
parent = parent,
children = children,
visibility = if (isBuzz) (if (isPrivate) BUZZ_VISIBILITY_PRIVATE else BUZZ_VISIBILITY_OPEN) else null,
)
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/**
* Archive or unarchive a Buzz channel (a minimal kind-9002 carrying only the `archived` tag). The
* relay hides an archived channel from the sidebar and stamps the 39000, but keeps it and its
* history the reversible counterpart to [deleteRelayGroup]. Admin/owner only; the relay enforces.
*/
suspend fun archiveRelayGroup(
channel: RelayGroupChannel,
archived: Boolean,
) {
val template = EditMetadataEvent.build(channel.groupId.id, archived = archived)
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
suspend fun follow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.follow(community))
suspend fun unfollow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.unfollow(community))
@@ -1675,6 +1675,15 @@ class AccountViewModel(
/** Delete the channel/group for everyone (kind-9008). Owner/admin only; the relay enforces it. */
fun deleteRelayGroup(channel: RelayGroupChannel) = launchSigner { account.deleteRelayGroup(channel) }
/**
* Archive/unarchive a Buzz channel (kind-9002 `archived` tag) — hides it from the sidebar without
* destroying it, and is reversible. Owner/admin only; the relay enforces it.
*/
fun archiveRelayGroup(
channel: RelayGroupChannel,
archived: Boolean,
) = launchSigner { account.archiveRelayGroup(channel, archived) }
/**
* Take a relay group off Messages WITHOUT leaving it: drop it from my kind-10009 list so it stops
* showing, but send no kind-9022 — I stay in the relay roster and can still read/post, and re-joining
@@ -274,21 +274,34 @@ fun RelayGroupChannelListScreen(
fun buzzSortKey(groupId: GroupId): String = channelsById[groupId.id]?.toBestDisplayName()?.lowercase() ?: groupId.id
// Archived channels (relay-signed `archived` tag on the 39000) drop out of their normal section and
// gather in a collapsed "Archived" tail — the same hide-from-the-sidebar behavior the Buzz client
// has. They stay reachable there so an admin can open one and Unarchive it from the top bar.
fun isArchived(groupId: GroupId): Boolean = channelsById[groupId.id]?.isArchived() == true
val buzzChatChannels =
remember(buzzGroupIds, channelsById, starred) {
buzzGroupIds
.filter { buzzTypeOf(it).let { t -> t != BUZZ_CHANNEL_TYPE_FORUM && t != BUZZ_CHANNEL_TYPE_DM } }
.filter { buzzTypeOf(it).let { t -> t != BUZZ_CHANNEL_TYPE_FORUM && t != BUZZ_CHANNEL_TYPE_DM } && !isArchived(it) }
.sortedWith(compareByDescending<GroupId> { it.id in starred }.thenBy { buzzSortKey(it) })
}
val buzzForumChannels =
remember(buzzGroupIds, channelsById, starred) {
buzzGroupIds
.filter { buzzTypeOf(it) == BUZZ_CHANNEL_TYPE_FORUM }
.filter { buzzTypeOf(it) == BUZZ_CHANNEL_TYPE_FORUM && !isArchived(it) }
.sortedWith(compareByDescending<GroupId> { it.id in starred }.thenBy { buzzSortKey(it) })
}
// Every archived non-DM channel (chat + forum together), newest section at the bottom.
val buzzArchivedChannels =
remember(buzzGroupIds, channelsById) {
buzzGroupIds
.filter { buzzTypeOf(it) != BUZZ_CHANNEL_TYPE_DM && isArchived(it) }
.sortedBy { buzzSortKey(it) }
}
// Which sections the user has collapsed (session-scoped). Keyed by section id below.
var collapsedSections by remember { mutableStateOf(emptySet<String>()) }
// Which sections the user has collapsed (session-scoped). Keyed by section id below. Archived
// starts collapsed — it's the out-of-the-way tail, expanded only when someone goes looking.
var collapsedSections by remember { mutableStateOf(setOf("archived")) }
fun toggleSection(key: String) {
collapsedSections = if (key in collapsedSections) collapsedSections - key else collapsedSections + key
@@ -508,6 +521,38 @@ fun RelayGroupChannelListScreen(
}
}
// -- ARCHIVED -- Channels the relay has archived (chat + forum), tucked into a
// collapsed tail. Opening one and using the top-bar Unarchive brings it back.
if (buzzArchivedChannels.isNotEmpty()) {
val archivedCollapsed = "archived" in collapsedSections
item(key = "sec-archived") {
RelayGroupSectionHeader(
title = stringRes(R.string.relay_group_section_archived),
collapsed = archivedCollapsed,
onToggle = { toggleSection("archived") },
)
}
if (!archivedCollapsed) {
itemsIndexed(buzzArchivedChannels, key = { _, it -> "archived-${it.id}" }) { index, groupId ->
RowHairline(index)
val isForum = buzzTypeOf(groupId) == BUZZ_CHANNEL_TYPE_FORUM
BuzzImportRow(
groupId = groupId,
accountViewModel = accountViewModel,
onOpen = {
if (isForum) {
nav.nav(Route.RelayGroupThreads(groupId.id, relay.url))
} else {
nav.nav(Route.RelayGroup(groupId.id, relay.url))
}
},
isStarred = groupId.id in starred,
showActivityPreview = !isForum,
)
}
}
}
// -- DIRECT MESSAGES -- (this community's private conversations, most recent first)
item(key = "sec-dms") {
RelayGroupSectionHeader(title = stringRes(R.string.buzz_dm_title)) {
@@ -35,6 +35,7 @@ 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.DropdownMenuItem
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
@@ -61,6 +62,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplyCount
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -172,10 +174,12 @@ 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.
// top-bar overflow: Pin/Unpin and the Add/Remove-from-Messages toggle, plus the
// admin-only Archive/Unarchive (so an archived forum can be brought back from here).
// Buzz-only, which every forum channel is.
if (isBuzz) {
var menuOpen by remember { mutableStateOf(false) }
val isAdmin = channel.membershipOf(accountViewModel.userProfile().pubkeyHex) == RelayGroupMembership.ADMIN
IconButton(onClick = { menuOpen = true }) {
Icon(
symbol = MaterialSymbols.MoreVert,
@@ -186,6 +190,16 @@ private fun RelayGroupThreads(
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
BuzzPinDropdownItem(channel.groupId) { menuOpen = false }
RelayGroupMessagesDropdownItem(channel, accountViewModel) { menuOpen = false }
if (isAdmin) {
val archived = channel.isArchived()
DropdownMenuItem(
text = { Text(stringRes(if (archived) R.string.buzz_channel_unarchive else R.string.buzz_channel_archive)) },
onClick = {
menuOpen = false
accountViewModel.archiveRelayGroup(channel, !archived)
},
)
}
}
}
},
@@ -370,6 +370,19 @@ fun RelayGroupTopBar(
if (canPop) nav.popBack()
},
)
// Archive/Unarchive (kind-9002 `archived` tag) — a reversible hide-from-the-sidebar,
// Buzz-only and admin-gated like Delete but NOT destructive, so no confirm dialog.
// A DM is never archived (it has its own hide), so this is channels/forums only.
if (isBuzzRelay && !isDm && displayMembership == RelayGroupMembership.ADMIN) {
val archived = channel.isArchived()
DropdownMenuItem(
text = { Text(stringRes(if (archived) R.string.buzz_channel_unarchive else R.string.buzz_channel_archive)) },
onClick = {
menuOpen = false
accountViewModel.archiveRelayGroup(channel, !archived)
},
)
}
// Deleting the whole channel/group (kind-9008) is destructive for everyone, so it's
// shown ONLY to an admin/owner — the same authorization gate as Edit above — and
// routed through a confirmation dialog rather than firing on tap.
+3
View File
@@ -2613,6 +2613,8 @@
<string name="relay_group_delete_confirm">Delete \"%1$s\"? This removes the group and its history for everyone, and cannot be undone.</string>
<string name="buzz_channel_delete">Delete channel</string>
<string name="buzz_channel_delete_confirm">Delete \"%1$s\"? This removes the channel and its messages for everyone, and cannot be undone.</string>
<string name="buzz_channel_archive">Archive channel</string>
<string name="buzz_channel_unarchive">Unarchive channel</string>
<string name="relay_group_threads_title">Threads</string>
<string name="relay_group_pin_message">Pin message</string>
<string name="relay_group_unpin_message">Unpin message</string>
@@ -3604,6 +3606,7 @@
<string name="relay_group_section_channels">Channels</string>
<string name="relay_group_section_forums">Forums</string>
<string name="relay_group_section_archived">Archived</string>
<string name="buzz_agent_working">Working…</string>
<string name="relay_group_add_member">Add member</string>
<string name="buzz_community_add_people">Add people to this workspace</string>
@@ -178,6 +178,9 @@ class RelayGroupChannel(
fun isPrivate(): Boolean = event?.isPrivate() ?: false
/** Buzz-only: the relay has archived this channel (hidden from the sidebar, but not deleted). */
fun isArchived(): Boolean = event?.isArchived() ?: false
fun isRestricted(): Boolean = event?.isRestricted() ?: false
fun isClosed(): Boolean = event?.isClosed() ?: false
@@ -57,6 +57,14 @@ class GroupMetadataEvent(
fun picture() = tags.firstTagValue("picture")
/**
* Buzz-only: whether the relay has marked this channel **archived** — a hide-from-the-sidebar
* state, distinct from a delete (the channel and its history live on). The Buzz relay stamps an
* `["archived","true"]` tag onto the 39000 for an archived channel and clients hide it; a plain
* NIP-29 relay has no such concept, so this is false there.
*/
fun isArchived() = tags.firstTagValue("archived") == "true"
/**
* Topic hashtags (`t` tags) the relay advertises for this group, used by the
* discovery feed's hashtag filter. NIP-29 doesn't define these; a group only
@@ -82,6 +82,12 @@ class EditMetadataEvent(
parent: String? = null,
children: List<String> = emptyList(),
previousEvents: List<String> = emptyList(),
// Buzz-only channel-settings tags (a Buzz relay reads these on 9002; a plain NIP-29 relay
// ignores them). [visibility] is "open"/"private" — Buzz's own vocabulary, which it reads
// instead of the NIP-29 `private` status flag, so editing visibility on a Buzz channel needs
// this tag to take. [archived] toggles the channel's archived state ("true"/"false").
visibility: String? = null,
archived: Boolean? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<EditMetadataEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
@@ -90,6 +96,8 @@ class EditMetadataEvent(
about?.let { add(arrayOf("about", it)) }
picture?.let { add(arrayOf("picture", it)) }
status.forEach { add(arrayOf(it.code)) }
visibility?.let { add(arrayOf("visibility", it)) }
archived?.let { add(arrayOf("archived", if (it) "true" else "false")) }
addAll(HashtagTag.assemble(hashtags))
// Mip-map each geohash into every prefix so a coarser followed geohash still matches.
geohashes.forEach { addAll(GeoHashTag.assemble(it).toList()) }
@@ -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.nip29RelayGroups
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
import com.vitorpamplona.quartz.utils.EventFactory
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The Buzz-only channel-settings tags on kind-9002 edit-metadata (`visibility`, `archived`) and the
* `archived` reflection on the relay-signed kind-39000. These ride the 9002 as tags — Buzz reads its
* own `visibility` vocabulary rather than the NIP-29 `private` status flag, and stamps `archived` onto
* the 39000 for an archived channel.
*/
class ChannelSettingsTagTest {
private val relaySelf = "aa".repeat(32)
private val sig = "bb".repeat(64)
private val id = "00".repeat(32)
private val gid = "0123456789abcdef"
private fun tagValue(
tags: Array<Array<String>>,
name: String,
): String? = tags.firstOrNull { it.isNotEmpty() && it[0] == name }?.getOrNull(1)
@Test
fun editEmitsVisibilityTagOnlyWhenSet() {
val priv = EditMetadataEvent.build(gid, visibility = "private")
assertEquals("private", tagValue(priv.tags, "visibility"))
val open = EditMetadataEvent.build(gid, visibility = "open")
assertEquals("open", tagValue(open.tags, "visibility"))
// Absent by default so an ordinary metadata edit doesn't reclassify visibility.
assertNull(tagValue(EditMetadataEvent.build(gid, name = "x").tags, "visibility"))
}
@Test
fun editEmitsArchivedTagAsTrueFalse() {
assertEquals("true", tagValue(EditMetadataEvent.build(gid, archived = true).tags, "archived"))
assertEquals("false", tagValue(EditMetadataEvent.build(gid, archived = false).tags, "archived"))
// Null archived means "don't touch it" — no tag emitted.
assertNull(tagValue(EditMetadataEvent.build(gid, name = "x").tags, "archived"))
}
@Test
fun metadataReflectsArchivedFlag() {
val archivedTemplate = GroupMetadataEvent.build(gid, name = gid) { add(arrayOf("archived", "true")) }
val archived = EventFactory.create(id, relaySelf, archivedTemplate.createdAt, GroupMetadataEvent.KIND, archivedTemplate.tags, "", sig) as GroupMetadataEvent
assertTrue(archived.isArchived())
val plainTemplate = GroupMetadataEvent.build(gid, name = gid)
val plain = EventFactory.create(id, relaySelf, plainTemplate.createdAt, GroupMetadataEvent.KIND, plainTemplate.tags, "", sig) as GroupMetadataEvent
assertFalse(plain.isArchived())
}
}