From 646459e367273f5bed3c36f9fc7f553fbd89ab65 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 05:44:28 +0000 Subject: [PATCH 1/9] fix(buzz): remove a deleted channel from the community channel list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting a channel (kind-9008) published the delete and dropped it from the user's kind-10009 list, but the community's browse list is built from the cached kind-39000 metadata and the Buzz membership (kind-44100) set — neither of which the delete touched — so the channel lingered in the list, and a stale 44100 re-announcement could bring it back after a restart. Track deleted relay-group channels in a device-global RelayGroupDeletions registry (keyed by GroupId.toKey, so it stays relay-scoped), persist it via RelayGroupDeletionPreferences, mark the channel on deleteRelayGroup, and filter deleted keys out of both the directory channels and the Buzz membership list in RelayGroupChannelListScreen. The delete now removes the row live and it stays gone across restarts. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG --- .../com/vitorpamplona/amethyst/AppModules.kt | 6 ++ .../vitorpamplona/amethyst/model/Account.kt | 5 ++ .../RelayGroupDeletionPreferences.kt | 77 ++++++++++++++++ .../relayGroup/RelayGroupChannelListScreen.kt | 42 +++++---- .../nip29RelayGroups/RelayGroupDeletions.kt | 75 ++++++++++++++++ .../RelayGroupDeletionsTest.kt | 87 +++++++++++++++++++ 6 files changed, 277 insertions(+), 15 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/RelayGroupDeletionPreferences.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletions.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletionsTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index f903682bfc..9f44436f55 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.model.preferences.BuzzChannelStarPreferences import com.vitorpamplona.amethyst.model.preferences.BuzzWorkspacePreferences import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences +import com.vitorpamplona.amethyst.model.preferences.RelayGroupDeletionPreferences import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder @@ -287,6 +288,11 @@ class AppModules( // Restore + persist the user's starred Buzz workspace channels across restarts (device-global). val buzzChannelStarPrefs = BuzzChannelStarPreferences(appContext, applicationIOScope) + // Restore + persist the set of relay-group channels deleted (kind-9008) on this device, so a + // deleted channel stays hidden across a restart even if the host relay re-announces a stale + // kind-44100 for it (device-global; a delete is authoritative and terminal for everyone). + val relayGroupDeletionPrefs = RelayGroupDeletionPreferences(appContext, applicationIOScope) + // Service that will run at all times to receive events from Pokey val pokeyReceiver = PokeyReceiver() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 6c7b0597de..91f929fc61 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChann import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListState import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListState import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership @@ -3465,6 +3466,10 @@ class Account( val template = DeleteGroupEvent.build(channel.groupId.id) signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } unfollow(channel) + // Remember the deletion so the channel leaves the community's browse list immediately and + // stays gone across a restart — the relay drops the group but our cached 39000 metadata (and a + // stale re-announced 44100 on a Buzz relay) would otherwise keep it visible. + RelayGroupDeletions.markDeleted(channel.groupId) } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/RelayGroupDeletionPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/RelayGroupDeletionPreferences.kt new file mode 100644 index 0000000000..99e1f99c01 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/RelayGroupDeletionPreferences.kt @@ -0,0 +1,77 @@ +/* + * 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.model.preferences + +import android.content.Context +import androidx.compose.runtime.Stable +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringSetPreferencesKey +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException + +/** + * Device-global persistence for the set of deleted NIP-29 relay-group channels ([RelayGroupDeletions]), + * so a channel the user deleted (kind-9008) stays gone across a restart — even if the host relay keeps + * re-announcing a stale kind-44100 for it. Mirrors [BuzzChannelStarPreferences]: app-wide (not + * per-account), loads the saved keys into the singleton on construction, then writes every later change + * back. Construct once, eagerly. + */ +@Stable +class RelayGroupDeletionPreferences( + private val context: Context, + private val scope: CoroutineScope, +) { + init { + scope.launch { + restoreFromDisk() + // drop(1) skips the value present at collection start, which restoreFromDisk already wrote. + RelayGroupDeletions.flow.drop(1).collect { persist(it) } + } + } + + private suspend fun restoreFromDisk() { + try { + val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return + if (raw.isNotEmpty()) RelayGroupDeletions.restore(raw) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("RelayGroupDeletionPrefs") { "Error reading deleted channels: ${e.message}" } + } + } + + private suspend fun persist(keys: Set) { + try { + context.sharedPreferencesDataStore.edit { prefs -> prefs[KEY] = keys } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("RelayGroupDeletionPrefs") { "Error writing deleted channels: ${e.message}" } + } + } + + companion object { + private val KEY = stringSetPreferencesKey("nip29.deletedChannels") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index c4d62d47f9..aa20a8aea0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -73,6 +73,7 @@ import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot import com.vitorpamplona.amethyst.model.LocalCache @@ -172,6 +173,11 @@ fun RelayGroupChannelListScreen( } } + // Channels this device has deleted (kind-9008). A delete is terminal — the relay drops the group — + // but our cached 39000 (and a Buzz relay's stale re-announced 44100) would keep it in the list, so + // filter them out everywhere below. Collected as a StateFlow so a delete removes the row live. + val deletedChannels by RelayGroupDeletions.flow.collectAsStateWithLifecycle() + // Prefer the relay's own genuine, relay-signed groups (39000 author == the NIP-11 `self`). // Recomputes as the NIP-11 doc resolves so real groups fill in and fakes stay hidden. But if // NIP-11 is unreachable (e.g. a Cloudflare-fronted relay that resets the plain HTTP GET while @@ -179,20 +185,22 @@ fun RelayGroupChannelListScreen( // its de-facto signer — so its relay-signed groups still show while a stray user-published 39000 // (a different author) stays filtered. val channels = - remember(allChannels, relayInfo) { + remember(allChannels, relayInfo, deletedChannels) { val nip11Known = relayInfo.self != null || relayInfo.supported_nips != null - if (nip11Known) { - allChannels.filter { isRelaySignedRelayGroup(it, relayInfo) } - } else { - val dominantSigner = - allChannels - .mapNotNull { it.event?.pubKey } - .groupingBy { it } - .eachCount() - .maxByOrNull { it.value } - ?.key - if (dominantSigner != null) allChannels.filter { it.event?.pubKey == dominantSigner } else allChannels - } + val signed = + if (nip11Known) { + allChannels.filter { isRelaySignedRelayGroup(it, relayInfo) } + } else { + val dominantSigner = + allChannels + .mapNotNull { it.event?.pubKey } + .groupingBy { it } + .eachCount() + .maxByOrNull { it.value } + ?.key + if (dominantSigner != null) allChannels.filter { it.event?.pubKey == dominantSigner } else allChannels + } + signed.filterNot { it.groupId.toKey() in deletedChannels } } // Buzz relays expose no public group directory (membership is server-side), so `channels` above @@ -226,9 +234,13 @@ fun RelayGroupChannelListScreen( // ids so nothing the old flat list showed disappears. val channelsById = remember(allChannels) { allChannels.associateBy { it.groupId.id } } val buzzGroupIds = - remember(buzzChannels, channels) { + remember(buzzChannels, channels, deletedChannels) { val seen = LinkedHashSet() - (buzzChannels + channels.map { it.groupId }).filter { seen.add(it.id) } + // `channels` is already delete-filtered; also drop deleted ids from the membership-scoped + // `buzzChannels` (kind-44100), which the relay can keep re-announcing after a delete. + (buzzChannels + channels.map { it.groupId }) + .filterNot { it.toKey() in deletedChannels } + .filter { seen.add(it.id) } } fun buzzTypeOf(groupId: GroupId): String? = channelsById[groupId.id]?.event?.buzzChannelType() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletions.kt new file mode 100644 index 0000000000..fc3df75e65 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletions.kt @@ -0,0 +1,75 @@ +/* + * 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.commons.model.nip29RelayGroups + +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * The set of NIP-29 relay-group channels this device has **deleted** (kind-9008), keyed by + * [GroupId.toKey] (`id@relay`, so a group is scoped to its host relay — group ids are only + * unique per relay). + * + * A delete is terminal and destroys the group for everyone: the relay drops it and stops serving + * its kind-39000 metadata. But the client already holds that metadata in `LocalCache`, and a Buzz + * relay may keep re-announcing a stale kind-44100 member-added notification that would re-surface + * the channel in the community's browse list — after a restart too, since it's re-fetched from the + * relay. So the deletion has to be remembered client-side and the channel filtered out everywhere + * the list is built. + * + * Like [BuzzChannelStars], there is no personal Nostr event for "I deleted this from my view", so + * this is a process-wide singleton mirrored to a device-global store by the platform + * ([com.vitorpamplona.amethyst] `RelayGroupDeletionPreferences`) and restored at startup. Deleting + * is authoritative and terminal, so an entry is only ever added, never removed. + */ +object RelayGroupDeletions { + private val deleted = MutableStateFlow>(emptySet()) + + /** The deleted group keys ([GroupId.toKey]); the community view collects this to hide them. */ + val flow: StateFlow> = deleted + + fun isDeleted(groupKey: String): Boolean = groupKey in deleted.value + + fun isDeleted(groupId: GroupId): Boolean = isDeleted(groupId.toKey()) + + /** Record [groupId] as deleted (idempotent). */ + fun markDeleted(groupId: GroupId) = markDeleted(groupId.toKey()) + + /** Record [groupKey] ([GroupId.toKey]) as deleted (idempotent). */ + fun markDeleted(groupKey: String) { + while (true) { + val current = deleted.value + if (groupKey in current) return + if (deleted.compareAndSet(current, current + groupKey)) return + } + } + + /** Replaces the whole set — used to restore from disk at startup. */ + fun restore(keys: Set) { + deleted.value = keys + } + + /** Test-only: clears the set so unit tests don't leak state into each other. */ + fun clearForTesting() { + deleted.value = emptySet() + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletionsTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletionsTest.kt new file mode 100644 index 0000000000..6a60adc6e1 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletionsTest.kt @@ -0,0 +1,87 @@ +/* + * 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.commons.model.nip29RelayGroups + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The device-global "deleted channels" bookkeeping: a delete is relay-scoped (keyed by + * [GroupId.toKey]) and terminal (only ever added), so the same id on a different relay stays visible. + */ +class RelayGroupDeletionsTest { + private val relayA = RelayUrlNormalizer.normalize("wss://a.example.com") + private val relayB = RelayUrlNormalizer.normalize("wss://b.example.com") + private val gid = "0123456789abcdef" + + @BeforeTest + fun reset() = RelayGroupDeletions.clearForTesting() + + @AfterTest + fun tearDown() = RelayGroupDeletions.clearForTesting() + + @Test + fun marksAChannelDeletedAndReflectsInTheFlow() { + val group = GroupId(gid, relayA) + assertFalse(RelayGroupDeletions.isDeleted(group)) + + RelayGroupDeletions.markDeleted(group) + + assertTrue(RelayGroupDeletions.isDeleted(group)) + assertTrue(RelayGroupDeletions.isDeleted(group.toKey())) + assertEquals(setOf(group.toKey()), RelayGroupDeletions.flow.value) + } + + @Test + fun deletionIsRelayScoped() { + RelayGroupDeletions.markDeleted(GroupId(gid, relayA)) + + // The same group id on a different host relay is a different group, so it stays visible. + assertTrue(RelayGroupDeletions.isDeleted(GroupId(gid, relayA))) + assertFalse(RelayGroupDeletions.isDeleted(GroupId(gid, relayB))) + } + + @Test + fun markingIsIdempotent() { + val group = GroupId(gid, relayA) + RelayGroupDeletions.markDeleted(group) + RelayGroupDeletions.markDeleted(group) + + assertEquals(1, RelayGroupDeletions.flow.value.size) + } + + @Test + fun restoreReplacesTheWholeSet() { + RelayGroupDeletions.markDeleted(GroupId(gid, relayA)) + + val restored = setOf(GroupId("aaaa", relayB).toKey(), GroupId("bbbb", relayB).toKey()) + RelayGroupDeletions.restore(restored) + + assertEquals(restored, RelayGroupDeletions.flow.value) + assertFalse(RelayGroupDeletions.isDeleted(GroupId(gid, relayA))) + } +} From 9a5b36d0ba12eaed7dec12d625d6b28cccb659f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 13:25:37 +0000 Subject: [PATCH 2/9] fix(buzz): honor the relay's channel_deleted signal so deleted channels leave the list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first pass only recorded a deletion when THIS device published the 9008, so a channel deleted on another Buzz client — or before the fix existed — kept showing and survived restarts. The Buzz relay's handle_delete_group soft-deletes the channel and its 39000/39001/39002 discovery events but emits NO member-removed notification and never retracts the kind-44100 that seeds the browse list; its only cross-device signal is the relay-signed kind-40099 system message with type=channel_deleted (verified against block/buzz side_effects.rs). Consume that signal: LocalCache records a relay-signed 40099 channel_deleted into RelayGroupDeletions (gated on isRelaySignedGroupEvent so a spoofed one can't hide a channel), and BuzzRelayImportViewModel.discover now also fetches the #h-scoped 40099 for its candidate channels and drops deleted ones — the relay keeps re-announcing their 44100 with blank metadata, so this is the only place they leave the list. Cross-device and retroactive: the relay replays the 40099 on subscribe. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG --- .../amethyst/model/LocalCache.kt | 28 ++++++++++++++++++- .../loggedIn/buzz/BuzzRelayImportViewModel.kt | 23 +++++++++++++-- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 820cbcaa1a..bdad25dece 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -41,6 +41,7 @@ import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtIdHexComparator import com.vitorpamplona.amethyst.commons.model.observables.EventListMatchingFilter @@ -114,6 +115,7 @@ import com.vitorpamplona.quartz.buzz.stream.StreamMessageScheduledEvent import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event import com.vitorpamplona.quartz.buzz.stream.StreamReminderEvent import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent +import com.vitorpamplona.quartz.buzz.stream.SystemMessagePayload import com.vitorpamplona.quartz.buzz.stream.sidecars.ChannelSummaryEvent import com.vitorpamplona.quartz.buzz.stream.sidecars.PresenceSnapshotEvent import com.vitorpamplona.quartz.buzz.teams.TeamEvent @@ -2223,6 +2225,30 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { attachToRelayGroupIfScoped(event, relay) } + /** + * A Buzz kind-40099 system message. It renders as a narration row in the channel feed (via + * [consumeBuzzTimelineEvent]), but a `channel_deleted` one is also the **authoritative signal that + * a channel is gone**: the relay soft-deletes the channel and its 39000/39001/39002 discovery + * events but emits no member-removed notification and never retracts the kind-44100 that seeds the + * browse list — so without this the deleted channel keeps re-appearing (a stale 44100 re-announced + * every restart, its metadata now blank so it shows optimistically). Recording the delete in + * [RelayGroupDeletions] filters it out of every list and persists it across restarts. + * + * Gated on [isRelaySignedGroupEvent] (the 40099 is signed by the relay keypair) so a spoofed + * system message from a stray author can't hide a channel. Cross-device by construction: the relay + * replays this on subscribe, so a channel deleted on Buzz web/desktop is honored here too. + */ + private fun consume( + event: SystemMessageEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean = + consumeBuzzTimelineEvent(event, relay, wasVerified).also { + if (relay != null && isRelaySignedGroupEvent(event, relay) && event.payload()?.type == SystemMessagePayload.CHANNEL_DELETED) { + event.channel()?.let { channelId -> RelayGroupDeletions.markDeleted(GroupId(channelId, relay)) } + } + } + /** Store-only consume for Buzz kinds that carry no channel timeline row. */ private fun consumeBuzzRegularEvent( event: Event, @@ -4798,7 +4824,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { is StreamMessageV2Event -> consumeBuzzTimelineEvent(event, relay, wasVerified) is StreamMessageEditEvent -> consume(event, relay, wasVerified) is StreamMessageDiffEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - is SystemMessageEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) + is SystemMessageEvent -> consume(event, relay, wasVerified) is CanvasEvent -> consume(event, relay, wasVerified) // Forum root (45001) is a thread, not a chat row → Threads collection. Comments (45003) // and votes (45002) are store-only: the forum-thread detail loads them on demand by root. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt index 92027d1cd2..30c5f73e2a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt @@ -23,11 +23,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_METADATA_KINDS import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent +import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -154,21 +156,36 @@ class BuzzRelayImportViewModel : ViewModel() { false } - // 2. Fetch each channel's NIP-29 metadata (39000-39003) so its name + Buzz `t` type load. + // 2. Fetch each channel's NIP-29 metadata (39000-39003, `#d`-scoped) so its name + Buzz + // `t` type load, AND the relay's kind-40099 system messages (`#h`-scoped) so a + // `channel_deleted` one is seen. The relay soft-deletes a deleted channel's 39000 + // and never retracts the kind-44100 that seeded `channelIds`, so without pulling the + // 40099 a deleted channel — its metadata now blank — would show optimistically here + // forever. LocalCache records the delete into RelayGroupDeletions on consume. if (channelIds.isNotEmpty()) { account.client.fetchAllWithHooks( - filters = mapOf(relay to listOf(Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to channelIds.toList())))), + filters = + mapOf( + relay to + listOf( + Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to channelIds.toList())), + Filter(kinds = listOf(SystemMessageEvent.KIND), tags = mapOf("h" to channelIds.toList())), + ), + ), timeoutMs = 8_000, pendingOnAuthRequired = true, ) { _, _ -> false } } // 3. Keep only non-DM workspace channels; a channel whose metadata hasn't arrived - // (type unknown) is optimistically shown as a workspace channel. + // (type unknown) is optimistically shown as a workspace channel. Deleted channels + // (kind-40099 `channel_deleted`, recorded above) are dropped — the relay keeps + // re-announcing their kind-44100, so this is the only place they leave the list. _channels.value = channelIds .mapNotNull { id -> val groupId = GroupId(id, relay) + if (RelayGroupDeletions.isDeleted(groupId)) return@mapNotNull null val channel = LocalCache.getOrCreateRelayGroupChannel(groupId) if (channel.event?.isBuzzDm() == true) null else groupId }.sortedBy { it.id } From a89aaed9faf5e571e5783fe2e7f9b04e9531f82d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 14:10:11 +0000 Subject: [PATCH 3/9] feat(buzz): create channels/forums from section-label "+" instead of a FAB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a Buzz community the FAB (which created a channel) is replaced by a "+" on the Channels label — matching how Direct Messages already offers a "+" — and a Forums label gains its own "+" that starts the create flow pre-set to a forum channel. Both section headers now always render so the "+" is reachable even before any channel loads; the collapse chevron is shown only when the section is non-empty. The create route gains an isForum flag (threaded onto RelayGroupCreateScreen → RelayGroupMetadataViewModel.isForum) so the Forums "+" opens the create screen on a forum, the Channels "+" on a chat channel. The vanilla NIP-29 relay (a flat directory with no sections) keeps its FAB. The stale "accept the invite in the browser" empty text is dropped — an empty community is now a starting point. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG --- .../amethyst/ui/navigation/AppNavigation.kt | 1 + .../amethyst/ui/navigation/routes/Routes.kt | 3 + .../relayGroup/RelayGroupChannelListScreen.kt | 88 +++++++++++++------ .../relayGroup/RelayGroupMetadataScreen.kt | 7 +- amethyst/src/main/res/values/strings.xml | 1 + 5 files changed, 73 insertions(+), 27 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 14faf2d41c..d520612fcb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -790,6 +790,7 @@ fun BuildNavigation( composableFromEndArgs { RelayGroupCreateScreen( relayUrl = it.relayUrl, + isForum = it.isForum, accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 57b5af66e8..4392da65e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -741,6 +741,9 @@ sealed class Route { @Serializable data class RelayGroupCreate( val relayUrl: String, + // Buzz only: start the create flow on a `forum` channel (threaded posts) instead of a + // `stream` (chat) one — set by the community screen's per-section "+" buttons. + val isForum: Boolean = false, ) : Route() @Serializable data class RelayGroupEdit( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index aa20a8aea0..b9236d68f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -338,13 +338,17 @@ fun RelayGroupChannelListScreen( } }, floatingActionButton = { - FloatingActionButton(onClick = { nav.nav(Route.RelayGroupCreate(relay.url)) }, shape = CircleShape) { - Icon( - symbol = MaterialSymbols.Add, - contentDescription = - stringRes(if (isBuzz) R.string.buzz_channel_create_title else R.string.relay_group_create_title), - modifier = Modifier.size(24.dp), - ) + // A Buzz community creates channels/forums from the per-section "+" in their labels (like + // Direct Messages), so no FAB there. A vanilla NIP-29 relay is a flat directory with no + // sections, so it keeps the FAB to create a group. + if (!isBuzz) { + FloatingActionButton(onClick = { nav.nav(Route.RelayGroupCreate(relay.url)) }, shape = CircleShape) { + Icon( + symbol = MaterialSymbols.Add, + contentDescription = stringRes(R.string.relay_group_create_title), + modifier = Modifier.size(24.dp), + ) + } } }, ) { padding -> @@ -378,9 +382,11 @@ fun RelayGroupChannelListScreen( // 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. + // Only the vanilla NIP-29 path has a FAB now; a Buzz community creates from its section + // headers, so it needs no bottom clearance. LazyColumn( modifier = Modifier.padding(padding), - contentPadding = PaddingValues(bottom = FAB_CLEARANCE), + contentPadding = PaddingValues(bottom = if (isBuzz) 0.dp else FAB_CLEARANCE), ) { if (showTorHint) { item(key = "tor-hint") { @@ -394,16 +400,15 @@ fun RelayGroupChannelListScreen( } if (isBuzz) { + // While the membership fetch is still running and nothing has loaded, show a + // "Loading…" line. The old "you're not a member — accept the invite in the browser" + // empty text is gone: the section labels below now each carry a "+" to create a + // channel/forum, so an empty community is a starting point, not a dead end. val noChannelsYet = buzzChatChannels.isEmpty() && buzzForumChannels.isEmpty() - if (noChannelsYet) { - item(key = "buzz-no-channels") { + if (noChannelsYet && buzzStatus is BuzzRelayImportViewModel.Status.Loading) { + item(key = "buzz-loading") { Text( - text = - if (buzzStatus is BuzzRelayImportViewModel.Status.Loading) { - stringRes(R.string.buzz_import_loading) - } else { - stringRes(R.string.buzz_import_empty_body) - }, + text = stringRes(R.string.buzz_import_loading), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth().padding(24.dp), @@ -411,17 +416,24 @@ fun RelayGroupChannelListScreen( } } - // -- CHANNELS -- (Add-all now lives in the community's top-bar overflow menu) - if (buzzChatChannels.isNotEmpty()) { + // -- CHANNELS -- The label carries a "+" to create a channel (the community's FAB + // moved here, like Direct Messages). Add-all lives in the top-bar overflow menu. + // The header always shows so the "+" is available even before any channel loads; + // the collapse toggle is offered only when there's something to collapse. + run { val channelsCollapsed = "channels" in collapsedSections item(key = "sec-channels") { RelayGroupSectionHeader( title = stringRes(R.string.relay_group_section_channels), collapsed = channelsCollapsed, - onToggle = { toggleSection("channels") }, - ) + onToggle = if (buzzChatChannels.isNotEmpty()) ({ toggleSection("channels") }) else null, + ) { + SectionAddButton(stringRes(R.string.buzz_channel_create_title)) { + nav.nav(Route.RelayGroupCreate(relay.url)) + } + } } - if (!channelsCollapsed) { + if (buzzChatChannels.isNotEmpty() && !channelsCollapsed) { itemsIndexed(buzzChatChannels, key = { _, it -> "chat-${it.id}" }) { index, groupId -> RowHairline(index) BuzzImportRow( @@ -438,17 +450,22 @@ fun RelayGroupChannelListScreen( } } - // -- FORUMS -- - if (buzzForumChannels.isNotEmpty()) { + // -- FORUMS -- Same treatment: an always-visible label with a "+" that starts the + // create flow on a forum channel (threaded posts) instead of a chat one. + run { val forumsCollapsed = "forums" in collapsedSections item(key = "sec-forums") { RelayGroupSectionHeader( title = stringRes(R.string.relay_group_section_forums), collapsed = forumsCollapsed, - onToggle = { toggleSection("forums") }, - ) + onToggle = if (buzzForumChannels.isNotEmpty()) ({ toggleSection("forums") }) else null, + ) { + SectionAddButton(stringRes(R.string.buzz_forum_create_title)) { + nav.nav(Route.RelayGroupCreate(relay.url, isForum = true)) + } + } } - if (!forumsCollapsed) { + if (buzzForumChannels.isNotEmpty() && !forumsCollapsed) { itemsIndexed(buzzForumChannels, key = { _, it -> "forum-${it.id}" }) { index, groupId -> RowHairline(index) BuzzImportRow( @@ -652,6 +669,25 @@ private fun RelayGroupSectionHeader( } } +/** + * The trailing "+" for a section label (Channels / Forums), matching the Direct Messages header's + * New-message icon: a primary-tinted Add glyph that creates a new item of that section's type. + */ +@Composable +private fun SectionAddButton( + contentDescription: String, + onClick: () -> Unit, +) { + IconButton(onClick = onClick) { + Icon( + symbol = MaterialSymbols.Add, + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp), + ) + } +} + /** * 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt index c71641b669..5f2c62d1d5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt @@ -97,10 +97,15 @@ fun RelayGroupCreateScreen( relayUrl: String, accountViewModel: AccountViewModel, nav: INav, + // Buzz only: pre-select the `forum` channel type (the Forums section's "+" routes here with true). + isForum: Boolean = false, ) { val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return val viewModel: RelayGroupMetadataViewModel = viewModel(key = "RelayGroupCreate:$relayUrl") - LaunchedEffect(relay) { viewModel.initCreate(accountViewModel, relay) } + LaunchedEffect(relay) { + viewModel.initCreate(accountViewModel, relay) + if (isForum) viewModel.isForum = true + } // A group only works if the relay actually runs NIP-29 (otherwise it stores our 9007/9002 as // ordinary events, never emits metadata/roster, and the "group" is a dead hex id). Gate creation diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 8cef251ec7..75d6b194a8 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2570,6 +2570,7 @@ Create a group New channel + New forum Private channel Hidden from the channel list and invite-only. Off means anyone on this relay can find and join it. Forum channel From 34fa103dbd6eaa70df083a441943b13e1d6d3e25 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 14:26:23 +0000 Subject: [PATCH 4/9] feat(buzz): gate the channel/forum "+" to community members Only a Buzz community member may create a channel (the relay makes the creator its owner; a non-member's kind-9007 is rejected), so the section-label "+" now shows only when the viewer is in the community's NIP-43 roster. Reactive: the relay-signed roster snapshot (kind 13534, republished on every membership change) can arrive after first composition, so BuzzCommunityMembership is re-read whenever one lands. The section labels themselves still render for everyone; only the "+" is gated. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG --- .../relayGroup/RelayGroupChannelListScreen.kt | 55 +++++++++++++++---- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index b9236d68f4..bcaedd0096 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -109,6 +109,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -208,6 +209,24 @@ fun RelayGroupChannelListScreen( // (kind-44100) so browsing the relay lists the channels you already belong to — each addable to // your kind-10009 list (so it then shows in Messages / Relay Groups). Same screen, one Browse. val isBuzz = BuzzRelayDialect.isBuzz(relay) || relayInfo.software?.contains("buzz", ignoreCase = true) == true + + // Who may create a channel/forum here — the gate for the section "+" buttons. A Buzz relay lets + // any community member create one (the creator becomes its owner); a non-member's 9007 is + // rejected, so we only offer the "+" to members of this community's NIP-43 roster. Reactive: the + // relay-signed roster snapshot (kind 13534, republished after every membership change) may land + // after first composition, so re-read [BuzzCommunityMembership] whenever one does. + val myPubkey = accountViewModel.account.signer.pubKey + val canCreateChannels by produceState( + initialValue = BuzzCommunityMembership.isMember(relay, myPubkey), + relay, + myPubkey, + ) { + value = BuzzCommunityMembership.isMember(relay, myPubkey) + LocalCache + .observeEvents(Filter(kinds = listOf(RelayMembershipListEvent.KIND))) + .collect { value = BuzzCommunityMembership.isMember(relay, myPubkey) } + } + val buzzVm: BuzzRelayImportViewModel = viewModel(key = "BuzzImport-${relay.url}") LaunchedEffect(relay, isBuzz) { if (isBuzz) buzzVm.bind(accountViewModel.account, relay.url) } val buzzChannels by buzzVm.channels.collectAsStateWithLifecycle() @@ -352,7 +371,6 @@ fun RelayGroupChannelListScreen( } }, ) { padding -> - val myPubkey = accountViewModel.userProfile().pubkeyHex // A Buzz relay is a community, so always render its sectioned list (Channels, Forums, Direct // Messages, Agent Console) even before anything loads, rather than the generic "empty" text. if (channels.isEmpty() && !isBuzz) { @@ -419,7 +437,8 @@ fun RelayGroupChannelListScreen( // -- CHANNELS -- The label carries a "+" to create a channel (the community's FAB // moved here, like Direct Messages). Add-all lives in the top-bar overflow menu. // The header always shows so the "+" is available even before any channel loads; - // the collapse toggle is offered only when there's something to collapse. + // the collapse toggle is offered only when there's something to collapse. The "+" + // is offered only to community members, who are the ones the relay lets create. run { val channelsCollapsed = "channels" in collapsedSections item(key = "sec-channels") { @@ -427,11 +446,17 @@ fun RelayGroupChannelListScreen( title = stringRes(R.string.relay_group_section_channels), collapsed = channelsCollapsed, onToggle = if (buzzChatChannels.isNotEmpty()) ({ toggleSection("channels") }) else null, - ) { - SectionAddButton(stringRes(R.string.buzz_channel_create_title)) { - nav.nav(Route.RelayGroupCreate(relay.url)) - } - } + trailing = + if (canCreateChannels) { + { + SectionAddButton(stringRes(R.string.buzz_channel_create_title)) { + nav.nav(Route.RelayGroupCreate(relay.url)) + } + } + } else { + null + }, + ) } if (buzzChatChannels.isNotEmpty() && !channelsCollapsed) { itemsIndexed(buzzChatChannels, key = { _, it -> "chat-${it.id}" }) { index, groupId -> @@ -459,11 +484,17 @@ fun RelayGroupChannelListScreen( title = stringRes(R.string.relay_group_section_forums), collapsed = forumsCollapsed, onToggle = if (buzzForumChannels.isNotEmpty()) ({ toggleSection("forums") }) else null, - ) { - SectionAddButton(stringRes(R.string.buzz_forum_create_title)) { - nav.nav(Route.RelayGroupCreate(relay.url, isForum = true)) - } - } + trailing = + if (canCreateChannels) { + { + SectionAddButton(stringRes(R.string.buzz_forum_create_title)) { + nav.nav(Route.RelayGroupCreate(relay.url, isForum = true)) + } + } + } else { + null + }, + ) } if (buzzForumChannels.isNotEmpty() && !forumsCollapsed) { itemsIndexed(buzzForumChannels, key = { _, it -> "forum-${it.id}" }) { index, groupId -> From 97ff9715426df5761e534c22f6baa8a6753ea5e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 14:36:42 +0000 Subject: [PATCH 5/9] feat(buzz): move per-row channel actions into the opened screen's top bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG --- .../ui/screen/loggedIn/AccountViewModel.kt | 16 +++ .../ui/screen/loggedIn/buzz/BuzzImportRow.kt | 94 +--------------- .../relayGroup/BuzzChannelMenuItems.kt | 104 ++++++++++++++++++ .../relayGroup/RelayGroupChannelListScreen.kt | 55 ++------- .../relayGroup/RelayGroupThreadsScreen.kt | 23 ++++ .../relayGroup/RelayGroupTopBar.kt | 37 ++++++- 6 files changed, 190 insertions(+), 139 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/BuzzChannelMenuItems.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index b105993fc6..363a703d06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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, + ) = 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. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt index a0d45791da..387a77b0eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt @@ -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, 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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/BuzzChannelMenuItems.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/BuzzChannelMenuItems.kt new file mode 100644 index 0000000000..6240aca33a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/BuzzChannelMenuItems.kt @@ -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) + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index bcaedd0096..a1cc027c72 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -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() - }, - ) - } - } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt index e015e48a7b..85f0725a78 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt @@ -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, ) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt index 47a19bea25..afb2682855 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt @@ -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 From f723310dc77fce717da1977a6b4fc55028ed488b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 15:05:59 +0000 Subject: [PATCH 6/9] feat(buzz): archive/unarchive channels + fix visibility edits on Buzz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG --- .../vitorpamplona/amethyst/model/Account.kt | 18 +++++ .../ui/screen/loggedIn/AccountViewModel.kt | 9 +++ .../relayGroup/RelayGroupChannelListScreen.kt | 53 ++++++++++++- .../relayGroup/RelayGroupThreadsScreen.kt | 18 ++++- .../relayGroup/RelayGroupTopBar.kt | 13 +++ amethyst/src/main/res/values/strings.xml | 3 + .../nip29RelayGroups/RelayGroupChannel.kt | 3 + .../metadata/GroupMetadataEvent.kt | 8 ++ .../moderation/EditMetadataEvent.kt | 8 ++ .../ChannelSettingsTagTest.kt | 79 +++++++++++++++++++ 10 files changed, 206 insertions(+), 6 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/ChannelSettingsTagTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 91f929fc61..68df7e7ed0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -3676,6 +3676,10 @@ class Account( parent: String? = channel.parentGroupId(), children: List = 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)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 363a703d06..a247e1cf1e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index a1cc027c72..3cbc0f9e39 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -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 { 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 { 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()) } + // 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)) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt index 85f0725a78..33354badb9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt @@ -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) + }, + ) + } } } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt index afb2682855..4d725b781b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt @@ -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. diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 75d6b194a8..75d3d2a7c8 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2613,6 +2613,8 @@ Delete \"%1$s\"? This removes the group and its history for everyone, and cannot be undone. Delete channel Delete \"%1$s\"? This removes the channel and its messages for everyone, and cannot be undone. + Archive channel + Unarchive channel Threads Pin message Unpin message @@ -3604,6 +3606,7 @@ Channels Forums + Archived Working… Add member Add people to this workspace diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt index 11c5bb23bd..9c029296f8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt @@ -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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt index a05f2d7b9f..aeb4721570 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt @@ -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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt index b503c830d3..61ccd41f3c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt @@ -82,6 +82,12 @@ class EditMetadataEvent( parent: String? = null, children: List = emptyList(), previousEvents: List = 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.() -> 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()) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/ChannelSettingsTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/ChannelSettingsTagTest.kt new file mode 100644 index 0000000000..080e477790 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/ChannelSettingsTagTest.kt @@ -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>, + 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()) + } +} From 9d2c5e0ceef9d2c62871d0a6ad860bd384e46d16 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 16:07:07 +0000 Subject: [PATCH 7/9] fix(buzz): show the channel/forum "+" on any community, not gated on an unloaded roster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "+" was gated on BuzzCommunityMembership (kind-13534), but that roster is only fetched by the Members screen — nothing on the channel-list screen subscribes to it, so isMember read false and the "+" hid even from admins. Match the workspace overflow menu's approach instead: offer create on any Buzz community and let the relay reject a non-member's kind-9007 (its own doc: "any member sees them, the relay only serves the owner/admin ones"). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG --- .../relayGroup/RelayGroupChannelListScreen.kt | 57 ++++++------------- 1 file changed, 17 insertions(+), 40 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index 3cbc0f9e39..7ff2e28dac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -107,7 +107,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent -import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -208,22 +207,13 @@ fun RelayGroupChannelListScreen( // your kind-10009 list (so it then shows in Messages / Relay Groups). Same screen, one Browse. val isBuzz = BuzzRelayDialect.isBuzz(relay) || relayInfo.software?.contains("buzz", ignoreCase = true) == true - // Who may create a channel/forum here — the gate for the section "+" buttons. A Buzz relay lets - // any community member create one (the creator becomes its owner); a non-member's 9007 is - // rejected, so we only offer the "+" to members of this community's NIP-43 roster. Reactive: the - // relay-signed roster snapshot (kind 13534, republished after every membership change) may land - // after first composition, so re-read [BuzzCommunityMembership] whenever one does. + // A Buzz relay lets any community member create a channel/forum (the creator becomes its owner), + // and the relay rejects a non-member's kind-9007. We don't hard-gate the "+" on membership: the + // NIP-43 roster (kind 13534) isn't fetched on this screen, so gating on it hid the "+" even from + // admins. Instead we offer it on any Buzz community and let the relay enforce — the same approach + // as the workspace overflow menu (Add people / Invite), whose own doc notes "any member sees them, + // the relay only serves the owner/admin ones." val myPubkey = accountViewModel.account.signer.pubKey - val canCreateChannels by produceState( - initialValue = BuzzCommunityMembership.isMember(relay, myPubkey), - relay, - myPubkey, - ) { - value = BuzzCommunityMembership.isMember(relay, myPubkey) - LocalCache - .observeEvents(Filter(kinds = listOf(RelayMembershipListEvent.KIND))) - .collect { value = BuzzCommunityMembership.isMember(relay, myPubkey) } - } val buzzVm: BuzzRelayImportViewModel = viewModel(key = "BuzzImport-${relay.url}") LaunchedEffect(relay, isBuzz) { if (isBuzz) buzzVm.bind(accountViewModel.account, relay.url) } @@ -448,8 +438,7 @@ fun RelayGroupChannelListScreen( // -- CHANNELS -- The label carries a "+" to create a channel (the community's FAB // moved here, like Direct Messages). Add-all lives in the top-bar overflow menu. // The header always shows so the "+" is available even before any channel loads; - // the collapse toggle is offered only when there's something to collapse. The "+" - // is offered only to community members, who are the ones the relay lets create. + // the collapse toggle is offered only when there's something to collapse. run { val channelsCollapsed = "channels" in collapsedSections item(key = "sec-channels") { @@ -457,17 +446,11 @@ fun RelayGroupChannelListScreen( title = stringRes(R.string.relay_group_section_channels), collapsed = channelsCollapsed, onToggle = if (buzzChatChannels.isNotEmpty()) ({ toggleSection("channels") }) else null, - trailing = - if (canCreateChannels) { - { - SectionAddButton(stringRes(R.string.buzz_channel_create_title)) { - nav.nav(Route.RelayGroupCreate(relay.url)) - } - } - } else { - null - }, - ) + ) { + SectionAddButton(stringRes(R.string.buzz_channel_create_title)) { + nav.nav(Route.RelayGroupCreate(relay.url)) + } + } } if (buzzChatChannels.isNotEmpty() && !channelsCollapsed) { itemsIndexed(buzzChatChannels, key = { _, it -> "chat-${it.id}" }) { index, groupId -> @@ -491,17 +474,11 @@ fun RelayGroupChannelListScreen( title = stringRes(R.string.relay_group_section_forums), collapsed = forumsCollapsed, onToggle = if (buzzForumChannels.isNotEmpty()) ({ toggleSection("forums") }) else null, - trailing = - if (canCreateChannels) { - { - SectionAddButton(stringRes(R.string.buzz_forum_create_title)) { - nav.nav(Route.RelayGroupCreate(relay.url, isForum = true)) - } - } - } else { - null - }, - ) + ) { + SectionAddButton(stringRes(R.string.buzz_forum_create_title)) { + nav.nav(Route.RelayGroupCreate(relay.url, isForum = true)) + } + } } if (buzzForumChannels.isNotEmpty() && !forumsCollapsed) { itemsIndexed(buzzForumChannels, key = { _, it -> "forum-${it.id}" }) { index, groupId -> From ca61eb107dad64c5a0cdeb175323c1a84879731a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 16:09:11 +0000 Subject: [PATCH 8/9] fix(buzz): drop deleted channels that leaked as bare UUID rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A channel deleted elsewhere (or before this device saw its metadata) keeps its kind-44100 membership — the relay never retracts it — but the relay soft-deletes its 39000, and it does NOT serve a channel_deleted 40099 for an already-deleted channel, so the discovery fetch can't catch it. Such a channel arrived with no metadata and was shown optimistically: a bare UUID row with "No messages yet". The reliable signal is that very metadata absence. BuzzRelayImportViewModel.discover now drops channels the relay serves no 39000 for — but only when the fetch clearly worked (some channel returned a 39000); if none did, the read failed (auth/ connectivity) and the whole set is kept rather than blanking the community. A genuinely new channel whose 39000 is merely slow returns on the next bind once its metadata is cached, so the prune is live (not persisted) and self-correcting. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG --- .../loggedIn/buzz/BuzzRelayImportViewModel.kt | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt index 30c5f73e2a..59a0aa3247 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt @@ -177,16 +177,28 @@ class BuzzRelayImportViewModel : ViewModel() { ) { _, _ -> false } } - // 3. Keep only non-DM workspace channels; a channel whose metadata hasn't arrived - // (type unknown) is optimistically shown as a workspace channel. Deleted channels - // (kind-40099 `channel_deleted`, recorded above) are dropped — the relay keeps - // re-announcing their kind-44100, so this is the only place they leave the list. + // 3. Keep only the non-DM workspace channels the relay still serves metadata for. + // + // A deleted (or never-really-there) channel keeps its kind-44100 — the relay never + // retracts it — but the relay soft-deletes its 39000, so it arrives here with NO + // metadata. That absence is the reliable "it's gone" signal (the relay does not serve + // a `channel_deleted` 40099 for an already-deleted channel, so the fetch above can't + // catch this case). Before, such a channel showed optimistically — as a bare UUID row + // with "No messages yet", which is exactly the leak reported. + // + // So drop channels with no metadata — but ONLY when the fetch clearly worked (at least + // one channel came back with a 39000). If none did, the read failed (auth/connectivity) + // and we keep the whole set rather than blank the community; a genuinely new channel + // whose 39000 is merely slow re-appears on the next bind once its metadata is cached. + val channels = channelIds.associateWith { LocalCache.getOrCreateRelayGroupChannel(GroupId(it, relay)) } + val fetchHadMetadata = channels.values.any { it.event != null } _channels.value = channelIds .mapNotNull { id -> val groupId = GroupId(id, relay) if (RelayGroupDeletions.isDeleted(groupId)) return@mapNotNull null - val channel = LocalCache.getOrCreateRelayGroupChannel(groupId) + val channel = channels.getValue(id) + if (fetchHadMetadata && channel.event == null) return@mapNotNull null if (channel.event?.isBuzzDm() == true) null else groupId }.sortedBy { it.id } _status.value = Status.Ready From d44264a1acb85e371268430b337ec096839ddabe Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 16:22:34 +0000 Subject: [PATCH 9/9] feat(buzz): fix channel/forum type on the create screen from the caller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The create screen no longer shows a "Forum channel" toggle — the type is decided by the community's per-section "+" (Channels vs Forums) and a Buzz channel's channel_type isn't editable anyway (the relay's 9002 has no such key). The screen loads with the fixed isForum parameter and creates the right type; its top-bar title reads "New forum" when isForum, "New channel" otherwise (on Buzz). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG --- .../relayGroup/RelayGroupMetadataScreen.kt | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt index 5f2c62d1d5..b2528156c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt @@ -202,8 +202,15 @@ private fun RelayGroupMetadataScaffold( Scaffold( topBar = { if (viewModel.isNewGroup) { + // The type is fixed by the caller (the community's per-section "+"), so the title names + // it — "New forum" vs "New channel" on Buzz — rather than offering a toggle to change it. CreatingTopBar( - titleRes = if (viewModel.isBuzzRelay) R.string.buzz_channel_create_title else R.string.relay_group_create_title, + titleRes = + when { + !viewModel.isBuzzRelay -> R.string.relay_group_create_title + viewModel.isForum -> R.string.buzz_forum_create_title + else -> R.string.buzz_channel_create_title + }, isActive = { viewModel.canPost && (nip29Support == true || viewModel.isBuzzRelay) }, onCancel = nav::popBack, onPost = onSubmit, @@ -415,21 +422,11 @@ private fun GroupMetadataFields(viewModel: RelayGroupMetadataViewModel) { viewModel.markTouched() } - if (viewModel.isBuzzRelay) { - // Buzz's `channel_type`. Only offered on create: the relay takes it on the 9007 and its - // 9002 handler has no `channel_type` key, so an existing channel cannot be converted. - if (viewModel.isNewGroup) { - LabeledSwitchRow( - label = stringRes(R.string.buzz_channel_flag_forum), - description = stringRes(R.string.buzz_channel_flag_forum_desc), - checked = viewModel.isForum, - ) { - viewModel.isForum = it - viewModel.markTouched() - } - } - return - } + // A Buzz channel's type (chat vs forum) is fixed by the caller — the community's per-section "+" — + // and isn't editable (the relay's 9002 has no `channel_type` key), so there's no toggle here. The + // remaining vanilla NIP-29 flags (invite-only / restricted below) don't apply to Buzz either, so + // stop here for a Buzz relay. + if (viewModel.isBuzzRelay) return LabeledSwitchRow( label = stringRes(R.string.relay_group_flag_invite_only),