refactor(notifications): per-kind Event.notifies(HexKey) routing
The notification pipeline previously hard-coded a lowercase-`p`-tag match in two places (the observer predicate and consumeFromCache via taggedUserIds). That's correct for most kinds but wrong for two: - NIP-22 CommentEvent: a comment several levels deep only tags the root author via uppercase `P` (RootAuthorTag). Pure lowercase-p routing missed "someone replied deep in your thread" notifications. - Experimental WakeUpEvent (kind 23903): its `p` tags are the authors of the subject events it references — Bob reacting to Alice's post yields a WakeUpEvent with p=Bob, even though Alice's device is the one that needs to wake up. Transport-layer routing (push/relay subscription) already delivered the event to the right device, so the in-event routing has to be permissive. Introduce `open fun Event.notifies(userHex: HexKey): Boolean` with a lowercase-`p` default that covers NIP-01/04/17/25/28/34/57/68/71/84/AC/ chess/wiki/long-form/poll mentions. Each kind with distinct semantics overrides: - CommentEvent.notifies: super.notifies(u) || rootAuthorKeys().contains(u) — picks up uppercase P root-author routing on top of lowercase p. - WakeUpEvent.notifies: true — every logged-in account is a valid wake target once the event has reached LocalCache on this device. NotificationDispatcher's observer predicate and EventNotificationConsumer. consumeFromCache both now ask `event.notifies(accountHex)` instead of extracting taggedUserIds themselves. The zap path's redundant isTaggedUser re-check is gone too (it was duplicating what the outer routing already enforced). https://claude.ai/code/session_01GQDJxiHPogdzCNhUBN7Pjc
This commit is contained in:
+73
-68
@@ -55,13 +55,13 @@ import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
|
||||
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
@@ -146,15 +146,15 @@ class EventNotificationConsumer(
|
||||
|
||||
if (!notificationManager().areNotificationsEnabled()) return@withWakeLock
|
||||
|
||||
val taggedNpubs =
|
||||
event
|
||||
.taggedUserIds()
|
||||
.mapTo(mutableSetOf()) { LocalCache.getOrCreateUser(it).pubkeyNpub() }
|
||||
if (taggedNpubs.isEmpty()) return@withWakeLock
|
||||
|
||||
// Ask the event which of our signing accounts it's notifying.
|
||||
// Each kind declares its own notification semantics in
|
||||
// [Event.notifies] (lowercase `p` by default, NIP-22 also checks
|
||||
// uppercase `P`, etc.), so we don't hard-code tag names here.
|
||||
LocalPreferences.allSavedAccounts().forEach { savedAccount ->
|
||||
if (!savedAccount.hasPrivKey && !savedAccount.loggedInWithExternalSigner) return@forEach
|
||||
if (savedAccount.npub !in taggedNpubs) return@forEach
|
||||
|
||||
val accountHex = npubToHexOrNull(savedAccount.npub) ?: return@forEach
|
||||
if (!event.notifies(accountHex)) return@forEach
|
||||
|
||||
val accountSettings = LocalPreferences.loadAccountConfigFromEncryptedStorage(savedAccount.npub) ?: return@forEach
|
||||
try {
|
||||
@@ -167,6 +167,11 @@ class EventNotificationConsumer(
|
||||
}
|
||||
}
|
||||
|
||||
private fun npubToHexOrNull(npub: String): String? =
|
||||
runCatching { npub.bechToBytes("npub").toHexKey() }
|
||||
.onFailure { Log.d(TAG) { "Skipping non-decodable npub $npub: ${it.message}" } }
|
||||
.getOrNull()
|
||||
|
||||
private suspend fun dispatchForAccount(
|
||||
event: Event,
|
||||
account: Account,
|
||||
@@ -513,79 +518,44 @@ class EventNotificationConsumer(
|
||||
|
||||
Log.d(TAG, "Notify Amount Bigger than 10")
|
||||
|
||||
if (event.isTaggedUser(account.signer.pubKey)) {
|
||||
val amount = showAmount(event.amount)
|
||||
// Zap routing (recipient == account) is enforced by the dispatcher
|
||||
// predicate + consumeFromCache via Event.notifies; no re-check here.
|
||||
val amount = showAmount(event.amount)
|
||||
|
||||
Log.d(TAG) { "Notify Amount $amount" }
|
||||
Log.d(TAG) { "Notify Amount $amount" }
|
||||
|
||||
(noteZapRequest.event as? LnZapRequestEvent)?.let { event ->
|
||||
decryptZapContentAuthor(event, account.signer)?.let { decryptedEvent ->
|
||||
Log.d(TAG) { "Notify Decrypted if Private Zap ${event.id}" }
|
||||
(noteZapRequest.event as? LnZapRequestEvent)?.let { event ->
|
||||
decryptZapContentAuthor(event, account.signer)?.let { decryptedEvent ->
|
||||
Log.d(TAG) { "Notify Decrypted if Private Zap ${event.id}" }
|
||||
|
||||
val author = LocalCache.getOrCreateUser(decryptedEvent.pubKey)
|
||||
val senderInfo = Pair(author, decryptedEvent.content.ifBlank { null })
|
||||
val author = LocalCache.getOrCreateUser(decryptedEvent.pubKey)
|
||||
val senderInfo = Pair(author, decryptedEvent.content.ifBlank { null })
|
||||
|
||||
if (noteZapped.event?.content != null) {
|
||||
decryptContent(noteZapped, account.signer)?.let { decrypted ->
|
||||
Log.d(TAG, "Notify Decrypted if Private Note")
|
||||
if (noteZapped.event?.content != null) {
|
||||
decryptContent(noteZapped, account.signer)?.let { decrypted ->
|
||||
Log.d(TAG, "Notify Decrypted if Private Note")
|
||||
|
||||
val zappedContent = decrypted.split("\n")[0]
|
||||
|
||||
val user = senderInfo.first.toBestDisplayName()
|
||||
var title = stringRes(applicationContext, R.string.app_notification_zaps_channel_message, amount)
|
||||
senderInfo.second?.ifBlank { null }?.let { title += " ($it)" }
|
||||
|
||||
var content =
|
||||
stringRes(
|
||||
applicationContext,
|
||||
R.string.app_notification_zaps_channel_message_from,
|
||||
user,
|
||||
)
|
||||
zappedContent.let {
|
||||
content +=
|
||||
" " +
|
||||
stringRes(
|
||||
applicationContext,
|
||||
R.string.app_notification_zaps_channel_message_for,
|
||||
zappedContent,
|
||||
)
|
||||
}
|
||||
val userPicture = senderInfo.first.profilePicture()
|
||||
val noteUri =
|
||||
"notifications$ACCOUNT_QUERY_PARAM" +
|
||||
account.signer.pubKey
|
||||
.hexToByteArray()
|
||||
.toNpub() +
|
||||
SCROLL_TO_QUERY_PARAM + event.id
|
||||
|
||||
Log.d(TAG) { "Notify ${event.id} $content $title $noteUri" }
|
||||
|
||||
notificationManager()
|
||||
.sendZapNotification(
|
||||
event.id,
|
||||
content,
|
||||
title,
|
||||
event.createdAt,
|
||||
userPicture,
|
||||
noteUri,
|
||||
applicationContext,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// doesn't have a base note to refer to.
|
||||
Log.d(TAG, "Notify Zapped note not available")
|
||||
val zappedContent = decrypted.split("\n")[0]
|
||||
|
||||
val user = senderInfo.first.toBestDisplayName()
|
||||
var title = stringRes(applicationContext, R.string.app_notification_zaps_channel_message, amount)
|
||||
senderInfo.second?.ifBlank { null }?.let { title += " ($it)" }
|
||||
|
||||
val content =
|
||||
var content =
|
||||
stringRes(
|
||||
applicationContext,
|
||||
R.string.app_notification_zaps_channel_message_from,
|
||||
user,
|
||||
)
|
||||
|
||||
zappedContent.let {
|
||||
content +=
|
||||
" " +
|
||||
stringRes(
|
||||
applicationContext,
|
||||
R.string.app_notification_zaps_channel_message_for,
|
||||
zappedContent,
|
||||
)
|
||||
}
|
||||
val userPicture = senderInfo.first.profilePicture()
|
||||
val noteUri =
|
||||
"notifications$ACCOUNT_QUERY_PARAM" +
|
||||
@@ -594,7 +564,7 @@ class EventNotificationConsumer(
|
||||
.toNpub() +
|
||||
SCROLL_TO_QUERY_PARAM + event.id
|
||||
|
||||
Log.d(TAG) { "Notify ${event.id} $title $noteUri" }
|
||||
Log.d(TAG) { "Notify ${event.id} $content $title $noteUri" }
|
||||
|
||||
notificationManager()
|
||||
.sendZapNotification(
|
||||
@@ -607,6 +577,41 @@ class EventNotificationConsumer(
|
||||
applicationContext,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// doesn't have a base note to refer to.
|
||||
Log.d(TAG, "Notify Zapped note not available")
|
||||
|
||||
val user = senderInfo.first.toBestDisplayName()
|
||||
var title = stringRes(applicationContext, R.string.app_notification_zaps_channel_message, amount)
|
||||
senderInfo.second?.ifBlank { null }?.let { title += " ($it)" }
|
||||
|
||||
val content =
|
||||
stringRes(
|
||||
applicationContext,
|
||||
R.string.app_notification_zaps_channel_message_from,
|
||||
user,
|
||||
)
|
||||
|
||||
val userPicture = senderInfo.first.profilePicture()
|
||||
val noteUri =
|
||||
"notifications$ACCOUNT_QUERY_PARAM" +
|
||||
account.signer.pubKey
|
||||
.hexToByteArray()
|
||||
.toNpub() +
|
||||
SCROLL_TO_QUERY_PARAM + event.id
|
||||
|
||||
Log.d(TAG) { "Notify ${event.id} $title $noteUri" }
|
||||
|
||||
notificationManager()
|
||||
.sendZapNotification(
|
||||
event.id,
|
||||
content,
|
||||
title,
|
||||
event.createdAt,
|
||||
userPicture,
|
||||
noteUri,
|
||||
applicationContext,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-3
@@ -170,13 +170,15 @@ class NotificationDispatcher(
|
||||
// matches the downstream per-channel policy. Calls
|
||||
// use a stricter 20s check in notifyIncomingCall so
|
||||
// they still pass through.
|
||||
// - any `p` tag matching one of our accounts — narrows
|
||||
// to events consumeFromCache would route anyway.
|
||||
// - event.notifies(pubkey) for any of our accounts —
|
||||
// each kind decides which tag(s) name its recipients
|
||||
// (lowercase `p` by default, plus uppercase `P` for
|
||||
// NIP-22 root authors, etc.).
|
||||
val predicate = { event: Event ->
|
||||
event.kind in NOTIFICATION_KINDS &&
|
||||
event.createdAt >= dispatcherSince &&
|
||||
event.createdAt >= TimeUtils.fifteenMinutesAgo() &&
|
||||
event.tags.any { it.size > 1 && it[0] == "p" && it[1] in pubkeys }
|
||||
pubkeys.any { event.notifies(it) }
|
||||
}
|
||||
|
||||
LocalCache
|
||||
|
||||
Reference in New Issue
Block a user