Merge pull request #3314 from vitorpamplona/claude/public-chat-notifications-qmn4gh

Notify on public chat replies without p-tags
This commit is contained in:
Vitor Pamplona
2026-06-21 10:29:54 -04:00
committed by GitHub
4 changed files with 228 additions and 4 deletions
@@ -175,7 +175,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
}
@@ -252,12 +258,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,
@@ -882,6 +889,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,51 @@ 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; 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. 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 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 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<HexKey>()
val toVisit = ArrayDeque(parents)
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 +200,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
}
@@ -370,10 +420,17 @@ class NotificationFeedFilter(
// follow/list modes) also applies the per-kind relevance heuristics.
val isRawGlobal = followList() is TopFilter.Global
// 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)) &&
noteEvent?.isTaggedUser(loggedInUserHex) ?: false &&
(noteEvent?.isTaggedUser(loggedInUserHex) == true || isNotifiablePublicChatReply(it, loggedInUserHex)) &&
(filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) &&
(noteEvent !is PrivateDmEvent || !account.isDecryptedContentHidden(noteEvent)) &&
(isRawGlobal || tagsAnEventByUser(it, loggedInUserHex))
@@ -0,0 +1,127 @@
/*
* 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 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))
}
@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))
}
}