feat(notifications): notify on public chat replies without a p-tag

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxDVCSWe1RwZ51vABBwqbG
This commit is contained in:
Claude
2026-06-20 16:39:54 +00:00
parent b7aad6f61c
commit 9cafbf02cc
4 changed files with 225 additions and 4 deletions
@@ -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,
@@ -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)
}
}
@@ -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<HexKey>()
val toVisit = ArrayDeque<Note>()
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))
@@ -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<Note> = emptyList(),
): Note =
Note(id).apply {
author = User(authorHex, noContext)
replyTo = parents
}
private fun channelMessage(
id: String,
authorHex: String,
parents: List<Note>,
): 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))
}
}