feat(buzz): use the full chat composer for forum replies

The forum thread's reply field was a bare OutlinedTextField. Replace it with the
same rich composer chats use (EditFieldRow): emoji + @mention suggestions, media
attach, drafts, and the typing indicator, with the standard send button styling.

Reuse works by making ChannelNewMessageViewModel.createTemplate() protected/open
and adding ForumReplyNewMessageViewModel, which overrides only the built event so
a send publishes a kind-45003 ForumCommentEvent scoped to the open thread.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-07-22 23:21:42 -04:00
co-authored by Claude Opus 4.8
parent dbf9b29e25
commit bb88a18474
4 changed files with 78 additions and 86 deletions
@@ -26,38 +26,32 @@ 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 androidx.lifecycle.viewmodel.compose.viewModel
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.screen.loggedIn.chats.publicChannels.send.EditFieldRow
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.quartz.buzz.forum.ForumCommentEvent
@@ -67,16 +61,15 @@ 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]).
* replies (kind 45003). Replies compose through the same rich chat composer everything else
* uses ([EditFieldRow] + [ForumReplyNewMessageViewModel]), which just swaps the built event for
* a kind-45003 [ForumCommentEvent]. 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.
*/
@Composable
fun BuzzForumThreadScreen(
@@ -88,6 +81,7 @@ fun BuzzForumThreadScreen(
) {
val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return
val rootNote = remember(rootId) { LocalCache.getOrCreateNote(rootId) }
val channel = remember(channelId, relay) { LocalCache.getOrCreateRelayGroupChannel(GroupId(channelId, relay)) }
// 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.
@@ -105,32 +99,31 @@ fun BuzzForumThreadScreen(
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()
LocalCache.filter(replyFilter).forEach { (it.event as? ForumCommentEvent)?.let(::accept) }
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)
val canPost = channel.canPost(accountViewModel.userProfile().pubkeyHex)
val replyModel: ForumReplyNewMessageViewModel = viewModel(key = "forum-reply-$rootId")
replyModel.init(accountViewModel)
replyModel.loadForumThread(channel, rootId)
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++ })
// Reuse the full chat composer; a send publishes the kind-45003 reply and then bumps
// refreshKey to re-fetch (the relay doesn't echo our reply on the open subscription).
EditFieldRow(replyModel, accountViewModel, onSendNewMessage = { refreshKey++ }, nav)
}
},
) { padding ->
@@ -182,62 +175,3 @@ private fun ForumReplyRow(
}
}
}
@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),
)
}
}
}
}
@@ -0,0 +1,58 @@
/*
* 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 com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel
import com.vitorpamplona.quartz.buzz.forum.ForumCommentEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
/**
* The Buzz forum-reply composer. Reuses the whole rich chat composer ([ChannelNewMessageViewModel] +
* `EditFieldRow`: emoji, mentions, media, drafts, typing) but overrides only the built event so a send
* publishes a kind-45003 [ForumCommentEvent] scoped to the open thread instead of a chat message.
*/
class ForumReplyNewMessageViewModel : ChannelNewMessageViewModel() {
private var forumRootId: HexKey? = null
/** Bind to the forum [channel] and the thread [rootId] this composer replies into. */
fun loadForumThread(
channel: RelayGroupChannel,
rootId: HexKey,
) {
load(channel)
forumRootId = rootId
}
override suspend fun createTemplate(): EventTemplate<out Event>? {
val forumChannel = channel as? RelayGroupChannel ?: return super.createTemplate()
val root = forumRootId ?: return super.createTemplate()
val text = message.text.toString().trim()
if (text.isEmpty()) return null
// A reply to another comment nests under it (parent = that comment); a top-level reply targets
// the root (parent == root), per ForumCommentEvent.build's root/parent contract.
val parent = replyTo.value?.idHex ?: root
val mentions = listOfNotNull(replyTo.value?.author?.pubkeyHex)
return ForumCommentEvent.build(forumChannel.groupId.id, text, rootEventId = root, parentEventId = parent, mentions = mentions)
}
}
@@ -530,7 +530,9 @@ open class ChannelNewMessageViewModel :
}
}
private suspend fun createTemplate(): EventTemplate<out Event>? {
// `protected open` so a specialized composer (e.g. the Buzz forum reply) can reuse this whole
// rich EditFieldRow but swap only the event it builds for the composed text.
protected open suspend fun createTemplate(): EventTemplate<out Event>? {
val channel = channel ?: return null
val messageText = message.text.toString()
-2
View File
@@ -3323,8 +3323,6 @@
<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>