From 7d4e4f1cc94fbf21753c2afbbb153c7b13208756 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 26 Jul 2026 19:48:41 -0400 Subject: [PATCH 1/2] 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)) + } +} From 3acec7ee878adda357ad184a3e5c0fd8ecbd3ce2 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 26 Jul 2026 21:20:05 -0400 Subject: [PATCH 2/2] fix(buzz): don't offer a canvas on a DM unless one already exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Canvas icon showed on every Buzz channel including DMs, and its edit FAB offered to create one there. Buzz itself never does: its canvas entry needs `hasCanvas || canEditNarrative`, and `canEditNarrative` is `canManageChannel && selfMember !== null && channelType !== "dm"` — so a DM can only ever *display* a canvas that already exists, never write one (ChannelManagementSheet.tsx). We were advertising "start a shared document" in a two-person conversation, and a canvas created there would have been ours alone to see. Mirror both halves: a DM gets the icon only when a canvas is already in cache, and the canvas screen drops its edit FAB for DMs so what remains is read-only. Non-DM Buzz channels are unchanged — icon always, editing always. The has-a-canvas check reads the same BuzzWorkspaceStates registry the canvas screen renders from and recomposes on `canvasUpdates`, so the icon appears when the document lands rather than on the next visit. Verified on emulator-5554: the Buzz DM with Vitor (no canvas) shows only the overflow, where it used to show Canvas + overflow; `general` on the same relay still shows Canvas + overflow. Co-Authored-By: Claude Opus 5 (1M context) --- .../screen/loggedIn/buzz/BuzzCanvasScreen.kt | 22 ++++++++---- .../relayGroup/RelayGroupTopBar.kt | 34 ++++++++++++++++--- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzCanvasScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzCanvasScreen.kt index 7fa6ad1a7a..61790f520b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzCanvasScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzCanvasScreen.kt @@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBack import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.buzz.stream.CanvasEvent +import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import kotlinx.coroutines.Dispatchers @@ -95,14 +96,21 @@ fun BuzzCanvasScreen( val canvas = remember(version) { state.canvasNote } val content = canvas?.event?.content - // The channel this canvas belongs to, for the subtitle. Null until its kind-39000 lands, which - // only costs the subtitle — the canvas itself is keyed by the raw channel id. - val channelName = + // The channel this canvas belongs to, for the subtitle and the edit gate. Null until its + // kind-39000 lands, which only costs the subtitle — the canvas itself is keyed by the raw id. + val channel = remember(channelId, relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl)?.let { relay -> - LocalCache.getRelayGroupChannelIfExists(GroupId(channelId, relay))?.toBestDisplayName() + LocalCache.getRelayGroupChannelIfExists(GroupId(channelId, relay)) } } + val channelName = channel?.toBestDisplayName() + + // Buzz never lets a DM's canvas be written: its editor's `canEdit` is `canEditNarrative`, which + // excludes `channelType === "dm"` outright. A DM reaching this screen at all means a canvas + // already exists (see the top bar's gate), so render it read-only rather than offering an edit + // that Buzz's own client would never show. + val canEdit = channel?.event?.isBuzzDm() != true var editing by remember { mutableStateOf(false) } @@ -145,8 +153,10 @@ fun BuzzCanvasScreen( ) }, floatingActionButton = { - FloatingActionButton(onClick = { editing = true }) { - Icon(symbol = MaterialSymbols.Edit, contentDescription = stringRes(R.string.buzz_canvas_edit)) + if (canEdit) { + FloatingActionButton(onClick = { editing = true }) { + Icon(symbol = MaterialSymbols.Edit, contentDescription = stringRes(R.string.buzz_canvas_edit)) + } } }, ) { padding -> 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 88a5fe3405..bb984b4ba6 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 @@ -37,6 +37,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -47,10 +48,12 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow 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.BuzzRelayDialect +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership import com.vitorpamplona.amethyst.model.LocalCache @@ -170,11 +173,17 @@ fun RelayGroupTopBar( } }, actions = { - // Buzz workspace canvas (kind 40100): shown on any Buzz-dialect relay so a member can - // open the shared markdown doc — or create one when the channel has none yet. The only - // affordance that stays an icon: it is this channel's shared document, i.e. content, while - // Threads and Share are navigation the reader needs once in a while. - if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) { + // Buzz canvas (kind 40100): shown on any Buzz-dialect relay so a member can open the + // channel's shared markdown doc — or create one when it has none yet. The only affordance + // that stays an icon: it is this channel's shared document, i.e. content, while Threads and + // Share are navigation the reader needs once in a while. + // + // Except on a DM, where Buzz itself never offers to *write* one: its canvas entry needs + // `hasCanvas || canEditNarrative`, and `canEditNarrative` excludes `channelType === "dm"` + // outright. So a DM shows the icon only when a canvas already exists — which is also what + // keeps us from advertising "start a shared doc" in a two-person conversation. + val hasCanvas by observeBuzzCanvas(channel.groupId.id) + if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) && (!isDm || hasCanvas)) { IconButton(onClick = { nav.nav(Route.BuzzCanvas(channel.groupId.id, channel.groupId.relayUrl.url)) }) { Icon( symbol = MaterialSymbols.Dashboard, @@ -372,3 +381,18 @@ private fun RoleBadge(membership: RelayGroupMembership) { ) } } + +/** + * Whether this Buzz channel has a canvas (kind 40100) in cache, recomposing when one lands. + * + * Only the top bar's DM case needs this: a DM gets the canvas affordance solely when a document + * already exists, mirroring Buzz's own `hasCanvas || canEditNarrative` where `canEditNarrative` + * excludes DMs. Reads the same [BuzzWorkspaceStates] registry the canvas screen renders from, so the + * icon appears the moment the document arrives rather than on the next visit. + */ +@Composable +private fun observeBuzzCanvas(channelId: String): State { + val state = remember(channelId) { BuzzWorkspaceStates.getOrCreate(channelId) } + val version by state.canvasUpdates.collectAsStateWithLifecycle() + return remember(channelId, version) { mutableStateOf(state.canvasNote != null) } +}