mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
feat(desktop): notifications redesign — inbox UX, native OS toasts, shared filter
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 <noreply@anthropic.com>
This commit is contained in:
+27
-31
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+101
@@ -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<PermissionState>
|
||||
|
||||
/**
|
||||
* 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<Boolean>
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
+151
@@ -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<Int> =
|
||||
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 }
|
||||
}
|
||||
+33
@@ -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<Long>
|
||||
|
||||
fun markAsRead(epochSec: Long)
|
||||
}
|
||||
+92
@@ -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<Boolean>
|
||||
val kinds: StateFlow<KindToggles>
|
||||
val dnd: StateFlow<DndState>
|
||||
val previewInToast: StateFlow<Boolean>
|
||||
|
||||
fun setEnabled(v: Boolean)
|
||||
|
||||
fun setKindToggle(
|
||||
kind: NotifKind,
|
||||
v: Boolean,
|
||||
)
|
||||
|
||||
fun setManualDndUntil(epochSec: Long?)
|
||||
|
||||
fun setPreviewInToast(v: Boolean)
|
||||
}
|
||||
+47
@@ -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()
|
||||
+97
@@ -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<String, Long>()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
+328
@@ -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)
|
||||
}
|
||||
}
|
||||
+212
@@ -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<Unit> {
|
||||
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
|
||||
}
|
||||
}
|
||||
+296
@@ -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<Boolean> = _nativeAvailable.asStateFlow()
|
||||
|
||||
private val _permission = MutableStateFlow<PermissionState>(initialPermissionState())
|
||||
override val permission: StateFlow<PermissionState> = _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<Boolean> { 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<PermissionState> { 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
|
||||
}
|
||||
}
|
||||
+52
@@ -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<Long> = _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"
|
||||
}
|
||||
}
|
||||
+103
@@ -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<Boolean> = _enabled.asStateFlow()
|
||||
override val kinds: StateFlow<KindToggles> = _kinds.asStateFlow()
|
||||
override val dnd: StateFlow<DndState> = _dnd.asStateFlow()
|
||||
override val previewInToast: StateFlow<Boolean> = _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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
+10
-20
@@ -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.
|
||||
|
||||
+652
-100
@@ -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<NotificationItem>(
|
||||
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<NotificationItem> { 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<NotificationFilter, Int>,
|
||||
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<String>,
|
||||
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
|
||||
) {
|
||||
|
||||
@@ -130,6 +130,7 @@ fun DeckColumnType.category(): ScreenCategory =
|
||||
|
||||
DeckColumnType.MyProfile,
|
||||
DeckColumnType.Settings,
|
||||
DeckColumnType.NotificationSettings,
|
||||
DeckColumnType.Wallet,
|
||||
-> ScreenCategory.IDENTITY
|
||||
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+27
-1
@@ -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",
|
||||
|
||||
+4
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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),
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+240
@@ -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<Boolean>,
|
||||
private val sessionStartSec: Long,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val log = Logger.getLogger(DesktopNotificationAutoDispatcher::class.java.simpleName)
|
||||
private val recentFires = HashMap<String, Long>()
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+37
@@ -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<NotificationSettings?> { null }
|
||||
|
||||
val LocalNotificationReadState = compositionLocalOf<NotificationReadState?> { null }
|
||||
|
||||
/** OS-level notification dispatcher (Nucleus-backed with AWT fallback). */
|
||||
val LocalNotificationDispatcher = compositionLocalOf<NotificationDispatcher?> { null }
|
||||
+49
@@ -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
|
||||
}
|
||||
}
|
||||
+144
@@ -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<NotificationItem.Reaction>,
|
||||
) : 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<NotificationItem.Repost>,
|
||||
) : 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<NotificationItem>): List<NotificationGroup> {
|
||||
if (items.isEmpty()) return emptyList()
|
||||
|
||||
val out = ArrayList<NotificationGroup>(items.size)
|
||||
val rxBuckets = LinkedHashMap<String, MutableList<NotificationItem.Reaction>>()
|
||||
val rxKeyToTarget = HashMap<String, Pair<String, String>>()
|
||||
val rpBuckets = LinkedHashMap<String, MutableList<NotificationItem.Repost>>()
|
||||
val rpKeyToTarget = HashMap<String, Pair<String, String>>()
|
||||
|
||||
// Track insertion position in `out` for each bucket key so we can replace
|
||||
// the placeholder once we know the full list.
|
||||
val bucketSlot = HashMap<String, Int>()
|
||||
|
||||
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<NotificationItem.Reaction>().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<NotificationItem.Repost>().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
|
||||
}
|
||||
+532
@@ -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>(PermissionState.NotApplicable) }
|
||||
)
|
||||
val nativeAvailable by (
|
||||
dispatcher?.nativeAvailable?.collectAsState()
|
||||
?: remember { mutableStateOf(false) }
|
||||
)
|
||||
val coroutineScope = androidx.compose.runtime.rememberCoroutineScope()
|
||||
var testStatus by remember { mutableStateOf<String?>(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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
-3
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user