mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
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:
+16
-6
@@ -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 ->
|
||||
|
||||
+33
-3
@@ -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),
|
||||
|
||||
+29
-5
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+68
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user