From a9fcd1c19c514ccc6e038a7837e0f42b8603cc3f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 23 Jul 2026 09:37:41 -0400 Subject: [PATCH] feat(buzz): Concord-style threading for Buzz chat replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concord threads chat messages with kind-1111 minichat comments, but Buzz relays reject kind-1111 — so Buzz replies fell back to inline 40002 and rendered as flat quotes, never opening a thread. Buzz already threads over 40002 NIP-10 markers (matching block/buzz): a reply-marked, non-`broadcast` 40002 is a thread reply; a `broadcast=1` one is an inline timeline sibling. Teach Amethyst's minichat to speak that dialect: - Add `isMinichatReply(event)`: kind-1111 CommentEvent, OR a Buzz 40002 thread reply (reply-marked, non-broadcast). Used by all three read sites so they agree. - `LocalCache.computeReplyTo`: link a 40002 thread reply into its parent's replies. - Timeline filter, minichat reply-count, and minichat feed now key on that predicate, so 40002 thread replies leave the timeline, get counted on the "N replies" chip, and render inside the minichat. - Send: an INLINE reply on Buzz sets `broadcast=1` (stays inline); MINICHAT omits it (threads). `sendMinichatReply` posts a 40002 thread reply on Buzz instead of kind-1111. Verified on device: a message shows an "N replies" chip, opens the Thread view with the root + replies + composer, replies post and increment the count, and thread replies no longer appear inline in the main chat. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../vitorpamplona/amethyst/model/Account.kt | 31 ++++++++++--- .../amethyst/model/LocalCache.kt | 6 +++ .../reqCommand/event/EventObservers.kt | 6 +-- .../ui/screen/loggedIn/chats/MinichatReply.kt | 45 +++++++++++++++++++ .../chats/minichat/MinichatFeedViewModel.kt | 4 +- .../publicChannels/dal/ChannelFeedFilter.kt | 10 ++--- .../send/ChannelNewMessageViewModel.kt | 6 ++- 7 files changed, 91 insertions(+), 17 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/MinichatReply.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 07cbdbfc68..02c6b2c2a8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermi import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore import com.vitorpamplona.amethyst.commons.marmot.MarmotManager import com.vitorpamplona.amethyst.commons.model.IAccount +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannelListState import com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager @@ -157,6 +158,10 @@ import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent import com.vitorpamplona.quartz.buzz.dm.DmHideEvent import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent +import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event +import com.vitorpamplona.quartz.buzz.threading.buzzThread +import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply +import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot @@ -229,6 +234,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasMoreHashtagsThan import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds import com.vitorpamplona.quartz.nip01Core.tags.references.references import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver @@ -2286,12 +2292,25 @@ class Account( gatherers?.firstNotNullOfOrNull { it as? RelayGroupChannel }?.let { group -> val hostRelay = group.groupId.relayUrl val signed = - signer.sign( - CommentEvent.replyBuilder(text, EventHintBundle(rootEvent, hostRelay)) { - hTag(group.groupId.id) - previous(group.previousEventRefs(pubKey)) - }, - ) + if (BuzzRelayDialect.isBuzz(hostRelay)) { + // Buzz rejects kind-1111, so its minichat threads with a 40002 marked at the message's + // root (never `broadcast` — a minichat reply always lives in the thread). + val root = rootEvent.tags.buzzThreadRoot() ?: rootEvent.tags.buzzThreadReply() ?: rootEvent.id + signer.sign( + StreamMessageV2Event.build(group.groupId.id, text) { + buzzThread(root, rootEvent.id) + rootNote.author?.pubkeyHex?.let { pTag(PTag(it)) } + previous(group.previousEventRefs(pubKey)) + }, + ) + } else { + signer.sign( + CommentEvent.replyBuilder(text, EventHintBundle(rootEvent, hostRelay)) { + hTag(group.groupId.id) + previous(group.previousEventRefs(pubKey)) + }, + ) + } cache.justConsumeMyOwnEvent(signed) client.publish(signed, setOf(hostRelay)) return true diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 88a47be534..bca302b8d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -1237,6 +1237,12 @@ object LocalCache : ILocalCache, ICacheProvider { event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } } + is StreamMessageV2Event -> { + // A Buzz thread reply links to the message it answers (its `reply`-marked e-tag) so it + // lands in that message's replies (the minichat). A plain message / non-reply has no marker. + listOfNotNull(event.tags.buzzThreadReply()?.let { checkGetOrCreateNote(it) }) + } + is VoiceReplyEvent -> { event.markedReplyTos().mapNotNull { checkGetOrCreateNote(it) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt index 4a98653b72..8715fdc6eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt @@ -30,10 +30,10 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.isMinichatReply import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent -import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.isForCommunity @@ -240,11 +240,11 @@ fun observeNoteMinichatReplyCount( .flow() .replies.stateFlow .sample(200) - .mapLatest { it.note.replies.count { reply -> reply.event is CommentEvent } } + .mapLatest { it.note.replies.count { reply -> isMinichatReply(reply.event) } } .distinctUntilChanged() } - return flow.collectAsStateWithLifecycle(note.replies.count { it.event is CommentEvent }) + return flow.collectAsStateWithLifecycle(note.replies.count { isMinichatReply(it.event) }) } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/MinichatReply.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/MinichatReply.kt new file mode 100644 index 0000000000..2434a67e12 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/MinichatReply.kt @@ -0,0 +1,45 @@ +/* + * 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 + +import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event +import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip22Comments.CommentEvent + +/** + * Whether [event] is a **minichat thread reply** — a reply that lives inside the thread opened from + * its parent message, NOT as a flat sibling in the main timeline. Two dialects express the same idea: + * + * - **NIP-28/NIP-29 (public chats, Concord)**: a kind-1111 [CommentEvent]. + * - **Buzz workspaces**: a kind-40002 [StreamMessageV2Event] carrying a NIP-10 `reply`-marked `e` tag + * and NOT flagged `broadcast` (Buzz rejects kind-1111, so it threads chat with 40002 markers; a + * `broadcast=1` reply is an inline timeline sibling, matching block/buzz's `isThreadReply`). + * + * The timeline filter drops these (they belong in the minichat), the minichat count counts them, and + * the minichat feed shows them — so all three agree on one definition. + */ +fun isMinichatReply(event: Event?): Boolean = + when (event) { + is CommentEvent -> true + is StreamMessageV2Event -> !event.isBroadcast() && event.tags.buzzThreadReply() != null + else -> false + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatFeedViewModel.kt index fe21faf8bc..c5089663e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatFeedViewModel.kt @@ -25,7 +25,7 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.isMinichatReply import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.SharingStarted @@ -57,7 +57,7 @@ class MinichatFeedViewModel( private fun collectReplies(): List = rootNote.replies - .filter { it.event is CommentEvent && account.isAcceptable(it) } + .filter { isMinichatReply(it.event) && account.isAcceptable(it) } .sortedWith(compareBy({ it.createdAt() ?: 0L }, { it.idHex })) class Factory( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt index a4efd8291f..f24b037534 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.ChangesFlowFilter import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder -import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.isMinichatReply class ChannelFeedFilter( val channel: Channel, @@ -37,10 +37,10 @@ class ChannelFeedFilter( override fun changesFlow() = channel.changesFlow() - // A kind-1111 comment is a *minichat* reply — it lives in the thread opened from its - // root message, not as a flat sibling in the main timeline (an inline reply is a normal - // kind-9/42 message and stays). Everything else the channel gathered is a timeline message. - private fun isTimelineMessage(note: Note): Boolean = note.event !is CommentEvent && account.isAcceptable(note) + // A minichat reply — a kind-1111 comment, or a Buzz 40002 thread reply (reply-marked, non-broadcast) + // — lives in the thread opened from its root message, not as a flat sibling in the main timeline. + // An inline reply (kind-9/42, or a Buzz broadcast=1 reply) is a normal message and stays. + private fun isTimelineMessage(note: Note): Boolean = !isMinichatReply(note.event) && account.isAcceptable(note) override fun feed(): List = sort(channel.notes.filterIntoSet { _, it -> isTimelineMessage(it) }) 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 76f0af4692..192108fd6d 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 @@ -773,7 +773,11 @@ open class ChannelNewMessageViewModel : // verified Buzz events), not a channel subtype: channel instances are // captured by screens for their whole life, so the dialect must be // able to flip mid-session without swapping objects. - StreamMessageV2Event.build(channel.groupId.id, tagger.message) { + // Reply routing (Buzz has no kind-1111): an INLINE reply is flagged `broadcast` so it stays + // a flat timeline sibling; a MINICHAT reply omits it so the thread markers pull it into the + // message's minichat (mirrors block/buzz's broadcast-vs-thread split). A non-reply is neither. + val broadcastReply = replyTo.value != null && replyMode.value == ReplyMode.INLINE + StreamMessageV2Event.build(channel.groupId.id, tagger.message, broadcast = broadcastReply) { replyTo.value?.let { parent -> // The parent's root marker (when it is itself a nested reply), // else the parent's OWN reply target (a direct reply's collapsed