mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
Merge pull request #3314 from vitorpamplona/claude/public-chat-notifications-qmn4gh
Notify on public chat replies without p-tags
This commit is contained in:
+35
-2
@@ -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,
|
||||
|
||||
+8
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+58
-1
@@ -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))
|
||||
|
||||
+127
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user