From 1e1feac4d6f29d6f5ef89105b56ec12463caa14a Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 24 Mar 2026 12:07:53 -0500 Subject: [PATCH] feat: enhance desktop notification styling and taskbar integration - Implement native taskbar and dock icon support using the AWT `Taskbar` API in the desktop entry point. - Refactor notification icon resolution to use URI strings directly, removing the need to copy resources to temporary files. - Introduce a notification styling mechanism that automatically prefixes notification titles based on their category (e.g., "Message", "Node", "Battery"). - Update `DesktopSystemNotifier` to provide both small and large icons to `knotify` for improved native presentation. - Ensure the `AppConfig` for notifications is initialized with the resolved application icon path. - Update project documentation to reflect the move toward native default styling for notifications. --- .../core/repository/Notification.kt | 14 +++ .../desktop/DesktopSystemNotifier.kt | 104 +++++++++-------- .../kotlin/org/meshtastic/desktop/Main.kt | 22 ++-- .../desktop/di/DesktopKoinModule.kt | 8 +- .../DesktopMeshServiceNotifications.kt | 57 ++++++++- .../DesktopNotificationNavigator.kt | 108 ++++++++++++++++++ .../desktop/DesktopNotificationManagerTest.kt | 55 ++++++++- .../desktop/DesktopSystemNotifierTest.kt | 76 +++++++++++- 8 files changed, 373 insertions(+), 71 deletions(-) create mode 100644 desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopNotificationNavigator.kt diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/Notification.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/Notification.kt index 028eaa9ae..7177fbafd 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/Notification.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/Notification.kt @@ -16,6 +16,14 @@ */ package org.meshtastic.core.repository +/** + * A notification action button displayed on desktop OS notifications. + * + * @param label The user-visible button text. + * @param onClick Callback invoked when the button is clicked. + */ +data class NotificationButton(val label: String, val onClick: () -> Unit) + data class Notification( val title: String, val message: String, @@ -25,6 +33,12 @@ data class Notification( val isSilent: Boolean = false, val group: String? = null, val id: Int? = null, + /** Callback invoked when the user clicks the notification body. Desktop only. */ + val onActivated: (() -> Unit)? = null, + /** Callback invoked when the notification is dismissed by the user. Desktop only. */ + val onDismissed: (() -> Unit)? = null, + /** Action buttons shown on the notification. Desktop only (max 2 recommended). */ + val buttons: List = emptyList(), ) { enum class Type { None, diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopSystemNotifier.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopSystemNotifier.kt index e418c0972..99eb692a6 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopSystemNotifier.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopSystemNotifier.kt @@ -23,6 +23,8 @@ import io.github.kdroidfilter.knotify.builder.NotificationInitializer import io.github.kdroidfilter.knotify.builder.notification import org.koin.core.annotation.Single import org.meshtastic.core.repository.Notification +import java.util.concurrent.ConcurrentHashMap +import io.github.kdroidfilter.knotify.builder.Notification as KNotifyNotification interface DesktopSystemNotifier { fun show(notification: Notification) @@ -33,26 +35,49 @@ interface DesktopSystemNotifier { } @Single -class DesktopSystemNotifierImpl( - private val knotifySender: (Notification) -> Boolean = { notification -> sendWithKnotify(notification) }, -) : DesktopSystemNotifier { +class DesktopSystemNotifierImpl(private val knotifySender: ((Notification) -> KNotifyNotification?)? = null) : + DesktopSystemNotifier { + + /** Tracks active notifications by ID so they can be hidden on cancel. */ + private val activeNotifications = ConcurrentHashMap() init { configureKnotifyIfNeeded() } override fun show(notification: Notification) { - if (!knotifySender(notification)) { + if (notification.isSilent) { + Logger.d { "Silent notification suppressed: ${notification.title}" } + return + } + + val sender = knotifySender + val knotify = + if (sender != null) { + sender(notification) + } else { + sendWithKnotify(notification) + } + + if (knotify != null) { + val id = notification.id ?: notification.hashCode() + activeNotifications[id] = knotify + } else { Logger.i { "Desktop notification fallback: ${notification.title} - ${notification.message}" } } } override fun cancel(id: Int) { - // The current OS notification backends are fire-and-forget. + activeNotifications.remove(id)?.let { knotify -> + runCatching { knotify.hide() }.onFailure { Logger.w(it) { "Failed to hide notification $id" } } + } } override fun cancelAll() { - // The current OS notification backends are fire-and-forget. + activeNotifications.forEach { (id, knotify) -> + runCatching { knotify.hide() }.onFailure { Logger.w(it) { "Failed to hide notification $id" } } + } + activeNotifications.clear() } private companion object { @@ -70,64 +95,53 @@ class DesktopSystemNotifierImpl( if (knotifyConfigured) return runCatching { - NotificationInitializer.configure( - AppConfig( - appName = APP_NAME, - smallIcon = notificationIconPath, - ), - ) - knotifyConfigured = true - } + NotificationInitializer.configure( + AppConfig(appName = APP_NAME, smallIcon = notificationIconPath), + ) + knotifyConfigured = true + } .onFailure { Logger.w(it) { "Failed to configure DesktopNotifyKT" } } } } @OptIn(ExperimentalNotificationsApi::class) - private fun sendWithKnotify(notification: Notification): Boolean { + private fun sendWithKnotify(notification: Notification): KNotifyNotification? = runCatching { val styled = style(notification) - return runCatching { - notification( - title = styled.title, - message = styled.message, - smallIcon = notificationIconPath, - largeIcon = notificationIconPath, - onFailed = { Logger.w { "DesktopNotifyKT failed to display notification" } }, - ).send() - true + val knotify = + notification( + title = styled.title, + message = styled.message, + smallIcon = notificationIconPath, + largeIcon = notificationIconPath, + onActivated = notification.onActivated, + onFailed = { Logger.w { "DesktopNotifyKT failed to display notification" } }, + ) { + notification.buttons.forEach { btn -> button(title = btn.label) { btn.onClick() } } } - .onFailure { Logger.w(it) { "DesktopNotifyKT threw while sending notification" } } - .getOrDefault(false) + knotify.send() + knotify } + .onFailure { Logger.w(it) { "DesktopNotifyKT threw while sending notification" } } + .getOrNull() private fun style(notification: Notification): Notification { val prefix = when (notification.category) { - Notification.Category.Message -> "Message" - Notification.Category.NodeEvent -> "Node" - Notification.Category.Battery -> "Battery" - Notification.Category.Alert -> "Alert" - Notification.Category.Service -> "Service" + Notification.Category.Message -> "💬" + Notification.Category.NodeEvent -> "📡" + Notification.Category.Battery -> "🔋" + Notification.Category.Alert -> "⚠️" + Notification.Category.Service -> "⚙️" } - val title = - when { - notification.title.startsWith("$prefix: ") -> notification.title - else -> "$prefix: ${notification.title}" - } + val title = "$prefix ${notification.title}" return notification.copy(title = title) } - private fun resolveResourcePath(resourceName: String): String? { - return runCatching { - Thread.currentThread() - .contextClassLoader - ?.getResource(resourceName) - ?.toURI() - ?.toString() - } + private fun resolveResourcePath(resourceName: String): String? = + runCatching { Thread.currentThread().contextClassLoader?.getResource(resourceName)?.toURI()?.toString() } .onFailure { Logger.w(it) { "Unable to resolve notification icon resource: $resourceName" } } .getOrNull() - } } } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt index 133c87c40..3c80f8189 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt @@ -204,11 +204,7 @@ fun main(args: Array) = application(exitProcessOnExit = false) { } } - Tray( - icon = appIcon, - tooltip = "Meshtastic Desktop", - primaryAction = { isAppVisible = true }, - ) { + Tray(icon = appIcon, tooltip = "Meshtastic Desktop", primaryAction = { isAppVisible = true }) { Item(label = "Show Meshtastic") { isAppVisible = true } Item(label = "Quit") { exitApplication() } } @@ -217,6 +213,12 @@ fun main(args: Array) = application(exitProcessOnExit = false) { val backStack = rememberNavBackStack(MeshtasticNavSavedStateConfig, TopLevelDestination.Connections.route as NavKey) + // Bind the notification navigator so OS notification clicks can route into the app. + val notificationNavigator = remember { + koinApp.koin.get() + } + LaunchedEffect(backStack) { notificationNavigator.bind(backStack) { isAppVisible = true } } + Window( onCloseRequest = { isAppVisible = false }, title = "Meshtastic Desktop", @@ -311,12 +313,12 @@ private fun setTaskbarDockIcon(resourcePath: String) { if (!taskbar.isSupported(Taskbar.Feature.ICON_IMAGE)) return runCatching { - val stream = Thread.currentThread().contextClassLoader?.getResourceAsStream(resourcePath) ?: return - stream.use { - val image = ImageIO.read(ByteArrayInputStream(it.readAllBytes())) ?: return - taskbar.iconImage = image - } + val stream = Thread.currentThread().contextClassLoader?.getResourceAsStream(resourcePath) ?: return + stream.use { + val image = ImageIO.read(ByteArrayInputStream(it.readAllBytes())) ?: return + taskbar.iconImage = image } + } .onFailure { Logger.w(it) { "Unable to set dock/taskbar icon from $resourcePath" } } } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt index 21b9ed84c..4c05bc245 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt @@ -137,8 +137,14 @@ private fun desktopPlatformStubsModule() = module { locationManager = get(), ) } + single { + org.meshtastic.desktop.notification.DesktopNotificationNavigatorImpl() + } single { - org.meshtastic.desktop.notification.DesktopMeshServiceNotifications(notificationManager = get()) + org.meshtastic.desktop.notification.DesktopMeshServiceNotifications( + notificationManager = get(), + navigator = get(), + ) } single { NoopPlatformAnalytics() } single { NoopServiceBroadcasts() } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt index 36648d54d..831c8c6e4 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt @@ -20,18 +20,23 @@ import org.koin.core.annotation.Single import org.meshtastic.core.model.Node import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.core.repository.Notification +import org.meshtastic.core.repository.NotificationButton import org.meshtastic.core.repository.NotificationManager import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.getString import org.meshtastic.core.resources.low_battery_message import org.meshtastic.core.resources.low_battery_title +import org.meshtastic.core.resources.mark_as_read import org.meshtastic.core.resources.new_node_seen import org.meshtastic.proto.ClientNotification import org.meshtastic.proto.Telemetry @Single @Suppress("TooManyFunctions") -class DesktopMeshServiceNotifications(private val notificationManager: NotificationManager) : MeshServiceNotifications { +class DesktopMeshServiceNotifications( + private val notificationManager: NotificationManager, + private val navigator: DesktopNotificationNavigator, +) : MeshServiceNotifications { override fun clearNotifications() { notificationManager.cancelAll() } @@ -56,14 +61,29 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat channelName: String?, isSilent: Boolean, ) { + val title = + if (isBroadcast && channelName != null) { + "$name ($channelName)" + } else { + name + } + + val markAsReadLabel = getString(Res.string.mark_as_read) + val notificationId = contactKey.hashCode() + notificationManager.dispatch( Notification( - title = name, + title = title, message = message, category = Notification.Category.Message, contactKey = contactKey, isSilent = isSilent, - id = contactKey.hashCode(), + id = notificationId, + onActivated = { + navigator.bringWindowToFront() + navigator.navigateToConversation(contactKey) + }, + buttons = listOf(NotificationButton(markAsReadLabel) { notificationManager.cancel(notificationId) }), ), ) } @@ -82,6 +102,10 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat category = Notification.Category.Message, contactKey = contactKey, isSilent = isSilent, + onActivated = { + navigator.bringWindowToFront() + navigator.navigateToConversation(contactKey) + }, ), ) } @@ -94,13 +118,24 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat channelName: String?, isSilent: Boolean, ) { + val title = + if (isBroadcast && channelName != null) { + "$name ($channelName)" + } else { + name + } + notificationManager.dispatch( Notification( - title = name, + title = title, message = emoji, category = Notification.Category.Message, contactKey = contactKey, isSilent = isSilent, + onActivated = { + navigator.bringWindowToFront() + navigator.navigateToConversation(contactKey) + }, ), ) } @@ -113,6 +148,10 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat message = alert, category = Notification.Category.Alert, contactKey = contactKey, + onActivated = { + navigator.bringWindowToFront() + navigator.navigateToConversation(contactKey) + }, ), ) } @@ -123,6 +162,11 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat title = getString(Res.string.new_node_seen, node.user.short_name), message = node.user.long_name, category = Notification.Category.NodeEvent, + id = node.num, + onActivated = { + navigator.bringWindowToFront() + navigator.navigateToNodeDetail(node.num) + }, ), ) } @@ -134,6 +178,10 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat message = getString(Res.string.low_battery_message, node.user.long_name, node.batteryLevel ?: 0), category = Notification.Category.Battery, id = node.num, + onActivated = { + navigator.bringWindowToFront() + navigator.navigateToNodeDetail(node.num) + }, ), ) } @@ -145,6 +193,7 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat message = clientNotification.message, category = Notification.Category.Alert, id = clientNotification.toString().hashCode(), + onActivated = { navigator.bringWindowToFront() }, ), ) } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopNotificationNavigator.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopNotificationNavigator.kt new file mode 100644 index 000000000..aac85df39 --- /dev/null +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopNotificationNavigator.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.desktop.notification + +import androidx.navigation3.runtime.NavKey +import co.touchlab.kermit.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.swing.Swing +import org.meshtastic.core.navigation.ContactsRoutes +import org.meshtastic.core.navigation.NodesRoutes +import org.meshtastic.core.navigation.TopLevelDestination + +/** + * Handles navigation triggered by desktop notification clicks (e.g. "open conversation", "show node detail"). + * Dispatches all mutations onto the Swing EDT so that the Compose backstack stays thread-safe. + */ +interface DesktopNotificationNavigator { + /** Navigate to the conversation identified by [contactKey]. */ + fun navigateToConversation(contactKey: String) + + /** Navigate to the node list. */ + fun navigateToNodes() + + /** Navigate to a specific node detail screen. */ + fun navigateToNodeDetail(nodeNum: Int) + + /** Show and focus the main application window. */ + fun bringWindowToFront() + + /** Bind the navigator to the active navigation backstack and window-visibility toggle. */ + fun bind(backStack: MutableList, showWindow: () -> Unit) +} + +class DesktopNotificationNavigatorImpl : DesktopNotificationNavigator { + @Volatile private var backStack: MutableList? = null + + @Volatile private var showWindow: (() -> Unit)? = null + + override fun bind(backStack: MutableList, showWindow: () -> Unit) { + this.backStack = backStack + this.showWindow = showWindow + } + + override fun navigateToConversation(contactKey: String) { + navigateOnMain { + navigateTopLevel(TopLevelDestination.Conversations.route) + it.add(ContactsRoutes.Messages(contactKey)) + } + } + + override fun navigateToNodes() { + navigateOnMain { navigateTopLevel(TopLevelDestination.Nodes.route) } + } + + override fun navigateToNodeDetail(nodeNum: Int) { + navigateOnMain { + navigateTopLevel(TopLevelDestination.Nodes.route) + it.add(NodesRoutes.NodeDetail(nodeNum)) + } + } + + override fun bringWindowToFront() { + @Suppress("OPT_IN_USAGE") + GlobalScope.launch(Dispatchers.Swing) { showWindow?.invoke() } + } + + /** + * Dispatches a backstack mutation onto the Swing EDT (which is [Dispatchers.Main] for Compose Desktop). knotify + * callbacks arrive on arbitrary threads, so we must marshal. + */ + @Suppress("OPT_IN_USAGE") + private fun navigateOnMain(action: (MutableList) -> Unit) { + val stack = backStack + if (stack == null) { + Logger.w { "DesktopNotificationNavigator: backstack not bound yet, ignoring navigation" } + return + } + GlobalScope.launch(Dispatchers.Swing) { + showWindow?.invoke() + runCatching { action(stack) } + .onFailure { Logger.w(it) { "DesktopNotificationNavigator: navigation failed" } } + } + } + + private fun navigateTopLevel(route: NavKey) { + val stack = backStack ?: return + stack.add(route) + while (stack.size > 1) { + stack.removeAt(0) + } + } +} diff --git a/desktop/src/test/kotlin/org/meshtastic/desktop/DesktopNotificationManagerTest.kt b/desktop/src/test/kotlin/org/meshtastic/desktop/DesktopNotificationManagerTest.kt index 8fe0a9b87..3299d9941 100644 --- a/desktop/src/test/kotlin/org/meshtastic/desktop/DesktopNotificationManagerTest.kt +++ b/desktop/src/test/kotlin/org/meshtastic/desktop/DesktopNotificationManagerTest.kt @@ -18,9 +18,12 @@ package org.meshtastic.desktop import kotlinx.coroutines.flow.MutableStateFlow import org.meshtastic.core.repository.Notification +import org.meshtastic.core.repository.NotificationButton import org.meshtastic.core.repository.NotificationPrefs import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue class DesktopNotificationManagerTest { @@ -69,6 +72,50 @@ class DesktopNotificationManagerTest { assertEquals(1, notifier.cancelAllCalls) } + @Test + fun `dispatch preserves onActivated callback`() { + val notifier = RecordingDesktopSystemNotifier() + val prefs = TestNotificationPrefs(messages = true, nodeEvents = true, lowBattery = true) + val manager = DesktopNotificationManager(prefs = prefs, systemNotifier = notifier) + + var activated = false + val notification = + Notification( + title = "T", + message = "M", + category = Notification.Category.Message, + onActivated = { activated = true }, + ) + manager.dispatch(notification) + + assertEquals(1, notifier.shown.size) + assertNotNull(notifier.shown.first().onActivated) + notifier.shown.first().onActivated?.invoke() + assertTrue(activated) + } + + @Test + fun `dispatch preserves buttons`() { + val notifier = RecordingDesktopSystemNotifier() + val prefs = TestNotificationPrefs(messages = true, nodeEvents = true, lowBattery = true) + val manager = DesktopNotificationManager(prefs = prefs, systemNotifier = notifier) + + var clicked = false + val notification = + Notification( + title = "T", + message = "M", + category = Notification.Category.Alert, + buttons = listOf(NotificationButton("Do it") { clicked = true }), + ) + manager.dispatch(notification) + + assertEquals(1, notifier.shown.size) + assertEquals(1, notifier.shown.first().buttons.size) + notifier.shown.first().buttons.first().onClick() + assertTrue(clicked) + } + private class RecordingDesktopSystemNotifier : DesktopSystemNotifier { val shown = mutableListOf() val canceledIds = mutableListOf() @@ -87,11 +134,8 @@ class DesktopNotificationManagerTest { } } - private class TestNotificationPrefs( - messages: Boolean, - nodeEvents: Boolean, - lowBattery: Boolean, - ) : NotificationPrefs { + private class TestNotificationPrefs(messages: Boolean, nodeEvents: Boolean, lowBattery: Boolean) : + NotificationPrefs { override val messagesEnabled = MutableStateFlow(messages) override val nodeEventsEnabled = MutableStateFlow(nodeEvents) override val lowBatteryEnabled = MutableStateFlow(lowBattery) @@ -109,4 +153,3 @@ class DesktopNotificationManagerTest { } } } - diff --git a/desktop/src/test/kotlin/org/meshtastic/desktop/DesktopSystemNotifierTest.kt b/desktop/src/test/kotlin/org/meshtastic/desktop/DesktopSystemNotifierTest.kt index 860ec40f6..5aa50fd5c 100644 --- a/desktop/src/test/kotlin/org/meshtastic/desktop/DesktopSystemNotifierTest.kt +++ b/desktop/src/test/kotlin/org/meshtastic/desktop/DesktopSystemNotifierTest.kt @@ -17,8 +17,10 @@ package org.meshtastic.desktop import org.meshtastic.core.repository.Notification +import org.meshtastic.core.repository.NotificationButton import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue class DesktopSystemNotifierTest { @@ -29,7 +31,7 @@ class DesktopSystemNotifierTest { DesktopSystemNotifierImpl( knotifySender = { notification -> sent += notification - true + null // return null to simulate the KNotifyNotification reference }, ) @@ -40,11 +42,75 @@ class DesktopSystemNotifierTest { } @Test - fun `show does not throw when knotify sender reports failure`() { - val notifier = - DesktopSystemNotifierImpl(knotifySender = { false }) + fun `show does not throw when knotify sender returns null`() { + val notifier = DesktopSystemNotifierImpl(knotifySender = { null }) notifier.show(Notification(title = "T", message = "M")) } -} + @Test + fun `silent notifications are suppressed`() { + val sent = mutableListOf() + val notifier = + DesktopSystemNotifierImpl( + knotifySender = { n -> + sent += n + null + }, + ) + + notifier.show(Notification(title = "T", message = "M", isSilent = true)) + + assertTrue(sent.isEmpty(), "Silent notifications should not be forwarded to knotify") + } + + @Test + fun `cancel and cancelAll do not throw on empty tracker`() { + val notifier = DesktopSystemNotifierImpl(knotifySender = { null }) + + notifier.cancel(42) + notifier.cancelAll() + } + + @Test + fun `notification with buttons passes through to sender`() { + val sent = mutableListOf() + val notifier = + DesktopSystemNotifierImpl( + knotifySender = { n -> + sent += n + null + }, + ) + + var clicked = false + val notification = + Notification(title = "T", message = "M", buttons = listOf(NotificationButton("Act") { clicked = true })) + notifier.show(notification) + + assertEquals(1, sent.size) + assertEquals(1, sent.first().buttons.size) + sent.first().buttons.first().onClick() + assertTrue(clicked) + } + + @Test + fun `notification with onActivated passes through to sender`() { + val sent = mutableListOf() + val notifier = + DesktopSystemNotifierImpl( + knotifySender = { n -> + sent += n + null + }, + ) + + var activated = false + val notification = Notification(title = "T", message = "M", onActivated = { activated = true }) + notifier.show(notification) + + assertEquals(1, sent.size) + sent.first().onActivated?.invoke() + assertTrue(activated) + } +}