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 c0a35d5f47..606a095faf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 2844127ec5..c3e4633ab2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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? = unreadPrivateChatRoute(chat.event, account.signer.pubKey, account::isAllHidden) - private fun markHiddenChatroomsAsRead() { account.chatroomList.rooms.forEach { roomKey, chatroom -> if (account.isAllHidden(roomKey.users)) { 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 index 2434a67e12..c6b8d8d4f9 100644 --- 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 @@ -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", , , ]` — 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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt index 75659f454e..a26ea3dc1f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomRowUnread.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomRowUnread.kt new file mode 100644 index 0000000000..f84fa24049 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomRowUnread.kt @@ -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` 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? { + // 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 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index 5ec4afb1a3..af44f5e0ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -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() 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() 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index f4ac0d4f2c..4685312f59 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -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", , "", "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): List = items.sortedByDefaultFeedOrder() diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/MinichatReplyTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/MinichatReplyTest.kt new file mode 100644 index 0000000000..acb9de2a98 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/MinichatReplyTest.kt @@ -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) = + 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")))) + } +}