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:
Claude
2026-04-24 16:06:55 +00:00
parent 86a86e1426
commit 10bbd0ddb2
5 changed files with 125 additions and 71 deletions
@@ -64,6 +64,23 @@ class WakeUpEvent(
fun kinds() = tags.kinds()
/**
* A WakeUpEvent's `p` tags point to the authors of the referenced subject
* events (see [authorKeys]), **not** to the account the wake-up should be
* delivered to. Example: Bob reacts to Alice's note, a WakeUpEvent about
* Bob's reaction p-tags Bob — but it's Alice's device that needs to wake
* up to process the reaction.
*
* WakeUpEvents reach this device through transport-level routing (push,
* relay subscription). By the time one lands in [LocalCache], it is
* already "for us" — so every logged-in signing account is a valid
* recipient to kick the relay wakeup on behalf of. Returning true here
* means the dispatcher invokes [com.vitorpamplona.quartz.experimental.
* notifications.wake.WakeUpEvent]-handling for each logged-in account,
* which is fine because keeping relay connections alive is idempotent.
*/
override fun notifies(userHex: HexKey): Boolean = true
companion object {
const val KIND = 23903
const val ALT_DESCRIPTION = "WakeUp"
@@ -45,6 +45,27 @@ open class Event(
*/
open fun isContentEncoded() = false
/**
* Returns true when this event is intended to notify [userHex].
*
* The default uses the lowercase `p` tag as the notification channel,
* which is the convention for most kinds that address a single recipient
* or a set of mentions (NIP-01 mentions, NIP-04/17 DMs, NIP-25 reactions,
* NIP-28 chat messages, NIP-34 git issues/patches, NIP-57 zap receipts,
* NIP-68 pictures, NIP-71 videos, NIP-84 highlights, NIP-AC calls, chess,
* WakeUps, wiki/long-form/poll mentions).
*
* Subclasses override when the NIP defines additional notification tags
* — e.g. NIP-22 comments use uppercase `P` for the root author in
* addition to lowercase `p` for the direct-reply author.
*/
open fun notifies(userHex: HexKey): Boolean {
for (tag in tags) {
if (tag.size >= 2 && tag[0] == "p" && tag[1] == userHex) return true
}
return false
}
fun toJson(): String = OptimizedJsonMapper.toJson(this)
companion object {
@@ -197,6 +197,15 @@ class CommentEvent(
override fun unmarkedReplyTos() = emptyList<String>()
/**
* NIP-22 addresses two distinct recipients: the direct-reply author
* (lowercase `p` via [ReplyAuthorTag]) and the root-scope author
* (uppercase `P` via [RootAuthorTag]). A comment several levels deep
* only tags the root author with uppercase `P`, so the base-class
* lowercase-only default would miss them.
*/
override fun notifies(userHex: HexKey): Boolean = super.notifies(userHex) || rootAuthorKeys().contains(userHex)
override fun replyingTo(): HexKey? =
tags.lastNotNullOfOrNull(ReplyEventTag::parseKey)
?: tags.lastNotNullOfOrNull(RootEventTag::parseKey)