feat(buzz): render forum posts as threads, not chat bubbles

Buzz forum roots (kind 45001) and comments (45003) were consumed onto the chat
timeline (consumeBuzzTimelineEvent → addNote), so a forum post showed up as a
kind-9 chat bubble and replies leaked into the chat feed.

Route them like the content they are:
- 45001 → the group's Threads collection (addThread) via a new consumeBuzzForumPost,
  so it surfaces in the forum/Threads view; 45003/45002 are now store-only.
- ThreadRow renders a ForumPostEvent (body-as-heading, no title) alongside kind-11.
- The Threads REQ (RELAY_GROUP_THREAD_KINDS) now also fetches 45001/45003.
- A forum-type channel (channel_type=forum) opens the Threads view directly from
  the community Forums section instead of the chat view.
- New BuzzForumThreadScreen: the root post + its 45003 replies + a reply composer
  (ForumCommentEvent.build). Own replies aren't re-delivered on the open sub, so a
  send restarts the REQ to re-fetch the thread.

Verified on device: forum posts list as threads, open to root+replies, replies post
and appear; the forum channel's chat no longer shows the posts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-07-22 23:10:57 -04:00
co-authored by Claude Opus 4.8
parent 681bd1b389
commit dbf9b29e25
9 changed files with 325 additions and 18 deletions
@@ -2184,6 +2184,22 @@ object LocalCache : ILocalCache, ICacheProvider {
markBuzzIfVerified(event, relay)
}
/**
* A Buzz forum ROOT post (kind 45001): a thread, not a chat message. Route it to the group's
* Threads collection ([RelayGroupChannel.addThread]) — the same place kind-11 threads go — so it
* surfaces in the forum/Threads view instead of leaking into the kind-9 chat feed as a bubble.
* Its kind-45003 comments are stored (store-only) and loaded on demand by the thread detail.
*/
private fun consumeBuzzForumPost(
event: Event,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean =
consumeRegularEvent(event, relay, wasVerified).also {
markBuzzIfVerified(event, relay)
attachThreadToRelayGroupIfScoped(event, relay)
}
private fun consume(
event: StreamMessageEditEvent,
relay: NormalizedRelayUrl?,
@@ -4673,9 +4689,11 @@ object LocalCache : ILocalCache, ICacheProvider {
is StreamMessageDiffEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is SystemMessageEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is CanvasEvent -> consume(event, relay, wasVerified)
is ForumPostEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is ForumCommentEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is ForumVoteEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
// Forum root (45001) is a thread, not a chat row → Threads collection. Comments (45003)
// and votes (45002) are store-only: the forum-thread detail loads them on demand by root.
is ForumPostEvent -> consumeBuzzForumPost(event, relay, wasVerified)
is ForumCommentEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is ForumVoteEvent -> consumeBuzzRegularEvent(event, relay, wasVerified)
is JobRequestEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is JobAcceptedEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
is JobProgressEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified)
@@ -105,6 +105,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.AgentPersonaEditScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzCanvasScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzDmListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzForumPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzForumThreadScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzInviteScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzNewDmScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarCollectionsScreen
@@ -769,6 +770,7 @@ fun BuildNavigation(
}
composableFromEndArgs<Route.BuzzCanvas> { BuzzCanvasScreen(it.channelId, accountViewModel, nav) }
composableFromBottomArgs<Route.BuzzForumPost> { BuzzForumPostScreen(it.channelId, it.relayUrl, accountViewModel, nav) }
composableFromEndArgs<Route.BuzzForumThread> { BuzzForumThreadScreen(it.channelId, it.relayUrl, it.rootId, accountViewModel, nav) }
composableFromEndArgs<Route.RelayGroupCreate> {
RelayGroupCreateScreen(
@@ -711,6 +711,12 @@ sealed class Route {
val relayUrl: String,
) : Route()
@Serializable data class BuzzForumThread(
val channelId: String,
val relayUrl: String,
val rootId: String,
) : Route()
@Serializable data class RelayGroupCreate(
val relayUrl: String,
) : Route()
@@ -0,0 +1,243 @@
/*
* 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.buzz
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
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.model.LocalCache
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.quartz.buzz.forum.ForumCommentEvent
import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* The detail view of a Buzz **forum thread**: the root post (kind 45001) and its comment
* replies (kind 45003), with an inline composer. Kept apart from the generic note thread
* screen because Buzz forum comments use NIP-10 `e` tags (not the kind-1111 NIP-22 shape),
* so they need their own root-scoped fetch + reply build ([ForumCommentEvent.build]).
*/
@Composable
fun BuzzForumThreadScreen(
channelId: String,
relayUrl: String,
rootId: HexKey,
accountViewModel: AccountViewModel,
nav: INav,
) {
val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return
val rootNote = remember(rootId) { LocalCache.getOrCreateNote(rootId) }
// The replies to this root. Seed from cache (the forum-list REQ already pulls 45003 for the
// channel), then live-subscribe by the root `e` tag so replies arrive while the thread is open.
val replies = remember(rootId) { mutableStateListOf<ForumCommentEvent>() }
val seen = remember(rootId) { HashSet<HexKey>() }
val replyFilter =
remember(rootId) {
Filter(kinds = listOf(ForumCommentEvent.KIND), tags = mapOf("e" to listOf(rootId)))
}
fun accept(event: ForumCommentEvent) {
if (event.threadRoot() != rootId && event.replyTo() != rootId) return
if (!seen.add(event.id)) return
val at = replies.indexOfFirst { it.createdAt > event.createdAt }
if (at < 0) replies.add(event) else replies.add(at, event)
}
fun seedFromCache() = LocalCache.filter(replyFilter).forEach { (it.event as? ForumCommentEvent)?.let(::accept) }
// Bumped after we post a reply. The relay stores our reply but doesn't re-deliver it on our already-open
// subscription, so a bump restarts the REQ — a fresh fetch returns every reply, including the new one.
var refreshKey by remember(rootId) { mutableIntStateOf(0) }
LaunchedEffect(rootId, refreshKey) {
if (refreshKey > 0) delay(600) // let our just-sent reply persist on the relay before re-fetching
seedFromCache()
accountViewModel.account.client.subscribeAsFlow(relay, replyFilter).collect { events ->
events.filterIsInstance<ForumCommentEvent>().forEach(::accept)
}
}
val canPost =
remember(rootId) {
LocalCache.getOrCreateRelayGroupChannel(GroupId(channelId, relay))
}.canPost(accountViewModel.userProfile().pubkeyHex)
Scaffold(
topBar = { TopBarWithBackButton(stringRes(R.string.buzz_forum_thread_title), nav) },
bottomBar = {
if (canPost) {
// The relay doesn't re-deliver our reply on the open subscription, so restart the REQ after
// a send (see [refreshKey]) to re-fetch the thread including it.
ForumReplyComposer(channelId, rootId, relay.url, accountViewModel, onSent = { refreshKey++ })
}
},
) { padding ->
LazyColumn(modifier = Modifier.fillMaxSize().padding(padding)) {
item(key = "root") {
ForumPostBody(rootNote.event as? ForumPostEvent, rootNote.author?.pubkeyHex ?: rootId, accountViewModel, nav)
HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
items(replies, key = { it.id }) { reply ->
ForumReplyRow(reply, accountViewModel, nav)
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
}
}
}
@Composable
private fun ForumPostBody(
event: ForumPostEvent?,
authorPubkey: HexKey,
accountViewModel: AccountViewModel,
nav: INav,
) {
val author = remember(authorPubkey) { LocalCache.getOrCreateUser(authorPubkey) }
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
UserPicture(author, Size35dp, accountViewModel = accountViewModel, nav = nav)
UsernameDisplay(author, accountViewModel = accountViewModel, fontWeight = FontWeight.SemiBold)
}
Text(text = event?.body().orEmpty(), style = MaterialTheme.typography.bodyLarge)
}
}
@Composable
private fun ForumReplyRow(
reply: ForumCommentEvent,
accountViewModel: AccountViewModel,
nav: INav,
) {
val author = remember(reply.pubKey) { LocalCache.getOrCreateUser(reply.pubKey) }
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
UserPicture(author, Size35dp, accountViewModel = accountViewModel, nav = nav)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
UsernameDisplay(author, accountViewModel = accountViewModel)
Text(text = reply.body(), style = MaterialTheme.typography.bodyMedium)
}
}
}
@Composable
private fun ForumReplyComposer(
channelId: String,
rootId: HexKey,
relayUrl: String,
accountViewModel: AccountViewModel,
onSent: () -> Unit,
) {
var body by remember { mutableStateOf("") }
var isPosting by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
Surface(tonalElevation = 2.dp) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = body,
onValueChange = { body = it },
placeholder = { Text(stringRes(R.string.buzz_forum_reply_hint)) },
modifier = Modifier.weight(1f),
enabled = !isPosting,
maxLines = 4,
)
IconButton(
enabled = body.isNotBlank() && !isPosting,
onClick = {
val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl) ?: return@IconButton
val text = body.trim()
isPosting = true
scope.launch {
try {
withContext(Dispatchers.IO) {
accountViewModel.account.signAndSendPrivatelyOrBroadcast(
// Direct reply to the root: rootEventId == parentEventId.
ForumCommentEvent.build(channelId, text, rootEventId = rootId, parentEventId = rootId),
) { listOf(relay) }
}
body = ""
onSent()
} finally {
isPosting = false
}
}
},
) {
Icon(
symbol = MaterialSymbols.AutoMirrored.Send,
contentDescription = stringRes(R.string.buzz_forum_reply_action),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
}
}
}
}
@@ -344,7 +344,9 @@ fun RelayGroupChannelListScreen(
isAdded = groupId.id in buzzAdded,
onAdd = { buzzVm.add(groupId) },
accountViewModel = accountViewModel,
onOpen = { nav.nav(Route.RelayGroup(groupId.id, relay.url)) },
// A forum channel's primary content is its threads (kind-45001 posts), not a
// kind-9 chat, so open the forum/threads view directly instead of the chat.
onOpen = { nav.nav(Route.RelayGroupThreads(groupId.id, relay.url)) },
)
}
}
@@ -69,6 +69,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenThreadsSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
@@ -189,7 +190,16 @@ private fun RelayGroupThreads(
if (index > 0) {
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
ThreadRow(thread, accountViewModel, nav) { nav.nav(Route.Note(thread.idHex)) }
// A Buzz forum root (45001) opens the forum-thread detail (root + kind-45003 replies);
// a NIP-29 kind-11 thread opens the generic note view with its kind-1111 comment tree.
val open: () -> Unit = {
if (thread.event is ForumPostEvent) {
nav.nav(Route.BuzzForumThread(channel.groupId.id, channel.groupId.relayUrl.url, thread.idHex))
} else {
nav.nav(Route.Note(thread.idHex))
}
}
ThreadRow(thread, accountViewModel, nav, open)
}
item(key = "threads-history-footer") {
RelayGroupThreadsHistoryFooter(loadingOlder, status.exhausted)
@@ -265,17 +275,31 @@ private fun ThreadRow(
nav: INav,
onClick: () -> Unit,
) {
// Observe the reply count so a kind-1111 comment arriving on an already-listed thread
// bumps it live (channel.threads only re-emits on add/remove of a thread).
// Observe the reply count so a comment arriving on an already-listed thread bumps it live
// (channel.threads only re-emits on add/remove of a thread).
val replyCount by observeNoteReplyCount(thread, accountViewModel)
val event = thread.event as? ThreadEvent
val title = event?.title()?.takeIf { it.isNotBlank() } ?: stringRes(R.string.relay_group_thread_untitled)
val preview =
event
?.content
?.replace('\n', ' ')
?.trim()
.orEmpty()
val untitled = stringRes(R.string.relay_group_thread_untitled)
// Two thread shapes share this list: NIP-29 kind-11 threads (title + body) and Buzz forum
// roots (kind 45001, body only — no title). For a forum post, surface the first line as the
// heading and the rest as the preview so it reads like a titled thread.
val title: String
val preview: String
when (val event = thread.event) {
is ThreadEvent -> {
title = event.title()?.takeIf { it.isNotBlank() } ?: untitled
preview = event.content.replace('\n', ' ').trim()
}
is ForumPostEvent -> {
val body = event.body().trim()
val firstLine = body.substringBefore('\n').trim()
title = firstLine.ifEmpty { untitled }
preview = body.removePrefix(firstLine).replace('\n', ' ').trim()
}
else -> {
title = untitled
preview = ""
}
}
val author = thread.author
Row(
@@ -156,8 +156,12 @@ val RELAY_GROUP_ALL_TIMELINE_KINDS = RELAY_GROUP_TIMELINE_KINDS + BUZZ_RELAY_GRO
*/
val RELAY_GROUP_OPEN_TAIL_KINDS = RELAY_GROUP_ALL_TIMELINE_KINDS + TypingIndicatorEvent.KIND
/** Forum-thread kinds shown in a group's Threads tab. */
val RELAY_GROUP_THREAD_KINDS = listOf(ThreadEvent.KIND, CommentEvent.KIND)
/**
* Forum-thread kinds shown in a group's Threads tab: NIP-29 kind-11 roots + kind-1111 comments, PLUS
* Buzz forum roots (45001) + comments (45003). Requested together (a vanilla relay matches nothing on
* the Buzz kinds, a Buzz relay nothing on kind-11), so the same Threads REQ surfaces either dialect.
*/
val RELAY_GROUP_THREAD_KINDS = listOf(ThreadEvent.KIND, CommentEvent.KIND, ForumPostEvent.KIND, ForumCommentEvent.KIND)
/** Content kinds a card warms ahead of a tap (chat + polls + threads + comments). */
val RELAY_GROUP_CARD_WARMUP_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND, ThreadEvent.KIND, CommentEvent.KIND)
+3
View File
@@ -3322,6 +3322,9 @@
<string name="buzz_forum_new_title">New forum topic</string>
<string name="buzz_forum_body_label">What do you want to discuss?</string>
<string name="buzz_forum_post_action">Post topic</string>
<string name="buzz_forum_thread_title">Forum thread</string>
<string name="buzz_forum_reply_hint">Write a reply…</string>
<string name="buzz_forum_reply_action">Send reply</string>
<string name="buzz_dm_title">Direct Messages</string>
<string name="buzz_dm_nav_label">Buzz DMs</string>
<string name="buzz_dm_card_subtitle">Private conversations on your workspaces</string>
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource
import com.vitorpamplona.quartz.buzz.forum.ForumCommentEvent
import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.FiltersChanged
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
@@ -60,7 +62,10 @@ class RelayGroupFilterBuildersTest {
private val g1OnA = GroupId("g1", relayA)
private val timelineKinds = listOf(ChatEvent.KIND, PollEvent.KIND)
private val threadKinds = listOf(ThreadEvent.KIND, CommentEvent.KIND)
// NIP-29 kind-11/1111 threads + Buzz forum roots (45001) & comments (45003) — the Threads REQ
// fetches both dialects (a vanilla relay matches nothing on the Buzz kinds and vice versa).
private val threadKinds = listOf(ThreadEvent.KIND, CommentEvent.KIND, ForumPostEvent.KIND, ForumCommentEvent.KIND)
// --- State (always-on): one #d filter per host relay, batching that relay's group ids ---