Merge pull request #3457 from nrobi144/feat/desktop-notifications

feat(desktop): notifications redesign — inbox UX, native OS toasts, shared filter
This commit is contained in:
Vitor Pamplona
2026-07-03 08:44:23 -04:00
committed by GitHub
30 changed files with 3357 additions and 155 deletions
@@ -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
+8
View File
@@ -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")
}
}
@@ -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
}
@@ -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 }
}
@@ -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)
}
@@ -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)
}
@@ -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()
@@ -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
}
}
@@ -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)
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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"
}
}
@@ -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
+5
View File
@@ -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),
)
}
@@ -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.
@@ -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
@@ -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
@@ -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",
@@ -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,
)
}
}
@@ -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),
@@ -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,
@@ -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,
)
}
}
@@ -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 }
@@ -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
}
}
@@ -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
}
@@ -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
},
)
}
}
}
@@ -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)