From 9cafbf02cc5a6e5892a944c70f4aa41f2a0a96d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 16:39:54 +0000 Subject: [PATCH 1/3] feat(notifications): notify on public chat replies without a p-tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public chats (NIP-28, kind 42) routinely reply to a user without adding a `p` tag, so the existing mention gate (Event.isTaggedUser) silently dropped them from both the in-app Notifications feed and Android tray push. Add NotificationFeedFilter.isNotifiablePublicChatReply: a cache-only check that walks a channel message's reply chain for one of the user's own messages — covering a direct reply to my message ("the previous message was mine") and later messages in a thread I'm already part of ("an active thread"). It is bounded (depth + visited-set) and reads only Note.replyTo, so the push dispatcher and the feed can both consult it without loading the account or decrypting anything. Wire it as an OR alongside the p-tag gate in the three relevance sites that share the rule — NotificationFeedFilter.acceptableEvent, the NotificationDispatcher observer predicate, and EventNotificationConsumer — while keeping tagsAnEventByUser as the scoping AND so unrelated channel chatter never leaks through, even in Global mode. Route ChannelMessageEvent to its own tray handler: reply-to-me renders as a threaded reply (with inline reply action) grouped by channel so a busy room collapses into one notification; a pure p-tag citation still renders as a mention. Muting a thread suppresses it via the existing isAcceptable gate. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PxDVCSWe1RwZ51vABBwqbG --- .../EventNotificationConsumer.kt | 37 +++++- .../notifications/NotificationDispatcher.kt | 9 +- .../dal/NotificationFeedFilter.kt | 60 ++++++++- .../dal/NotificationPublicChatReplyTest.kt | 123 ++++++++++++++++++ 4 files changed, 225 insertions(+), 4 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationPublicChatReplyTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 5860829872..f7411f7905 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -174,7 +174,13 @@ class EventNotificationConsumer( val accountHex = npubToHexOrNull(savedAccount.npub) ?: return@forEach if (matchingNote != null) { - if (!event.isTaggedUser(accountHex)) return@forEach + // Public chat replies into my messages often omit the `p` + // tag; relax the gate for them (tagsAnEventByUser keeps it + // scoped to messages actually replying to me). + val taggedOrPublicChatReply = + event.isTaggedUser(accountHex) || + NotificationFeedFilter.isNotifiablePublicChatReply(matchingNote, accountHex) + if (!taggedOrPublicChatReply) return@forEach if (!NotificationFeedFilter.tagsAnEventByUser(matchingNote, accountHex)) return@forEach } @@ -251,12 +257,13 @@ class EventNotificationConsumer( is CommentEvent -> notify(event, account) + is ChannelMessageEvent -> notify(event, account) + is PictureEvent, is VideoNormalEvent, is VideoShortEvent, is VideoHorizontalEvent, is VideoVerticalEvent, - is ChannelMessageEvent, is PollEvent, is GitPatchEvent, is GitIssueEvent, @@ -865,6 +872,32 @@ class EventNotificationConsumer( notifyReply(event, account, parentContent, threadRoot) } + private suspend fun notify( + event: ChannelMessageEvent, + account: Account, + ) { + Log.d(TAG, "New Public Chat Message to Notify") + // Age + self-author gates run centrally in dispatchForAccount. + val note = LocalCache.getNoteIfExists(event.id) ?: return + + // A reply into one of my messages in this channel — even when the + // sender didn't p-tag me. Render it as a reply (threaded + inline + // reply action) grouped by channel so a busy room collapses into one + // notification instead of spamming. Falls back to a plain mention when + // I'm only p-tagged (a citation, not a reply to my message). + if (NotificationFeedFilter.isNotifiablePublicChatReply(note, account.signer.pubKey)) { + val parentContent = + note.replyTo + ?.lastOrNull() + ?.event + ?.content + val threadRoot = event.channelId() ?: event.id + notifyReply(event, account, parentContent, threadRoot) + } else { + notifyMention(event, account) + } + } + private suspend fun notifyReply( event: Event, account: Account, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt index 67407a0907..37e6fb2f96 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt @@ -203,7 +203,14 @@ class NotificationDispatcher( // always miss for replies into addressable posts. val note = LocalCache.getNoteIfExists(event) ?: return@predicate false pubkeys.any { pubkey -> - event.isTaggedUser(pubkey) && + // Public chat replies into my own messages often + // omit the `p` tag; relax the gate for them (the + // tagsAnEventByUser check still scopes it to + // messages actually replying to me). + ( + event.isTaggedUser(pubkey) || + NotificationFeedFilter.isNotifiablePublicChatReply(note, pubkey) + ) && NotificationFeedFilter.tagsAnEventByUser(note, pubkey) } } 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 a0fc6989ec..598b8bd279 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 @@ -147,6 +147,49 @@ class NotificationFeedFilter( VoiceReplyEvent.KIND, ) + ADDRESSABLE_KINDS + // How deep to walk a public chat reply chain looking for one of the + // user's own messages. Bounds the cost on very long threads and the + // visited-set guards against malformed cyclic replyTo links. + private const val PUBLIC_CHAT_ANCESTOR_SCAN_LIMIT = 30 + + /** + * Public chats (NIP-28, kind 42) routinely reply to a user without + * adding a `p` tag, so the normal mention gate ([Event.isTaggedUser]) + * misses them. Treat a channel message as "for me" when one of my own + * messages appears in its reply chain — a direct reply to my message + * (the common case the user described as "the previous message was + * mine") or a later message in a thread I'm already part of ("an + * active thread"). Reactions/zaps/reposts target via `replyTo` and are + * handled by the generic rules below; this is scoped to channel + * messages only. + * + * Cache-only (reads [Note.replyTo] + author, never the account), so the + * push dispatcher and the in-app feed can both call it to relax the + * p-tag gate without loading the account or decrypting anything. + */ + fun isNotifiablePublicChatReply( + note: Note, + authorHex: HexKey, + ): Boolean { + if (note.event !is ChannelMessageEvent) return false + + var scanned = 0 + val seen = HashSet() + val toVisit = ArrayDeque() + note.replyTo?.let { toVisit.addAll(it) } + + while (toVisit.isNotEmpty() && scanned < PUBLIC_CHAT_ANCESTOR_SCAN_LIMIT) { + val ancestor = toVisit.removeFirst() + if (!seen.add(ancestor.idHex)) continue + scanned++ + + if (ancestor.author?.pubkeyHex == authorHex) return true + ancestor.replyTo?.let { toVisit.addAll(it) } + } + + return false + } + // Shared with EventNotificationConsumer so push notifications and the // in-app feed apply the same per-kind "is this event for me" rule. fun tagsAnEventByUser( @@ -155,6 +198,11 @@ class NotificationFeedFilter( ): Boolean { val event = note.event + // Public chat replies into my messages, even without a p-tag. + if (isNotifiablePublicChatReply(note, authorHex)) { + return true + } + if (event is GitIssueEvent || event is GitPatchEvent) { return true } @@ -355,10 +403,20 @@ class NotificationFeedFilter( // follow/list modes) also applies the per-kind relevance heuristics. val isRawGlobal = followList() is TopFilter.Global + // Channel messages may reply to one of my messages without a p-tag + // (common in NIP-28 clients), so the p-tag gate is OR'd with the + // public-chat reply check. tagsAnEventByUser below (also gated for + // Selected/follow modes) returns true for exactly the same events, so + // unrelated channel chatter never leaks through — even in Global mode, + // where it is the only relevance check. + val isTaggedOrPublicChatReply = + noteEvent?.isTaggedUser(loggedInUserHex) == true || + isNotifiablePublicChatReply(it, loggedInUserHex) + return noteEvent?.kind in NOTIFICATION_KINDS && (noteEvent is LnZapEvent || notifAuthor != loggedInUserHex) && (isChessEvent || filterParams.isGlobal() || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) && - noteEvent?.isTaggedUser(loggedInUserHex) ?: false && + isTaggedOrPublicChatReply && (filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) && (noteEvent !is PrivateDmEvent || !account.isDecryptedContentHidden(noteEvent)) && (isRawGlobal || tagsAnEventByUser(it, loggedInUserHex)) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationPublicChatReplyTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationPublicChatReplyTest.kt new file mode 100644 index 0000000000..16681183b9 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationPublicChatReplyTest.kt @@ -0,0 +1,123 @@ +/* + * 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.notifications.dal + +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.UserContext +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Public chats (NIP-28, kind 42) frequently reply to a user without adding a + * `p` tag. [NotificationFeedFilter.isNotifiablePublicChatReply] recovers those + * by walking the cached reply chain for one of the user's own messages — a + * direct reply to my message, or a later message in a thread I am already part + * of — so the push dispatcher and the in-app feed can relax the p-tag gate for + * exactly these events and nothing else. + */ +class NotificationPublicChatReplyTest { + // User eagerly pins a few addressable note shells on construction; empty + // shells are enough since the relevance check only reads pubkeyHex. + private val noContext = UserContext { addr -> AddressableNote(addr) } + + private val me = "1".repeat(64) + private val other = "2".repeat(64) + private val sig = "f".repeat(128) + + private fun authoredNote( + id: String, + authorHex: String, + parents: List = emptyList(), + ): Note = + Note(id).apply { + author = User(authorHex, noContext) + replyTo = parents + } + + private fun channelMessage( + id: String, + authorHex: String, + parents: List, + ): Note = + Note(id).apply { + event = ChannelMessageEvent(id, authorHex, 1000, emptyArray(), "hi", sig) + author = User(authorHex, noContext) + replyTo = parents + } + + @Test + fun `direct reply to my channel message without a p-tag is notifiable`() { + val myMessage = channelMessage("a".repeat(64), me, emptyList()) + val reply = channelMessage("b".repeat(64), other, listOf(myMessage)) + + assertTrue(NotificationFeedFilter.isNotifiablePublicChatReply(reply, me)) + assertTrue(NotificationFeedFilter.tagsAnEventByUser(reply, me)) + } + + @Test + fun `reply deeper in a thread i posted in is notifiable`() { + // root(other) <- myMessage(me) <- someoneElse(other) <- newReply(other) + val root = channelMessage("d".repeat(64), other, emptyList()) + val myMessage = channelMessage("e".repeat(64), me, listOf(root)) + val someoneElse = channelMessage("9".repeat(64), other, listOf(myMessage)) + val newReply = channelMessage("8".repeat(64), other, listOf(someoneElse)) + + assertTrue(NotificationFeedFilter.isNotifiablePublicChatReply(newReply, me)) + } + + @Test + fun `channel message in a thread i never posted in is not notifiable`() { + val root = channelMessage("a1".repeat(32), other, emptyList()) + val reply = channelMessage("a2".repeat(32), other, listOf(root)) + + assertFalse(NotificationFeedFilter.isNotifiablePublicChatReply(reply, me)) + assertFalse(NotificationFeedFilter.tagsAnEventByUser(reply, me)) + } + + @Test + fun `unloaded ancestor without an author does not match`() { + // Parent shell present in cache but its event/author hasn't loaded yet. + val unloadedParent = Note("a3".repeat(32)) + val reply = channelMessage("a4".repeat(32), other, listOf(unloadedParent)) + + assertFalse(NotificationFeedFilter.isNotifiablePublicChatReply(reply, me)) + } + + @Test + fun `non channel replies are out of scope for the public-chat relaxation`() { + // A kind-1 reply to my note must NOT be picked up by the public-chat + // path — the normal p-tag gate governs those. + val myNote = authoredNote("a5".repeat(32), me) + val reply = + Note("a6".repeat(32)).apply { + event = TextNoteEvent("a6".repeat(32), other, 1000, emptyArray(), "hi", sig) + author = User(other, noContext) + replyTo = listOf(myNote) + } + + assertFalse(NotificationFeedFilter.isNotifiablePublicChatReply(reply, me)) + } +} From 2ec5c81fd3184c0bd6a61ed833a7dfb0f760829f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 19:22:23 +0000 Subject: [PATCH 2/3] perf(notifications): keep the public-chat reply gate inline so it short-circuits Hoisting the p-tag/public-chat-reply check into a pre-computed val made it eager: the tag scan (and, for channel messages, the reply-chain walk) ran on every Note in the cache, even the overwhelming majority rejected by the cheap `kind in NOTIFICATION_KINDS` check. Inline it back into the && chain in its original 4th position so that kind check short-circuits ahead of it, and order the OR so the cheaper tag scan runs before the reply-chain walk. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PxDVCSWe1RwZ51vABBwqbG --- .../notifications/dal/NotificationFeedFilter.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 598b8bd279..210ea42ac4 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 @@ -405,18 +405,18 @@ class NotificationFeedFilter( // Channel messages may reply to one of my messages without a p-tag // (common in NIP-28 clients), so the p-tag gate is OR'd with the - // public-chat reply check. tagsAnEventByUser below (also gated for + // public-chat reply check. Kept inline (not a pre-computed val) so the + // cheap `kind in NOTIFICATION_KINDS` check short-circuits ahead of it — + // otherwise the tag scan + reply-chain walk would run on every note in + // the cache. Within the OR, the cheaper tag scan runs before the + // reply-chain walk. tagsAnEventByUser below (also gated for // Selected/follow modes) returns true for exactly the same events, so // unrelated channel chatter never leaks through — even in Global mode, // where it is the only relevance check. - val isTaggedOrPublicChatReply = - noteEvent?.isTaggedUser(loggedInUserHex) == true || - isNotifiablePublicChatReply(it, loggedInUserHex) - return noteEvent?.kind in NOTIFICATION_KINDS && (noteEvent is LnZapEvent || notifAuthor != loggedInUserHex) && (isChessEvent || filterParams.isGlobal() || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) && - isTaggedOrPublicChatReply && + (noteEvent?.isTaggedUser(loggedInUserHex) == true || isNotifiablePublicChatReply(it, loggedInUserHex)) && (filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) && (noteEvent !is PrivateDmEvent || !account.isDecryptedContentHidden(noteEvent)) && (isRawGlobal || tagsAnEventByUser(it, loggedInUserHex)) From e3d3cebdd454bf9e0a16d4f147bcaf8a0155d1a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 20:08:08 +0000 Subject: [PATCH 3/3] refactor(notifications): tighten public-chat reply walk and its docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit follow-ups, no behaviour change: - isNotifiablePublicChatReply bails before allocating the HashSet/ArrayDeque scratch when the channel message has no parents (top-level posts — the common case), and builds the deque straight from the parent list. - Correct the docstring: a kind-42 replyTo holds only the immediate parent (the channel root is filtered out), so the chain is walked hop-by-hop through each cached ancestor — the previous wording implied replyTo already held the ancestors. - Trim the over-long inline comment on the acceptableEvent gate. - Make the multi-hop test prove what it claims: assert the immediate parent is not me, so the walk only passes by climbing to the grandparent. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PxDVCSWe1RwZ51vABBwqbG --- .../dal/NotificationFeedFilter.kt | 41 +++++++++---------- .../dal/NotificationPublicChatReplyTest.kt | 6 ++- 2 files changed, 25 insertions(+), 22 deletions(-) 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 210ea42ac4..76e2799c96 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 @@ -148,35 +148,37 @@ class NotificationFeedFilter( ) + ADDRESSABLE_KINDS // How deep to walk a public chat reply chain looking for one of the - // user's own messages. Bounds the cost on very long threads and the + // user's own messages. Bounds the cost on very long threads; the // visited-set guards against malformed cyclic replyTo links. private const val PUBLIC_CHAT_ANCESTOR_SCAN_LIMIT = 30 /** * Public chats (NIP-28, kind 42) routinely reply to a user without * adding a `p` tag, so the normal mention gate ([Event.isTaggedUser]) - * misses them. Treat a channel message as "for me" when one of my own + * misses them. Treats a channel message as "for me" when one of my own * messages appears in its reply chain — a direct reply to my message - * (the common case the user described as "the previous message was - * mine") or a later message in a thread I'm already part of ("an - * active thread"). Reactions/zaps/reposts target via `replyTo` and are - * handled by the generic rules below; this is scoped to channel - * messages only. + * (the common case: "the previous message was mine") or a later message + * in a thread I'm already part of (an "active thread"). A kind-42 + * `replyTo` holds only the immediate parent, so the chain is walked + * hop-by-hop through each cached ancestor. * * Cache-only (reads [Note.replyTo] + author, never the account), so the - * push dispatcher and the in-app feed can both call it to relax the - * p-tag gate without loading the account or decrypting anything. + * push dispatcher and the in-app feed can both relax their p-tag gate + * with it without loading the account or decrypting anything. */ fun isNotifiablePublicChatReply( note: Note, authorHex: HexKey, ): Boolean { if (note.event !is ChannelMessageEvent) return false + // Top-level channel posts have no parent to reply to — bail before + // allocating the walk's scratch structures (the common case). + val parents = note.replyTo + if (parents.isNullOrEmpty()) return false var scanned = 0 val seen = HashSet() - val toVisit = ArrayDeque() - note.replyTo?.let { toVisit.addAll(it) } + val toVisit = ArrayDeque(parents) while (toVisit.isNotEmpty() && scanned < PUBLIC_CHAT_ANCESTOR_SCAN_LIMIT) { val ancestor = toVisit.removeFirst() @@ -403,16 +405,13 @@ class NotificationFeedFilter( // follow/list modes) also applies the per-kind relevance heuristics. val isRawGlobal = followList() is TopFilter.Global - // Channel messages may reply to one of my messages without a p-tag - // (common in NIP-28 clients), so the p-tag gate is OR'd with the - // public-chat reply check. Kept inline (not a pre-computed val) so the - // cheap `kind in NOTIFICATION_KINDS` check short-circuits ahead of it — - // otherwise the tag scan + reply-chain walk would run on every note in - // the cache. Within the OR, the cheaper tag scan runs before the - // reply-chain walk. tagsAnEventByUser below (also gated for - // Selected/follow modes) returns true for exactly the same events, so - // unrelated channel chatter never leaks through — even in Global mode, - // where it is the only relevance check. + // The p-tag gate is OR'd with isNotifiablePublicChatReply so channel + // replies into my messages still notify without a p-tag. Kept inline + // (not a pre-computed val) so the cheap kind check short-circuits ahead + // of the tag scan + reply walk, which is also why the cheaper tag scan + // is ordered first within the OR. In Global mode this is the only + // relevance check (tagsAnEventByUser is skipped below); it still scopes + // to genuine replies, so unrelated channel chatter never leaks through. return noteEvent?.kind in NOTIFICATION_KINDS && (noteEvent is LnZapEvent || notifAuthor != loggedInUserHex) && (isChessEvent || filterParams.isGlobal() || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) && diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationPublicChatReplyTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationPublicChatReplyTest.kt index 16681183b9..92022ae414 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationPublicChatReplyTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationPublicChatReplyTest.kt @@ -78,13 +78,17 @@ class NotificationPublicChatReplyTest { } @Test - fun `reply deeper in a thread i posted in is notifiable`() { + fun `reply two hops above me in a thread i posted in is notifiable`() { // root(other) <- myMessage(me) <- someoneElse(other) <- newReply(other) + // newReply's immediate parent is `other`, so a single-hop check would + // miss me — this only passes if the walk climbs to the grandparent. val root = channelMessage("d".repeat(64), other, emptyList()) val myMessage = channelMessage("e".repeat(64), me, listOf(root)) val someoneElse = channelMessage("9".repeat(64), other, listOf(myMessage)) val newReply = channelMessage("8".repeat(64), other, listOf(someoneElse)) + // Sanity: the immediate parent is not me, so this is a true multi-hop hit. + assertFalse(newReply.replyTo?.any { it.author?.pubkeyHex == me } == true) assertTrue(NotificationFeedFilter.isNotifiablePublicChatReply(newReply, me)) }