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))
+ }
+}