mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
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>
331 lines
14 KiB
Kotlin
331 lines
14 KiB
Kotlin
|
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|
import org.jetbrains.kotlin.gradle.plugin.mpp.DisableCacheInKotlinVersion
|
|
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeCacheApi
|
|
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
|
|
import org.jetbrains.kotlin.gradle.plugin.mpp.TestExecutable
|
|
|
|
// Disables the Kotlin/Native compiler cache for an iOS test binary so the
|
|
// Compose ui-uikit klib recompiles fresh instead of linking the broken prebuilt
|
|
// cache (see the call site in the `kotlin {}` block). The version guard makes
|
|
// Kotlin re-surface this workaround once we move past 2.4.0, so it can be
|
|
// dropped when a newer Compose/Kotlin pairing fixes the cache. Wrapped in a
|
|
// helper because @OptIn only applies to declarations, not bare statements.
|
|
@OptIn(KotlinNativeCacheApi::class)
|
|
fun TestExecutable.disableUiKitPrebuiltCache() =
|
|
disableNativeCache(
|
|
DisableCacheInKotlinVersion.`2_4_0`,
|
|
"Compose ui-uikit prebuilt cache references UIViewLayoutRegion (iOS 17+); " +
|
|
"linking the iOS test binary fails under Xcode 16.4.",
|
|
)
|
|
|
|
plugins {
|
|
alias(libs.plugins.kotlinMultiplatform)
|
|
alias(libs.plugins.androidKotlinMultiplatformLibrary)
|
|
alias(libs.plugins.jetbrainsComposeCompiler)
|
|
alias(libs.plugins.composeMultiplatform)
|
|
alias(libs.plugins.serialization)
|
|
}
|
|
|
|
kotlin {
|
|
compilerOptions {
|
|
freeCompilerArgs.add("-Xexpect-actual-classes")
|
|
}
|
|
|
|
jvm {
|
|
compilerOptions {
|
|
jvmTarget.set(JvmTarget.JVM_21)
|
|
}
|
|
}
|
|
|
|
android {
|
|
namespace = "com.vitorpamplona.amethyst.commons"
|
|
compileSdk =
|
|
libs.versions.android.compileSdk
|
|
.get()
|
|
.toInt()
|
|
minSdk =
|
|
libs.versions.android.minSdk
|
|
.get()
|
|
.toInt()
|
|
|
|
compilerOptions {
|
|
jvmTarget.set(JvmTarget.JVM_21)
|
|
}
|
|
|
|
androidResources.enable = true
|
|
|
|
withHostTest {
|
|
isReturnDefaultValues = true
|
|
}
|
|
|
|
withDeviceTest {
|
|
instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
|
}
|
|
}
|
|
|
|
// iOS targets — Phase 2 spike. Compile-only for now (no framework binary
|
|
// configured yet). Reveals which transitive deps need iOS variants and
|
|
// which commonMain files still reach for platform-only APIs.
|
|
iosArm64()
|
|
iosSimulatorArm64()
|
|
|
|
// Compose Multiplatform 1.11.x ships an `org.jetbrains.compose.ui:ui-uikit`
|
|
// prebuilt Kotlin/Native cache whose CMPLayoutRegion object hard-references
|
|
// the UIKit class `UIViewLayoutRegion` (introduced in iOS 17). Linking the
|
|
// iOS *test* executable against that cache under Xcode 16.4 fails with
|
|
// ld: Undefined symbols: _OBJC_CLASS_$_UIViewLayoutRegion
|
|
// because the cached object was built for a newer simulator SDK (18.5) than
|
|
// the test binary is being linked for (14.0). Disabling the native cache for
|
|
// the iOS test binaries makes ui-uikit recompile against the active SDK,
|
|
// where the symbol resolves. See disableUiKitPrebuiltCache() above and
|
|
// https://kotl.in/disable-native-cache
|
|
targets.withType<KotlinNativeTarget>().configureEach {
|
|
binaries.withType<TestExecutable>().configureEach {
|
|
disableUiKitPrebuiltCache()
|
|
}
|
|
}
|
|
|
|
sourceSets {
|
|
commonMain {
|
|
dependencies {
|
|
implementation(project(":quartz"))
|
|
|
|
// Compose Multiplatform
|
|
implementation(libs.jetbrains.compose.ui)
|
|
implementation(libs.jetbrains.compose.foundation)
|
|
implementation(libs.jetbrains.compose.runtime)
|
|
implementation(libs.jetbrains.compose.material3)
|
|
implementation(libs.jetbrains.compose.ui.tooling.preview)
|
|
|
|
// Lifecycle (KMP since 2.8.0). lifecycle-viewmodel and
|
|
// lifecycle-runtime-compose ship iOS variants;
|
|
// lifecycle-viewmodel-compose (the viewModel() Composable
|
|
// helper) is Android-only and lives in jvmAndroid below.
|
|
implementation(libs.androidx.lifecycle.viewmodel)
|
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
|
|
|
// Image loading (Coil 3 - KMP). The okhttp network fetcher is
|
|
// JVM-only and lives in jvmAndroid; iOS will pull coil-ktor
|
|
// when that target wires its actual.
|
|
implementation(libs.coil.compose)
|
|
|
|
// LruCache (KMP-ready)
|
|
implementation(libs.androidx.collection)
|
|
|
|
// Immutable collections
|
|
api(libs.kotlinx.collections.immutable)
|
|
|
|
// JSON for custom-feed definitions (KMP — replaces Jackson
|
|
// for the one commonMain serializer that was blocking iOS).
|
|
implementation(libs.kotlinx.serialization.json)
|
|
|
|
// Compose Multiplatform Resources
|
|
implementation(libs.jetbrains.compose.components.resources)
|
|
|
|
// KMP syntax highlighter (Apache-2.0) for the git code browser.
|
|
implementation(libs.highlights)
|
|
}
|
|
}
|
|
|
|
commonTest {
|
|
dependencies {
|
|
implementation(libs.kotlin.test)
|
|
implementation(libs.kotlinx.coroutines.test)
|
|
}
|
|
}
|
|
|
|
// Shared JVM code for both Android and Desktop
|
|
val jvmAndroid =
|
|
create("jvmAndroid") {
|
|
dependsOn(commonMain.get())
|
|
dependencies {
|
|
// Audio-rooms ViewModel needs the listener orchestration +
|
|
// audio pipeline types (NestsListener, AudioRoomPlayer,
|
|
// AudioPlayer interface). The :nestsClient module is
|
|
// jvmAndroid-only today (its QUIC + Opus + AudioRecord/Track
|
|
// stacks are JVM-bound), so the dep lives here, not in
|
|
// commonMain. iOS will need an audio-rooms reroute when
|
|
// Phase 5 lands.
|
|
implementation(project(":nestsClient"))
|
|
|
|
// Coil's OkHttp network fetcher (JVM-only). iOS will use
|
|
// coil-ktor when the iOS Compose UI ships.
|
|
implementation(libs.coil.okhttp)
|
|
|
|
// OkHttp (+ coroutines bridge) for the link-preview fetcher
|
|
// (service/preview/UrlPreview). JVM-only; iOS will swap to
|
|
// Ktor when its UI ships.
|
|
implementation(libs.okhttp)
|
|
implementation(libs.okhttpCoroutines)
|
|
|
|
// Markdown rendering (richtext-commonmark). The single
|
|
// consumer (RenderMarkdown.kt) already lives in jvmAndroid.
|
|
// iOS support pending Phase 3 markdown decision.
|
|
implementation(libs.markdown.commonmark)
|
|
implementation(libs.markdown.ui)
|
|
implementation(libs.markdown.ui.material3)
|
|
|
|
// viewModel() Compose helper. AndroidX publishes this
|
|
// artifact for android/jvmStubs/linuxx64Stubs but not iOS,
|
|
// so it stays in jvmAndroid until we either swap to the
|
|
// org.jetbrains.androidx.lifecycle variant or accept a
|
|
// platform-specific ViewModel access pattern on iOS.
|
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
|
}
|
|
}
|
|
|
|
jvmMain {
|
|
dependsOn(jvmAndroid)
|
|
dependencies {
|
|
// Desktop-specific Compose
|
|
implementation(compose.desktop.currentOs)
|
|
implementation(libs.jetbrains.compose.ui.tooling)
|
|
|
|
// Secure key storage via OS keychain (macOS/Windows/Linux)
|
|
implementation(libs.java.keyring)
|
|
|
|
// EXIF stripping for image uploads (used by service/upload/MediaCompressor).
|
|
implementation(libs.commons.imaging)
|
|
|
|
// Image re-encode + progressive downscale (used by service/upload/ImageReencoder).
|
|
// Pure-Java, MIT. See docs/plans/2026-06-08-feat-desktop-image-compression-plan.md.
|
|
implementation(libs.thumbnailator)
|
|
|
|
// Native OS notification bridges — Nucleus per-OS JNI shims.
|
|
// macOS: UNUserNotificationCenter. Windows: WinRT Toasts. Linux: freedesktop D-Bus.
|
|
// Only the matching-OS module's native lib loads at runtime; the others
|
|
// stay dormant on the classpath.
|
|
implementation("io.github.kdroidfilter:nucleus.notification-macos:1.15.7")
|
|
implementation("io.github.kdroidfilter:nucleus.notification-windows:1.15.7")
|
|
implementation("io.github.kdroidfilter:nucleus.notification-linux:1.15.7")
|
|
}
|
|
}
|
|
|
|
androidMain {
|
|
dependsOn(jvmAndroid)
|
|
dependencies {
|
|
// Android-specific Compose tooling
|
|
implementation(libs.androidx.ui.tooling.preview)
|
|
|
|
// Secure key storage via Android Keystore
|
|
implementation(libs.androidx.security.crypto.ktx)
|
|
implementation(libs.androidx.datastore.preferences)
|
|
}
|
|
}
|
|
|
|
// iOS intermediate so iosArm64Main and iosSimulatorArm64Main share code.
|
|
val iosMain =
|
|
create("iosMain") {
|
|
dependsOn(commonMain.get())
|
|
}
|
|
getByName("iosArm64Main").dependsOn(iosMain)
|
|
getByName("iosSimulatorArm64Main").dependsOn(iosMain)
|
|
|
|
getByName("androidHostTest") {
|
|
dependencies {
|
|
implementation(libs.junit)
|
|
|
|
// Bitcoin secp256k1 bindings
|
|
implementation(libs.secp256k1.kmp.jni.jvm)
|
|
}
|
|
}
|
|
|
|
// jvmTest needs the JVM secp256k1 bindings whenever a test
|
|
// exercises a real signer (e.g. UploadOrchestratorTest, which
|
|
// signs Blossom auth events end-to-end).
|
|
getByName("jvmTest") {
|
|
dependencies {
|
|
implementation(libs.secp256k1.kmp.jni.jvm)
|
|
}
|
|
}
|
|
|
|
getByName("androidDeviceTest") {
|
|
dependencies {
|
|
implementation(libs.androidx.junit)
|
|
implementation(libs.androidx.espresso.core)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
compose.resources {
|
|
publicResClass = true
|
|
packageOfResClass = "com.vitorpamplona.amethyst.commons.resources"
|
|
generateResClass = always
|
|
}
|
|
|
|
// JVM tests run AWT-backed code (ImageIO, Thumbnailator, BufferedImage) — pin
|
|
// headless mode so a stray Toolkit.getDefaultToolkit() in a transitive dep
|
|
// never bounces the macOS Dock during CI/local test runs.
|
|
tasks.withType<Test>().configureEach {
|
|
if (name == "jvmTest") {
|
|
jvmArgs("-Djava.awt.headless=true")
|
|
}
|
|
}
|
|
|
|
// iOS purity gate — same shape as :quartz:verifyKmpPurity. See the rationale
|
|
// there. Commons gains this gate once FeedDefinitionSerializer.kt has been
|
|
// migrated off Jackson; future commonMain code must not reintroduce JVM-only
|
|
// JSON / HTTP deps.
|
|
val verifyKmpPurity by tasks.registering {
|
|
group = "verification"
|
|
description = "Fails if iOS-targeted source sets import JVM-only deps."
|
|
val checkedDirs =
|
|
listOf(
|
|
"src/commonMain", "src/commonTest",
|
|
"src/appleMain", "src/appleTest",
|
|
"src/nativeMain", "src/nativeTest",
|
|
"src/iosMain", "src/iosTest",
|
|
"src/iosArm64Main", "src/iosArm64Test",
|
|
"src/iosSimulatorArm64Main", "src/iosSimulatorArm64Test",
|
|
"src/linuxMain", "src/linuxTest",
|
|
"src/linuxX64Main", "src/linuxX64Test",
|
|
"src/macosMain", "src/macosTest",
|
|
"src/macosArm64Main", "src/macosArm64Test",
|
|
).map { layout.projectDirectory.dir(it).asFile }
|
|
.filter { it.exists() }
|
|
inputs.files(checkedDirs)
|
|
doLast {
|
|
// Each pattern is paired with a short hint so the failure message
|
|
// points at the canonical KMP replacement.
|
|
val forbidden =
|
|
listOf(
|
|
"com.fasterxml.jackson" to "Jackson is JVM-only — use kotlinx.serialization",
|
|
"okhttp3" to "OkHttp is JVM-only — wrap behind expect/actual or use Ktor on iOS",
|
|
"System.currentTimeMillis" to "use TimeUtils.now()",
|
|
"Thread.sleep" to "use kotlinx.coroutines.delay or platform-specific actual",
|
|
"java.util.UUID" to "use kotlin.uuid.Uuid",
|
|
"kotlin.jvm.Synchronized" to "use KmpLock.withLock {}",
|
|
"kotlin.jvm.Volatile" to "use kotlin.concurrent.Volatile",
|
|
)
|
|
val offenders =
|
|
checkedDirs.flatMap { dir ->
|
|
dir.walkTopDown()
|
|
.filter { it.isFile && it.extension == "kt" }
|
|
.flatMap { file ->
|
|
file.readLines().withIndex().mapNotNull { (idx, line) ->
|
|
val trimmed = line.trimStart()
|
|
// Skip KDoc / line-comment lines — those legitimately
|
|
// mention forbidden names (migration notes, doc refs).
|
|
if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) {
|
|
return@mapNotNull null
|
|
}
|
|
forbidden.firstOrNull { (pattern, _) -> line.contains(pattern) }?.let { (hit, hint) ->
|
|
"${file.relativeTo(rootDir)}:${idx + 1}: '$hit' — $hint"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (offenders.isNotEmpty()) {
|
|
throw GradleException(
|
|
"iOS-targeted source sets must not reference JVM-only APIs. " +
|
|
"Move the offending code to jvmAndroid/ or behind an expect/actual:\n " +
|
|
offenders.joinToString("\n "),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
tasks.named("check").configure { dependsOn(verifyKmpPurity) }
|