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