From 7d4e4f1cc94fbf21753c2afbbb153c7b13208756 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 26 Jul 2026 19:48:41 -0400 Subject: [PATCH] fix(buzz): only offer "new thread" where a forum post belongs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Threads compose FAB keyed on relay dialect, not channel type: on any Buzz relay it published a kind-45001 forum post into whatever channel you were in. Buzz only ever creates those in a `t=forum` channel — its client mounts the forum view for `channelType === "forum"` alone — so a 45001 in a `t=stream` chat channel is a post that no Buzz user can see. The relay accepts it (nothing there gates 45001 by channel type), which is exactly why the client has to. Gate the FAB on the channel's declared type when the host speaks Buzz, and leave every other relay alone: there the same button writes a NIP-7D kind-11, which is valid in any group. A Buzz channel whose kind-39000 hasn't landed yet reads as null and fails closed — a FAB appearing a moment later beats publishing into the wrong channel. Reading is untouched. An existing thread still renders wherever it came from; this only removes the offer to create one where it would be invisible. The empty state said "No threads yet. Start one with the + button." while the FAB was hidden, which was already wrong for non-members and is now wrong for chat channels too. Added a read-only variant. The gate is a named function rather than an inline condition so the allow-path has a test: no relay reachable in a manual pass exposes a `t=forum` channel, and a gate that only ever denies is indistinguishable from deleting the feature. Denials are covered on-device — a Buzz stream channel loses the FAB, a NIP-29 group (basspistol.org) keeps it. Co-Authored-By: Claude Opus 5 (1M context) --- .../relayGroup/RelayGroupThreadsScreen.kt | 36 +++++++++- amethyst/src/main/res/values/strings.xml | 2 + .../relayGroup/CanStartThreadHereTest.kt | 68 +++++++++++++++++++ 3 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/CanStartThreadHereTest.kt 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 3f4663ba3e..f49f63c876 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 @@ -70,6 +70,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_FORUM +import com.vitorpamplona.quartz.buzz.workspace.buzzChannelType import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip29RelayGroups.GroupId @@ -99,6 +101,26 @@ fun RelayGroupThreadsScreen( } } +/** + * Whether *starting* a thread belongs on this channel, given its Buzz `t` [channelType] (null on a + * relay that doesn't declare one) and whether its host speaks the Buzz dialect. + * + * On a Buzz relay the compose FAB writes a kind-45001 forum post, and Buzz only ever puts those in a + * `t=forum` channel — its own client mounts the forum view for `channelType === "forum"` alone, so a + * 45001 in a `t=stream` chat channel is a post nobody outside Amethyst can see. The relay itself + * accepts it (nothing there gates 45001 by channel type), which is exactly why the client has to. + * + * A Buzz channel whose kind-39000 hasn't landed yet reads as null and is treated as not-a-forum: the + * FAB appearing a moment later is a smaller error than publishing into the wrong channel. + * + * This gates writing only. Reading stays open on every channel — an existing thread is worth showing + * wherever it came from — and non-Buzz relays are untouched, where the FAB writes a NIP-7D kind-11. + */ +fun canStartThreadHere( + channelType: String?, + isBuzzRelay: Boolean, +): Boolean = !isBuzzRelay || channelType == BUZZ_CHANNEL_TYPE_FORUM + @Composable private fun RelayGroupThreads( channel: RelayGroupChannel, @@ -121,7 +143,10 @@ private fun RelayGroupThreads( // Hide the compose FAB where the relay would reject the kind-11: on membership-gated groups // that don't list me. Open Buzz channels accept any authenticated member. See [RelayGroupChannel.canPost]. - val canPost = channel.canPost(accountViewModel.userProfile().pubkeyHex) + val isBuzz = remember(channel.groupId.relayUrl) { BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) } + val canPost = + channel.canPost(accountViewModel.userProfile().pubkeyHex) && + canStartThreadHere(channel.event?.buzzChannelType(), isBuzz) Scaffold( topBar = { @@ -153,7 +178,7 @@ private fun RelayGroupThreads( // On a Buzz workspace, "new thread" is a Buzz forum post (kind 45001); // vanilla NIP-29 relays use a kind-11 thread. Buzz relays reject // unknown kinds, so a kind-11 thread would be refused there. - if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) { + if (isBuzz) { nav.nav(Route.BuzzForumPost(channel.groupId.id, channel.groupId.relayUrl.url)) } else { nav.nav( @@ -178,7 +203,12 @@ private fun RelayGroupThreads( if (threads.isEmpty()) { Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { Text( - text = stringRes(R.string.relay_group_threads_empty), + text = + if (canPost) { + stringRes(R.string.relay_group_threads_empty) + } else { + stringRes(R.string.relay_group_threads_empty_read_only) + }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(32.dp), diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index eddb89c5a6..0253dfbb19 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2636,6 +2636,8 @@ No groups found for this filter yet. Favorite this relay No threads yet. Start one with the + button. + + No threads yet. Loading older threads… No older threads New thread diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/CanStartThreadHereTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/CanStartThreadHereTest.kt new file mode 100644 index 0000000000..624d5bdf15 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/CanStartThreadHereTest.kt @@ -0,0 +1,68 @@ +/* + * 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 com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_DM +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_FORUM +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_STREAM +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Which channels may *start* a thread. The Threads screen's compose FAB writes a Buzz kind-45001 + * forum post on a Buzz relay and a NIP-7D kind-11 everywhere else, so the gate has to key on the + * channel's declared type — not just on whether the relay would accept the event, which it does. + * + * The forum case is here rather than on-device because no relay we can reach in a manual pass + * currently exposes a `t=forum` channel, and the allow-path is the half that must not regress: a + * gate that only ever denies is indistinguishable from deleting the feature. + */ +class CanStartThreadHereTest { + @Test + fun buzzForumChannelCanStartAThread() { + assertTrue(canStartThreadHere(BUZZ_CHANNEL_TYPE_FORUM, isBuzzRelay = true)) + } + + @Test + fun buzzChatAndDmChannelsCannot() { + // A 45001 here is accepted by the relay and then rendered by nobody — Buzz's own client + // mounts its forum view for `channelType === "forum"` alone. + assertFalse(canStartThreadHere(BUZZ_CHANNEL_TYPE_STREAM, isBuzzRelay = true)) + assertFalse(canStartThreadHere(BUZZ_CHANNEL_TYPE_DM, isBuzzRelay = true)) + } + + @Test + fun buzzChannelWithUnknownOrUnloadedTypeCannot() { + // kind-39000 not in yet, or a type this version predates: fail closed rather than publish + // into a channel that may not be a forum. + assertFalse(canStartThreadHere(null, isBuzzRelay = true)) + assertFalse(canStartThreadHere("workflow", isBuzzRelay = true)) + } + + @Test + fun nonBuzzRelaysAreUntouchedWhateverTheTypeSays() { + // Vanilla NIP-29: the FAB writes a kind-11 thread, which is valid in any group, and these + // relays declare no `t` at all. + assertTrue(canStartThreadHere(null, isBuzzRelay = false)) + assertTrue(canStartThreadHere(BUZZ_CHANNEL_TYPE_STREAM, isBuzzRelay = false)) + } +}