mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
Merge pull request #3718 from vitorpamplona/fix/buzz-minichat-kind9-replies
fix(buzz): thread kind-9 replies into the minichat instead of the channel
This commit is contained in:
@@ -165,7 +165,6 @@ import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent
|
||||
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
|
||||
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminAddMemberEvent
|
||||
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminRemoveMemberEvent
|
||||
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
|
||||
@@ -2438,12 +2437,24 @@ class Account(
|
||||
val hostRelay = group.groupId.relayUrl
|
||||
val signed =
|
||||
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). Attached
|
||||
// media is carried as URLs appended to the content (no `imeta` on the stream event).
|
||||
// Buzz rejects kind-1111, so its minichat threads with a NIP-10 `reply`-marked `e`
|
||||
// on a plain kind-9 chat — byte-identical to `_buildReplyTags` in Buzz's own client
|
||||
// (direct reply -> one `reply` marker; nested -> `root` + `reply`), which is what
|
||||
// [buzzThread] emits.
|
||||
//
|
||||
// This used to write kind-40002. Nothing in Buzz writes 40002 any more — every send
|
||||
// path in their mobile, desktop and CLI clients emits kind 9, and their NOSTR.md
|
||||
// grades 40002 "Buzz-only — no standard NIP-29 client renders these" against kind 9's
|
||||
// blessed status. 40002 survives only as a read-compat tail from the
|
||||
// 10002 -> 40001 -> 40002 migration, so we were the last active writer of a kind
|
||||
// their clients no longer thread on. Reading 40002 stays supported (see
|
||||
// [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.isMinichatReply]).
|
||||
//
|
||||
// Attached media rides as URLs appended to the content.
|
||||
val root = rootEvent.tags.buzzThreadRoot() ?: rootEvent.tags.buzzThreadReply() ?: rootEvent.id
|
||||
signer.sign(
|
||||
StreamMessageV2Event.build(group.groupId.id, finalText) {
|
||||
ChatEvent.build(finalText) {
|
||||
hTag(group.groupId.id)
|
||||
buzzThread(root, rootEvent.id)
|
||||
rootNote.author?.pubkeyHex?.let { pTag(PTag(it)) }
|
||||
previous(group.previousEventRefs(pubKey))
|
||||
|
||||
+11
-25
@@ -76,7 +76,6 @@ import com.vitorpamplona.amethyst.model.privacyOptions.EmptyRoleBasedHttpClientB
|
||||
import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuilder
|
||||
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
|
||||
import com.vitorpamplona.amethyst.model.privateChatLastReadRoute
|
||||
import com.vitorpamplona.amethyst.model.unreadPrivateChatRoute
|
||||
import com.vitorpamplona.amethyst.service.ClinkDebitPayer
|
||||
import com.vitorpamplona.amethyst.service.OnlineChecker
|
||||
import com.vitorpamplona.amethyst.service.V4VPaymentHandler
|
||||
@@ -105,6 +104,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.Marm
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotGroupIconUpload
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotGroupIconUploader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.markRoomNoteAsRead
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.rowHasUnreadFlow
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NOTIFICATION_LAST_READ_KEY
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync
|
||||
@@ -440,6 +440,15 @@ class AccountViewModel(
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(30000), false)
|
||||
|
||||
/**
|
||||
* The bottom-bar envelope dot: true when ANY Messages row is showing its blue dot.
|
||||
*
|
||||
* Per-row via [rowHasUnreadFlow], which mirrors what each row composable computes for itself.
|
||||
* This used to call `unreadPrivateChatRoute` directly, which returns null for anything that is not
|
||||
* `ChatroomKeyable` — so only NIP-17/NIP-04 DMs counted, and a public chat, ephemeral room, geohash
|
||||
* cell, Marmot group, NIP-29/Buzz channel or Concord channel could sit there with a visible dot
|
||||
* while the envelope stayed clean.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val messagesHasNewItems =
|
||||
feedStates.dmKnown.feedContent
|
||||
@@ -450,14 +459,7 @@ class AccountViewModel(
|
||||
MutableStateFlow(null)
|
||||
}
|
||||
}.flatMapLatest { loadedFeedState ->
|
||||
val flows =
|
||||
loadedFeedState?.list?.mapNotNull { chat ->
|
||||
unreadPrivateChatRoute(chat)?.let { (route, createdAt) ->
|
||||
account.settings.getLastReadFlow(route).map { lastReadAt ->
|
||||
createdAt > lastReadAt
|
||||
}
|
||||
}
|
||||
}
|
||||
val flows = loadedFeedState?.list?.mapNotNull { chat -> rowHasUnreadFlow(chat, account) }
|
||||
|
||||
if (!flows.isNullOrEmpty()) {
|
||||
combine(flows) { newItems ->
|
||||
@@ -466,20 +468,6 @@ class AccountViewModel(
|
||||
} else {
|
||||
MutableStateFlow(false)
|
||||
}
|
||||
}.onStart {
|
||||
val feed = feedStates.dmKnown.feedContent.value
|
||||
if (feed is FeedState.Loaded) {
|
||||
val newItems =
|
||||
feed.feed.value.list.any { chat ->
|
||||
unreadPrivateChatRoute(chat)?.let { (route, createdAt) ->
|
||||
val lastReadAt =
|
||||
account.settings.lastReadPerRoute.value[route]
|
||||
?.value ?: 0L
|
||||
createdAt > lastReadAt
|
||||
} == true
|
||||
}
|
||||
emit(newItems)
|
||||
}
|
||||
}
|
||||
|
||||
val messagesHasNewItemsFlow =
|
||||
@@ -2203,8 +2191,6 @@ class AccountViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun unreadPrivateChatRoute(chat: Note): Pair<String, Long>? = unreadPrivateChatRoute(chat.event, account.signer.pubKey, account::isAllHidden)
|
||||
|
||||
private fun markHiddenChatroomsAsRead() {
|
||||
account.chatroomList.rooms.forEach { roomKey, chatroom ->
|
||||
if (account.isAllHidden(roomKey.users)) {
|
||||
|
||||
+23
-4
@@ -24,15 +24,33 @@ 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
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
|
||||
/**
|
||||
* 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:
|
||||
* its parent message, NOT as a flat sibling in the main timeline. Three 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`).
|
||||
* - **Buzz workspaces, current**: a kind-9 [ChatEvent] carrying a NIP-10 `reply`-marked `e` tag. This
|
||||
* is what every live Buzz client writes — `_buildReplyTags` in its Flutter client emits
|
||||
* `["e", id, "", "reply"]` for a direct reply and `["e", root, "", "root"]` +
|
||||
* `["e", parent, "", "reply"]` for a nested one, and all three of their clients send chat as kind 9.
|
||||
* - **Buzz workspaces, legacy**: a kind-40002 [StreamMessageV2Event] with the same markers and NOT
|
||||
* flagged `broadcast`. Nothing in Buzz writes 40002 any more (their own NOSTR.md grades it
|
||||
* "Buzz-only — no standard NIP-29 client renders these"), but events exist in the wild from the
|
||||
* 10002 -> 40001 -> 40002 migration, and Amethyst itself wrote some, so it stays readable.
|
||||
*
|
||||
* ### Why a marked `e` and not `q`
|
||||
*
|
||||
* NIP-C7 gives kind 9 exactly one reply mechanism — `["q", <id>, <relay>, <pubkey>]` — and never
|
||||
* mentions `e` at all. So a marked `e` carries no C7 meaning and is free to denote a *thread* reply,
|
||||
* which is precisely how Buzz uses it. The marker is what separates the cases: WhiteNoise/Marmot
|
||||
* thread kind-9 chat with a **plain, unmarked** `e`, which is an in-chat reply and must keep rendering
|
||||
* as a quote bubble in the timeline — so matching on the `reply` marker (never on the bare tag) leaves
|
||||
* that dialect untouched.
|
||||
*
|
||||
* A `broadcast=1` reply is an inline timeline sibling ("also send to channel"), matching block/buzz's
|
||||
* `isThreadReply`. Kind 9 has no broadcast tag, so a marked kind-9 is always thread-only.
|
||||
*
|
||||
* 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.
|
||||
@@ -40,6 +58,7 @@ import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
fun isMinichatReply(event: Event?): Boolean =
|
||||
when (event) {
|
||||
is CommentEvent -> true
|
||||
is ChatEvent -> event.tags.buzzThreadReply() != null
|
||||
is StreamMessageV2Event -> !event.isBroadcast() && event.tags.buzzThreadReply() != null
|
||||
else -> false
|
||||
}
|
||||
|
||||
+35
-2
@@ -23,6 +23,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relay
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.isMinichatReply
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
@@ -78,11 +81,41 @@ fun relayGroupServerHasUnreadFlow(
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
|
||||
/** Whether this group's message store holds any acceptable chat content created after [sinceSecs]. */
|
||||
/**
|
||||
* Whether [note] is one of this group's **timeline** messages — what the channel feed renders, what
|
||||
* the Messages row previews, and what the unread dot counts.
|
||||
*
|
||||
* Two exclusions, and both matter for the same reason: the row summary must not disagree with what
|
||||
* opening the channel shows.
|
||||
* - Non-content (`reaction`/deletion/label) carries the group's `h` tag too, so [isGroupChatContent]
|
||||
* gates it out — a trailing 👍 must not become the "last message".
|
||||
* - A **minichat thread reply** lives in the thread opened from its parent, not in the timeline
|
||||
* ([isMinichatReply], the same predicate `ChannelFeedFilter` uses). Without this the Messages row
|
||||
* previews a reply the channel never displays, and the unread dot lights for activity that leaves
|
||||
* the timeline unchanged — you open the group, see nothing new, and the dot clears.
|
||||
*
|
||||
* The Concord side solves this identically with `isConcordTimelineMessage`.
|
||||
*/
|
||||
fun isRelayGroupTimelineMessage(
|
||||
note: Note,
|
||||
account: Account,
|
||||
): Boolean = note.event?.isGroupChatContent() == true && !isMinichatReply(note.event) && account.isAcceptable(note)
|
||||
|
||||
/**
|
||||
* The newest timeline message in this group (see [isRelayGroupTimelineMessage]), or null if none —
|
||||
* the note the Messages row shows as the group's "last message".
|
||||
*/
|
||||
fun RelayGroupChannel.newestTimelineNote(account: Account): Note? =
|
||||
notes
|
||||
.filter { _, note -> isRelayGroupTimelineMessage(note, account) }
|
||||
.sortedByDefaultFeedOrder()
|
||||
.firstOrNull()
|
||||
|
||||
/** Whether this group's message store holds any acceptable timeline message created after [sinceSecs]. */
|
||||
private fun RelayGroupChannel.hasChatNewerThan(
|
||||
account: Account,
|
||||
sinceSecs: Long,
|
||||
): Boolean =
|
||||
notes.count { _, note ->
|
||||
(note.createdAt() ?: 0L) > sinceSecs && account.isAcceptable(note) && note.event?.isGroupChatContent() == true
|
||||
(note.createdAt() ?: 0L) > sinceSecs && isRelayGroupTimelineMessage(note, account)
|
||||
} > 0
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* 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.rooms
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.unreadPrivateChatRoute
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupLastReadRoute
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordChannelLastReadRoute
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordCommunityHasUnreadFlow
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupChannelLastReadRoute
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupServerHasUnreadFlow
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ConcordServerRoomNote
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.RelayGroupServerRoomNote
|
||||
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Whether one Messages row is showing a blue dot — the SAME question each row composable answers for
|
||||
* itself in [ChatroomHeaderCompose], hoisted so the bottom-bar badge can ask it about every row.
|
||||
*
|
||||
* The badge used to run on `unreadPrivateChatRoute` alone, which opens with
|
||||
* `if (newestMessage !is ChatroomKeyable) return null`. Only NIP-17/NIP-04 DM events implement that
|
||||
* interface, so eight of the nine row types — public chats, ephemeral rooms, geohash cells, Marmot
|
||||
* groups, NIP-29/Buzz channels and Concord channels, plus both collapsed "grouped" rows — were
|
||||
* silently skipped: their row could show a dot while the envelope stayed clean.
|
||||
*
|
||||
* Returns null when the row cannot be unread at all (no event, my own newest message in a DM, everyone
|
||||
* hidden), so callers can skip it rather than subscribe to a flow that is always false.
|
||||
*
|
||||
* The two collapsed rows are why this returns a `Flow<Boolean>` rather than a `(route, createdAt)`
|
||||
* pair: their dot is a fan-in over every child channel, not one timestamp against one marker, and
|
||||
* approximating them by the newest child would miss an older channel that is still unread.
|
||||
*/
|
||||
fun rowHasUnreadFlow(
|
||||
row: Note,
|
||||
account: Account,
|
||||
): Flow<Boolean>? {
|
||||
// Collapsed rows own a fan-in flow across their children — reuse the row's own signal verbatim.
|
||||
if (row is RelayGroupServerRoomNote) return relayGroupServerHasUnreadFlow(account, row.relay)
|
||||
if (row is ConcordServerRoomNote) return concordCommunityHasUnreadFlow(account, row.communityId)
|
||||
|
||||
val route = rowLastReadRoute(row, account) ?: return null
|
||||
val createdAt = row.createdAt() ?: return null
|
||||
return account.settings.getLastReadFlow(route).map { lastReadAt -> createdAt > lastReadAt }
|
||||
}
|
||||
|
||||
/**
|
||||
* The last-read marker route behind a row's dot, mirroring what each row composable loads. Channel-type
|
||||
* rows are identified by their gatherer (the channel the note was filed into) rather than by event kind,
|
||||
* because a Buzz channel and a Concord channel can both carry a kind-9 message.
|
||||
*/
|
||||
private fun rowLastReadRoute(
|
||||
row: Note,
|
||||
account: Account,
|
||||
): String? {
|
||||
row.inGatherers?.forEach { gatherer ->
|
||||
when (gatherer) {
|
||||
is RelayGroupChannel -> return relayGroupChannelLastReadRoute(gatherer.groupId)
|
||||
is ConcordChannel -> return concordChannelLastReadRoute(gatherer.channelId.communityId, gatherer.channelId.channelId)
|
||||
is MarmotGroupChatroom -> return marmotGroupLastReadRoute(gatherer.nostrGroupId)
|
||||
is GeohashChatChannel -> return "Geohash/${gatherer.geohash}"
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
return when (val event = row.event) {
|
||||
// Same route strings the row composables use — see ChatroomHeaderCompose.
|
||||
is ChannelMessageEvent -> event.channelId()?.let { "Channel/$it" }
|
||||
is EphemeralChatEvent -> event.roomId()?.let { "Channel/${it.toKey()}" }
|
||||
is GeohashChatEvent -> event.geohash()?.let { "Geohash/$it" }
|
||||
// DMs keep their own rule: a room whose newest message is mine counts as read.
|
||||
else -> unreadPrivateChatRoute(row.event, account.signer.pubKey, account::isAllHidden)?.first
|
||||
}
|
||||
}
|
||||
+7
-13
@@ -33,6 +33,8 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.isConcordTimelineMessage
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.isRelayGroupTimelineMessage
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.newestTimelineNote
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
|
||||
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
|
||||
@@ -157,7 +159,7 @@ class ChatroomListKnownFeedFilter(
|
||||
// Newest loaded chat message, or a placeholder row so a just-joined group shows
|
||||
// up on Messages before its first kind-9 arrives (mirrors the Marmot-group path
|
||||
// above). Content kinds only — never a reaction/deletion as the "last message".
|
||||
channel.newestChatNote(account) ?: channel.placeholderNote()
|
||||
channel.newestTimelineNote(account) ?: channel.placeholderNote()
|
||||
}
|
||||
|
||||
RelayGroupViewMode.GROUPED ->
|
||||
@@ -169,7 +171,7 @@ class ChatroomListKnownFeedFilter(
|
||||
val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl) ?: return@mapNotNull null
|
||||
val newest =
|
||||
tags
|
||||
.mapNotNull { LocalCache.getOrCreateRelayGroupChannel(GroupId(it.groupId, relay)).newestChatNote(account) }
|
||||
.mapNotNull { LocalCache.getOrCreateRelayGroupChannel(GroupId(it.groupId, relay)).newestTimelineNote(account) }
|
||||
.maxByOrNull { it.createdAt() ?: 0L }
|
||||
RelayGroupServerRoomNote(relay, newest)
|
||||
}
|
||||
@@ -489,13 +491,6 @@ class ChatroomListKnownFeedFilter(
|
||||
return newRelevantEphemeralChats
|
||||
}
|
||||
|
||||
/** The newest actual chat message loaded in this group's channel, or null if none yet. */
|
||||
private fun RelayGroupChannel.newestChatNote(account: Account): Note? =
|
||||
notes
|
||||
.filter { _, it -> account.isAcceptable(it) && it.event?.isGroupChatContent() == true }
|
||||
.sortedByDefaultFeedOrder()
|
||||
.firstOrNull()
|
||||
|
||||
/**
|
||||
* The newest decrypted *timeline* message loaded in this Concord channel, or null if none yet.
|
||||
* Uses [isConcordTimelineMessage] so a trailing kind-1111 thread reply (or a hidden author)
|
||||
@@ -540,8 +535,8 @@ class ChatroomListKnownFeedFilter(
|
||||
val joinedGroupIds = joined.mapTo(HashSet()) { it.groupId }
|
||||
val result = mutableMapOf<String, Note>()
|
||||
newItems.forEach { newNote ->
|
||||
val gid = newNote.event?.takeIf { it.isGroupChatContent() }?.groupId()
|
||||
if (gid != null && gid in joinedGroupIds && account.isAcceptable(newNote)) {
|
||||
val gid = newNote.event?.takeIf { isRelayGroupTimelineMessage(newNote, account) }?.groupId()
|
||||
if (gid != null && gid in joinedGroupIds) {
|
||||
val lastNote = result[gid]
|
||||
if (lastNote == null || (newNote.createdAt() ?: 0L) > (lastNote.createdAt() ?: 0L)) {
|
||||
result[gid] = newNote
|
||||
@@ -559,9 +554,8 @@ class ChatroomListKnownFeedFilter(
|
||||
// Newest new message per host relay, collapsed into one per-relay row.
|
||||
val newestPerRelay = HashMap<NormalizedRelayUrl, Note>()
|
||||
newItems.forEach { newNote ->
|
||||
val gid = newNote.event?.takeIf { it.isGroupChatContent() }?.groupId() ?: return@forEach
|
||||
val gid = newNote.event?.takeIf { isRelayGroupTimelineMessage(newNote, account) }?.groupId() ?: return@forEach
|
||||
val relay = groupToRelay[gid] ?: return@forEach
|
||||
if (!account.isAcceptable(newNote)) return@forEach
|
||||
val lastNote = newestPerRelay[relay]
|
||||
if (lastNote == null || (newNote.createdAt() ?: 0L) > (lastNote.createdAt() ?: 0L)) {
|
||||
newestPerRelay[relay] = newNote
|
||||
|
||||
+47
-6
@@ -33,6 +33,8 @@ import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
|
||||
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
|
||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
|
||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot
|
||||
import com.vitorpamplona.quartz.buzz.workspace.buzzParticipants
|
||||
import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm
|
||||
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
|
||||
@@ -148,10 +150,16 @@ class NotificationFeedFilter(
|
||||
// filterGroupNotificationsToPubkey.
|
||||
// • NIP-C7 / Concord: an inline reply (Concord's default reply mode) or an @-mention
|
||||
// p-tags me; a minichat reply is a kind-1111 CommentEvent below.
|
||||
// Either way it notifies only when it p-tags me — a plain channel message tags no one
|
||||
// and never reaches here. Without kind 9 the acceptableEvent kind gate would drop these
|
||||
// replies before the p-tag check, so they'd never render on the Notifications tab.
|
||||
// • Buzz channels: a THREAD reply carries no `p` tag at all (Buzz only p-tags
|
||||
// @mentions), so it qualifies through [isBuzzThreadReplyToMyEvent] instead, which
|
||||
// resolves the author of its `root`/`reply` marked `e` tags.
|
||||
// A plain channel message tags no one and matches none of those, so it never reaches
|
||||
// here. Without kind 9 the acceptableEvent kind gate would drop these replies before
|
||||
// any of the checks, so they'd never render on the Notifications tab.
|
||||
ChatEvent.KIND,
|
||||
// Legacy Buzz thread replies (nothing writes 40002 any more, but they exist in the
|
||||
// wild). Same no-`p`-tag shape as kind 9 above.
|
||||
StreamMessageV2Event.KIND,
|
||||
ChatMessageEvent.KIND,
|
||||
ChatMessageEncryptedFileHeaderEvent.KIND,
|
||||
CommentEvent.KIND,
|
||||
@@ -414,6 +422,36 @@ class NotificationFeedFilter(
|
||||
?.pubkeyHex == me
|
||||
}
|
||||
|
||||
/**
|
||||
* A Buzz chat **thread reply** into one of my messages, when the reply carries no `p` tag.
|
||||
*
|
||||
* Buzz's clients thread with `["e", <id>, "", "reply"]` (nested: `root` + `reply`) and only ever
|
||||
* `p`-tag @mentions, so a reply to my message names me nowhere. That is the same shape as a Buzz
|
||||
* reaction, which [isReactionToMyEvent] already rescues by resolving the target's author instead of
|
||||
* trusting a tag — this does the same for replies, looking at the author of the `root`/`reply`
|
||||
* targets specifically (never a bare `e`, which is WhiteNoise/Marmot's in-chat reply, not a thread).
|
||||
*
|
||||
* Without it a reply to my message in a Buzz channel notifies nothing — and since a thread reply is
|
||||
* deliberately kept out of the channel timeline and its unread dot
|
||||
* ([com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.isRelayGroupTimelineMessage]),
|
||||
* it would be invisible on every surface.
|
||||
*
|
||||
* Only consulted when there is no `p` tag: a reply that does name me already passes the p-tag gate.
|
||||
*/
|
||||
private fun isBuzzThreadReplyToMyEvent(
|
||||
note: Note,
|
||||
me: HexKey,
|
||||
): Boolean {
|
||||
val event = note.event
|
||||
if (event !is ChatEvent && event !is StreamMessageV2Event) return false
|
||||
if (event.tags.any { it.getOrNull(0) == "p" }) return false
|
||||
|
||||
val threadTargets = setOfNotNull(event.tags.buzzThreadReply(), event.tags.buzzThreadRoot())
|
||||
if (threadTargets.isEmpty()) return false
|
||||
|
||||
return note.replyTo?.any { it.idHex in threadTargets && it.author?.pubkeyHex == me } == true
|
||||
}
|
||||
|
||||
fun acceptableEvent(
|
||||
it: Note,
|
||||
filterParams: FilterByListParams,
|
||||
@@ -534,6 +572,9 @@ class NotificationFeedFilter(
|
||||
// exactly like a Concord reaction, since being the author of the liked post is the only signal.
|
||||
val isReactionToMe = isReactionToMyEvent(it, loggedInUserHex)
|
||||
|
||||
// Same no-`p`-tag rescue for Buzz thread replies into my messages.
|
||||
val isThreadReplyToMe = isBuzzThreadReplyToMyEvent(it, loggedInUserHex)
|
||||
|
||||
// Concord CHAT (a message/reply) honors the "Messages in notifications" toggle that silences DMs
|
||||
// and Marmot groups above. A reaction isn't a message — regular reactions ignore that toggle, so
|
||||
// Concord reactions do too (only isConcordMessage is gated).
|
||||
@@ -552,14 +593,14 @@ class NotificationFeedFilter(
|
||||
// to genuine replies, so unrelated channel chatter never leaks through.
|
||||
return noteEvent?.kind in NOTIFICATION_KINDS &&
|
||||
(noteEvent is LnZapEvent || noteEvent is Bolt12ZapEvent || notifAuthor != loggedInUserHex) &&
|
||||
(isChessEvent || isConcord || isReactionToMe || filterParams.isGlobal() || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) &&
|
||||
(noteEvent?.isTaggedUser(loggedInUserHex) == true || isNotifiablePublicChatReply(it, loggedInUserHex) || isReactionToMe) &&
|
||||
(isChessEvent || isConcord || isReactionToMe || isThreadReplyToMe || filterParams.isGlobal() || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) &&
|
||||
(noteEvent?.isTaggedUser(loggedInUserHex) == true || isNotifiablePublicChatReply(it, loggedInUserHex) || isReactionToMe || isThreadReplyToMe) &&
|
||||
(filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) &&
|
||||
(noteEvent !is PrivateDmEvent || !account.isDecryptedContentHidden(noteEvent)) &&
|
||||
// For a Concord note the explicit p-tag above IS the relevance signal (the reply/reaction/
|
||||
// mention targets me directly), so skip the per-kind heuristic — which for a reaction would
|
||||
// otherwise need my target message already loaded to resolve replyTo.
|
||||
(isRawGlobal || isConcord || tagsAnEventByUser(it, loggedInUserHex))
|
||||
(isRawGlobal || isConcord || isThreadReplyToMe || tagsAnEventByUser(it, loggedInUserHex))
|
||||
}
|
||||
|
||||
override fun sort(items: Set<Note>): List<Note> = items.sortedByDefaultFeedOrder()
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* 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.nipC7Chats.ChatEvent
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* A kind-9 chat message is a thread reply only when its `e` tag carries a NIP-10 marker.
|
||||
*
|
||||
* Three conventions share kind 9 and must not be confused:
|
||||
* - **NIP-C7** spends `q` on the in-chat reply and never mentions `e` at all, which is what leaves a
|
||||
* marked `e` free to mean "thread reply".
|
||||
* - **WhiteNoise / Marmot** thread chat with a **plain, unmarked** `e` — an *in-chat* reply that has to
|
||||
* keep rendering as a quote bubble in the timeline.
|
||||
* - **Buzz** threads with `["e", id, "", "reply"]` (nested: `root` + `reply`), which belongs in the
|
||||
* minichat and must be dropped from the channel timeline.
|
||||
*
|
||||
* Getting this wrong is what put a Buzz thread reply in the main channel as a quote instead of in the
|
||||
* thread on its parent.
|
||||
*/
|
||||
class MinichatReplyTest {
|
||||
private val parentId = "1a05130cc86929f267747b17761d5873a95dbab66d5298c38a352bdfd0edc730"
|
||||
private val rootId = "bf2e60b69fdf6bf3aa11223344556677889900aabbccddeeff00112233445566"
|
||||
private val channel = "6a39da2f-33c0-44f6-a050-c4da0138644a"
|
||||
|
||||
private fun chat(vararg tags: Array<String>) =
|
||||
ChatEvent(
|
||||
id = "id",
|
||||
pubKey = "pk",
|
||||
createdAt = 1L,
|
||||
tags = arrayOf(arrayOf("h", channel), *tags),
|
||||
content = "hi",
|
||||
sig = "sig",
|
||||
)
|
||||
|
||||
/** The exact shape observed on the wire from Buzz's client for a direct reply. */
|
||||
@Test
|
||||
fun `buzz direct reply - reply-marked e tag - is a thread reply`() {
|
||||
assertTrue(isMinichatReply(chat(arrayOf("e", parentId, "", "reply"))))
|
||||
}
|
||||
|
||||
/** Nested reply: `root` + `reply`, matching Buzz's `_buildReplyTags`. */
|
||||
@Test
|
||||
fun `buzz nested reply - root plus reply markers - is a thread reply`() {
|
||||
assertTrue(
|
||||
isMinichatReply(
|
||||
chat(arrayOf("e", rootId, "", "root"), arrayOf("e", parentId, "", "reply")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression: WhiteNoise/Marmot use a bare `e`, which is an *in-chat* reply. Matching on the tag
|
||||
* rather than the marker would swallow those into the minichat and empty the timeline.
|
||||
*/
|
||||
@Test
|
||||
fun `whitenoise unmarked e tag stays an in-chat reply`() {
|
||||
assertFalse(isMinichatReply(chat(arrayOf("e", parentId))))
|
||||
assertFalse(isMinichatReply(chat(arrayOf("e", parentId, ""))))
|
||||
}
|
||||
|
||||
/** NIP-C7's own reply mechanism renders inline, not in a thread. */
|
||||
@Test
|
||||
fun `nip-c7 q tag reply stays an in-chat reply`() {
|
||||
assertFalse(isMinichatReply(chat(arrayOf("q", parentId, "", "pk"))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a plain top-level chat message is not a thread reply`() {
|
||||
assertFalse(isMinichatReply(chat()))
|
||||
}
|
||||
|
||||
/** A `root`-only marker (no `reply`) is a thread root reference, not a reply to that message. */
|
||||
@Test
|
||||
fun `root marker alone is not a reply`() {
|
||||
assertFalse(isMinichatReply(chat(arrayOf("e", rootId, "", "root"))))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user