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