Merge pull request #3732 from vitorpamplona/fix/buzz-forum-post-only-in-forum-channels

fix(buzz): only offer "new thread" where a forum post belongs
This commit is contained in:
Vitor Pamplona
2026-07-26 22:09:19 -04:00
committed by GitHub
5 changed files with 148 additions and 14 deletions
@@ -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 ->
@@ -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),
@@ -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<Boolean> {
val state = remember(channelId) { BuzzWorkspaceStates.getOrCreate(channelId) }
val version by state.canvasUpdates.collectAsStateWithLifecycle()
return remember(channelId, version) { mutableStateOf(state.canvasNote != null) }
}
+2
View File
@@ -2636,6 +2636,8 @@
<string name="relay_group_discovery_empty_filtered">No groups found for this filter yet.</string>
<string name="relay_group_favorite_relay">Favorite this relay</string>
<string name="relay_group_threads_empty">No threads yet. Start one with the + button.</string>
<!-- Same, where this viewer cannot start one: not a member, or a Buzz chat channel (forum posts belong to forum channels). -->
<string name="relay_group_threads_empty_read_only">No threads yet.</string>
<string name="relay_group_threads_loading_older">Loading older threads…</string>
<string name="relay_group_threads_all_caught_up">No older threads</string>
<string name="relay_group_thread_new">New thread</string>
@@ -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))
}
}