diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzForumThreadScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzForumThreadScreen.kt index 357246e3d8..8a12e28463 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzForumThreadScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzForumThreadScreen.kt @@ -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().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), - ) - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/ForumReplyNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/ForumReplyNewMessageViewModel.kt new file mode 100644 index 0000000000..9cfe621f10 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/ForumReplyNewMessageViewModel.kt @@ -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? { + 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) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 68d7545a96..54ee90ba01 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -530,7 +530,9 @@ open class ChannelNewMessageViewModel : } } - private suspend fun createTemplate(): EventTemplate? { + // `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? { val channel = channel ?: return null val messageText = message.text.toString() diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 7d5fdddb17..00d8f01124 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3323,8 +3323,6 @@ What do you want to discuss? Post topic Forum thread - Write a reply… - Send reply Direct Messages Buzz DMs Private conversations on your workspaces