mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 08:47:33 +00:00
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:
co-authored by
Claude Opus 4.8
parent
f2dd8cd0f5
commit
a9fcd1c19c
@@ -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) }
|
||||
}
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+45
@@ -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
|
||||
}
|
||||
+2
-2
@@ -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(
|
||||
|
||||
+5
-5
@@ -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) })
|
||||
|
||||
|
||||
+5
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user