From 3c6b2bec9fec13ead05a82e7bc553a617485150c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 01:26:50 +0000 Subject: [PATCH 1/4] fix: make WakeUp events actually deliver notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WakeUp notification path had three latent bugs that caused pushed WakeUps to silently not deliver anything to the user: - WakeUpEvent.build() tagged the about event's AUTHOR as the recipient (via EventHintBundle.toPTag()), so a WakeUp about a zap from Alice to Bob would p-tag Alice. The consumer matches recipients by p-tag, so Bob — the intended recipient — was filtered out. Forward the about event's audience (its own p-tags) instead. - wakeUpFor() passed the WakeUp's own note to EventFinderQueryState. The finder's filterMissingEvents only queries for the note itself (already in cache) or its replyTo (empty because computeReplyTo had no WakeUp case). The referenced events were never REQ'd on relays. Link the referenced events via computeReplyTo and subscribe the finder on each referenced note directly so filterMissingEvents picks them up. - UserFinder was subscribed against the WakeUp's own pubKey (typically a push bot), not the author of the event the notification is about. Pull author pubkeys from the e-tag author hint, falling back to the WakeUp signer only when the hint is absent. Also: bound e-tag count per WakeUp to 16 to prevent subscription flooding, log the 30s timeout, extract WAKEUP_WINDOW_MS constant, drop the now-unused Note parameter from wakeUpFor. --- .../amethyst/model/LocalCache.kt | 6 ++ .../EventNotificationConsumer.kt | 69 +++++++++++++++---- .../notifications/wake/WakeUpEvent.kt | 8 ++- 3 files changed, 66 insertions(+), 17 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 2254da21f..6fcfc444b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -923,6 +923,12 @@ object LocalCache : ILocalCache, ICacheProvider { event.taggedAddresses().map { getOrCreateAddressableNote(it) } } + is WakeUpEvent -> { + // Link the referenced events so filterMissingEvents will query + // for them when the WakeUp note is in an EventFinder subscription. + event.eventIds().mapNotNull { checkGetOrCreateNote(it) } + } + is ChannelMessageEvent -> { event.tagsWithoutCitations().filter { it != event.channelId() }.mapNotNull { checkGetOrCreateNote(it) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index e40adf983..62dfa359d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -89,6 +89,11 @@ class EventNotificationConsumer( ) { companion object { private const val WAKELOCK_TIMEOUT_MS = 10 * 60 * 1000L // 10 minutes + private const val WAKEUP_WINDOW_MS = 30_000L + + // Upper bound on referenced events we'll chase per WakeUp. Guards against + // a malicious sender opening hundreds of subscriptions per wake-up. + private const val MAX_WAKEUP_REFS = 16 } /** @@ -162,7 +167,7 @@ class EventNotificationConsumer( } is WakeUpEvent -> { - wakeUpFor(event, LocalCache.getOrCreateNote(event.id), account) + wakeUpFor(event, account) return } } @@ -185,40 +190,74 @@ class EventNotificationConsumer( suspend fun wakeUpFor( event: WakeUpEvent, - note: Note, account: Account, ) { + // A WakeUp's whole purpose is the events it references. If it carries + // none, there's nothing to fetch — skip the 30s subscription window. + val referencedTags = + event + .events() + .distinctBy { it.eventId } + .take(MAX_WAKEUP_REFS) + if (referencedTags.isEmpty()) { + Log.d(TAG) { "WakeUp ${event.id} has no referenced events — skipping" } + return + } + + // The referenced event's author is who the user will see in the final + // notification ("Alice zapped you"). The WakeUp's own pubKey is typically + // a push bot and not useful. Fall back to it only when the `e` tag omits + // the author hint. + val referencedNotes = referencedTags.map { LocalCache.getOrCreateNote(it.eventId) } + val authorCandidates = + referencedTags + .mapNotNull { it.author } + .distinct() + .ifEmpty { listOf(event.pubKey) } + .map { LocalCache.getOrCreateUser(it) } + coroutineScope { // keeps the relay connection active for 30 seconds. launch { try { - withTimeout(30_000L) { + withTimeout(WAKEUP_WINDOW_MS) { Amethyst.instance.relayProxyClientConnector.relayServices .collect() } } catch (_: TimeoutCancellationException) { + Log.d(TAG) { "WakeUp ${event.id} — ${WAKEUP_WINDOW_MS}ms relay window elapsed" } } } - // keeps the subscription to download this event active for 30 seconds. + // keeps subscriptions active for 30 seconds so EventFinder can pull + // the referenced events from relays and UserFinder can resolve the + // referenced authors' metadata. launch { val accountState = ScreenAuthAccount(account) - val eventState = EventFinderQueryState(note, account) - val authorState = UserFinderQueryState(note.author ?: LocalCache.getOrCreateUser(event.pubKey), account) + val eventStates = referencedNotes.map { EventFinderQueryState(it, account) } + val authorStates = authorCandidates.map { UserFinderQueryState(it, account) } try { Amethyst.instance.authCoordinator.subscribe(accountState) - Amethyst.instance.sources.eventFinder - .subscribe(eventState) - Amethyst.instance.sources.userFinder - .subscribe(authorState) - delay(30_000) + eventStates.forEach { + Amethyst.instance.sources.eventFinder + .subscribe(it) + } + authorStates.forEach { + Amethyst.instance.sources.userFinder + .subscribe(it) + } + delay(WAKEUP_WINDOW_MS) } finally { Amethyst.instance.authCoordinator.unsubscribe(accountState) - Amethyst.instance.sources.eventFinder - .unsubscribe(eventState) - Amethyst.instance.sources.userFinder - .unsubscribe(authorState) + eventStates.forEach { + Amethyst.instance.sources.eventFinder + .unsubscribe(it) + } + authorStates.forEach { + Amethyst.instance.sources.userFinder + .unsubscribe(it) + } } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/notifications/wake/WakeUpEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/notifications/wake/WakeUpEvent.kt index 7019d3355..925efbf85 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/notifications/wake/WakeUpEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/notifications/wake/WakeUpEvent.kt @@ -35,7 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.kinds.kind import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag.Companion.parse import com.vitorpamplona.quartz.nip01Core.tags.people.PTag.Companion.parseKey -import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag +import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.serialization.json.JsonNull.content @@ -68,6 +68,10 @@ class WakeUpEvent( const val KIND = 23903 const val ALT_DESCRIPTION = "WakeUp" + // Tags the audience of [about] (its `p` tags) as the recipients to wake up. + // The about event's author is NOT tagged: a WakeUp about a zap from Alice + // to Bob should notify Bob, not Alice. Callers can add more recipients via + // [initializer]. fun build( about: EventHintBundle, createdAt: Long = TimeUtils.now(), @@ -75,7 +79,7 @@ class WakeUpEvent( ) = eventTemplate(KIND, content, createdAt) { alt(ALT_DESCRIPTION) about(about) - notify(about.toPTag()) + notify(about.event.tags.taggedUsers()) kind(about.event.kind) initializer() } From 9574f4b68d00cbb9911cb805a45f4cc567453994 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 01:54:48 +0000 Subject: [PATCH 2/4] fix: align WakeUp handling with spec semantics (p-tags = authors) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A previous commit misread the spec: p-tags on a WakeUp identify the AUTHORS of the referenced events (the people whose events are the subject of the wake-up), not the recipients. The consumer's existing npub-in-p-tag match is therefore correct — "is this logged-in account the author of a referenced event?". - Revert WakeUpEvent.build() back to notify(about.toPTag()) and replace the comment with a spec-accurate one. - In wakeUpFor, source author pubkeys from event.authorKeys() (p-tags, canonical) first, merge in the e-tag author hints, and fall back to the WakeUp signer only when both are empty. Bound by MAX_WAKEUP_REFS. computeReplyTo and the referenced-event fetch path remain untouched — those fixes are orthogonal to the p-tag semantic. --- .../notifications/EventNotificationConsumer.kt | 15 ++++++++------- .../notifications/wake/WakeUpEvent.kt | 12 ++++++------ 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 62dfa359d..c64f4005a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -204,16 +204,17 @@ class EventNotificationConsumer( return } - // The referenced event's author is who the user will see in the final - // notification ("Alice zapped you"). The WakeUp's own pubKey is typically - // a push bot and not useful. Fall back to it only when the `e` tag omits - // the author hint. + // Per spec, p-tags on a WakeUp are the authors of the referenced + // events; those are whose metadata we need to render the notification. + // Fall back to e-tag author hints and finally to the WakeUp signer. val referencedNotes = referencedTags.map { LocalCache.getOrCreateNote(it.eventId) } - val authorCandidates = - referencedTags - .mapNotNull { it.author } + val authorKeys = + (event.authorKeys() + referencedTags.mapNotNull { it.author }) .distinct() .ifEmpty { listOf(event.pubKey) } + val authorCandidates = + authorKeys + .take(MAX_WAKEUP_REFS) .map { LocalCache.getOrCreateUser(it) } coroutineScope { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/notifications/wake/WakeUpEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/notifications/wake/WakeUpEvent.kt index 925efbf85..5fc6ee712 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/notifications/wake/WakeUpEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/notifications/wake/WakeUpEvent.kt @@ -35,7 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.kinds.kind import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag.Companion.parse import com.vitorpamplona.quartz.nip01Core.tags.people.PTag.Companion.parseKey -import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers +import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.serialization.json.JsonNull.content @@ -68,10 +68,10 @@ class WakeUpEvent( const val KIND = 23903 const val ALT_DESCRIPTION = "WakeUp" - // Tags the audience of [about] (its `p` tags) as the recipients to wake up. - // The about event's author is NOT tagged: a WakeUp about a zap from Alice - // to Bob should notify Bob, not Alice. Callers can add more recipients via - // [initializer]. + // p-tags on a WakeUp identify the AUTHORS of the referenced events — + // the people whose events are the subject of the wake-up and who should + // come online to handle new activity on them. Callers can add extra + // e/p tags via [initializer] when waking up about multiple events. fun build( about: EventHintBundle, createdAt: Long = TimeUtils.now(), @@ -79,7 +79,7 @@ class WakeUpEvent( ) = eventTemplate(KIND, content, createdAt) { alt(ALT_DESCRIPTION) about(about) - notify(about.event.tags.taggedUsers()) + notify(about.toPTag()) kind(about.event.kind) initializer() } From ae6ed9ecb28e3d10c345d440d90c992164aac929 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 02:04:59 +0000 Subject: [PATCH 3/4] chore: drop MAX_WAKEUP_REFS cap in wakeUpFor Filter assemblers already handle batching and sizing; capping here adds nothing. --- .../notifications/EventNotificationConsumer.kt | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index c64f4005a..ca76dbec0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -90,10 +90,6 @@ class EventNotificationConsumer( companion object { private const val WAKELOCK_TIMEOUT_MS = 10 * 60 * 1000L // 10 minutes private const val WAKEUP_WINDOW_MS = 30_000L - - // Upper bound on referenced events we'll chase per WakeUp. Guards against - // a malicious sender opening hundreds of subscriptions per wake-up. - private const val MAX_WAKEUP_REFS = 16 } /** @@ -194,11 +190,7 @@ class EventNotificationConsumer( ) { // A WakeUp's whole purpose is the events it references. If it carries // none, there's nothing to fetch — skip the 30s subscription window. - val referencedTags = - event - .events() - .distinctBy { it.eventId } - .take(MAX_WAKEUP_REFS) + val referencedTags = event.events().distinctBy { it.eventId } if (referencedTags.isEmpty()) { Log.d(TAG) { "WakeUp ${event.id} has no referenced events — skipping" } return @@ -208,13 +200,10 @@ class EventNotificationConsumer( // events; those are whose metadata we need to render the notification. // Fall back to e-tag author hints and finally to the WakeUp signer. val referencedNotes = referencedTags.map { LocalCache.getOrCreateNote(it.eventId) } - val authorKeys = + val authorCandidates = (event.authorKeys() + referencedTags.mapNotNull { it.author }) .distinct() .ifEmpty { listOf(event.pubKey) } - val authorCandidates = - authorKeys - .take(MAX_WAKEUP_REFS) .map { LocalCache.getOrCreateUser(it) } coroutineScope { From 5b1be8ded5b2e2839b03cb0a97505fce89597ba3 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 23 Apr 2026 22:10:18 -0400 Subject: [PATCH 4/4] Maybe 300 weight is better for these fonts --- .../amethyst/commons/icons/symbols/MaterialSymbolsDefaults.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolsDefaults.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolsDefaults.kt index a41303cea..b6774a7be 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolsDefaults.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolsDefaults.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.commons.icons.symbols // Single source of truth for Material Symbols visual axes. Change WEIGHT here to globally // re-tune line thickness (100 = Thin, 200 = ExtraLight, 300 = Light, 400 = Regular, …, 700 = Bold). object MaterialSymbolsDefaults { - const val WEIGHT: Int = 100 + const val WEIGHT: Int = 300 const val FILL: Float = 0f const val OPTICAL_SIZE: Float = 24f const val GRADE: Float = 0f