Skip to content

Commit 7ab4a4a

Browse files
Merge pull request #9854 from rafaeltonholo/feat/9626/handle-in-app-notification-dismiss
feat(notifications): add capability to dismiss a notification using NotificationDismisser or NotificationManager
2 parents 335ed15 + 6a8b9f8 commit 7ab4a4a

File tree

44 files changed

+1499
-206
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+1499
-206
lines changed

feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/InAppNotificationHost.kt

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@ import androidx.compose.runtime.LaunchedEffect
77
import androidx.compose.runtime.collectAsState
88
import androidx.compose.runtime.getValue
99
import androidx.compose.ui.Modifier
10-
import androidx.lifecycle.compose.collectAsStateWithLifecycle
10+
import androidx.lifecycle.compose.LifecycleStartEffect
11+
import androidx.lifecycle.coroutineScope
1112
import app.k9mail.core.ui.compose.designsystem.template.Scaffold
1213
import kotlinx.collections.immutable.ImmutableSet
14+
import kotlinx.coroutines.flow.filter
15+
import kotlinx.coroutines.launch
1316
import net.thunderbird.feature.notification.api.receiver.InAppNotificationEvent
1417
import net.thunderbird.feature.notification.api.receiver.InAppNotificationReceiver
1518
import net.thunderbird.feature.notification.api.ui.action.NotificationAction
@@ -94,20 +97,22 @@ fun InAppNotificationHost(
9497
eventFilter: (InAppNotificationEvent) -> Boolean = { true },
9598
content: @Composable (PaddingValues) -> Unit,
9699
) {
97-
val inAppNotificationEvents by koinInject<InAppNotificationReceiver>()
98-
.events
99-
.collectAsStateWithLifecycle(initialValue = null)
100-
100+
val receiver = koinInject<InAppNotificationReceiver>()
101101
val state by hostStateHolder.currentInAppNotificationHostState.collectAsState()
102102

103-
LaunchedEffect(inAppNotificationEvents, eventFilter) {
104-
val event = inAppNotificationEvents
105-
if (event != null && eventFilter(event)) {
106-
when (event) {
107-
is InAppNotificationEvent.Dismiss -> Unit // TODO(#9626): Handle dismiss
108-
is InAppNotificationEvent.Show -> hostStateHolder.showInAppNotification(event.notification)
109-
}
103+
LifecycleStartEffect(receiver, eventFilter) {
104+
val job = lifecycle.coroutineScope.launch {
105+
receiver
106+
.events
107+
.filter(eventFilter)
108+
.collect { event ->
109+
when (event) {
110+
is InAppNotificationEvent.Dismiss -> hostStateHolder.dismiss(event.notification)
111+
is InAppNotificationEvent.Show -> hostStateHolder.showInAppNotification(event.notification)
112+
}
113+
}
110114
}
115+
onStopOrDispose { job.cancel() }
111116
}
112117

113118
LaunchedEffect(state.snackbarVisual, onSnackbarNotificationEvent) {

feature/notification/api/src/commonMain/composeResources/values/strings.xml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
<string name="notification_notify_error_text">An error has occurred while trying to create a system notification for a new message. The reason is most likely a missing notification sound.\n\nTap to open notification settings.</string>
1616

1717
<string name="notification_authentication_error_title">Authentication failed</string>
18-
<string name="notification_authentication_error_text">Authentication failed for %1$s. Update your server settings.</string>
18+
<string name="notification_authentication_incoming_server_error_text">Authentication failed for %1$s. Update your incoming server settings.</string>
19+
<string name="notification_authentication_outgoing_server_error_text">Authentication failed for %1$s. Update your outgoing server settings.</string>
1920

2021
<string name="notification_certificate_error_public">Certificate error</string>
2122
<string name="notification_certificate_error_title">Certificate error for %1$s</string>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package net.thunderbird.feature.notification.api
2+
3+
import net.thunderbird.feature.notification.api.dismisser.NotificationDismisser
4+
import net.thunderbird.feature.notification.api.sender.NotificationSender
5+
6+
/**
7+
* Manages sending and dismissing notifications.
8+
*
9+
* This interface combines the functionalities of [NotificationSender] and [NotificationDismisser]
10+
* to provide a unified API for notification management.
11+
*/
12+
interface NotificationManager : NotificationSender, NotificationDismisser

feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/NotificationRegistry.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,20 @@ interface NotificationRegistry {
5858
* @param notification The [Notification] object to unregister.
5959
*/
6060
fun unregister(notification: Notification)
61+
62+
/**
63+
* Checks if a specific notification is currently registered.
64+
*
65+
* @param notification The [Notification] object to check.
66+
* @return `true` if the notification is registered, `false` otherwise.
67+
*/
68+
operator fun contains(notification: Notification): Boolean
69+
70+
/**
71+
* Checks if a notification with the given [notificationId] is currently registered.
72+
*
73+
* @param notificationId The ID of the notification to check.
74+
* @return `true` if the notification is registered, `false` otherwise.
75+
*/
76+
operator fun contains(notificationId: NotificationId): Boolean
6177
}

feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/command/NotificationCommand.kt

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package net.thunderbird.feature.notification.api.command
22

3+
import androidx.annotation.Discouraged
34
import net.thunderbird.core.outcome.Outcome
5+
import net.thunderbird.feature.notification.api.NotificationId
46
import net.thunderbird.feature.notification.api.content.Notification
57
import net.thunderbird.feature.notification.api.receiver.NotificationNotifier
68

@@ -33,11 +35,24 @@ abstract class NotificationCommand<TNotification : Notification>(
3335
* Represents a successful command execution.
3436
*
3537
* @param TNotification The type of notification associated with the command.
38+
* @property notificationId The ID of the notification that was successfully acted upon.
3639
* @property command The command that was executed successfully.
3740
*/
3841
data class Success<out TNotification : Notification>(
42+
val notificationId: NotificationId,
3943
val command: NotificationCommand<out TNotification>,
40-
) : CommandOutcome
44+
) : CommandOutcome {
45+
companion object {
46+
@Discouraged(
47+
message = "This is a utility function to enable usage in Java code. " +
48+
"Use Success(NotificationId, NotificationCommand) instead.",
49+
)
50+
operator fun invoke(
51+
notificationId: Int,
52+
command: NotificationCommand<*>,
53+
): Success<Notification> = Success(NotificationId(notificationId), command)
54+
}
55+
}
4156

4257
/**
4358
* Represents a failed command execution.
@@ -47,7 +62,7 @@ abstract class NotificationCommand<TNotification : Notification>(
4762
* @property throwable The exception that caused the failure.
4863
*/
4964
data class Failure<out TNotification : Notification>(
50-
val command: NotificationCommand<out TNotification>,
65+
val command: NotificationCommand<out TNotification>?,
5166
val throwable: Throwable,
5267
) : CommandOutcome
5368
}

feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/AuthenticationErrorNotification.kt

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ import net.thunderbird.feature.notification.api.ui.icon.NotificationIcon
88
import net.thunderbird.feature.notification.api.ui.icon.NotificationIcons
99
import net.thunderbird.feature.notification.api.ui.style.inAppNotificationStyle
1010
import net.thunderbird.feature.notification.resources.api.Res
11-
import net.thunderbird.feature.notification.resources.api.notification_authentication_error_text
1211
import net.thunderbird.feature.notification.resources.api.notification_authentication_error_title
12+
import net.thunderbird.feature.notification.resources.api.notification_authentication_incoming_server_error_text
13+
import net.thunderbird.feature.notification.resources.api.notification_authentication_outgoing_server_error_text
1314
import org.jetbrains.compose.resources.getString
1415

1516
/**
@@ -25,8 +26,8 @@ data class AuthenticationErrorNotification private constructor(
2526
override val title: String,
2627
override val contentText: String?,
2728
override val channel: NotificationChannel,
28-
override val icon: NotificationIcon = NotificationIcons.AuthenticationError,
2929
) : AppNotification(), SystemNotification, InAppNotification {
30+
override val icon: NotificationIcon = NotificationIcons.AuthenticationError
3031
override val severity: NotificationSeverity = NotificationSeverity.Fatal
3132
override val actions: Set<NotificationAction> = buildSet {
3233
val action = if (isIncomingServerError) {
@@ -63,7 +64,11 @@ data class AuthenticationErrorNotification private constructor(
6364
accountNumber = accountNumber,
6465
title = getString(resource = Res.string.notification_authentication_error_title),
6566
contentText = getString(
66-
resource = Res.string.notification_authentication_error_text,
67+
resource = if (isIncomingServerError) {
68+
Res.string.notification_authentication_incoming_server_error_text
69+
} else {
70+
Res.string.notification_authentication_outgoing_server_error_text
71+
},
6772
accountDisplayName,
6873
),
6974
channel = NotificationChannel.Miscellaneous(accountUuid = accountUuid),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package net.thunderbird.feature.notification.api.dismisser
2+
3+
import kotlinx.coroutines.flow.Flow
4+
import net.thunderbird.core.outcome.Outcome
5+
import net.thunderbird.feature.notification.api.NotificationId
6+
import net.thunderbird.feature.notification.api.command.NotificationCommand.Failure
7+
import net.thunderbird.feature.notification.api.command.NotificationCommand.Success
8+
import net.thunderbird.feature.notification.api.content.Notification
9+
10+
/**
11+
* Responsible for dismissing notifications by creating and executing the appropriate commands.
12+
*/
13+
interface NotificationDismisser {
14+
/**
15+
* Dismisses a notification with the given ID.
16+
*
17+
* @param id The ID of the notification to dismiss.
18+
* @return A [Flow] of [Outcome] that emits either a [Success] with the dismissed [Notification]
19+
* or a [Failure] with the [Notification] that failed to be dismissed.
20+
*/
21+
fun dismiss(id: NotificationId): Flow<Outcome<Success<Notification>, Failure<Notification>>>
22+
23+
/**
24+
* Dismisses a notification.
25+
*
26+
* @param notification The notification to dismiss.
27+
* @return A [Flow] of [Outcome] that emits the result of the dismiss operation.
28+
* The [Outcome] will be a [Success] containing the dismissed [Notification] if the operation was successful,
29+
* or a [Failure] containing the [Notification] if the operation failed.
30+
*/
31+
fun dismiss(notification: Notification): Flow<Outcome<Success<Notification>, Failure<Notification>>>
32+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package net.thunderbird.feature.notification.api.dismisser.compat
2+
3+
import androidx.annotation.Discouraged
4+
import kotlinx.coroutines.CoroutineDispatcher
5+
import kotlinx.coroutines.CoroutineScope
6+
import kotlinx.coroutines.Dispatchers
7+
import kotlinx.coroutines.DisposableHandle
8+
import kotlinx.coroutines.SupervisorJob
9+
import kotlinx.coroutines.cancel
10+
import kotlinx.coroutines.flow.launchIn
11+
import kotlinx.coroutines.flow.onEach
12+
import net.thunderbird.core.outcome.Outcome
13+
import net.thunderbird.feature.notification.api.command.NotificationCommand.Failure
14+
import net.thunderbird.feature.notification.api.command.NotificationCommand.Success
15+
import net.thunderbird.feature.notification.api.content.Notification
16+
import net.thunderbird.feature.notification.api.dismisser.NotificationDismisser
17+
18+
/**
19+
* A compatibility layer for dismissing notifications from Java code.
20+
*
21+
* This class wraps [NotificationDismisser] and provides a Java-friendly API for sending notifications
22+
* and receiving results via a callback interface.
23+
*
24+
* It is marked as [Discouraged] because it is intended only for use within Java classes.
25+
* Kotlin code should use [NotificationDismisser] directly.
26+
*
27+
* @property notificationDismisser The underlying [NotificationDismisser] instance.
28+
* @property mainImmediateDispatcher The [CoroutineDispatcher] used for launching coroutines.
29+
*/
30+
@Discouraged("Only for usage within a Java class. Use NotificationDismisser instead.")
31+
class NotificationDismisserCompat @JvmOverloads constructor(
32+
private val notificationDismisser: NotificationDismisser,
33+
mainImmediateDispatcher: CoroutineDispatcher = Dispatchers.Main.immediate,
34+
) : DisposableHandle {
35+
private val scope = CoroutineScope(SupervisorJob() + mainImmediateDispatcher)
36+
37+
fun dismiss(notification: Notification, onResultListener: OnResultListener) {
38+
notificationDismisser.dismiss(notification)
39+
.onEach { outcome -> onResultListener.onResult(outcome) }
40+
.launchIn(scope)
41+
}
42+
43+
override fun dispose() {
44+
scope.cancel()
45+
}
46+
47+
fun interface OnResultListener {
48+
fun onResult(outcome: Outcome<Success<Notification>, Failure<Notification>>)
49+
}
50+
}

feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/receiver/NotificationNotifier.kt

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,40 @@ import net.thunderbird.feature.notification.api.NotificationId
44
import net.thunderbird.feature.notification.api.content.Notification
55

66
/**
7-
* Interface for displaying notifications.
7+
* Abstraction for components that present and manage a specific kind of notification.
88
*
9-
* This is a sealed interface, meaning that all implementations must be declared in this file.
9+
* Implementations are responsible for rendering notifications (e.g., system tray notifications,
10+
* in-app notifications) and for dismissing them when requested. The generic type parameter
11+
* allows an implementation to declare which [Notification] sub-type it can handle.
1012
*
11-
* @param TNotification The type of notification to display.
13+
* Type parameters:
14+
* @param TNotification The specific subtype of [Notification] this notifier can display. The
15+
* contravariant `in` variance allows a notifier for a base type to also accept its subtypes.
1216
*/
1317
interface NotificationNotifier<in TNotification : Notification> {
1418
/**
15-
* Shows a notification to the user.
19+
* Displays or updates a notification associated with the given [id].
1620
*
17-
* @param id The notification id. Mostly used by System Notifications.
18-
* @param notification The notification to show.
21+
* Implementations should render [notification] according to their medium. If a notification
22+
* with the same [id] is already visible, this call should update/replace it when supported
23+
* by the underlying mechanism.
24+
*
25+
* @param id A stable identifier that correlates to this notification instance across updates
26+
* and dismissal. Often maps to a system notification ID when using platform notifications.
27+
* @param notification The domain model describing what to present to the user.
1928
*/
2029
suspend fun show(id: NotificationId, notification: TNotification)
2130

31+
/**
32+
* Dismisses the notification previously shown with [id].
33+
*
34+
* If no notification is currently displayed for [id], implementations should treat this as a
35+
* no-op.
36+
*
37+
* @param id The identifier of the notification to dismiss.
38+
*/
39+
suspend fun dismiss(id: NotificationId)
40+
2241
/**
2342
* Disposes of any resources used by the notifier.
2443
*

feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/host/InAppNotificationHostStateHolder.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,21 @@ class InAppNotificationHostStateHolder(private val enabled: ImmutableSet<Display
8282
}
8383
}
8484

85+
/**
86+
* Dismisses all visual representations of the given in-app notification.
87+
*
88+
* This function will attempt to dismiss the global banner, inline banners,
89+
* and snackbar associated with the provided notification.
90+
*
91+
* @param notification The [InAppNotification] to dismiss.
92+
*/
93+
fun dismiss(notification: InAppNotification) {
94+
val data = notification.toInAppNotificationData()
95+
data.bannerInlineVisuals.singleOrNull()?.let(::dismiss)
96+
data.bannerGlobalVisual?.let(::dismiss)
97+
data.snackbarVisual?.let(::dismiss)
98+
}
99+
85100
/**
86101
* Dismisses the given in-app notification visual.
87102
*

0 commit comments

Comments
 (0)