diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index f75d0f8c3e..124e578ad0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -64,7 +64,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEven import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent -import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent @@ -80,6 +80,7 @@ import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import kotlinx.coroutines.flow.MutableStateFlow @@ -120,42 +121,47 @@ class NotificationFeedFilter( ) val NOTIFICATION_KINDS = - // The core subscription kinds are shared with Desktop through - // `commons/.../moderation/notifications/NotificationKinds.SUBSCRIPTION_KINDS` - // so a change on either platform automatically propagates. - // Android-only extras (badge awards, git issues/patches/PRs, - // highlights, polls, videos, voice, public messages, - // live-activities chat) stay here because Desktop has no - // rendering for those kinds today. - // - // GiftWrap (1059) is subscription-only: the wrap is an envelope whose - // created_at is randomized up to 2 days back (NIP-59), so rendering it - // as a feed row would misorder the tab with undecryptable entries. On - // Android the unwrapped inner event (kind 14/15/…) is what notifies — - // same rule NotificationDispatcher applies to push. Desktop keeps the - // wrap in its inbox because it has no unwrap pipeline there yet. - ( - com.vitorpamplona.amethyst.commons.moderation.notifications.NotificationKinds - .SUBSCRIPTION_KINDS - .toSet() - GiftWrapEvent.KIND - ) + - setOf( - BadgeAwardEvent.KIND, - GitIssueEvent.KIND, - GitPatchEvent.KIND, - GitPullRequestEvent.KIND, - GitPullRequestUpdateEvent.KIND, - HighlightEvent.KIND, - LiveActivitiesChatMessageEvent.KIND, - PictureEvent.KIND, - PollEvent.KIND, - ZapPollEvent.KIND, - PublicMessageEvent.KIND, - VideoNormalEvent.KIND, - VideoShortEvent.KIND, - VoiceEvent.KIND, - VoiceReplyEvent.KIND, - ) + ADDRESSABLE_KINDS + // Kinds that RENDER as a row on the Notifications tab. This is a + // display gate over whatever is already in LocalCache — it plays no + // part in relay subscriptions (those live in + // FilterNotificationsToPubkey, the chat datasources, and the wallet + // assembler). It deliberately does NOT share Desktop's + // `NotificationKinds.SUBSCRIPTION_KINDS`: that list answers "what to + // ask relays for / toast on" and includes envelope kinds like + // GiftWrap (1059), whose created_at is randomized up to 2 days back + // (NIP-59). Envelopes never render here — the unwrapped inner event + // (kind 14/15/…) is the feed row, same rule NotificationDispatcher + // applies to push. NotificationKindsContractTest pins the + // relationship between the two lists. + setOf( + BadgeAwardEvent.KIND, + ChannelMessageEvent.KIND, + ChatMessageEvent.KIND, + ChatMessageEncryptedFileHeaderEvent.KIND, + CommentEvent.KIND, + GenericRepostEvent.KIND, + GitIssueEvent.KIND, + GitPatchEvent.KIND, + GitPullRequestEvent.KIND, + GitPullRequestUpdateEvent.KIND, + HighlightEvent.KIND, + TextNoteEvent.KIND, + ReactionEvent.KIND, + RepostEvent.KIND, + LnZapEvent.KIND, + NutzapEvent.KIND, + OnchainZapEvent.KIND, + LiveActivitiesChatMessageEvent.KIND, + PictureEvent.KIND, + PollEvent.KIND, + ZapPollEvent.KIND, + PrivateDmEvent.KIND, + PublicMessageEvent.KIND, + VideoNormalEvent.KIND, + VideoShortEvent.KIND, + VoiceEvent.KIND, + 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 diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationKindsContractTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationKindsContractTest.kt new file mode 100644 index 0000000000..0e49022cf9 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationKindsContractTest.kt @@ -0,0 +1,76 @@ +/* + * 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.moderation.notifications.NotificationKinds +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pins the relationship between the two independently-maintained kind lists: + * + * - `NotificationKinds.SUBSCRIPTION_KINDS` (commons) — what Desktop asks + * relays for and toasts on. May contain ENVELOPE kinds (gift wraps) whose + * created_at is randomized per NIP-59, because Desktop surfaces the wrap + * itself as a DM row. + * - `NotificationFeedFilter.NOTIFICATION_KINDS` (Android) — what renders as + * a row on the Notifications tab. Envelopes must never appear here; the + * unwrapped inner event is the row. + * + * The lists were briefly coupled (NOTIFICATION_KINDS spread + * SUBSCRIPTION_KINDS), which silently pulled the kind-1059 wrap into the + * Android feed. They are now maintained separately; this test is the tripwire + * that keeps them from drifting apart unintentionally in either direction. + */ +class NotificationKindsContractTest { + private val envelopeKinds = setOf(GiftWrapEvent.KIND, EphemeralGiftWrapEvent.KIND) + + @Test + fun `envelope kinds never render on the Android notifications tab`() { + val leaked = envelopeKinds.intersect(NotificationFeedFilter.NOTIFICATION_KINDS.toSet()) + assertTrue( + "Envelope kinds $leaked are in NOTIFICATION_KINDS. Wraps have a " + + "randomized created_at (NIP-59) and no decryptable payload to render — " + + "the unwrapped inner event is the feed row. If a new envelope kind is " + + "intentional, unwrap it instead of displaying it.", + leaked.isEmpty(), + ) + } + + @Test + fun `every kind desktop notifies on is displayable on Android or a known envelope`() { + val unaccounted = + NotificationKinds.SUBSCRIPTION_KINDS.toSet() - + NotificationFeedFilter.NOTIFICATION_KINDS.toSet() - + envelopeKinds + + assertTrue( + "Kinds $unaccounted were added to the shared SUBSCRIPTION_KINDS but are " + + "neither displayable on the Android notifications tab nor a known " + + "envelope kind. Either add them to NOTIFICATION_KINDS (if Android " + + "should render them) or to envelopeKinds in this test (if they only " + + "deliver an inner payload).", + unaccounted.isEmpty(), + ) + } +}