feat(buzz): Concord-style threading for Buzz chat replies

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) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-07-23 09:37:41 -04:00
co-authored by Claude Opus 4.8
parent f2dd8cd0f5
commit a9fcd1c19c
7 changed files with 91 additions and 17 deletions
@@ -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
@@ -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) }
}
@@ -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
@@ -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
}
@@ -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<Note> =
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(
@@ -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<Note> = sort(channel.notes.filterIntoSet { _, it -> isTimelineMessage(it) })
@@ -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