mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-30 19:46:17 +00:00
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:
@@ -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))
|
||||
|
||||
+9
@@ -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
|
||||
|
||||
+49
-4
@@ -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)) {
|
||||
|
||||
+16
-2
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+13
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
+8
@@ -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
|
||||
|
||||
+8
@@ -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()) }
|
||||
|
||||
+79
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user