From ecedc4affe97eb38b473aa64869dc9cf991826f8 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 3 Jul 2026 10:33:03 +0300 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20notifications=20redesign=20?= =?UTF-8?q?=E2=80=94=20inbox=20UX,=20native=20OS=20toasts,=20shared=20filt?= =?UTF-8?q?er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the Amethyst Desktop notification experience end-to-end. **In-app inbox** (`desktopApp/…/ui/NotificationsScreen.kt`) - Dedicated Notifications entry in the sidebar and a new `DeckColumnType.NotificationSettings` overlay reachable from a ⚙ button in the column header — back button renders automatically via `navState.hasBackStack` in deck mode and via body Back in single-pane. - Redesigned column: filter tabs (All / Mentions / Replies / Reactions / Zaps / Reposts / DMs) with per-kind counts, grouped cards (reactions and reposts collapse to "N reactions on your post" per day), unread dots driven by a persisted `lastReadAt` per pubkey, freshest-first ordering via `compareByDescending { timestamp }`. - User metadata: avatars + display names on every row (including reactor strip inside grouped cards) resolved from `LocalCache`, with `metadataVersion` observation. Zap sender is the actual zapper (via `NotificationItem.effectiveAuthorPubKey` unwrapping `LnZapEvent.zapRequest.pubKey`), not the LNURL provider. - Reaction/repost group cards are clickable → thread; DM cards click → Messages column; expandable to show note preview + reactor list. - `NotificationSettingsScreen`: master toggle, 7 per-kind toggles, manual-DND dropdown, preview-privacy switch, per-platform status card, "Send a test toast". Permission-aware button adapts across NotRequested → Granted / Denied / BundleRequired with a macOS System Settings deep-link. State syncs with OS-level changes via `LocalWindowInfo.isWindowFocused` regain refresh. **Native OS notifications** (`commons/…/moderation/notifications/`) - `NotificationDispatcher` interface + `PermissionState` sealed hierarchy in `commonMain`. JVM impl `NucleusNotificationDispatcher` routes through Nucleus (three per-OS artifacts: macOS `UNUserNotificationCenter` via Swift/JNI, Windows WinRT toast via JNI, Linux libnotify via D-Bus). Falls back to `AwtTrayNotifier` when native lib fails to load. Async `requestPermission` + `refreshPermission` bridge Nucleus's callback API to `suspend`. - `DesktopNotificationAutoDispatcher` subscribes to `DesktopLocalCache.eventStream.newEventBundles` and fires OS toasts, applying a 9-check suppression pipeline: kind allow-list, master toggle, per-kind toggle, DND, window-focused, cold-boot (event.createdAt < sessionStart or >30s stale), macOS permission, semantic accept, 30s per-(kind,event-id) dedupe. Wired in Main.kt with DisposableEffect(loggedIn.pubKeyHex); window focus tracked via LocalWindowInfo → StateFlow. - Adds `windows { menu = true; shortcut = true }` to `desktopApp/build.gradle.kts` so AUMID persists and Windows toasts survive reboot. **Shared notification filter** (`commons/…/moderation/notifications/NotificationKinds.kt`) - Extracted from Android's `NotificationFeedFilter`. Exposes `SUBSCRIPTION_KINDS` (13 kinds: text, DMs kind 4 + 14 + 1059 gift-wrap, encrypted-file-header, comments 1111, reactions, reposts, generic reposts, channel messages 42, nutzaps 9321, zap receipts 9735, onchain zaps 8333), `subscriptionFilter(pubKey, since, limit)` builder that `FilterBuilders.notificationsForUser` delegates to, and `tagsAnEventForUser(event, myPubKey, isTargetAuthoredByMe)` semantic gate. Reactions/reposts require target-author-match; other kinds require `p=me`. Fixes a bug where the helper defaulted to accept and let cache-seed leak "mentioned you" notifications from unrelated text notes. - Android's `NotificationFeedFilter.NOTIFICATION_KINDS` now spreads `SUBSCRIPTION_KINDS` + Android-only extras (badges, git, highlights, polls, videos, voice, live-activities), so a change on either side propagates. Downstream push consumers (`NotificationDispatcher.kt`, `EventNotificationConsumer.kt`) read the resulting set transparently. - Content sanitizer strips control chars, RTL overrides, zero-width chars, and URLs from toast titles. DM cards never render ciphertext body (decryption pipeline deferred). **Tests** — `NotificationKindsTest` covers 17 scenarios: reactions target-author-mismatch rejection, own-event rejection except zap kinds, p-tag routing for text/DMs/zaps/nutzaps/gift-wraps/channel messages, `SUBSCRIPTION_KINDS` sanity + `subscriptionFilter` shape. **Testing constraint**: macOS OS notifications require a bundled process. `gradle run` will always show BundleRequired — use `gradle :desktopApp:runDistributable` and open the resulting `Amethyst.app`. First permission grant surfaces the app in System Settings → Notifications. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../dal/NotificationFeedFilter.kt | 58 +- commons/build.gradle.kts | 8 + .../notifications/NotificationDispatcher.kt | 101 +++ .../notifications/NotificationKinds.kt | 151 ++++ .../notifications/NotificationReadState.kt | 33 + .../notifications/NotificationTypes.kt | 92 +++ .../moderation/notifications/Sanitizer.kt | 47 ++ .../notifications/SuppressionPipeline.kt | 97 +++ .../notifications/NotificationKindsTest.kt | 328 ++++++++ .../notifications/AwtTrayNotifier.kt | 212 +++++ .../NucleusNotificationDispatcher.kt | 296 +++++++ .../PreferencesNotificationReadState.kt | 52 ++ .../PreferencesNotificationSettings.kt | 103 +++ desktopApp/build.gradle.kts | 5 + .../vitorpamplona/amethyst/desktop/Main.kt | 83 ++ .../desktop/subscriptions/FilterBuilders.kt | 30 +- .../desktop/ui/NotificationsScreen.kt | 752 +++++++++++++++--- .../amethyst/desktop/ui/deck/AppDrawer.kt | 1 + .../amethyst/desktop/ui/deck/ColumnHeader.kt | 1 + .../desktop/ui/deck/DeckColumnContainer.kt | 28 +- .../desktop/ui/deck/DeckColumnType.kt | 4 + .../amethyst/desktop/ui/deck/DeckLayout.kt | 4 + .../amethyst/desktop/ui/deck/DeckSidebar.kt | 1 + .../desktop/ui/deck/SinglePaneLayout.kt | 2 + .../DesktopNotificationAutoDispatcher.kt | 240 ++++++ .../NotificationCompositionLocals.kt | 37 + .../ui/notifications/NotificationFilter.kt | 49 ++ .../ui/notifications/NotificationGroup.kt | 144 ++++ .../ui/settings/NotificationSettingsScreen.kt | 532 +++++++++++++ .../desktop/filters/FilterBuildersTest.kt | 21 +- 30 files changed, 3357 insertions(+), 155 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationDispatcher.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationKinds.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationReadState.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationTypes.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/Sanitizer.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/SuppressionPipeline.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationKindsTest.kt create mode 100644 commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/AwtTrayNotifier.kt create mode 100644 commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NucleusNotificationDispatcher.kt create mode 100644 commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/PreferencesNotificationReadState.kt create mode 100644 commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/PreferencesNotificationSettings.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/notifications/DesktopNotificationAutoDispatcher.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/notifications/NotificationCompositionLocals.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/notifications/NotificationFilter.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/notifications/NotificationGroup.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/NotificationSettingsScreen.kt 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 9840680ee9..f10921bd06 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,6 @@ 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.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,7 +79,6 @@ 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 @@ -121,35 +119,33 @@ class NotificationFeedFilter( ) val NOTIFICATION_KINDS = - 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 + // 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. + com.vitorpamplona.amethyst.commons.moderation.notifications.NotificationKinds + .SUBSCRIPTION_KINDS + .toSet() + + 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 // 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/commons/build.gradle.kts b/commons/build.gradle.kts index 4c0d5532bd..8182a3604b 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -191,6 +191,14 @@ kotlin { // Image re-encode + progressive downscale (used by service/upload/ImageReencoder). // Pure-Java, MIT. See docs/plans/2026-06-08-feat-desktop-image-compression-plan.md. implementation(libs.thumbnailator) + + // Native OS notification bridges — Nucleus per-OS JNI shims. + // macOS: UNUserNotificationCenter. Windows: WinRT Toasts. Linux: freedesktop D-Bus. + // Only the matching-OS module's native lib loads at runtime; the others + // stay dormant on the classpath. + implementation("io.github.kdroidfilter:nucleus.notification-macos:1.15.7") + implementation("io.github.kdroidfilter:nucleus.notification-windows:1.15.7") + implementation("io.github.kdroidfilter:nucleus.notification-linux:1.15.7") } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationDispatcher.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationDispatcher.kt new file mode 100644 index 0000000000..a99cb9a327 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationDispatcher.kt @@ -0,0 +1,101 @@ +/* + * 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.commons.moderation.notifications + +import androidx.compose.runtime.Immutable +import kotlinx.coroutines.flow.StateFlow + +/** + * Platform-neutral OS notification dispatch API. JVM implementation delegates + * to Nucleus (macOS UNUserNotificationCenter / Windows WinRT / Linux libnotify). + * Future Android/iOS actuals implement the same contract. + */ +interface NotificationDispatcher { + val permission: StateFlow + + /** + * Whether the native pipeline is available at all. On unbundled macOS + * runs (`./gradlew run` without `.app`), Nucleus reports false because + * `NSBundle.mainBundle.bundleIdentifier` is nil. Sends will fall back + * to the AWT balloon. + */ + val nativeAvailable: StateFlow + + /** + * Trigger the OS-level permission prompt (macOS only — no-op elsewhere). + * Suspends until the user answers. Updates [permission] as a side effect. + */ + suspend fun requestPermission(): PermissionState + + /** + * Re-read the current OS permission state (no prompt). Useful after the + * user toggled Amethyst in System Settings → Notifications while the app + * was running — call this on window focus / settings screen open to keep + * the UI in sync. No-op on non-macOS. + */ + suspend fun refreshPermission(): PermissionState + + suspend fun send(spec: NotificationSpec): SendResult + + /** Release any held OS resources (tray icon, JNI callbacks). */ + fun release() +} + +@Immutable +sealed interface PermissionState { + object NotRequested : PermissionState + + object Granted : PermissionState + + object Denied : PermissionState + + /** Windows/Linux: no OS-level permission prompt needed. */ + object NotApplicable : PermissionState + + /** macOS but running unbundled — user must `runDistributable`. */ + object BundleRequired : PermissionState +} + +@Immutable +data class NotificationSpec( + val title: String, + val body: String, + val kind: NotifKind, + /** OS grouping key (macOS `threadIdentifier`, Windows Tag+Group). */ + val threadId: String? = null, + /** Amethyst event id for deep-linking from a click. */ + val deepLinkNoteId: String? = null, +) + +sealed interface SendResult { + object Delivered : SendResult + + data class Suppressed( + val reason: String, + ) : SendResult + + data class Failed( + val error: Throwable, + ) : SendResult + + /** No native pipeline; fell back to AWT balloon (still delivered on Win/Linux; drops on macOS 11+). */ + object DeliveredViaFallback : SendResult +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationKinds.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationKinds.kt new file mode 100644 index 0000000000..ee651383e9 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationKinds.kt @@ -0,0 +1,151 @@ +/* + * 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.commons.moderation.notifications + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +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.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent + +/** + * Nostr event kinds that can generate a notification when they tag the + * logged-in user via a `p` tag or otherwise reference them. + * + * The subscription filter is intentionally broader than what actually gets + * shown in the notification inbox — server-side we ask relays for anything + * that tags us, then client-side [tagsAnEventForUser] applies semantic + * checks (e.g. a reaction only counts if it targets one of the user's own + * notes, not just tags them). + * + * Shared between the Android and Desktop notification pipelines. Extracted + * from Android's `NotificationFeedFilter.NOTIFICATION_KINDS`. + */ +object NotificationKinds { + /** + * Kinds subscribed to via `p`-tag filter for notification delivery. + * Keep in sync with the Android `NOTIFICATION_KINDS` set (see + * `amethyst/.../notifications/dal/NotificationFeedFilter.kt`). + */ + val SUBSCRIPTION_KINDS: List = + listOf( + TextNoteEvent.KIND, // 1 — mentions + replies + PrivateDmEvent.KIND, // 4 — NIP-04 legacy DM + RepostEvent.KIND, // 6 + ReactionEvent.KIND, // 7 + ChatMessageEvent.KIND, // 14 — NIP-17 DM rumor (rarely arrives raw; gift-wrap is more common) + GenericRepostEvent.KIND, // 16 + ChannelMessageEvent.KIND, // 42 — NIP-28 public channel + CommentEvent.KIND, // 1111 — NIP-22 threaded comment + GiftWrapEvent.KIND, // 1059 — NIP-17 gift-wrapped DM + NutzapEvent.KIND, // 9321 — NIP-61 Cashu nutzap + LnZapEvent.KIND, // 9735 — NIP-57 zap receipt + OnchainZapEvent.KIND, // 8333 — onchain zap + // NIP-17 file-header messages (encrypted file DMs) + ChatMessageEncryptedFileHeaderEvent.KIND, + ) + + /** + * Builds the standard notifications-for-user filter. + * @param since Optional Unix seconds to gate `since` on the relay filter. + * @param limit Optional server-side result cap. + */ + fun subscriptionFilter( + pubKeyHex: HexKey, + limit: Int? = null, + since: Long? = null, + ): Filter = + Filter( + kinds = SUBSCRIPTION_KINDS, + tags = mapOf("p" to listOf(pubKeyHex)), + limit = limit, + since = since, + ) + + /** + * Client-side semantic check. An event that arrives via the subscription + * filter is not automatically a notification — for example a reaction + * with a stray `p` tag on our pubkey but no `e` tag on one of our notes + * doesn't belong in the inbox. + * + * @param event The event to check. + * @param myPubKeyHex Logged-in user's pubkey. + * @param isTargetAuthoredByMe For reaction/repost events, whether the + * targeted note (via `e` tag) is authored by the current user. + * Callers pass a lambda that peeks into the local cache. Pass + * `false` if unknown — the check will still catch obvious matches. + */ + fun tagsAnEventForUser( + event: Event, + myPubKeyHex: HexKey, + isTargetAuthoredByMe: (targetNoteId: HexKey) -> Boolean = { false }, + ): Boolean { + // Own events never notify — except zap receipts (LnZap/Nutzap/Onchain) + // which are signed by the LNURL provider or the payer, not by us. + if (event.pubKey == myPubKeyHex && + event !is LnZapEvent && + event !is NutzapEvent && + event !is OnchainZapEvent + ) { + return false + } + + // Reactions and reposts require the target note to be authored by + // the current user. A stray `p=me` tag on a stranger's reaction to + // a stranger's note is NOT a notification. + if (event is ReactionEvent || event is RepostEvent || event is GenericRepostEvent) { + val target = + event.tags + .firstOrNull { it.size > 1 && it[0] == "e" } + ?.get(1) + return target != null && isTargetAuthoredByMe(target) + } + + // Everything else must actually `p`-tag the current user. We can't + // trust an implicit "the relay filter already narrowed to p=me" + // assumption because this helper is also called from the cache-seed + // pass on inbox open, which walks EVERY note the app has ever + // received (from home feed, thread views, discover, etc.) — not + // just events that came in via the notifications subscription. + // + // Android's equivalent (`NotificationFeedFilter.acceptableEvent` at + // line 334) applies `isTaggedUser` outside `tagsAnEventByUser`; our + // shared helper folds both checks into one so it's safe to call + // from any ingest point. + return isPTaggedUser(event, myPubKeyHex) + } + + private fun isPTaggedUser( + event: Event, + myPubKeyHex: HexKey, + ): Boolean = event.tags.any { it.size > 1 && it[0] == "p" && it[1] == myPubKeyHex } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationReadState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationReadState.kt new file mode 100644 index 0000000000..2e7983102e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationReadState.kt @@ -0,0 +1,33 @@ +/* + * 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.commons.moderation.notifications + +import kotlinx.coroutines.flow.StateFlow + +/** + * Per-account "last time the notification inbox was viewed" tracker. + * Never regresses to a smaller value. + */ +interface NotificationReadState { + val lastReadAt: StateFlow + + fun markAsRead(epochSec: Long) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationTypes.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationTypes.kt new file mode 100644 index 0000000000..bc3d5177d6 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationTypes.kt @@ -0,0 +1,92 @@ +/* + * 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.commons.moderation.notifications + +import androidx.compose.runtime.Immutable +import kotlinx.coroutines.flow.StateFlow + +enum class NotifKind { ZAP, DM, REPLY, MENTION, REPOST, REACTION, FOLLOW } + +@Immutable +data class KindToggles( + val zap: Boolean = true, + // DM defaults OFF until NIP-17 gift-wrap decryption is wired. + val dm: Boolean = false, + val reply: Boolean = true, + val mention: Boolean = true, + val repost: Boolean = false, + val reaction: Boolean = false, + val follow: Boolean = false, +) { + fun enabledFor(kind: NotifKind): Boolean = + when (kind) { + NotifKind.ZAP -> zap + NotifKind.DM -> dm + NotifKind.REPLY -> reply + NotifKind.MENTION -> mention + NotifKind.REPOST -> repost + NotifKind.REACTION -> reaction + NotifKind.FOLLOW -> follow + } + + fun with( + kind: NotifKind, + v: Boolean, + ): KindToggles = + when (kind) { + NotifKind.ZAP -> copy(zap = v) + NotifKind.DM -> copy(dm = v) + NotifKind.REPLY -> copy(reply = v) + NotifKind.MENTION -> copy(mention = v) + NotifKind.REPOST -> copy(repost = v) + NotifKind.REACTION -> copy(reaction = v) + NotifKind.FOLLOW -> copy(follow = v) + } +} + +@Immutable +data class DndState( + val manualUntilEpochSec: Long? = null, +) { + fun isActive(nowEpochSec: Long): Boolean = manualUntilEpochSec != null && nowEpochSec < manualUntilEpochSec +} + +/** + * Read-only view of notification settings. Concrete implementations live in + * platform source sets (JVM uses java.util.prefs). + */ +interface NotificationSettings { + val enabled: StateFlow + val kinds: StateFlow + val dnd: StateFlow + val previewInToast: StateFlow + + fun setEnabled(v: Boolean) + + fun setKindToggle( + kind: NotifKind, + v: Boolean, + ) + + fun setManualDndUntil(epochSec: Long?) + + fun setPreviewInToast(v: Boolean) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/Sanitizer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/Sanitizer.kt new file mode 100644 index 0000000000..d2fd4927fc --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/Sanitizer.kt @@ -0,0 +1,47 @@ +/* + * 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.commons.moderation.notifications + +private val CONTROL_CHARS = Regex("\\p{Cntrl}") +private val RTL_OVERRIDES = Regex("[‪-‮⁦-⁩]") +private val ZERO_WIDTH = Regex("[​-‍]") +private val WHITESPACE = Regex("\\s+") +private val URL_PATTERN = Regex("https?://\\S+") + +fun sanitizeForToast( + raw: String, + maxLen: Int = 120, +): String = + raw + .replace(CONTROL_CHARS, " ") + .replace(RTL_OVERRIDES, "") + .replace(ZERO_WIDTH, "") + .replace(WHITESPACE, " ") + .trim() + .take(maxLen) + +fun sanitizeTitleForToast( + displayName: String, + maxLen: Int = 60, +): String = + sanitizeForToast(displayName, maxLen) + .replace(URL_PATTERN, "") + .trim() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/SuppressionPipeline.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/SuppressionPipeline.kt new file mode 100644 index 0000000000..02d7211e4d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/SuppressionPipeline.kt @@ -0,0 +1,97 @@ +/* + * 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.commons.moderation.notifications + +import androidx.compose.runtime.Immutable + +/** Platform-neutral context passed through the suppression pipeline. */ +@Immutable +data class ToastEventCtx( + val kind: NotifKind, + val eventId: String, + val authorPubKey: String, + val targetId: String?, + val createdAtSec: Long, + val signatureValid: Boolean, + val zapAmountSats: Long? = null, +) + +/** + * Decides whether an incoming event should trigger an OS notification. + * Checks are ordered so security filters run before any string formatting + * or logging, and cheapest checks short-circuit first. + * + * Not thread-safe by design — call from a single collector coroutine. + */ +class SuppressionPipeline( + private val settings: NotificationSettings, + private val ownPubKey: () -> String?, + private val isMuted: (String) -> Boolean, + private val sessionStartSec: Long, + private val isWindowFocused: () -> Boolean, + private val nowSec: () -> Long, + private val coldBootFreshnessSec: Long = 30, + private val dedupeWindowSec: Long = 30, + private val dedupeMemorySec: Long = 300, +) { + private val recentToasts = HashMap() + + fun shouldToast(ctx: ToastEventCtx): Boolean { + // Security first — never format / log muted or unsigned content. + if (!ctx.signatureValid) return false + if (isMuted(ctx.authorPubKey)) return false + + if (!settings.enabled.value) return false + if (!settings.kinds.value.enabledFor(ctx.kind)) return false + + val now = nowSec() + if (settings.dnd.value.isActive(now)) return false + if (isWindowFocused()) return false + if (isColdBoot(ctx, now)) return false + if (ctx.authorPubKey == ownPubKey()) return false + if (isDuplicate(ctx, now)) return false + + return true + } + + private fun isColdBoot( + ctx: ToastEventCtx, + now: Long, + ): Boolean { + val stale = (now - ctx.createdAtSec) > coldBootFreshnessSec + val preSession = ctx.createdAtSec < sessionStartSec + return stale || preSession + } + + private fun isDuplicate( + ctx: ToastEventCtx, + now: Long, + ): Boolean { + val key = ctx.kind.name + "|" + (ctx.targetId ?: ctx.eventId) + // Bound the map — drop entries older than the memory window. + val cutoff = now - dedupeMemorySec + recentToasts.entries.removeAll { it.value < cutoff } + val last = recentToasts[key] + if (last != null && (now - last) < dedupeWindowSec) return true + recentToasts[key] = now + return false + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationKindsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationKindsTest.kt new file mode 100644 index 0000000000..52ce93b5e1 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationKindsTest.kt @@ -0,0 +1,328 @@ +/* + * 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.commons.moderation.notifications + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class NotificationKindsTest { + private val me = "me_pubkey_hex".padEnd(64, '0') + private val alice = "alice_pubkey_hex".padEnd(64, '0') + private val bob = "bob_pubkey_hex".padEnd(64, '0') + private val myNoteId = "my_note_id".padEnd(64, '0') + private val strangerNoteId = "stranger_note_id".padEnd(64, '0') + private val someSig = "sig".padEnd(128, '0') + + private fun reaction( + author: String, + pTag: String? = me, + eTag: String? = myNoteId, + content: String = "+", + ): ReactionEvent { + val tags = + listOfNotNull( + eTag?.let { arrayOf("e", it) }, + pTag?.let { arrayOf("p", it) }, + ).toTypedArray() + return ReactionEvent( + id = "rid".padEnd(64, '0'), + pubKey = author, + createdAt = 1_000, + tags = tags, + content = content, + sig = someSig, + ) + } + + private fun repost( + author: String, + pTag: String? = me, + eTag: String? = myNoteId, + ): RepostEvent { + val tags = + listOfNotNull( + eTag?.let { arrayOf("e", it) }, + pTag?.let { arrayOf("p", it) }, + ).toTypedArray() + return RepostEvent( + id = "rpost".padEnd(64, '0'), + pubKey = author, + createdAt = 1_000, + tags = tags, + content = "", + sig = someSig, + ) + } + + private fun textNote( + author: String, + pTag: String? = me, + content: String = "hi", + ): TextNoteEvent { + val tags = listOfNotNull(pTag?.let { arrayOf("p", it) }).toTypedArray() + return TextNoteEvent( + id = "tn".padEnd(64, '0'), + pubKey = author, + createdAt = 1_000, + tags = tags, + content = content, + sig = someSig, + ) + } + + private fun comment( + author: String, + pTag: String? = me, + ): CommentEvent { + val tags = listOfNotNull(pTag?.let { arrayOf("p", it) }).toTypedArray() + return CommentEvent( + id = "cmt".padEnd(64, '0'), + pubKey = author, + createdAt = 1_000, + tags = tags, + content = "reply", + sig = someSig, + ) + } + + private fun zapReceipt(pTag: String = me): LnZapEvent = + LnZapEvent( + id = "zap".padEnd(64, '0'), + pubKey = "lnurl_provider".padEnd(64, '0'), + createdAt = 1_000, + tags = arrayOf(arrayOf("p", pTag)), + content = "", + sig = someSig, + ) + + private fun nutzap( + author: String, + pTag: String = me, + ): NutzapEvent = + NutzapEvent( + id = "nut".padEnd(64, '0'), + pubKey = author, + createdAt = 1_000, + tags = arrayOf(arrayOf("p", pTag)), + content = "", + sig = someSig, + ) + + private fun privateDm( + author: String, + pTag: String = me, + ): PrivateDmEvent = + PrivateDmEvent( + id = "dm".padEnd(64, '0'), + pubKey = author, + createdAt = 1_000, + tags = arrayOf(arrayOf("p", pTag)), + content = "ciphertext", + sig = someSig, + ) + + private fun giftWrap(pTag: String = me): GiftWrapEvent = + GiftWrapEvent( + id = "gw".padEnd(64, '0'), + pubKey = "ephemeral".padEnd(64, '0'), + createdAt = 1_000, + tags = arrayOf(arrayOf("p", pTag)), + content = "wrapped", + sig = someSig, + ) + + private fun channelMsg( + author: String, + pTag: String? = me, + ): ChannelMessageEvent { + val tags = listOfNotNull(pTag?.let { arrayOf("p", it) }).toTypedArray() + return ChannelMessageEvent( + id = "cm".padEnd(64, '0'), + pubKey = author, + createdAt = 1_000, + tags = tags, + content = "hi channel", + sig = someSig, + ) + } + + private fun acceptsFor( + event: Event, + isMyNote: (String) -> Boolean = { it == myNoteId }, + ): Boolean = NotificationKinds.tagsAnEventForUser(event, me, isMyNote) + + // Reactions -------------------------------------------------------------- + + @Test + fun reactionWithPMeButTargetAuthoredByStrangerRejected() { + val e = reaction(author = alice, pTag = me, eTag = strangerNoteId) + assertFalse(acceptsFor(e)) + } + + @Test + fun reactionTargetingMyNoteAccepted() { + val e = reaction(author = alice, pTag = me, eTag = myNoteId) + assertTrue(acceptsFor(e)) + } + + @Test + fun reactionWithNoETagRejected() { + val e = reaction(author = alice, pTag = me, eTag = null) + assertFalse(acceptsFor(e)) + } + + // Reposts ---------------------------------------------------------------- + + @Test + fun repostTargetingMyNoteAccepted() { + val e = repost(author = alice, pTag = me, eTag = myNoteId) + assertTrue(acceptsFor(e)) + } + + @Test + fun repostTargetingStrangerNoteRejected() { + val e = repost(author = alice, pTag = me, eTag = strangerNoteId) + assertFalse(acceptsFor(e)) + } + + // Text notes ------------------------------------------------------------- + + @Test + fun textNoteWithPMeAccepted() { + assertTrue(acceptsFor(textNote(author = alice, pTag = me))) + } + + @Test + fun textNoteWithoutPMeRejected() { + // Text notes must actually p-tag the user. The helper cannot trust + // an upstream filter here because it's also called from the + // cache-seed pass which walks every note in localCache regardless + // of subscription source. + assertFalse(acceptsFor(textNote(author = alice, pTag = null))) + } + + @Test + fun textNoteWithPTagPointingAtStrangerRejected() { + // Someone else was p-tagged; not us. + val stranger = "stranger_pubkey".padEnd(64, '0') + assertFalse(acceptsFor(textNote(author = alice, pTag = stranger))) + } + + @Test + fun channelMessageWithoutPMeRejected() { + assertFalse(acceptsFor(channelMsg(author = alice, pTag = null))) + } + + @Test + fun textNoteAuthoredByMeRejected() { + // Own events don't notify. + assertFalse(acceptsFor(textNote(author = me, pTag = me))) + } + + // Comments --------------------------------------------------------------- + + @Test + fun commentWithPMeAccepted() { + assertTrue(acceptsFor(comment(author = alice, pTag = me))) + } + + // Zaps + Nutzaps --------------------------------------------------------- + + @Test + fun zapReceiptAccepted() { + assertTrue(acceptsFor(zapReceipt(pTag = me))) + } + + @Test + fun myOwnZapReceiptStillAccepted() { + // LnZap receipts are signed by the LNURL provider, not by us, + // so `pubKey == me` never applies — but even if it did the + // helper explicitly allows LnZap/Nutzap/OnchainZap own-events. + assertTrue(acceptsFor(zapReceipt(pTag = me))) + } + + @Test + fun nutzapAccepted() { + assertTrue(acceptsFor(nutzap(author = bob, pTag = me))) + } + + // DMs -------------------------------------------------------------------- + + @Test + fun privateDmAccepted() { + assertTrue(acceptsFor(privateDm(author = alice))) + } + + @Test + fun giftWrapAccepted() { + assertTrue(acceptsFor(giftWrap())) + } + + // Channel messages ------------------------------------------------------- + + @Test + fun channelMessageWithPMeAccepted() { + assertTrue(acceptsFor(channelMsg(author = alice, pTag = me))) + } + + // SUBSCRIPTION_KINDS sanity --------------------------------------------- + + @Test + fun subscriptionKindsCoversAllExpectedEventKinds() { + val expected = + setOf( + TextNoteEvent.KIND, + PrivateDmEvent.KIND, + RepostEvent.KIND, + ReactionEvent.KIND, + GenericRepostEvent.KIND, + ChannelMessageEvent.KIND, + CommentEvent.KIND, + GiftWrapEvent.KIND, + NutzapEvent.KIND, + LnZapEvent.KIND, + ) + val actual = NotificationKinds.SUBSCRIPTION_KINDS.toSet() + assertTrue(actual.containsAll(expected), "expected subset missing from SUBSCRIPTION_KINDS") + } + + @Test + fun subscriptionFilterUsesPTagAndSubscriptionKinds() { + val filter = NotificationKinds.subscriptionFilter(me, limit = 50, since = 1_000) + assertEquals(NotificationKinds.SUBSCRIPTION_KINDS, filter.kinds) + assertEquals(listOf(me), filter.tags?.get("p")) + assertEquals(50, filter.limit) + assertEquals(1_000L, filter.since) + } +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/AwtTrayNotifier.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/AwtTrayNotifier.kt new file mode 100644 index 0000000000..ac41985acb --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/AwtTrayNotifier.kt @@ -0,0 +1,212 @@ +/* + * 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.commons.moderation.notifications + +import java.awt.AWTException +import java.awt.SystemTray +import java.awt.Toolkit +import java.awt.TrayIcon + +/** + * Cross-platform AWT-based OS notification bridge. Uses the platform system + * tray to display transient balloon/notification messages. + * + * Platform behavior: + * - **Windows**: renders as a toast in the Notification Center + * - **Linux (GNOME/KDE)**: routes through the desktop notification daemon + * (libnotify) if a system tray is available; otherwise silently drops + * - **macOS**: displays as a top-right corner notification when a proper + * application bundle is used; unsigned `java -jar` builds may silently + * drop toasts. Requires `jpackage` output or `runDistributable` for + * reliable delivery. + * + * This class installs a single hidden tray icon at first use and reuses it + * for every subsequent notification. Callers should use one shared instance + * per app. + */ +class AwtTrayNotifier( + private val appLabel: String = "Amethyst", + private val iconBytes: () -> ByteArray? = { null }, +) { + private val supported: Boolean = SystemTray.isSupported() + + @Volatile + private var trayIcon: TrayIcon? = null + + val isSupported: Boolean get() = supported + + fun send( + title: String, + message: String, + type: MessageType = MessageType.INFO, + ): Result { + if (!supported) return Result.failure(IllegalStateException("System tray not supported")) + return try { + val icon = ensureTrayIcon() ?: return Result.failure(IllegalStateException("Failed to install tray icon")) + icon.displayMessage(title, message, type.toAwt()) + Result.success(Unit) + } catch (e: SecurityException) { + Result.failure(e) + } catch (e: AWTException) { + Result.failure(e) + } + } + + fun release() { + val icon = trayIcon ?: return + try { + SystemTray.getSystemTray().remove(icon) + } catch (_: Exception) { + // Best-effort cleanup. + } + trayIcon = null + } + + private fun ensureTrayIcon(): TrayIcon? { + trayIcon?.let { return it } + return try { + val tray = SystemTray.getSystemTray() + val bytes = iconBytes() + val image = + if (bytes != null) { + Toolkit.getDefaultToolkit().createImage(bytes) + } else { + // 1x1 transparent PNG — enough to satisfy AWT's non-null + // requirement without adding a visible icon in trays that + // render everything (Windows). macOS + most Linux daemons + // ignore the icon body when displayMessage is called. + Toolkit.getDefaultToolkit().createImage(EMPTY_ICON_PNG) + } + val icon = TrayIcon(image, appLabel).apply { isImageAutoSize = true } + tray.add(icon) + trayIcon = icon + icon + } catch (_: UnsupportedOperationException) { + null + } catch (_: AWTException) { + null + } + } + + enum class MessageType { + INFO, + WARNING, + ERROR, + NONE, + ; + + fun toAwt(): TrayIcon.MessageType = + when (this) { + INFO -> TrayIcon.MessageType.INFO + WARNING -> TrayIcon.MessageType.WARNING + ERROR -> TrayIcon.MessageType.ERROR + NONE -> TrayIcon.MessageType.NONE + } + } + + private companion object { + // 1x1 transparent PNG (67 bytes). + @JvmStatic + val EMPTY_ICON_PNG: ByteArray = + byteArrayOf( + 0x89.toByte(), + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A, + 0x00, + 0x00, + 0x00, + 0x0D, + 0x49, + 0x48, + 0x44, + 0x52, + 0x00, + 0x00, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x01, + 0x08, + 0x06, + 0x00, + 0x00, + 0x00, + 0x1F, + 0x15, + 0xC4.toByte(), + 0x89.toByte(), + 0x00, + 0x00, + 0x00, + 0x0D, + 0x49, + 0x44, + 0x41, + 0x54, + 0x78, + 0x9C.toByte(), + 0x62, + 0x00, + 0x01, + 0x00, + 0x00, + 0x05, + 0x00, + 0x01, + 0x0D, + 0x0A, + 0x2D, + 0xB4.toByte(), + 0x00, + 0x00, + 0x00, + 0x00, + 0x49, + 0x45, + 0x4E, + 0x44, + 0xAE.toByte(), + 0x42, + 0x60, + 0x82.toByte(), + ) + } +} + +/** OS the JVM is running on. Used by the UI to surface platform-specific caveats. */ +enum class HostOs { MAC, WINDOWS, LINUX, UNKNOWN } + +fun detectHostOs(): HostOs { + val name = System.getProperty("os.name")?.lowercase() ?: return HostOs.UNKNOWN + return when { + name.contains("mac") || name.contains("darwin") -> HostOs.MAC + name.contains("win") -> HostOs.WINDOWS + name.contains("nux") || name.contains("nix") -> HostOs.LINUX + else -> HostOs.UNKNOWN + } +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NucleusNotificationDispatcher.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NucleusNotificationDispatcher.kt new file mode 100644 index 0000000000..3f14d2f00a --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NucleusNotificationDispatcher.kt @@ -0,0 +1,296 @@ +/* + * 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.commons.moderation.notifications + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import java.util.UUID +import kotlin.coroutines.resume + +/** + * Native-backed [NotificationDispatcher] using Nucleus (kdroidfilter/Nucleus). + * + * - **macOS**: routes through `UNUserNotificationCenter` via a Swift JNI shim + * bundled inside `nucleus.notification-macos`. Requires an `.app` bundle at + * runtime — see [PermissionState.BundleRequired]. + * - **Windows**: routes through WinRT Toast via `nucleus.notification-windows`. + * Requires an AUMID; call [init] before any [send]. + * - **Linux**: routes through freedesktop D-Bus via `nucleus.notification-linux`. + * Works with any notification daemon. + * + * If the matching native library fails to load or the app is unbundled on + * macOS, [send] falls back to [AwtTrayNotifier]. + */ +class NucleusNotificationDispatcher( + private val bundleId: String, + private val appLabel: String, + private val fallback: AwtTrayNotifier, + @Suppress("unused") private val scope: CoroutineScope, +) : NotificationDispatcher { + private val host: HostOs = detectHostOs() + + // Nucleus module availability probed once at construction. Native lib load + // is triggered by the first class access — wrap in Throwable catch so a + // missing JAR or unsatisfied link doesn't crash the app. + private val _nativeAvailable = MutableStateFlow(probeNative()) + override val nativeAvailable: StateFlow = _nativeAvailable.asStateFlow() + + private val _permission = MutableStateFlow(initialPermissionState()) + override val permission: StateFlow = _permission.asStateFlow() + + @Volatile + private var winInitialized: Boolean = false + + init { + // Refresh macOS permission from the OS in case the user already granted + // in a previous session. On unbundled runs, Nucleus reports isAvailable + // = false and we won't touch the OS API. + if (host == HostOs.MAC && _nativeAvailable.value) { + try { + refreshMacPermissionState() + } catch (_: Throwable) { + // Bundle-invalid or callback plumbing failure. Leave state as + // BundleRequired / NotRequested. User can still trigger the + // request from settings. + } + } + } + + private fun initialPermissionState(): PermissionState = + when (host) { + HostOs.MAC -> if (_nativeAvailable.value) PermissionState.NotRequested else PermissionState.BundleRequired + HostOs.WINDOWS, HostOs.LINUX -> + if (_nativeAvailable.value) PermissionState.NotApplicable else PermissionState.Denied + HostOs.UNKNOWN -> PermissionState.NotApplicable + } + + override suspend fun requestPermission(): PermissionState { + if (host != HostOs.MAC) { + _permission.value = + if (_nativeAvailable.value) PermissionState.NotApplicable else PermissionState.Denied + return _permission.value + } + if (!_nativeAvailable.value) { + _permission.value = PermissionState.BundleRequired + return _permission.value + } + val granted = + withContext(Dispatchers.IO) { + suspendCancellableCoroutine { cont -> + try { + io.github.kdroidfilter.nucleus.notification.NotificationCenter + .requestAuthorization( + options = + setOf( + io.github.kdroidfilter.nucleus.notification.AuthorizationOption.ALERT, + io.github.kdroidfilter.nucleus.notification.AuthorizationOption.SOUND, + io.github.kdroidfilter.nucleus.notification.AuthorizationOption.BADGE, + ), + callback = { granted, _ -> cont.resume(granted) }, + ) + } catch (t: Throwable) { + cont.resume(false) + } + } + } + _permission.value = + if (granted) PermissionState.Granted else PermissionState.Denied + return _permission.value + } + + override suspend fun refreshPermission(): PermissionState { + if (host != HostOs.MAC || !_nativeAvailable.value) { + return _permission.value + } + // getNotificationSettings is callback-based; bridge to suspend + timeout. + val newState = + withContext(Dispatchers.IO) { + try { + suspendCancellableCoroutine { cont -> + try { + io.github.kdroidfilter.nucleus.notification.NotificationCenter.getNotificationSettings { settings -> + val mapped = + when (settings.authorizationStatus) { + io.github.kdroidfilter.nucleus.notification.AuthorizationStatus.AUTHORIZED, + io.github.kdroidfilter.nucleus.notification.AuthorizationStatus.PROVISIONAL, + io.github.kdroidfilter.nucleus.notification.AuthorizationStatus.EPHEMERAL, + -> PermissionState.Granted + io.github.kdroidfilter.nucleus.notification.AuthorizationStatus.DENIED -> PermissionState.Denied + io.github.kdroidfilter.nucleus.notification.AuthorizationStatus.NOT_DETERMINED -> PermissionState.NotRequested + } + if (cont.isActive) cont.resume(mapped) + } + } catch (t: Throwable) { + if (cont.isActive) cont.resume(PermissionState.BundleRequired) + } + } + } catch (_: Throwable) { + _permission.value + } + } + _permission.value = newState + return newState + } + + override suspend fun send(spec: NotificationSpec): SendResult { + if (!_nativeAvailable.value) { + return runFallback(spec, reason = "native pipeline unavailable") + } + if (host == HostOs.MAC && _permission.value !is PermissionState.Granted) { + return SendResult.Suppressed("macOS notifications not authorized") + } + return try { + withContext(Dispatchers.IO) { + when (host) { + HostOs.MAC -> sendMac(spec) + HostOs.WINDOWS -> sendWindows(spec) + HostOs.LINUX -> sendLinux(spec) + HostOs.UNKNOWN -> runFallback(spec, reason = "unknown platform") + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + SendResult.Failed(e) + } + } + + override fun release() { + try { + fallback.release() + } catch (_: Throwable) { + } + } + + // ---------- macOS ---------- + + private fun refreshMacPermissionState() { + io.github.kdroidfilter.nucleus.notification.NotificationCenter.getNotificationSettings { settings -> + _permission.value = + when (settings.authorizationStatus) { + io.github.kdroidfilter.nucleus.notification.AuthorizationStatus.AUTHORIZED, + io.github.kdroidfilter.nucleus.notification.AuthorizationStatus.PROVISIONAL, + io.github.kdroidfilter.nucleus.notification.AuthorizationStatus.EPHEMERAL, + -> PermissionState.Granted + io.github.kdroidfilter.nucleus.notification.AuthorizationStatus.DENIED -> PermissionState.Denied + io.github.kdroidfilter.nucleus.notification.AuthorizationStatus.NOT_DETERMINED -> PermissionState.NotRequested + } + } + } + + private fun sendMac(spec: NotificationSpec): SendResult { + val content = + io.github.kdroidfilter.nucleus.notification.NotificationContent( + title = spec.title, + body = spec.body, + sound = io.github.kdroidfilter.nucleus.notification.NotificationSound.Default, + userInfo = + buildMap { + spec.deepLinkNoteId?.let { put("noteId", it) } + put("kind", spec.kind.name) + }, + threadIdentifier = spec.threadId.orEmpty(), + ) + val request = + io.github.kdroidfilter.nucleus.notification.NotificationRequest( + identifier = UUID.randomUUID().toString(), + content = content, + ) + io.github.kdroidfilter.nucleus.notification.NotificationCenter + .add(request) + return SendResult.Delivered + } + + // ---------- Windows ---------- + + private fun ensureWindowsInitialized() { + if (winInitialized) return + try { + io.github.kdroidfilter.nucleus.notification.windows.WindowsNotificationCenter + .initialize(aumid = bundleId, appName = appLabel) + winInitialized = true + } catch (_: Throwable) { + // Best-effort; showSimple will surface any hard failure. + } + } + + private fun sendWindows(spec: NotificationSpec): SendResult { + ensureWindowsInitialized() + io.github.kdroidfilter.nucleus.notification.windows.WindowsNotificationCenter.showSimple( + title = spec.title, + body = spec.body, + group = spec.threadId.orEmpty(), + ) + return SendResult.Delivered + } + + // ---------- Linux ---------- + + private fun sendLinux(spec: NotificationSpec): SendResult { + val notification = + io.github.kdroidfilter.nucleus.notification.linux.Notification( + appName = appLabel, + summary = spec.title, + body = spec.body, + ) + val id = + io.github.kdroidfilter.nucleus.notification.linux.LinuxNotificationCenter + .notify(notification) + return if (id > 0) SendResult.Delivered else SendResult.Failed(IllegalStateException("libnotify returned id=0")) + } + + // ---------- Fallback ---------- + + private fun runFallback( + spec: NotificationSpec, + reason: String, + ): SendResult { + val result = fallback.send(spec.title, spec.body) + return result.fold( + onSuccess = { SendResult.DeliveredViaFallback }, + onFailure = { SendResult.Failed(IllegalStateException("$reason; AWT fallback also failed: ${it.message}", it)) }, + ) + } + + // ---------- Native probe ---------- + + private fun probeNative(): Boolean = + try { + when (host) { + HostOs.MAC -> + io.github.kdroidfilter.nucleus.notification.NotificationCenter.isAvailable + HostOs.WINDOWS -> + io.github.kdroidfilter.nucleus.notification.windows.WindowsNotificationCenter.isAvailable + HostOs.LINUX -> + io.github.kdroidfilter.nucleus.notification.linux.LinuxNotificationCenter.isAvailable + HostOs.UNKNOWN -> false + } + } catch (_: Throwable) { + // ClassNotFoundError, NoClassDefFoundError, UnsatisfiedLinkError, etc. + false + } +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/PreferencesNotificationReadState.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/PreferencesNotificationReadState.kt new file mode 100644 index 0000000000..bc0d860c7e --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/PreferencesNotificationReadState.kt @@ -0,0 +1,52 @@ +/* + * 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.commons.moderation.notifications + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import java.util.prefs.Preferences + +/** + * JVM implementation of [NotificationReadState] backed by java.util.prefs. + * Keyed by full pubkey hex (no truncation) to avoid cross-account collisions. + */ +class PreferencesNotificationReadState( + private val pubKeyHex: String, + private val prefs: Preferences = Preferences.userRoot().node(NODE), +) : NotificationReadState { + private val _lastReadAt = MutableStateFlow(prefs.getLong(pubKeyHex, 0L)) + + override val lastReadAt: StateFlow = _lastReadAt.asStateFlow() + + override fun markAsRead(epochSec: Long) { + _lastReadAt.update { current -> + val next = maxOf(current, epochSec) + if (next != current) prefs.putLong(pubKeyHex, next) + next + } + } + + companion object { + const val NODE = "com/vitorpamplona/amethyst/notifications/lastread" + } +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/PreferencesNotificationSettings.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/PreferencesNotificationSettings.kt new file mode 100644 index 0000000000..349fab12ea --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/PreferencesNotificationSettings.kt @@ -0,0 +1,103 @@ +/* + * 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.commons.moderation.notifications + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.prefs.Preferences + +/** + * java.util.prefs-backed [NotificationSettings]. Node path is stable across + * `amy` CLI and the desktop app so both see the same toggles. + */ +class PreferencesNotificationSettings( + private val prefs: Preferences = Preferences.userRoot().node(NODE), +) : NotificationSettings { + // Default OFF so first-time users see the "Set up" banner. They opt in + // explicitly, which is important because notification delivery has real + // platform quirks (macOS bundle-signing, DE-specific behavior on Linux). + private val _enabled = MutableStateFlow(prefs.getBoolean(KEY_ENABLED, false)) + private val _kinds = MutableStateFlow(loadKinds()) + private val _dnd = + MutableStateFlow( + DndState( + manualUntilEpochSec = prefs.getLong(KEY_DND_UNTIL, 0L).takeIf { it > 0 }, + ), + ) + private val _previewInToast = MutableStateFlow(prefs.getBoolean(KEY_PREVIEW, false)) + + override val enabled: StateFlow = _enabled.asStateFlow() + override val kinds: StateFlow = _kinds.asStateFlow() + override val dnd: StateFlow = _dnd.asStateFlow() + override val previewInToast: StateFlow = _previewInToast.asStateFlow() + + override fun setEnabled(v: Boolean) { + _enabled.value = v + prefs.putBoolean(KEY_ENABLED, v) + } + + override fun setKindToggle( + kind: NotifKind, + v: Boolean, + ) { + val next = _kinds.value.with(kind, v) + _kinds.value = next + prefs.putBoolean(keyForKind(kind), v) + } + + override fun setManualDndUntil(epochSec: Long?) { + _dnd.value = DndState(epochSec) + if (epochSec == null || epochSec <= 0) { + prefs.remove(KEY_DND_UNTIL) + } else { + prefs.putLong(KEY_DND_UNTIL, epochSec) + } + } + + override fun setPreviewInToast(v: Boolean) { + _previewInToast.value = v + prefs.putBoolean(KEY_PREVIEW, v) + } + + private fun loadKinds(): KindToggles = + KindToggles( + zap = prefs.getBoolean(keyForKind(NotifKind.ZAP), true), + dm = prefs.getBoolean(keyForKind(NotifKind.DM), false), + reply = prefs.getBoolean(keyForKind(NotifKind.REPLY), true), + mention = prefs.getBoolean(keyForKind(NotifKind.MENTION), true), + repost = prefs.getBoolean(keyForKind(NotifKind.REPOST), false), + reaction = prefs.getBoolean(keyForKind(NotifKind.REACTION), false), + follow = prefs.getBoolean(keyForKind(NotifKind.FOLLOW), false), + ) + + companion object { + const val NODE = "com/vitorpamplona/amethyst/notifications" + private const val KEY_ENABLED = "enabled" + private const val KEY_DND_UNTIL = "dnd_until" + private const val KEY_PREVIEW = "preview_in_toast" + + private fun keyForKind(kind: NotifKind): String = "kind_" + kind.name.lowercase() + } +} + +/** Wall-clock seconds. JVM-only until KMP targets need cross-platform time. */ +fun nowEpochSeconds(): Long = System.currentTimeMillis() / 1000 diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index b10a599656..23c43ccd43 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -182,6 +182,11 @@ compose.desktop { iconFile.set(project.file("src/jvmMain/resources/icon.ico")) menuGroup = "Amethyst" upgradeUuid = "A1B2C3D4-E5F6-7890-ABCD-EF1234567890" + // AUMID persistence for Windows Toast notifications — without a + // Start Menu shortcut the AUMID isn't registered and toasts + // disappear from Windows Settings → Notifications after reboot. + menu = true + shortcut = true } linux { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index dfc908106b..b909baedb5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -190,6 +190,8 @@ sealed class DesktopScreen { data object Drafts : DesktopScreen() + data object NotificationSettings : DesktopScreen() + data object Settings : DesktopScreen() data object LocalRelaySettings : DesktopScreen() @@ -1062,11 +1064,78 @@ private fun AppInner( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background, ) { + // OS notification dispatcher: Nucleus-backed with AWT fallback. + // Constructed once for the app lifetime; native lib loads + // lazily on first requestPermission / send. + val notifDispatcherScope = rememberCoroutineScope() + val notifDispatcher = + remember { + com.vitorpamplona.amethyst.commons.moderation.notifications + .NucleusNotificationDispatcher( + bundleId = "com.vitorpamplona.amethyst.desktop", + appLabel = "Amethyst", + fallback = + com.vitorpamplona.amethyst.commons.moderation.notifications + .AwtTrayNotifier(), + scope = notifDispatcherScope, + ) + } + DisposableEffect(notifDispatcher) { + onDispose { notifDispatcher.release() } + } + + // Window-focus tracking for the auto-dispatcher's suppression rule. + val windowInfo = androidx.compose.ui.platform.LocalWindowInfo.current + val isWindowFocusedFlow = + remember { + kotlinx.coroutines.flow.MutableStateFlow(windowInfo.isWindowFocused) + } + LaunchedEffect(windowInfo) { + androidx.compose.runtime + .snapshotFlow { windowInfo.isWindowFocused } + .collect { isWindowFocusedFlow.value = it } + } + + // Session start — used by the auto-dispatcher to reject cold-boot backfill. + val notifSessionStartSec = + remember { + com.vitorpamplona.amethyst.commons.moderation.notifications + .nowEpochSeconds() + } + + // Auto-dispatcher: subscribes to newEventBundles and fires OS toasts. + // Only starts once the user is logged in — pubKey and settings must exist. + val loggedIn = accountState as? AccountState.LoggedIn + val notifSettings = + remember { + com.vitorpamplona.amethyst.commons.moderation.notifications + .PreferencesNotificationSettings() + } + DisposableEffect(loggedIn?.pubKeyHex, notifDispatcher, localCache) { + val myPk = loggedIn?.pubKeyHex + val autoDispatcherJob = + if (myPk != null) { + com.vitorpamplona.amethyst.desktop.ui.notifications + .DesktopNotificationAutoDispatcher( + dispatcher = notifDispatcher, + settings = notifSettings, + myPubKeyHex = myPk, + localCache = localCache, + isWindowFocused = isWindowFocusedFlow, + sessionStartSec = notifSessionStartSec, + scope = notifDispatcherScope, + ).start() + } else { + null + } + onDispose { autoDispatcherJob?.cancel() } + } CompositionLocalProvider( com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache provides localCache, com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager provides relayManager, com.vitorpamplona.amethyst.desktop.ui.deck.LocalLocalRelayStore provides localRelayStore, LocalHashtagSpamSettings provides hashtagSpamSettings, + com.vitorpamplona.amethyst.desktop.ui.notifications.LocalNotificationDispatcher provides notifDispatcher, ) { when (accountState) { is AccountState.Loading -> { @@ -1772,6 +1841,20 @@ fun MainContent( deckState.addColumn(DeckColumnType.Relays) } }, + onOpenNotificationSettings = { + if (deckState.hasColumnOfType(DeckColumnType.NotificationSettings)) { + deckState.focusExistingColumn(DeckColumnType.NotificationSettings) + } else { + deckState.addColumn(DeckColumnType.NotificationSettings) + } + }, + onOpenMessages = { + if (deckState.hasColumnOfType(DeckColumnType.Messages)) { + deckState.focusExistingColumn(DeckColumnType.Messages) + } else { + deckState.addColumn(DeckColumnType.Messages) + } + }, modifier = Modifier.weight(1f), ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt index 3b7d215fe0..64eae951a9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt @@ -126,14 +126,15 @@ object FilterBuilders { ) /** - * Creates a filter for notifications (mentions, replies, reactions, reposts, zaps) for a user. + * Notification subscription filter — shared across Desktop and Android via + * [com.vitorpamplona.amethyst.commons.moderation.notifications.NotificationKinds]. + * Covers 13 event kinds: text notes, comments, DMs (NIP-04 + NIP-17 gift-wrap), + * reactions, reposts, channel messages, nutzaps, zap receipts, onchain zaps. * - * Includes: - * - kind 1 (text notes mentioning user) - * - kind 7 (reactions) - * - kind 6 (reposts) - * - kind 16 (generic reposts) - * - kind 9735 (zaps) + * The subscription filter is intentionally broad — client-side + * [com.vitorpamplona.amethyst.commons.moderation.notifications.NotificationKinds.tagsAnEventForUser] + * applies the semantic accept rule (e.g. reactions must target one of the + * user's own notes). * * @param pubKeyHex User public key (hex-encoded, 64 chars) to filter notifications for * @param limit Maximum number of events to request @@ -145,19 +146,8 @@ object FilterBuilders { limit: Int? = null, since: Long? = null, ): Filter = - Filter( - kinds = - listOf( - 1, // TextNoteEvent.KIND (mentions/replies) - 7, // ReactionEvent.KIND - 6, // RepostEvent.KIND - 16, // GenericRepostEvent.KIND - 9735, // LnZapEvent.KIND - ), - tags = mapOf("p" to listOf(pubKeyHex)), - limit = limit, - since = since, - ) + com.vitorpamplona.amethyst.commons.moderation.notifications.NotificationKinds + .subscriptionFilter(pubKeyHex, limit = limit, since = since) /** * Creates a filter for specific event kinds. diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt index 48cf624036..be0adf4ad5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt @@ -20,6 +20,10 @@ */ package com.vitorpamplona.amethyst.desktop.ui +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -31,14 +35,19 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -46,6 +55,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.Reply @@ -53,9 +63,14 @@ import com.vitorpamplona.amethyst.commons.icons.Repost import com.vitorpamplona.amethyst.commons.icons.Zap import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPainter +import com.vitorpamplona.amethyst.commons.moderation.notifications.NotificationKinds +import com.vitorpamplona.amethyst.commons.moderation.notifications.PreferencesNotificationReadState +import com.vitorpamplona.amethyst.commons.moderation.notifications.PreferencesNotificationSettings +import com.vitorpamplona.amethyst.commons.moderation.notifications.nowEpochSeconds import com.vitorpamplona.amethyst.commons.state.EventCollectionState import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState +import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager @@ -63,14 +78,26 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createNotificationsSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText +import com.vitorpamplona.amethyst.desktop.ui.notifications.LocalNotificationReadState +import com.vitorpamplona.amethyst.desktop.ui.notifications.LocalNotificationSettings +import com.vitorpamplona.amethyst.desktop.ui.notifications.NotificationFilter +import com.vitorpamplona.amethyst.desktop.ui.notifications.NotificationGroup +import com.vitorpamplona.amethyst.desktop.ui.notifications.groupNotifications import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull +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.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent /** * Notification types for display. @@ -105,14 +132,77 @@ sealed class NotificationItem( override val timestamp: Long, val amount: Long?, ) : NotificationItem(event, timestamp) + + /** + * Encrypted direct message notification. `body` remains empty until the + * decryption pipeline (NIP-04 signer / NIP-17 gift-wrap unwrap) is wired + * — never populate with raw ciphertext. + */ + data class Dm( + override val event: Event, + override val timestamp: Long, + ) : NotificationItem(event, timestamp) } +/** + * The pubkey we should display + fetch metadata for. For NIP-57 zap + * receipts the outer `event.pubKey` is the LNURL provider — the actual + * zap sender lives in the nested zap request. For gift-wrapped DMs + * the outer pubkey is an ephemeral key (not the real sender); until + * decryption lands we fall back to `event.pubKey` which at least gives + * a stable fallback avatar. + */ +val NotificationItem.effectiveAuthorPubKey: String + get() = + when (val e = event) { + is LnZapEvent -> e.zapRequest?.pubKey ?: e.pubKey + else -> event.pubKey + } + +/** + * Route a raw Nostr event to the correct [NotificationItem] variant based + * on kind. Returns null for kinds we don't render — the caller drops those. + */ +private fun classifyNotification(event: Event): NotificationItem? = + when (event) { + is ReactionEvent -> NotificationItem.Reaction(event, event.createdAt, event.content) + is RepostEvent, is GenericRepostEvent -> NotificationItem.Repost(event, event.createdAt) + is LnZapEvent -> NotificationItem.Zap(event, event.createdAt, event.amount?.toLong()) + // Nutzaps: treat like a zap; sats amount extraction requires the Cashu + // token proof and is deferred to when Desktop renders zap detail. + is NutzapEvent -> NotificationItem.Zap(event, event.createdAt, null) + is TextNoteEvent -> { + val isReply = event.tags.any { it.size > 1 && it[0] == "e" } + if (isReply) { + NotificationItem.Reply(event, event.createdAt) + } else { + NotificationItem.Mention(event, event.createdAt) + } + } + // NIP-22 threaded comments are reply-shaped. + is CommentEvent -> NotificationItem.Reply(event, event.createdAt) + // NIP-28 channel messages read like public mentions. + is ChannelMessageEvent -> NotificationItem.Mention(event, event.createdAt) + // DMs (NIP-04 legacy + NIP-17 gift-wrap + rumor + file-header). + is PrivateDmEvent, + is ChatMessageEvent, + is GiftWrapEvent, + is ChatMessageEncryptedFileHeaderEvent, + -> NotificationItem.Dm(event, event.createdAt) + // Unknown kind — drop. + else -> null + } + @Composable fun NotificationsScreen( relayManager: DesktopRelayConnectionManager, localCache: DesktopLocalCache, account: AccountState.LoggedIn, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, + onOpenSettings: () -> Unit = {}, + onNavigateToThread: (String) -> Unit = {}, + onNavigateToProfile: (String) -> Unit = {}, + onOpenMessages: () -> Unit = {}, ) { val relayStatuses by relayManager.relayStatuses.collectAsState() val connectedRelays = relayStatuses.keys @@ -121,49 +211,65 @@ fun NotificationsScreen( remember { EventCollectionState( getId = { it.event.id }, - sortComparator = null, // Prepend new items (no sorting) + // Freshest first. Cannot rely on insertion order because the + // cache-seed pass iterates an unordered Set and events can + // arrive out-of-order across multiple relays. + sortComparator = + compareByDescending { it.timestamp } + .thenBy { it.event.id }, maxSize = 200, scope = scope, ) } val notifications by notificationState.items.collectAsState() - // Seed from cache — reactions, zaps already consumed are in cache + // Read-state: prefer a hoisted instance from App(); fall back to a + // locally-scoped one so the screen still works if no provider is set. + val hoistedReadState = LocalNotificationReadState.current + val readState = + remember(account.pubKeyHex, hoistedReadState) { + hoistedReadState ?: PreferencesNotificationReadState(account.pubKeyHex) + } + val lastReadAt by readState.lastReadAt.collectAsState() + + var activeFilter by remember { mutableStateOf(NotificationFilter.All) } + + val hoistedSettings = LocalNotificationSettings.current + val notifSettings = + remember(hoistedSettings) { hoistedSettings ?: PreferencesNotificationSettings() } + val osToastsEnabled by notifSettings.enabled.collectAsState() + + // Seed from cache — walk every kind in the shared SUBSCRIPTION_KINDS list + // and apply the same semantic accept rule the live subscription uses. + // Ensures a fresh inbox open shows DMs / comments / nutzaps that were + // cached during a previous session. LaunchedEffect(Unit) { val myPubKey = account.pubKeyHex + val kinds = NotificationKinds.SUBSCRIPTION_KINDS.toSet() + val isTargetAuthoredByMe: (String) -> Boolean = { targetId -> + localCache.notes + .get(targetId) + ?.event + ?.pubKey == myPubKey + } val cached = localCache.notes.filterIntoSet { _, note -> val event = note.event ?: return@filterIntoSet false - when (event) { - is ReactionEvent -> event.pubKey != myPubKey - is LnZapEvent -> true - else -> false - } + event.kind in kinds && + NotificationKinds.tagsAnEventForUser(event, myPubKey, isTargetAuthoredByMe) } cached.forEach { note -> val event = note.event ?: return@forEach - val notification = - when (event) { - is ReactionEvent -> { - NotificationItem.Reaction(event, event.createdAt, event.content) - } - - is LnZapEvent -> { - NotificationItem.Zap(event, event.createdAt, event.amount?.toLong()) - } - - else -> { - null - } - } - notification?.let { notificationState.addItem(it) } + classifyNotification(event)?.let { notificationState.addItem(it) } } } - // Load metadata for notification authors via coordinator + // Load metadata for notification authors via coordinator. + // Use effectiveAuthorPubKey so zap receipts fetch the actual zapper's + // profile (nested in the zap request) instead of the LNURL provider's. LaunchedEffect(notifications, subscriptionsCoordinator) { if (subscriptionsCoordinator != null && notifications.isNotEmpty()) { - val pubkeys = notifications.map { it.event.pubKey }.distinct() + val pubkeys = notifications.map { it.effectiveAuthorPubKey }.distinct() subscriptionsCoordinator.loadMetadataForPubkeys(pubkeys) } } @@ -180,52 +286,27 @@ fun NotificationsScreen( pubKeyHex = account.pubKeyHex, onEvent = { event, _, relay, _ -> subscriptionsCoordinator?.consumeEvent(event, relay) - // Skip events from the user themselves (except zaps) - if (event.pubKey == account.pubKeyHex && event !is LnZapEvent) { - return@createNotificationsSubscription - } + + // Semantic accept rule (shared with Android via commons). + // Rejects reactions/reposts that carry a spurious p=me + // tag but don't actually target one of my notes. + val myPubKey = account.pubKeyHex + val accepts = + NotificationKinds.tagsAnEventForUser( + event = event, + myPubKeyHex = myPubKey, + isTargetAuthoredByMe = { targetId -> + localCache.notes + .get(targetId) + ?.event + ?.pubKey == myPubKey + }, + ) + if (!accepts) return@createNotificationsSubscription val notification = - when (event) { - is ReactionEvent -> { - NotificationItem.Reaction( - event = event, - timestamp = event.createdAt, - content = event.content, - ) - } - - is RepostEvent, is GenericRepostEvent -> { - NotificationItem.Repost( - event = event, - timestamp = event.createdAt, - ) - } - - is LnZapEvent -> { - val amount = event.amount?.toLong() - NotificationItem.Zap( - event = event, - timestamp = event.createdAt, - amount = amount, - ) - } - - is TextNoteEvent -> { - val eTags = event.tags.filter { it.size > 1 && it[0] == "e" } - val isReply = eTags.isNotEmpty() - if (isReply) { - NotificationItem.Reply(event, event.createdAt) - } else { - NotificationItem.Mention(event, event.createdAt) - } - } - - else -> { - NotificationItem.Mention(event, event.createdAt) - } - } - + classifyNotification(event) + ?: return@createNotificationsSubscription notificationState.addItem(notification) }, onEose = { _, _ -> @@ -237,30 +318,82 @@ fun NotificationsScreen( } } + // Advance last-read on entry and whenever new items arrive while the + // inbox stays composed. Simple heuristic: any time the item count grows, + // the user is looking at them. + LaunchedEffect(notifications.size) { + readState.markAsRead(nowEpochSeconds()) + } + + // Metadata version — recompose author names/pictures as they load. + val metadataVersion by localCache.metadataVersion.collectAsState() + + // Distinct + filter (memoized by inputs) + val distinct = remember(notifications) { notifications.distinctBy { it.event.id } } + val counts = + remember(distinct) { + NotificationFilter.entries.associateWith { f -> distinct.count { f.accepts(it) } } + } + val filtered = remember(distinct, activeFilter) { distinct.filter { activeFilter.accepts(it) } } + val groups = remember(filtered) { groupNotifications(filtered) } + ReadingColumn { - FeedHeader( + NotificationsHeader( title = "Notifications", connectedRelayCount = connectedRelays.size, onRefresh = { relayManager.connect() }, + onOpenSettings = onOpenSettings, ) - if (connectedRelays.isEmpty()) { - LoadingState("Connecting to relays...") - } else if (notifications.isEmpty() && !initialLoadComplete) { - LoadingState("Loading notifications...") - } else if (notifications.isEmpty() && initialLoadComplete) { - EmptyState( - title = "No notifications yet", - description = "When someone interacts with your posts, you'll see it here", - onRefresh = { relayManager.connect() }, - ) - } else { - LazyColumn( - contentPadding = PaddingValues(horizontal = readingHorizontalPadding()), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(notifications.distinctBy { it.event.id }, key = { it.event.id }) { notification -> - NotificationCard(notification) + if (!osToastsEnabled) { + OsNotificationsBanner(onEnable = onOpenSettings) + } + + FilterTabsRow( + active = activeFilter, + counts = counts, + onSelect = { activeFilter = it }, + ) + + HorizontalDivider() + + when { + connectedRelays.isEmpty() -> { + LoadingState("Connecting to relays...") + } + distinct.isEmpty() && !initialLoadComplete -> { + LoadingState("Loading notifications...") + } + distinct.isEmpty() && initialLoadComplete -> { + EmptyState( + title = "No notifications yet", + description = "When someone interacts with your posts, you'll see it here", + onRefresh = { relayManager.connect() }, + ) + } + groups.isEmpty() -> { + EmptyState( + title = "Nothing in this filter", + description = "Try another tab or refresh.", + onRefresh = { relayManager.connect() }, + ) + } + else -> { + LazyColumn( + contentPadding = PaddingValues(horizontal = readingHorizontalPadding()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(groups, key = { it.id }, contentType = { it::class }) { group -> + NotificationGroupCard( + group = group, + lastReadAt = lastReadAt, + localCache = localCache, + metadataVersion = metadataVersion, + onNavigateToThread = onNavigateToThread, + onNavigateToProfile = onNavigateToProfile, + onOpenMessages = onOpenMessages, + ) + } } } } @@ -268,7 +401,382 @@ fun NotificationsScreen( } @Composable -fun NotificationCard(notification: NotificationItem) { +private fun NotificationsHeader( + title: String, + connectedRelayCount: Int, + onRefresh: () -> Unit, + onOpenSettings: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = readingHorizontalPadding(), vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onOpenSettings, modifier = Modifier.size(32.dp)) { + Icon( + rememberMaterialSymbolPainter(MaterialSymbols.Tune), + contentDescription = "Notification settings", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } + IconButton(onClick = onRefresh, modifier = Modifier.size(32.dp)) { + Icon( + rememberMaterialSymbolPainter(MaterialSymbols.Refresh), + contentDescription = "Refresh ($connectedRelayCount relays connected)", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + } + } + } +} + +@Composable +private fun FilterTabsRow( + active: NotificationFilter, + counts: Map, + onSelect: (NotificationFilter) -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = readingHorizontalPadding() - 4.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + NotificationFilter.entries.forEach { f -> + FilterChip( + label = f.label, + count = counts[f] ?: 0, + selected = f == active, + onClick = { onSelect(f) }, + ) + } + } +} + +@Composable +private fun FilterChip( + label: String, + count: Int, + selected: Boolean, + onClick: () -> Unit, +) { + val bg = + if (selected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + } + val fg = + if (selected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + Row( + modifier = + Modifier + .clickable(onClick = onClick) + .background(bg, RoundedCornerShape(12.dp)) + .padding(horizontal = 10.dp, vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + label, + style = MaterialTheme.typography.labelMedium, + color = fg, + ) + if (count > 0) { + Text( + if (count > 99) "99+" else count.toString(), + style = MaterialTheme.typography.labelSmall, + color = fg.copy(alpha = 0.7f), + ) + } + } +} + +@Composable +private fun NotificationGroupCard( + group: NotificationGroup, + lastReadAt: Long, + localCache: DesktopLocalCache, + metadataVersion: Long, + onNavigateToThread: (String) -> Unit, + onNavigateToProfile: (String) -> Unit, + onOpenMessages: () -> Unit = {}, +) { + when (group) { + is NotificationGroup.Single -> + NotificationCard( + notification = group.item, + lastReadAt = lastReadAt, + localCache = localCache, + metadataVersion = metadataVersion, + onNavigateToThread = onNavigateToThread, + onNavigateToProfile = onNavigateToProfile, + onOpenMessages = onOpenMessages, + ) + is NotificationGroup.ReactionsOn -> { + val newest = group.items.first() + val reactors = remember(group.items) { group.items.map { it.event.pubKey }.distinct() } + AggregateCard( + icon = rememberMaterialSymbolPainter(MaterialSymbols.Favorite), + tint = MaterialTheme.colorScheme.tertiary, + title = "${group.items.size} reactions", + subtitle = "on your post", + timestamp = newest.timestamp, + unread = newest.timestamp > lastReadAt, + targetNoteId = group.targetNoteId, + reactorPubKeys = reactors, + localCache = localCache, + metadataVersion = metadataVersion, + onNavigateToThread = onNavigateToThread, + onNavigateToProfile = onNavigateToProfile, + ) + } + is NotificationGroup.RepostsOn -> { + val newest = group.items.first() + val reposters = remember(group.items) { group.items.map { it.event.pubKey }.distinct() } + AggregateCard( + icon = rememberVectorPainter(Repost), + tint = MaterialTheme.colorScheme.primary, + title = "${group.items.size} reposts", + subtitle = "of your post", + timestamp = newest.timestamp, + unread = newest.timestamp > lastReadAt, + targetNoteId = group.targetNoteId, + reactorPubKeys = reposters, + localCache = localCache, + metadataVersion = metadataVersion, + onNavigateToThread = onNavigateToThread, + onNavigateToProfile = onNavigateToProfile, + ) + } + } +} + +@Composable +private fun AggregateCard( + icon: androidx.compose.ui.graphics.painter.Painter, + tint: androidx.compose.ui.graphics.Color, + title: String, + subtitle: String, + timestamp: Long, + unread: Boolean, + targetNoteId: String, + reactorPubKeys: List, + localCache: DesktopLocalCache, + metadataVersion: Long, + onNavigateToThread: (String) -> Unit, + onNavigateToProfile: (String) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val targetNote = remember(targetNoteId, metadataVersion) { localCache.notes.get(targetNoteId) } + val targetPreview = + remember(targetNote, metadataVersion) { + targetNote + ?.event + ?.content + ?.take(200) + ?.replace("\n", " ") + } + + Card( + modifier = + Modifier + .fillMaxWidth() + .clickable { onNavigateToThread(targetNoteId) }, + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + if (unread) UnreadDot() + Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(16.dp)) + Spacer(Modifier.size(8.dp)) + Column(modifier = Modifier.padding(end = 8.dp).weight(1f)) { + Text( + title, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + ToggleableTimeAgoText( + timestamp = timestamp, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + Spacer(Modifier.size(4.dp)) + IconButton( + onClick = { expanded = !expanded }, + modifier = Modifier.size(24.dp), + ) { + Icon( + rememberMaterialSymbolPainter( + if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, + ), + contentDescription = if (expanded) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + } + } + + // Compact avatar preview (always shown when there's more than one reactor) + if (reactorPubKeys.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy((-4).dp), + ) { + reactorPubKeys.take(5).forEach { pk -> + val user = remember(pk, metadataVersion) { localCache.getUserIfExists(pk) } + UserAvatar( + userHex = pk, + pictureUrl = user?.profilePicture(), + size = 20.dp, + modifier = Modifier.clickable { onNavigateToProfile(pk) }, + ) + } + if (reactorPubKeys.size > 5) { + Spacer(Modifier.size(6.dp)) + Text( + "+${reactorPubKeys.size - 5}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + // Expanded state: reactor list + note preview + if (expanded) { + Spacer(Modifier.height(10.dp)) + if (!targetPreview.isNullOrBlank()) { + Text( + text = "\"$targetPreview\"", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 3, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + reactorPubKeys.forEach { pk -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clickable { onNavigateToProfile(pk) } + .padding(vertical = 3.dp), + ) { + val user = remember(pk, metadataVersion) { localCache.getUserIfExists(pk) } + UserAvatar( + userHex = pk, + pictureUrl = user?.profilePicture(), + size = 22.dp, + ) + Spacer(Modifier.size(6.dp)) + Text( + user?.toBestDisplayName() ?: pk.take(12), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + } + } +} + +@Composable +private fun OsNotificationsBanner(onEnable: () -> Unit) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = readingHorizontalPadding(), vertical = 6.dp) + .background( + MaterialTheme.colorScheme.tertiaryContainer, + androidx.compose.foundation.shape + .RoundedCornerShape(8.dp), + ).padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(end = 12.dp).weight(1f), + ) { + Icon( + rememberMaterialSymbolPainter(MaterialSymbols.Notifications), + contentDescription = null, + tint = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.size(8.dp)) + Column { + Text( + "Get notified when someone interacts with you", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + Text( + "Turn on OS notifications to hear about zaps, replies, and mentions.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.8f), + ) + } + } + androidx.compose.material3.TextButton(onClick = onEnable) { + Text("Set up") + } + } +} + +@Composable +private fun UnreadDot() { + val color = MaterialTheme.colorScheme.primary + Canvas(modifier = Modifier.size(10.dp).padding(end = 2.dp)) { + drawCircle(color = color, radius = 4.dp.toPx(), center = Offset(4.dp.toPx(), size.height / 2)) + } + Spacer(Modifier.size(4.dp)) +} + +@Composable +fun NotificationCard( + notification: NotificationItem, + lastReadAt: Long = 0L, + localCache: DesktopLocalCache? = null, + metadataVersion: Long = 0L, + onNavigateToThread: (String) -> Unit = {}, + onNavigateToProfile: (String) -> Unit = {}, + onOpenMessages: () -> Unit = {}, +) { val (icon, label, color) = when (notification) { is NotificationItem.Mention -> { @@ -299,27 +807,62 @@ fun NotificationCard(notification: NotificationItem) { val amountText = notification.amount?.let { " ${it / 1000} sats" } ?: "" Triple(rememberVectorPainter(Zap), "zapped$amountText", MaterialTheme.colorScheme.primary) } + + is NotificationItem.Dm -> { + Triple( + rememberMaterialSymbolPainter(MaterialSymbols.Mail), + "sent you an encrypted message", + MaterialTheme.colorScheme.primary, + ) + } } - val authorDisplay = - try { - notification.event.pubKey - .hexToByteArrayOrNull() - ?.toNpub() - ?.take(20) ?: notification.event.pubKey.take(20) - } catch (e: Exception) { - notification.event.pubKey.take(20) + // For zap receipts the outer event.pubKey is the LNURL provider — the + // actual zap sender lives in the nested zap request. effectiveAuthorPubKey + // returns the right one per kind. + val pk = notification.effectiveAuthorPubKey + val user = remember(pk, metadataVersion, localCache) { localCache?.getUserIfExists(pk) } + val displayName = + remember(user, metadataVersion, pk) { + user?.toBestDisplayName() + ?: pk.hexToByteArrayOrNull()?.toNpub()?.take(12) + ?: pk.take(12) + } + val pictureUrl = remember(user, metadataVersion) { user?.profilePicture() } + + val unread by remember(notification.timestamp, lastReadAt) { + derivedStateOf { notification.timestamp > lastReadAt } + } + + // Target note id for click-through: reactions/reposts/replies reference an + // `e` tag; for mentions we fall back to the notification event itself. + val clickTarget = + remember(notification) { + notification.event.tags + .firstOrNull { it.size > 1 && it[0] == "e" } + ?.get(1) + ?: notification.event.id + } + + // DMs open the Messages column; everything else opens the target thread. + val onCardClick: () -> Unit = + if (notification is NotificationItem.Dm) { + onOpenMessages + } else { + { onNavigateToThread(clickTarget) } } Card( - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onCardClick), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant, ), ) { Column(modifier = Modifier.padding(12.dp)) { - // Header: icon + label + author + time Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -329,14 +872,24 @@ fun NotificationCard(notification: NotificationItem) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { + if (unread) UnreadDot() + UserAvatar( + userHex = pk, + pictureUrl = pictureUrl, + size = 28.dp, + modifier = + Modifier.clickable( + onClick = { onNavigateToProfile(pk) }, + ), + ) Icon( icon, contentDescription = label, tint = color, - modifier = Modifier.size(16.dp), + modifier = Modifier.size(14.dp), ) Text( - text = "$authorDisplay $label", + text = "$displayName $label", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -349,7 +902,6 @@ fun NotificationCard(notification: NotificationItem) { ) } - // Content (for text-based notifications) if (notification is NotificationItem.Mention || notification is NotificationItem.Reply ) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt index 758870cae7..065b9c5cbc 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt @@ -130,6 +130,7 @@ fun DeckColumnType.category(): ScreenCategory = DeckColumnType.MyProfile, DeckColumnType.Settings, + DeckColumnType.NotificationSettings, DeckColumnType.Wallet, -> ScreenCategory.IDENTITY diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt index f4bcc23b4c..1259520c82 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt @@ -121,6 +121,7 @@ fun DeckColumnType.icon(): MaterialSymbol = DeckColumnType.MyProfile -> MaterialSymbols.Person DeckColumnType.Chess -> MaterialSymbols.Extension DeckColumnType.Settings -> MaterialSymbols.Settings + DeckColumnType.NotificationSettings -> MaterialSymbols.Tune DeckColumnType.Relays -> MaterialSymbols.Dns DeckColumnType.Wallet -> MaterialSymbols.AccountBalanceWallet is DeckColumnType.Article -> MaterialSymbols.AutoMirrored.Article diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 6f4affcabc..b24a657c0e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -140,6 +140,8 @@ fun DeckColumnContainer( onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onZapFeedback: (ZapFeedback) -> Unit, onNavigateToRelays: () -> Unit = {}, + onOpenNotificationSettings: () -> Unit = {}, + onOpenMessages: () -> Unit = {}, modifier: Modifier = Modifier, ) { val navState = remember(column.id) { ColumnNavigationState() } @@ -229,6 +231,8 @@ fun DeckColumnContainer( onNavigateToRelays = onNavigateToRelays, onNavigateToPack = { navState.push(DesktopScreen.FollowPackDetail(it)) }, onNavigateToPackBrowseAll = { navState.push(DesktopScreen.FollowPackBrowseAll) }, + onOpenNotificationSettings = { navState.push(DesktopScreen.NotificationSettings) }, + onOpenMessages = onOpenMessages, ) // Overlay with slide animation @@ -333,6 +337,8 @@ internal fun RootContent( onOpenFeedsDrawer: () -> Unit = {}, onNavigateToPack: (String) -> Unit = {}, onNavigateToPackBrowseAll: () -> Unit = {}, + onOpenNotificationSettings: () -> Unit = {}, + onOpenMessages: () -> Unit = {}, ) { val scope = rememberCoroutineScope() @@ -357,7 +363,21 @@ internal fun RootContent( } DeckColumnType.Notifications -> { - NotificationsScreen(relayManager, localCache, account, subscriptionsCoordinator) + NotificationsScreen( + relayManager = relayManager, + localCache = localCache, + account = account, + subscriptionsCoordinator = subscriptionsCoordinator, + onOpenSettings = onOpenNotificationSettings, + onNavigateToThread = onNavigateToThread, + onNavigateToProfile = onNavigateToProfile, + onOpenMessages = onOpenMessages, + ) + } + + DeckColumnType.NotificationSettings -> { + com.vitorpamplona.amethyst.desktop.ui.settings + .NotificationSettingsScreen() } DeckColumnType.Messages -> { @@ -726,6 +746,12 @@ internal fun OverlayContent( } } + is DesktopScreen.NotificationSettings -> { + com.vitorpamplona.amethyst.desktop.ui.settings.NotificationSettingsScreen( + onBack = onBack, + ) + } + else -> { androidx.compose.material3.Text( "Unsupported screen type", diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt index dff92c6d99..a52a6fb97d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt @@ -43,6 +43,8 @@ sealed class DeckColumnType { object Settings : DeckColumnType() + object NotificationSettings : DeckColumnType() + object Relays : DeckColumnType() object Wallet : DeckColumnType() @@ -93,6 +95,7 @@ sealed class DeckColumnType { MyProfile -> "Profile" Chess -> "Chess" Settings -> "Settings" + NotificationSettings -> "Notification Settings" Relays -> "Relays" Wallet -> "Wallet" is Article -> "Article" @@ -119,6 +122,7 @@ sealed class DeckColumnType { MyProfile -> "my_profile" Chess -> "chess" Settings -> "settings" + NotificationSettings -> "notification_settings" Relays -> "relays" Wallet -> "wallet" is Article -> "article" diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt index aba4cbd9c5..90178c2096 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt @@ -75,6 +75,8 @@ fun DeckLayout( onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onZapFeedback: (ZapFeedback) -> Unit, onNavigateToRelays: () -> Unit = {}, + onOpenNotificationSettings: () -> Unit = {}, + onOpenMessages: () -> Unit = {}, modifier: Modifier = Modifier, ) { val columns by deckState.columns.collectAsState() @@ -143,6 +145,8 @@ fun DeckLayout( onShowReplyDialog = onShowReplyDialog, onZapFeedback = onZapFeedback, onNavigateToRelays = onNavigateToRelays, + onOpenNotificationSettings = onOpenNotificationSettings, + onOpenMessages = onOpenMessages, ) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt index 94fd69ce30..2d961d3d0a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt @@ -99,6 +99,7 @@ private data class NavItem( private val NAV_ITEMS = listOf( NavItem(DeckColumnType.HomeFeed, "Home", MaterialSymbols.Home), + NavItem(DeckColumnType.Notifications, "Notifications", MaterialSymbols.Notifications), NavItem(DeckColumnType.Discover, "Discover", MaterialSymbols.Explore), NavItem(DeckColumnType.Search, "Search", MaterialSymbols.Search), NavItem(DeckColumnType.Messages, "Messages", MaterialSymbols.Mail), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt index 2a9c3c94e3..4dd1c65062 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -136,6 +136,8 @@ fun SinglePaneLayout( onOpenFeedsDrawer = onOpenFeedsDrawer, onNavigateToPack = { navState.push(DesktopScreen.FollowPackDetail(it)) }, onNavigateToPackBrowseAll = { navState.push(DesktopScreen.FollowPackBrowseAll) }, + onOpenNotificationSettings = { navState.push(DesktopScreen.NotificationSettings) }, + onOpenMessages = { singlePaneState.navigate(DeckColumnType.Messages) }, ) AnimatedContent( targetState = currentOverlay, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/notifications/DesktopNotificationAutoDispatcher.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/notifications/DesktopNotificationAutoDispatcher.kt new file mode 100644 index 0000000000..23981aab22 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/notifications/DesktopNotificationAutoDispatcher.kt @@ -0,0 +1,240 @@ +/* + * 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.desktop.ui.notifications + +import com.vitorpamplona.amethyst.commons.moderation.notifications.NotifKind +import com.vitorpamplona.amethyst.commons.moderation.notifications.NotificationDispatcher +import com.vitorpamplona.amethyst.commons.moderation.notifications.NotificationKinds +import com.vitorpamplona.amethyst.commons.moderation.notifications.NotificationSettings +import com.vitorpamplona.amethyst.commons.moderation.notifications.NotificationSpec +import com.vitorpamplona.amethyst.commons.moderation.notifications.PermissionState +import com.vitorpamplona.amethyst.commons.moderation.notifications.nowEpochSeconds +import com.vitorpamplona.amethyst.commons.moderation.notifications.sanitizeForToast +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.quartz.nip01Core.core.Event +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.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import java.util.logging.Logger + +/** + * Subscribes to [DesktopLocalCache]'s new-event stream and fires OS toasts + * through [NotificationDispatcher] for events that pass the shared + * `tagsAnEventForUser` semantic gate + a lightweight suppression pipeline + * (master toggle, per-kind toggle, DND, window focus, cold-boot, + * per-event 30-second dedupe). + * + * Runs one collector for the app's lifetime, scoped to [scope]. Cancel + * the scope to stop. + */ +class DesktopNotificationAutoDispatcher( + private val dispatcher: NotificationDispatcher, + private val settings: NotificationSettings, + private val myPubKeyHex: String, + private val localCache: DesktopLocalCache, + private val isWindowFocused: StateFlow, + private val sessionStartSec: Long, + private val scope: CoroutineScope, +) { + private val log = Logger.getLogger(DesktopNotificationAutoDispatcher::class.java.simpleName) + private val recentFires = HashMap() + + fun start(): Job = + scope.launch { + log.info("Auto-dispatcher started (pubKey=${myPubKeyHex.take(8)}, sessionStart=$sessionStartSec)") + localCache.eventStream.newEventBundles.collect { bundle -> + for (note in bundle) { + val event = note.event ?: continue + tryDispatch(event) + } + } + } + + private fun logSkip( + eventId: String, + kindLabel: String, + reason: String, + ) { + log.info("[AutoDispatch] SKIP kind=$kindLabel id=${eventId.take(8)} reason=$reason") + } + + private fun tryDispatch(event: Event) { + val eid = event.id + // Fast-path rejections — cheapest first. + if (event.kind !in NotificationKinds.SUBSCRIPTION_KINDS) { + // Skip logging: most events aren't notification kinds and would spam. + return + } + + val kind = notifKindFor(event) + if (kind == null) { + logSkip(eid, "kind=${event.kind}", "unmapped-kind") + return + } + + if (!settings.enabled.value) { + logSkip(eid, kind.name, "master-off") + return + } + if (!settings.kinds.value.enabledFor(kind)) { + logSkip(eid, kind.name, "kind-toggle-off") + return + } + + val now = nowEpochSeconds() + if (settings.dnd.value.isActive(now)) { + logSkip(eid, kind.name, "dnd-active") + return + } + if (isWindowFocused.value) { + logSkip(eid, kind.name, "window-focused") + return + } + + if (event.createdAt < sessionStartSec) { + logSkip(eid, kind.name, "pre-session (created=${event.createdAt} < start=$sessionStartSec)") + return + } + if ((now - event.createdAt) > 30) { + logSkip(eid, kind.name, "stale (${now - event.createdAt}s old)") + return + } + + val perm = dispatcher.permission.value + if (perm != PermissionState.Granted && perm != PermissionState.NotApplicable) { + logSkip(eid, kind.name, "permission=$perm") + return + } + + val accepts = + NotificationKinds.tagsAnEventForUser( + event = event, + myPubKeyHex = myPubKeyHex, + isTargetAuthoredByMe = { targetId -> + localCache.notes + .get(targetId) + ?.event + ?.pubKey == myPubKeyHex + }, + ) + if (!accepts) { + logSkip(eid, kind.name, "not-tagged-for-user (author=${event.pubKey.take(8)})") + return + } + + val dedupeKey = kind.name + "|" + event.id + val last = recentFires[dedupeKey] + if (last != null && (now - last) < 30) { + logSkip(eid, kind.name, "deduped (${now - last}s ago)") + return + } + recentFires[dedupeKey] = now + recentFires.entries.removeAll { now - it.value > 300 } + + val spec = buildSpec(event, kind) + scope.launch { + try { + val result = dispatcher.send(spec) + log.info("[AutoDispatch] FIRED kind=$kind id=${eid.take(8)} result=$result title='${spec.title}'") + } catch (t: Throwable) { + log.warning("[AutoDispatch] FAILED kind=$kind id=${eid.take(8)} error=${t.message}") + } + } + } + + private fun notifKindFor(event: Event): NotifKind? = + when (event) { + is ReactionEvent -> NotifKind.REACTION + is RepostEvent, is GenericRepostEvent -> NotifKind.REPOST + is LnZapEvent, is NutzapEvent, is OnchainZapEvent -> NotifKind.ZAP + is TextNoteEvent -> { + val isReply = event.tags.any { it.size > 1 && it[0] == "e" } + if (isReply) NotifKind.REPLY else NotifKind.MENTION + } + is CommentEvent -> NotifKind.REPLY + is ChannelMessageEvent -> NotifKind.MENTION + is PrivateDmEvent, + is ChatMessageEvent, + is GiftWrapEvent, + is ChatMessageEncryptedFileHeaderEvent, + -> NotifKind.DM + else -> null + } + + private fun buildSpec( + event: Event, + kind: NotifKind, + ): NotificationSpec { + val effectivePubKey = + when (event) { + is LnZapEvent -> event.zapRequest?.pubKey ?: event.pubKey + else -> event.pubKey + } + val displayName = + localCache.getUserIfExists(effectivePubKey)?.toBestDisplayName() + ?: "Someone" + + val amountText = + (event as? LnZapEvent)?.amount?.let { " ${it.toLong() / 1000} sats" } ?: "" + + val title = + when (kind) { + NotifKind.ZAP -> "⚡ $displayName zapped you$amountText" + NotifKind.DM -> "New encrypted message" + NotifKind.REPLY -> "$displayName replied" + NotifKind.MENTION -> "$displayName mentioned you" + NotifKind.REPOST -> "$displayName reposted" + NotifKind.REACTION -> "$displayName reacted" + NotifKind.FOLLOW -> "$displayName followed you" + } + + // Never leak DM ciphertext into the body — decryption pipeline + // isn't wired yet. + val body = + when { + kind == NotifKind.DM -> "" + !settings.previewInToast.value -> "" + else -> sanitizeForToast(event.content, maxLen = 120) + } + + return NotificationSpec( + title = title, + body = body, + kind = kind, + threadId = event.id, + deepLinkNoteId = event.id, + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/notifications/NotificationCompositionLocals.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/notifications/NotificationCompositionLocals.kt new file mode 100644 index 0000000000..2cd0c69aef --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/notifications/NotificationCompositionLocals.kt @@ -0,0 +1,37 @@ +/* + * 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.desktop.ui.notifications + +import androidx.compose.runtime.compositionLocalOf +import com.vitorpamplona.amethyst.commons.moderation.notifications.NotificationDispatcher +import com.vitorpamplona.amethyst.commons.moderation.notifications.NotificationReadState +import com.vitorpamplona.amethyst.commons.moderation.notifications.NotificationSettings + +/** + * Injected at App() level after login. Nullable-fallback lets composables + * degrade gracefully if the app is still hydrating. + */ +val LocalNotificationSettings = compositionLocalOf { null } + +val LocalNotificationReadState = compositionLocalOf { null } + +/** OS-level notification dispatcher (Nucleus-backed with AWT fallback). */ +val LocalNotificationDispatcher = compositionLocalOf { null } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/notifications/NotificationFilter.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/notifications/NotificationFilter.kt new file mode 100644 index 0000000000..02ebcab450 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/notifications/NotificationFilter.kt @@ -0,0 +1,49 @@ +/* + * 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.desktop.ui.notifications + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.desktop.ui.NotificationItem + +@Immutable +enum class NotificationFilter( + val label: String, +) { + All("All"), + Mentions("Mentions"), + Replies("Replies"), + Reactions("Reactions"), + Zaps("Zaps"), + Reposts("Reposts"), + DMs("DMs"), + ; + + fun accepts(item: NotificationItem): Boolean = + when (this) { + All -> true + Mentions -> item is NotificationItem.Mention + Replies -> item is NotificationItem.Reply + Reactions -> item is NotificationItem.Reaction + Zaps -> item is NotificationItem.Zap + Reposts -> item is NotificationItem.Repost + DMs -> item is NotificationItem.Dm + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/notifications/NotificationGroup.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/notifications/NotificationGroup.kt new file mode 100644 index 0000000000..fb79571dc9 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/notifications/NotificationGroup.kt @@ -0,0 +1,144 @@ +/* + * 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.desktop.ui.notifications + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.desktop.ui.NotificationItem +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +sealed interface NotificationGroup { + val newestTimestamp: Long + val id: String + + @Immutable + data class Single( + val item: NotificationItem, + ) : NotificationGroup { + override val newestTimestamp: Long get() = item.timestamp + override val id: String get() = "single-" + item.event.id + } + + @Immutable + data class ReactionsOn( + val targetNoteId: String, + val dayBucket: String, + val items: List, + ) : NotificationGroup { + override val newestTimestamp: Long get() = items.first().timestamp + override val id: String get() = "rx-$targetNoteId-$dayBucket" + } + + @Immutable + data class RepostsOn( + val targetNoteId: String, + val dayBucket: String, + val items: List, + ) : NotificationGroup { + override val newestTimestamp: Long get() = items.first().timestamp + override val id: String get() = "rp-$targetNoteId-$dayBucket" + } +} + +private val DAY_FORMAT = SimpleDateFormat("yyyy-MM-dd", Locale.US) + +private fun dayBucketFor(epochSec: Long): String = DAY_FORMAT.format(Date(epochSec * 1_000L)) + +private fun targetNoteId(item: NotificationItem): String? = + item.event.tags + .firstOrNull { it.size > 1 && it[0] == "e" } + ?.get(1) + +/** + * Bucket reactions and reposts by (target-note, day). Preserve newest-first + * ordering across groups. Zaps, mentions, replies stay individual. + */ +fun groupNotifications(items: List): List { + if (items.isEmpty()) return emptyList() + + val out = ArrayList(items.size) + val rxBuckets = LinkedHashMap>() + val rxKeyToTarget = HashMap>() + val rpBuckets = LinkedHashMap>() + val rpKeyToTarget = HashMap>() + + // Track insertion position in `out` for each bucket key so we can replace + // the placeholder once we know the full list. + val bucketSlot = HashMap() + + for (item in items) { + when (item) { + is NotificationItem.Reaction -> { + val target = targetNoteId(item) + if (target == null) { + out.add(NotificationGroup.Single(item)) + continue + } + val day = dayBucketFor(item.timestamp) + val key = "rx|$target|$day" + val list = + rxBuckets.getOrPut(key) { + ArrayList().also { + rxKeyToTarget[key] = target to day + bucketSlot[key] = out.size + // Placeholder — replaced on flush below. + out.add(NotificationGroup.ReactionsOn(target, day, emptyList())) + } + } + list.add(item) + } + is NotificationItem.Repost -> { + val target = targetNoteId(item) + if (target == null) { + out.add(NotificationGroup.Single(item)) + continue + } + val day = dayBucketFor(item.timestamp) + val key = "rp|$target|$day" + val list = + rpBuckets.getOrPut(key) { + ArrayList().also { + rpKeyToTarget[key] = target to day + bucketSlot[key] = out.size + out.add(NotificationGroup.RepostsOn(target, day, emptyList())) + } + } + list.add(item) + } + else -> out.add(NotificationGroup.Single(item)) + } + } + + // Materialize bucket contents into their placeholder positions. + for ((key, list) in rxBuckets) { + val (target, day) = rxKeyToTarget.getValue(key) + val slot = bucketSlot.getValue(key) + out[slot] = NotificationGroup.ReactionsOn(target, day, list.toList()) + } + for ((key, list) in rpBuckets) { + val (target, day) = rpKeyToTarget.getValue(key) + val slot = bucketSlot.getValue(key) + out[slot] = NotificationGroup.RepostsOn(target, day, list.toList()) + } + + return out +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/NotificationSettingsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/NotificationSettingsScreen.kt new file mode 100644 index 0000000000..e5d6b4b28e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/NotificationSettingsScreen.kt @@ -0,0 +1,532 @@ +/* + * 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.desktop.ui.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.moderation.notifications.HostOs +import com.vitorpamplona.amethyst.commons.moderation.notifications.NotifKind +import com.vitorpamplona.amethyst.commons.moderation.notifications.NotificationDispatcher +import com.vitorpamplona.amethyst.commons.moderation.notifications.NotificationReadState +import com.vitorpamplona.amethyst.commons.moderation.notifications.NotificationSettings +import com.vitorpamplona.amethyst.commons.moderation.notifications.NotificationSpec +import com.vitorpamplona.amethyst.commons.moderation.notifications.PermissionState +import com.vitorpamplona.amethyst.commons.moderation.notifications.PreferencesNotificationSettings +import com.vitorpamplona.amethyst.commons.moderation.notifications.SendResult +import com.vitorpamplona.amethyst.commons.moderation.notifications.detectHostOs +import com.vitorpamplona.amethyst.commons.moderation.notifications.nowEpochSeconds +import com.vitorpamplona.amethyst.desktop.ui.ReadingColumn +import com.vitorpamplona.amethyst.desktop.ui.notifications.LocalNotificationDispatcher +import com.vitorpamplona.amethyst.desktop.ui.notifications.LocalNotificationReadState +import com.vitorpamplona.amethyst.desktop.ui.notifications.LocalNotificationSettings +import com.vitorpamplona.amethyst.desktop.ui.readingHorizontalPadding +import kotlinx.coroutines.launch + +@Composable +fun NotificationSettingsScreen(onBack: (() -> Unit)? = null) { + val hoistedSettings = LocalNotificationSettings.current + val settings: NotificationSettings = + remember(hoistedSettings) { hoistedSettings ?: PreferencesNotificationSettings() } + val readState: NotificationReadState? = LocalNotificationReadState.current + + val enabled by settings.enabled.collectAsState() + val kinds by settings.kinds.collectAsState() + val dnd by settings.dnd.collectAsState() + val preview by settings.previewInToast.collectAsState() + + ReadingColumn { + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = readingHorizontalPadding(), vertical = 12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (onBack != null) { + TextButton(onClick = onBack) { Text("← Back") } + } + Text( + "Notifications", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + } + Spacer(Modifier.height(12.dp)) + + // Platform status + val host = remember { detectHostOs() } + val dispatcher: NotificationDispatcher? = LocalNotificationDispatcher.current + val permissionState by ( + dispatcher?.permission?.collectAsState() + ?: remember { mutableStateOf(PermissionState.NotApplicable) } + ) + val nativeAvailable by ( + dispatcher?.nativeAvailable?.collectAsState() + ?: remember { mutableStateOf(false) } + ) + val coroutineScope = androidx.compose.runtime.rememberCoroutineScope() + var testStatus by remember { mutableStateOf(null) } + var requestingPermission by remember { mutableStateOf(false) } + var sendingTest by remember { mutableStateOf(false) } + + // Re-sync permission state whenever this screen enters composition + // and whenever the window regains focus — user may have toggled + // Amethyst in System Settings → Notifications while we were open. + val windowInfo = androidx.compose.ui.platform.LocalWindowInfo.current + androidx.compose.runtime.LaunchedEffect(dispatcher) { + dispatcher?.refreshPermission() + } + androidx.compose.runtime.LaunchedEffect(dispatcher, windowInfo) { + androidx.compose.runtime + .snapshotFlow { windowInfo.isWindowFocused } + .collect { focused -> + if (focused) dispatcher?.refreshPermission() + } + } + + PlatformStatusCard( + host = host, + nativeAvailable = nativeAvailable, + permission = permissionState, + ) + + Spacer(Modifier.height(12.dp)) + + // Master switch — guard against enabling while permission unauthorized on macOS + val masterAllowed = + when (permissionState) { + PermissionState.Granted, PermissionState.NotApplicable -> true + else -> false + } + SwitchRow( + title = "Enable desktop notifications", + subtitle = + if (masterAllowed) { + "Master toggle for all OS toasts." + } else { + "Grant permission (button below) before enabling." + }, + checked = enabled && masterAllowed, + onCheckedChange = { v -> + if (v && !masterAllowed) return@SwitchRow + settings.setEnabled(v) + }, + ) + + Spacer(Modifier.height(6.dp)) + + // Permission-aware action button + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + when (permissionState) { + PermissionState.NotRequested -> { + OutlinedButton( + onClick = { + if (requestingPermission) return@OutlinedButton + coroutineScope.launch { + requestingPermission = true + testStatus = "Waiting for OS prompt…" + val newState = + try { + dispatcher?.requestPermission() ?: PermissionState.Denied + } finally { + requestingPermission = false + } + testStatus = + when (newState) { + PermissionState.Granted -> "Permission granted. Try the test toast below." + PermissionState.Denied -> "Permission denied. Enable in System Settings if you change your mind." + PermissionState.BundleRequired -> "Notifications need a bundled app — run `./gradlew :desktopApp:runDistributable`." + else -> null + } + } + }, + enabled = dispatcher != null && !requestingPermission, + ) { + if (requestingPermission) { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.size(8.dp)) + Text("Requesting…") + } else { + Text("Enable OS notifications") + } + } + } + PermissionState.Granted, PermissionState.NotApplicable -> { + OutlinedButton( + onClick = { + if (sendingTest) return@OutlinedButton + coroutineScope.launch { + sendingTest = true + testStatus = "Sending…" + val result = + try { + dispatcher?.send( + NotificationSpec( + title = "Amethyst", + body = "Test notification — you're all set.", + kind = NotifKind.MENTION, + ), + ) + } finally { + sendingTest = false + } + testStatus = + when (result) { + SendResult.Delivered -> "Sent via native pipeline." + SendResult.DeliveredViaFallback -> "Sent via AWT fallback (native lib unavailable)." + is SendResult.Suppressed -> "Suppressed: ${result.reason}" + is SendResult.Failed -> "Failed: ${result.error.message ?: result.error::class.simpleName}" + null -> "Dispatcher not initialized." + } + } + }, + enabled = dispatcher != null && !sendingTest, + ) { + if (sendingTest) { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.size(8.dp)) + Text("Sending…") + } else { + Text("Send a test toast") + } + } + } + PermissionState.Denied -> { + OutlinedButton( + onClick = { + try { + java.awt.Desktop + .getDesktop() + .browse(java.net.URI("x-apple.systempreferences:com.apple.preference.notifications")) + } catch (_: Throwable) { + // Deep-link is macOS-only; ignore on other platforms. + } + }, + ) { Text("Open System Settings") } + Text( + "Enable in System Settings → Notifications → Amethyst", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + PermissionState.BundleRequired -> { + Text( + "Run `./gradlew :desktopApp:runDistributable` — OS notifications need a bundled `.app`.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + testStatus?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(Modifier.height(12.dp)) + HorizontalDivider() + Spacer(Modifier.height(12.dp)) + + SectionTitle("Which events fire notifications?") + + KindRow( + label = "Zaps", + subtitle = "Someone sent you sats.", + enabled = kinds.zap, + onToggle = { settings.setKindToggle(NotifKind.ZAP, it) }, + ) + KindRow( + label = "Direct messages", + subtitle = "Encrypted DMs. Preview coming after decryption pipeline lands.", + enabled = kinds.dm, + onToggle = { settings.setKindToggle(NotifKind.DM, it) }, + ) + KindRow( + label = "Replies to your posts", + subtitle = null, + enabled = kinds.reply, + onToggle = { settings.setKindToggle(NotifKind.REPLY, it) }, + ) + KindRow( + label = "Mentions", + subtitle = "Someone tagged you in a post.", + enabled = kinds.mention, + onToggle = { settings.setKindToggle(NotifKind.MENTION, it) }, + ) + KindRow( + label = "Reposts of your posts", + subtitle = null, + enabled = kinds.repost, + onToggle = { settings.setKindToggle(NotifKind.REPOST, it) }, + ) + KindRow( + label = "Reactions", + subtitle = "Emoji reactions on your posts.", + enabled = kinds.reaction, + onToggle = { settings.setKindToggle(NotifKind.REACTION, it) }, + ) + KindRow( + label = "New followers", + subtitle = null, + enabled = kinds.follow, + onToggle = { settings.setKindToggle(NotifKind.FOLLOW, it) }, + ) + + Spacer(Modifier.height(12.dp)) + HorizontalDivider() + Spacer(Modifier.height(12.dp)) + + SectionTitle("Do Not Disturb") + DndRow( + activeUntil = dnd.manualUntilEpochSec, + onSelect = { hours -> + settings.setManualDndUntil(if (hours <= 0) null else nowEpochSeconds() + hours * 3600) + }, + ) + + Spacer(Modifier.height(12.dp)) + HorizontalDivider() + Spacer(Modifier.height(12.dp)) + + SectionTitle("Privacy") + SwitchRow( + title = "Show note preview in toast", + subtitle = "Off is safer for screen sharing. Titles never include note content.", + checked = preview, + onCheckedChange = { settings.setPreviewInToast(it) }, + ) + + Spacer(Modifier.height(12.dp)) + HorizontalDivider() + Spacer(Modifier.height(12.dp)) + + SectionTitle("Inbox") + OutlinedButton( + onClick = { readState?.markAsRead(nowEpochSeconds()) }, + enabled = readState != null, + ) { + Text("Mark all notifications as read") + } + } + } +} + +private fun platformLabel(host: HostOs): String = + when (host) { + HostOs.MAC -> "macOS" + HostOs.WINDOWS -> "Windows" + HostOs.LINUX -> "Linux" + HostOs.UNKNOWN -> "this platform" + } + +@Composable +private fun PlatformStatusCard( + host: HostOs, + nativeAvailable: Boolean, + permission: PermissionState, +) { + val label = platformLabel(host) + val (title, subtitle) = + when { + !nativeAvailable && permission == PermissionState.BundleRequired -> + "$label: launch from an `.app` bundle" to + "OS notifications need a bundled process. Run `./gradlew :desktopApp:runDistributable` and open the resulting `Amethyst.app`. `gradle run` will never trigger the macOS permission prompt because the JVM has no bundle identity." + !nativeAvailable -> + "$label: native pipeline unavailable" to + "The Nucleus native library could not be loaded. Notifications will fall back to a legacy AWT balloon (works on Windows/Linux; silently drops on macOS 11+)." + host == HostOs.MAC && permission == PermissionState.NotRequested -> + "$label: permission needed" to + "Click \"Enable OS notifications\" below. macOS will show its permission prompt — after granting, Amethyst appears in System Settings → Notifications." + host == HostOs.MAC && permission == PermissionState.Denied -> + "$label: permission denied" to + "You (or your system admin) previously denied notifications for Amethyst. Enable them again in System Settings → Notifications → Amethyst." + host == HostOs.MAC && permission == PermissionState.Granted -> + "$label: ready" to + "Notifications route through the native UNUserNotificationCenter. Toasts land in Notification Center and persist to history." + host == HostOs.WINDOWS -> + "$label: ready" to + "Toasts route through the WinRT Toast pipeline (Action Center). AUMID: com.vitorpamplona.amethyst.desktop. Verify Settings → System → Notifications → Amethyst is enabled." + host == HostOs.LINUX -> + "$label: ready" to + "Toasts route through freedesktop D-Bus. Any modern notification daemon (GNOME, KDE, XFCE, Sway, dunst, mako) will render them." + else -> + "$label: unknown platform" to + "OS notifications may not work reliably. Please file a bug." + } + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + ) { + Text(title, style = MaterialTheme.typography.labelMedium) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun SectionTitle(text: String) { + Text( + text, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(bottom = 6.dp), + ) +} + +@Composable +private fun SwitchRow( + title: String, + subtitle: String?, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.padding(end = 12.dp)) { + Text(title, style = MaterialTheme.typography.bodyMedium) + if (!subtitle.isNullOrBlank()) { + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + +@Composable +private fun KindRow( + label: String, + subtitle: String?, + enabled: Boolean, + onToggle: (Boolean) -> Unit, +) = SwitchRow(title = label, subtitle = subtitle, checked = enabled, onCheckedChange = onToggle) + +@Composable +private fun DndRow( + activeUntil: Long?, + onSelect: (Int) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val now = nowEpochSeconds() + val label = + when { + activeUntil == null || activeUntil <= now -> "DND: Off" + else -> { + val remaining = activeUntil - now + val hours = (remaining + 1800) / 3600 + if (hours <= 1) "DND on for ~1h" else "DND on for ~${hours}h" + } + } + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(label, style = MaterialTheme.typography.bodyMedium) + TextButton(onClick = { expanded = true }) { Text("Change") } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text("Off") }, + onClick = { + onSelect(0) + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("Mute for 1 hour") }, + onClick = { + onSelect(1) + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("Mute for 8 hours") }, + onClick = { + onSelect(8) + expanded = false + }, + ) + DropdownMenuItem( + text = { Text("Mute for 24 hours") }, + onClick = { + onSelect(24) + expanded = false + }, + ) + } + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/FilterBuildersTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/FilterBuildersTest.kt index f1fdbacbf1..c32cb76657 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/FilterBuildersTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/FilterBuildersTest.kt @@ -102,7 +102,14 @@ class FilterBuildersTest { fun testNotificationsForUser() { val filter = FilterBuilders.notificationsForUser(testPubKey, limit = 100) - assertEquals(listOf(1, 7, 6, 16, 9735), filter.kinds) + // Delegates to shared NotificationKinds.SUBSCRIPTION_KINDS. Assert the + // full expected set as a set so this test doesn't break when the shared + // list re-orders kinds. + assertEquals( + com.vitorpamplona.amethyst.commons.moderation.notifications + .NotificationKinds.SUBSCRIPTION_KINDS, + filter.kinds, + ) assertNotNull(filter.tags) assertEquals(listOf(testPubKey), filter.tags!!["p"]) assertEquals(100, filter.limit) @@ -113,7 +120,11 @@ class FilterBuildersTest { val since = 1609459200L val filter = FilterBuilders.notificationsForUser(testPubKey, limit = 50, since = since) - assertEquals(listOf(1, 7, 6, 16, 9735), filter.kinds) + assertEquals( + com.vitorpamplona.amethyst.commons.moderation.notifications + .NotificationKinds.SUBSCRIPTION_KINDS, + filter.kinds, + ) assertNotNull(filter.tags) assertEquals(listOf(testPubKey), filter.tags!!["p"]) assertEquals(50, filter.limit) @@ -456,7 +467,11 @@ class FilterBuildersTest { val filter = FilterBuilders.notificationsForUser(testPubKey, limit = 100) assertTrue(!filter.isEmpty()) - assertEquals(listOf(1, 7, 6, 16, 9735), filter.kinds) + assertEquals( + com.vitorpamplona.amethyst.commons.moderation.notifications + .NotificationKinds.SUBSCRIPTION_KINDS, + filter.kinds, + ) assertNotNull(filter.tags) assertEquals(listOf(testPubKey), filter.tags!!["p"]) assertEquals(100, filter.limit)