Repository: thunderbird/thunderbird-android (language: kotlin)

Issue:
In-App Error Notifications - Scaffolding - Create InAppNotificationHost



Discussion hints:
nan

Pull request: feat(notifications): add in-app notification scaffold and host
Resolves #9537.
Part of #9312.
Depends on #9624.

- Introduce `InAppNotificationHost` used to properly show, hide and dismiss in-app notifications, listening events from `InAppNotificationReceiver`
- Introduce `InAppNotificationScaffold` acting as a wrapper for `Scaffold` composable, letting any screen which uses it be able to display in-app notifications without many changes
- Migrate `SnackbarNotification.duration` from `kotlin.time.Duration` to `SnackbarDuration`, making it compatible with Compose implementation
- Unit test

Test command: ./gradlew :feature:notification:api:testDebugUnitTest --tests "net.thunderbird.feature.notification.api.ui.host.InAppNotificationHostStateHolderTest" --tests "net.thunderbird.feature.notification.api.ui.BannerGlobalNotificationHostTest" --tests "net.thunderbird.feature.notification.api.ui.BannerInlineNotificationListHostTest" --tests "net.thunderbird.feature.notification.api.ui.InAppNotificationScaffoldTest" --tests "net.thunderbird.feature.notification.api.ui.style.InAppNotificationStyleTest"

Fix patch:
diff --git a/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/SecretDebugSettingsScreen.kt b/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/SecretDebugSettingsScreen.kt
index de310296608..5fed06dedc3 100644
--- a/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/SecretDebugSettingsScreen.kt
+++ b/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/SecretDebugSettingsScreen.kt
@@ -10,16 +10,16 @@ import androidx.compose.ui.res.stringResource
 import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonIcon
 import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons
 import app.k9mail.core.ui.compose.designsystem.organism.TopAppBar
-import app.k9mail.core.ui.compose.designsystem.template.Scaffold
 import app.k9mail.core.ui.compose.theme2.MainTheme
 import net.thunderbird.feature.debug.settings.notification.DebugNotificationSection
+import net.thunderbird.feature.notification.api.ui.InAppNotificationScaffold
 
 @Composable
 fun SecretDebugSettingsScreen(
     onNavigateBack: () -> Unit,
     modifier: Modifier = Modifier,
 ) {
-    Scaffold(
+    InAppNotificationScaffold(
         topBar = {
             TopAppBar(
                 title = stringResource(R.string.debug_settings_screen_title),
diff --git a/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/BannerGlobalNotificationHost.kt b/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/BannerGlobalNotificationHost.kt
index 8284e4ab0a9..1e637ca2b82 100644
--- a/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/BannerGlobalNotificationHost.kt
+++ b/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/BannerGlobalNotificationHost.kt
@@ -12,6 +12,7 @@ import app.k9mail.core.ui.compose.designsystem.organism.banner.global.InfoBanner
 import app.k9mail.core.ui.compose.designsystem.organism.banner.global.WarningBannerGlobalNotificationCard
 import net.thunderbird.core.ui.compose.common.modifier.testTagAsResourceId
 import net.thunderbird.feature.notification.api.NotificationSeverity
+import net.thunderbird.feature.notification.api.ui.BannerGlobalNotificationHostDefaults.TEST_TAG_BANNER_GLOBAL_ACTION
 import net.thunderbird.feature.notification.api.ui.action.NotificationAction
 import net.thunderbird.feature.notification.api.ui.action.ResolvedNotificationActionButton
 import net.thunderbird.feature.notification.api.ui.animation.bannerSlideInSlideOutAnimationSpec
@@ -62,6 +63,7 @@ private fun BannerGlobalNotificationHostLayout(
                 ResolvedNotificationActionButton(
                     action = action,
                     onActionClick = onActionClick,
+                    modifier = Modifier.testTagAsResourceId(TEST_TAG_BANNER_GLOBAL_ACTION),
                 )
             }
         }
@@ -93,4 +95,5 @@ object BannerGlobalNotificationHostDefaults {
     internal const val TEST_TAG_ERROR_BANNER = "error_banner_global_notification"
     internal const val TEST_TAG_WARNING_BANNER = "warning_banner_global_notification"
     internal const val TEST_TAG_INFO_BANNER = "info_banner_global_notification"
+    internal const val TEST_TAG_BANNER_GLOBAL_ACTION = "banner_global_action"
 }
diff --git a/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/BannerInlineNotificationListHost.kt b/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/BannerInlineNotificationListHost.kt
index 6f1f0e10820..c25ad7afc2e 100644
--- a/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/BannerInlineNotificationListHost.kt
+++ b/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/BannerInlineNotificationListHost.kt
@@ -18,6 +18,7 @@ import kotlinx.collections.immutable.ImmutableSet
 import net.thunderbird.core.ui.compose.common.modifier.testTagAsResourceId
 import net.thunderbird.feature.notification.api.ui.BannerInlineNotificationListHostDefaults.TEST_TAG_BANNER_INLINE_LIST
 import net.thunderbird.feature.notification.api.ui.BannerInlineNotificationListHostDefaults.TEST_TAG_CHECK_ERROR_NOTIFICATIONS
+import net.thunderbird.feature.notification.api.ui.BannerInlineNotificationListHostDefaults.TEST_TAG_CHECK_ERROR_NOTIFICATIONS_ACTION
 import net.thunderbird.feature.notification.api.ui.BannerInlineNotificationListHostDefaults.TEST_TAG_HOST_PARENT
 import net.thunderbird.feature.notification.api.ui.action.NotificationAction
 import net.thunderbird.feature.notification.api.ui.action.ResolvedNotificationActionButton
@@ -101,15 +102,21 @@ private fun BannerInlineNotificationListHostLayout(
             .testTagAsResourceId(TEST_TAG_BANNER_INLINE_LIST),
         verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.half),
     ) {
-        displayableNotifications.forEach { banner ->
+        displayableNotifications.forEachIndexed { index, banner ->
             ErrorBannerInlineNotificationCard(
                 title = banner.title,
                 supportingText = banner.supportingText,
                 actions = {
-                    banner.actions.forEach { action ->
+                    banner.actions.forEachIndexed { actionIndex, action ->
                         ResolvedNotificationActionButton(
                             action = action,
                             onActionClick = onActionClick,
+                            modifier = Modifier.testTagAsResourceId(
+                                tag = BannerInlineNotificationListHostDefaults.testTagBannerInlineListItemAction(
+                                    index = index,
+                                    actionIndex = actionIndex,
+                                ),
+                            ),
                         )
                     }
                 },
@@ -129,6 +136,7 @@ private fun BannerInlineNotificationListHostLayout(
                             resource = Res.string.banner_inline_notification_open_notifications,
                         ),
                         onClick = onOpenErrorNotificationsClick,
+                        modifier = Modifier.testTagAsResourceId(TEST_TAG_CHECK_ERROR_NOTIFICATIONS_ACTION),
                     )
                 },
                 modifier = Modifier.testTagAsResourceId(TEST_TAG_CHECK_ERROR_NOTIFICATIONS),
@@ -141,4 +149,8 @@ object BannerInlineNotificationListHostDefaults {
     internal const val TEST_TAG_HOST_PARENT = "banner_inline_notification_host"
     internal const val TEST_TAG_BANNER_INLINE_LIST = "banner_inline_notification_list"
     internal const val TEST_TAG_CHECK_ERROR_NOTIFICATIONS = "check_notifications_composable"
+    internal const val TEST_TAG_CHECK_ERROR_NOTIFICATIONS_ACTION = "check_notifications_action"
+
+    internal fun testTagBannerInlineListItemAction(index: Int, actionIndex: Int) =
+        "banner_inline_notification_list_item_action_${index}_$actionIndex"
 }
diff --git a/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/InAppNotificationHost.kt b/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/InAppNotificationHost.kt
new file mode 100644
index 00000000000..6863d66e7a5
--- /dev/null
+++ b/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/InAppNotificationHost.kt
@@ -0,0 +1,142 @@
+package net.thunderbird.feature.notification.api.ui
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import app.k9mail.core.ui.compose.designsystem.template.Scaffold
+import kotlinx.collections.immutable.ImmutableSet
+import net.thunderbird.feature.notification.api.receiver.InAppNotificationEvent
+import net.thunderbird.feature.notification.api.receiver.InAppNotificationReceiver
+import net.thunderbird.feature.notification.api.ui.action.NotificationAction
+import net.thunderbird.feature.notification.api.ui.host.DisplayInAppNotificationFlag
+import net.thunderbird.feature.notification.api.ui.host.InAppNotificationHostStateHolder
+import net.thunderbird.feature.notification.api.ui.host.rememberInAppNotificationHostStateHolder
+import net.thunderbird.feature.notification.api.ui.host.visual.SnackbarVisual
+import net.thunderbird.feature.notification.api.ui.layout.InAppNotificationHostLayout
+import net.thunderbird.feature.notification.api.ui.layout.rememberBannerInlineScrollBehaviour
+import org.koin.compose.koinInject
+
+/**
+ * Host used to properly show, hide and dismiss in-app notifications listening events from
+ * [InAppNotificationReceiver].
+ *
+ * This component takes in consideration the [InAppNotificationHostStateHolder]'s enabled flags to
+ * decide which kind of notifications to show.
+ *
+ * Additionally, the events can be filtered by passing a [eventFilter] lambda.
+ *
+ * @param onActionClick invoked when a notification action is clicked.
+ * @param modifier the modifier to apply to this layout.
+ * @param enabled a set of [DisplayInAppNotificationFlag] that determines which types of notifications are displayed.
+ * @param contentPadding a padding around the whole content.
+ * @param onSnackbarNotificationEvent invoked when a snackbar notification event is triggered. This is
+ *  required as the snackbar component can be shared among other screens via [InAppNotificationScaffold] or [Scaffold]
+ * @param eventFilter a lambda to filter in-app notification events. Only events for which this lambda returns
+ * true will be processed.
+ * @param content a block which describes the content. This composable will rearrange the content to properly
+ * display the in-app notifications.
+ */
+@Composable
+fun InAppNotificationHost(
+    onActionClick: (NotificationAction) -> Unit,
+    modifier: Modifier = Modifier,
+    enabled: ImmutableSet<DisplayInAppNotificationFlag> = DisplayInAppNotificationFlag.AllNotifications,
+    contentPadding: PaddingValues = PaddingValues(),
+    onSnackbarNotificationEvent: suspend (SnackbarVisual) -> Unit = {},
+    eventFilter: (InAppNotificationEvent) -> Boolean = { true },
+    content: @Composable (PaddingValues) -> Unit = {},
+) {
+    InAppNotificationHost(
+        onActionClick = onActionClick,
+        modifier = modifier,
+        contentPadding = contentPadding,
+        hostStateHolder = rememberInAppNotificationHostStateHolder(enabled),
+        onSnackbarNotificationEvent = onSnackbarNotificationEvent,
+        eventFilter = eventFilter,
+        content = content,
+    )
+}
+
+/**
+ * Host used to properly show, hide and dismiss in-app notifications listening events from
+ * [InAppNotificationReceiver].
+ *
+ * This component takes in consideration the [InAppNotificationHostStateHolder]'s enabled flags to
+ * decide which kind of notifications to show.
+ *
+ * Additionally, the events can be filtered by passing a [eventFilter] lambda.
+ *
+ * @param onActionClick invoked when a notification action is clicked.
+ * @param modifier the modifier to apply to this layout.
+ * @param hostStateHolder the state holder for the in-app notification host.
+ * @param contentPadding a padding around the whole content.
+ * @param onSnackbarNotificationEvent invoked when a snackbar notification event is triggered. This is
+ *  required as the snackbar component can be shared among other screens via [InAppNotificationScaffold] or [Scaffold]
+ * @param eventFilter a lambda to filter in-app notification events. Only events for which this lambda returns
+ * true will be processed.
+ * @param content a block which describes the content. This composable will rearrange the content to properly
+ * display the in-app notifications.
+ */
+@Composable
+fun InAppNotificationHost(
+    onActionClick: (NotificationAction) -> Unit,
+    modifier: Modifier = Modifier,
+    hostStateHolder: InAppNotificationHostStateHolder = rememberInAppNotificationHostStateHolder(
+        enabled = DisplayInAppNotificationFlag.AllNotifications,
+    ),
+    contentPadding: PaddingValues = PaddingValues(),
+    onSnackbarNotificationEvent: suspend (SnackbarVisual) -> Unit = {},
+    eventFilter: (InAppNotificationEvent) -> Boolean = { true },
+    content: @Composable (PaddingValues) -> Unit,
+) {
+    val inAppNotificationEvents by koinInject<InAppNotificationReceiver>()
+        .events
+        .collectAsStateWithLifecycle(initialValue = null)
+
+    val state by hostStateHolder.currentInAppNotificationHostState.collectAsState()
+
+    LaunchedEffect(inAppNotificationEvents) {
+        val event = inAppNotificationEvents
+        if (event != null && eventFilter(event)) {
+            when (event) {
+                is InAppNotificationEvent.Dismiss -> Unit // TODO(#9626): Handle dismiss
+                is InAppNotificationEvent.Show -> hostStateHolder.showInAppNotification(event.notification)
+            }
+        }
+    }
+
+    LaunchedEffect(state.snackbarVisual, onSnackbarNotificationEvent) {
+        val snackbarVisual = state.snackbarVisual
+        if (snackbarVisual != null) {
+            onSnackbarNotificationEvent(snackbarVisual)
+            hostStateHolder.dismiss(snackbarVisual)
+        }
+    }
+
+    InAppNotificationHostLayout(
+        behaviour = rememberBannerInlineScrollBehaviour(),
+        scaffoldPaddingValues = contentPadding,
+        bannerGlobal = {
+            BannerGlobalNotificationHost(
+                hostStateHolder = hostStateHolder,
+                onActionClick = onActionClick,
+                modifier = Modifier.fillMaxWidth(),
+            )
+        },
+        bannerInlineList = {
+            BannerInlineNotificationListHost(
+                hostStateHolder = hostStateHolder,
+                onActionClick = onActionClick,
+                onOpenErrorNotificationsClick = { onActionClick(NotificationAction.OpenNotificationCentre) },
+                modifier = Modifier.fillMaxWidth(),
+            )
+        },
+        content = content,
+        modifier = modifier,
+    )
+}
diff --git a/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/InAppNotificationScaffold.kt b/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/InAppNotificationScaffold.kt
new file mode 100644
index 00000000000..cd7aa5a12a1
--- /dev/null
+++ b/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/InAppNotificationScaffold.kt
@@ -0,0 +1,96 @@
+package net.thunderbird.feature.notification.api.ui
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import app.k9mail.core.ui.compose.designsystem.organism.snackbar.SnackbarHost
+import app.k9mail.core.ui.compose.designsystem.organism.snackbar.SnackbarHostState
+import app.k9mail.core.ui.compose.designsystem.organism.snackbar.rememberSnackbarHostState
+import app.k9mail.core.ui.compose.designsystem.template.Scaffold
+import app.k9mail.core.ui.compose.designsystem.template.ScaffoldFabPosition
+import kotlinx.collections.immutable.ImmutableSet
+import net.thunderbird.core.ui.compose.common.modifier.testTagAsResourceId
+import net.thunderbird.feature.notification.api.ui.InAppNotificationScaffoldDefaults.TEST_TAG_INNER_SCAFFOLD
+import net.thunderbird.feature.notification.api.ui.InAppNotificationScaffoldDefaults.TEST_TAG_IN_APP_NOTIFICATION_HOST
+import net.thunderbird.feature.notification.api.ui.InAppNotificationScaffoldDefaults.TEST_TAG_SNACKBAR_HOST
+import net.thunderbird.feature.notification.api.ui.action.NotificationAction
+import net.thunderbird.feature.notification.api.ui.host.DisplayInAppNotificationFlag
+import net.thunderbird.feature.notification.api.ui.host.rememberInAppNotificationHostStateHolder
+import net.thunderbird.feature.notification.api.ui.host.visual.SnackbarVisual
+import net.thunderbird.feature.notification.api.ui.style.SnackbarDuration
+import app.k9mail.core.ui.compose.designsystem.organism.snackbar.SnackbarDuration as UiSnackbarDuration
+
+/**
+ * A scaffold that displays in-app notifications.
+ *
+ * This composable function is a wrapper around [Scaffold] that adds support for displaying in-app notifications.
+ * It uses an [InAppNotificationHost] to display the notifications.
+ *
+ * @param modifier the modifier to apply to this layout.
+ * @param enabled a set of [DisplayInAppNotificationFlag] that determines which types of notifications are displayed.
+ * @param topBar top app bar of the screen.
+ * @param bottomBar bottom bar of the screen.
+ * @param snackbarHostState the [SnackbarHostState] used to show snackbars.
+ * @param floatingActionButton the main action button of the screen.
+ * @param floatingActionButtonPosition the position of the floating action button.
+ * @param onNotificationActionClick invoked when an in-app notification action is clicked.
+ * @param content content of the screen. The lambda receives a [PaddingValues] that should be
+ *   applied to the content root via [Modifier.padding] and [Modifier.consumeWindowInsets] to
+ *   properly offset top and bottom bars. If using [Modifier.verticalScroll], apply this modifier to
+ *   the child of the scroll, and not on the scroll itself.
+ */
+@Composable
+fun InAppNotificationScaffold(
+    modifier: Modifier = Modifier,
+    enabled: ImmutableSet<DisplayInAppNotificationFlag> = DisplayInAppNotificationFlag.AllNotifications,
+    topBar: @Composable () -> Unit = {},
+    bottomBar: @Composable () -> Unit = {},
+    snackbarHostState: SnackbarHostState = rememberSnackbarHostState(),
+    floatingActionButton: @Composable () -> Unit = {},
+    floatingActionButtonPosition: ScaffoldFabPosition = ScaffoldFabPosition.End,
+    onNotificationActionClick: (NotificationAction) -> Unit = {},
+    content: @Composable (PaddingValues) -> Unit,
+) {
+    val hostStateHolder = rememberInAppNotificationHostStateHolder(enabled)
+    Scaffold(
+        modifier = modifier.testTagAsResourceId(TEST_TAG_INNER_SCAFFOLD),
+        topBar = topBar,
+        bottomBar = bottomBar,
+        snackbarHost = {
+            SnackbarHost(
+                hostState = snackbarHostState,
+                modifier = Modifier.testTagAsResourceId(TEST_TAG_SNACKBAR_HOST),
+            )
+        },
+        floatingActionButton = floatingActionButton,
+        floatingActionButtonPosition = floatingActionButtonPosition,
+    ) { paddingValues ->
+        InAppNotificationHost(
+            onActionClick = onNotificationActionClick,
+            contentPadding = paddingValues,
+            hostStateHolder = hostStateHolder,
+            onSnackbarNotificationEvent = { visual: SnackbarVisual ->
+                snackbarHostState.showSnackbar(
+                    message = visual.message,
+                    actionLabel = visual.action?.resolveTitle(),
+                    duration = when (visual.duration) {
+                        SnackbarDuration.Short -> UiSnackbarDuration.Short
+                        SnackbarDuration.Long -> UiSnackbarDuration.Long
+                        SnackbarDuration.Indefinite -> UiSnackbarDuration.Indefinite
+                    },
+                )
+            },
+            modifier = Modifier.testTagAsResourceId(TEST_TAG_IN_APP_NOTIFICATION_HOST),
+            content = content,
+        )
+    }
+}
+
+object InAppNotificationScaffoldDefaults {
+    internal const val TEST_TAG_INNER_SCAFFOLD = "ins_inner_scaffold"
+    internal const val TEST_TAG_IN_APP_NOTIFICATION_HOST = "ins_in_app_notification_host"
+    internal const val TEST_TAG_SNACKBAR_HOST = "ins_snackbar_host"
+}
diff --git a/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/layout/BannerInlineListScrollBehaviour.kt b/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/layout/BannerInlineListScrollBehaviour.kt
new file mode 100644
index 00000000000..9706e4e0e32
--- /dev/null
+++ b/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/layout/BannerInlineListScrollBehaviour.kt
@@ -0,0 +1,144 @@
+package net.thunderbird.feature.notification.api.ui.layout
+
+import androidx.compose.animation.core.AnimationState
+import androidx.compose.animation.core.DecayAnimationSpec
+import androidx.compose.animation.core.animateDecay
+import androidx.compose.animation.rememberSplineBasedDecay
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.NestedScrollSource
+import androidx.compose.ui.unit.Velocity
+import kotlin.math.abs
+import kotlin.math.roundToInt
+
+private const val POST_FLING_UNCONSUMED_THRESHOLD = 0.5f
+
+/**
+ * Handles the nested scrolling behavior for the banner inline list that is displayed with a scrollable content.
+ *
+ * This behavior allows the banner inline list to scroll together with the scrollable content, as if it was part of it.
+ * The banner will initially be visible. As the user scrolls down, the banner will scroll off-screen.
+ * When the user scrolls up, the banner will reappear when there is no more content to scroll.
+ *
+ * This class implements [NestedScrollConnection] to intercept scroll events and adjust
+ * the banner's visibility and position accordingly.
+ *
+ * @param state The [BannerInlineListScrollState] that holds the current state of the banner and list.
+ * @param flingAnimation The [DecayAnimationSpec] used for animating the banner's collapse/expand during flings.
+ */
+internal class BannerInlineListScrollBehaviour(
+    val state: BannerInlineListScrollState,
+    val flingAnimation: DecayAnimationSpec<Float>,
+) {
+    val connection = object : NestedScrollConnection {
+        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
+            val height = state.bannerInlineListHeight
+            return when {
+                // Return earlier if banner inline height isn't visible.
+                height <= 0 -> super.onPreScroll(available, source)
+
+                // We don't consume the scroll if the content was scrolled
+                state.contentOffset != 0f -> Offset.Zero
+
+                // We'll consume the scroll offset, to scroll the banner inline if the
+                // content isn't scrolled yet, until the banner inline height offset
+                // didn't reach its height
+                state.contentOffset == 0f -> {
+                    val delta = available.y
+                    val prevHeightOffset = state.bannerInlineListHeightOffset
+                    state.bannerInlineListHeightOffset = (state.bannerInlineListHeightOffset + delta).coerceIn(
+                        minimumValue = -height,
+                        maximumValue = 0f,
+                    )
+
+                    // If the previous and new heightOffset are different, it means the height offset
+                    // didn't reach its height, thus we consume the scroll value in this connection.
+                    if (prevHeightOffset != state.bannerInlineListHeightOffset) {
+                        available.copy(y = delta)
+                    } else {
+                        Offset.Zero
+                    }
+                }
+
+                else -> Offset.Zero
+            }
+
+            return super.onPreScroll(available, source)
+        }
+
+        override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
+            state.accumulatedContentOffset += consumed.y
+            // Return earlier if banner inline height isn't visible.
+            if (state.bannerInlineListHeight <= 0f) return super.onPostScroll(consumed, available, source)
+
+            val height = state.bannerInlineListHeight
+            val heightOffset = state.bannerInlineListHeightOffset
+            val heightOffsetLimit = -height
+
+            if (heightOffset == -height) {
+                state.contentOffset += consumed.y
+
+                if (heightOffset == 0f || heightOffset == heightOffsetLimit) {
+                    if (consumed.y == 0f && available.y > 0f) {
+                        // Reset the total content offset to zero when scrolling all the way down.
+                        // This will eliminate some float precision inaccuracies.
+                        state.contentOffset = 0f
+                    }
+                }
+            }
+
+            return Offset.Zero
+        }
+
+        override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
+            val heightOffset = state.bannerInlineListHeightOffset
+            val height = state.bannerInlineListHeight
+            val superConsumed = super.onPostFling(consumed, available)
+
+            if (heightOffset != -height) {
+                return Velocity.Zero
+            }
+
+            var remaining = available.y
+            if (abs(remaining) > 0 && state.contentOffset.roundToInt() == 0 && heightOffset in -height..0f) {
+                var lastValue = 0f
+                AnimationState(
+                    initialValue = 0f,
+                    initialVelocity = available.y,
+                ).animateDecay(flingAnimation) {
+                    val delta = value - lastValue
+                    val initialHeightOffset = state.bannerInlineListHeightOffset
+                    state.bannerInlineListHeightOffset = (initialHeightOffset + delta).coerceAtMost(0f)
+                    val consumed = abs(initialHeightOffset - state.bannerInlineListHeightOffset)
+                    lastValue = value
+                    remaining = this.velocity
+                    // avoid rounding errors and stop if anything is unconsumed
+                    if (abs(delta - consumed) > POST_FLING_UNCONSUMED_THRESHOLD) {
+                        this.cancelAnimation()
+                        state.contentOffset = 0f
+                    }
+                }
+            }
+
+            return superConsumed + Velocity(0f, remaining)
+        }
+    }
+}
+
+/**
+ * Creates a [BannerInlineListScrollBehaviour] that is remembered across compositions and it's state will survive the
+ * activity or process recreation using the saved instance state mechanism.
+ *
+ * @param state The state object to be used to control or observe the banner inline state.
+ * @param flingAnimation The animation spec to be used for flinging content.
+ */
+@Composable
+internal fun rememberBannerInlineScrollBehaviour(
+    state: BannerInlineListScrollState = rememberSaveable(saver = BannerInlineListScrollState.Saver) {
+        BannerInlineListScrollState()
+    },
+    flingAnimation: DecayAnimationSpec<Float> = rememberSplineBasedDecay(),
+): BannerInlineListScrollBehaviour = remember { BannerInlineListScrollBehaviour(state, flingAnimation) }
diff --git a/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/layout/BannerInlineListScrollState.kt b/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/layout/BannerInlineListScrollState.kt
new file mode 100644
index 00000000000..9d97f35de3e
--- /dev/null
+++ b/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/layout/BannerInlineListScrollState.kt
@@ -0,0 +1,132 @@
+package net.thunderbird.feature.notification.api.ui.layout
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.saveable.Saver
+import androidx.compose.runtime.saveable.listSaver
+import androidx.compose.runtime.setValue
+import kotlin.math.abs
+import kotlin.math.roundToInt
+
+internal class BannerInlineListScrollState(
+    initialBannerInlineListHeight: Float = 0f,
+    initialBannerInlineListHeightOffset: Float = 0f,
+    initialContentOffset: Float = 0f,
+    initialAccumulatedContentOffset: Float = 0f,
+) {
+    /**
+     * The height of the inline banner list. This is used to calculate the offset of the banner
+     * list when scrolling.
+     */
+    var bannerInlineListHeight by mutableFloatStateOf(initialBannerInlineListHeight)
+
+    /**
+     * The current offset of the banner inline list. This is used to adjust the position of the list
+     * when it is scrolled.
+     * The value is between -[bannerInlineListHeight] and 0.
+     *
+     * Example:
+     * - If the banner inline list is fully visible, the value is 0.
+     * - If the banner inline list is scrolled up by 100px, the value is -100.
+     * - If the banner inline list is fully scrolled up (not visible), the value is -[bannerInlineListHeight].
+     */
+    var bannerInlineListHeightOffset by mutableFloatStateOf(initialBannerInlineListHeightOffset)
+
+    /**
+     * The current offset of the content. This is used to adjust the position of the content
+     * when the banner inline list is scrolled.
+     */
+    var contentOffset by mutableFloatStateOf(initialContentOffset)
+
+    /**
+     * The total accumulated scroll offset of the content. This value is used to determine
+     * if the banner list is off-screen and to adjust the content offset when the banner
+     * list height changes.
+     */
+    var accumulatedContentOffset by mutableFloatStateOf(initialAccumulatedContentOffset)
+
+    /**
+     * Checks if the banner inline list is completely off-screen.
+     *
+     * This is true if either the current scroll offset ([contentOffset]) indicates scrolling away from the
+     * initial position, or if the total accumulated offset ([accumulatedContentOffset]) exceeds the height
+     * of the banner inline list.
+     *
+     * @return `true` if the banner inline list is off screen, `false` otherwise.
+     */
+    fun isBannerListOffScreen(): Boolean {
+        return abs(contentOffset) > 0f ||
+            abs(accumulatedContentOffset) > bannerInlineListHeight
+    }
+
+    /**
+     * Adjusts the offset of the banner inline list.
+     *
+     * This function is called when the height of the banner inline list changes. It calculates the new
+     * offset based on the previous height and the accumulated content offset.
+     *
+     * @param prevHeight The previous height of the banner inline list.
+     */
+    fun adjustOffset(prevHeight: Float) {
+        val adjustedOffset = accumulatedContentOffset - prevHeight
+        contentOffset = adjustedOffset
+        bannerInlineListHeightOffset = adjustedOffset.coerceIn(
+            minimumValue = -bannerInlineListHeight.toFloat(),
+            maximumValue = 0f,
+        )
+    }
+
+    /**
+     * Calculates the extra padding needed for the content below the banner list.
+     *
+     * This padding ensures that the content is not obscured by the banner list when it's visible.
+     * If the banner list is off-screen, no extra padding is needed.
+     * Otherwise, the padding is the sum of the banner list height and its current offset.
+     *
+     * @return The calculated extra padding as a Float.
+     */
+    fun calculateExtraPadding(): Float {
+        return if (isBannerListOffScreen()) {
+            0f
+        } else {
+            bannerInlineListHeight + bannerInlineListHeightOffset
+        }
+    }
+
+    /**
+     * Calculates the Y offset for the inline banner list.
+     *
+     * This function determines the vertical position of the inline banner list based on the scaffold's top padding,
+     * the global height of the banner, and the current offset of the inline list.
+     *
+     * @param scaffoldTopPadding The top padding of the scaffold, typically the height of the app bar.
+     * @param bannerGlobalHeight The global height of the banner.
+     * @return The calculated Y offset for the inline banner list, rounded to the nearest integer.
+     */
+    fun calculateInlineOffsetY(scaffoldTopPadding: Float, bannerGlobalHeight: Int): Int {
+        val inlineBaseTop = scaffoldTopPadding + bannerGlobalHeight
+        val offset = bannerInlineListHeightOffset
+        return (inlineBaseTop + offset).roundToInt()
+    }
+
+    companion object Companion {
+        val Saver: Saver<BannerInlineListScrollState, *> = listSaver(
+            save = {
+                listOf(
+                    it.bannerInlineListHeight,
+                    it.bannerInlineListHeightOffset,
+                    it.contentOffset,
+                    it.accumulatedContentOffset,
+                )
+            },
+            restore = {
+                BannerInlineListScrollState(
+                    initialBannerInlineListHeight = it[0],
+                    initialBannerInlineListHeightOffset = it[1],
+                    initialContentOffset = it[2],
+                    initialAccumulatedContentOffset = it[3],
+                )
+            },
+        )
+    }
+}
diff --git a/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/layout/InAppNotificationHostLayout.kt b/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/layout/InAppNotificationHostLayout.kt
new file mode 100644
index 00000000000..39d78fba6da
--- /dev/null
+++ b/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/layout/InAppNotificationHostLayout.kt
@@ -0,0 +1,164 @@
+package net.thunderbird.feature.notification.api.ui.layout
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.calculateEndPadding
+import androidx.compose.foundation.layout.calculateStartPadding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.layout.Placeable
+import androidx.compose.ui.layout.SubcomposeLayout
+import androidx.compose.ui.layout.SubcomposeMeasureScope
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.util.fastForEach
+import androidx.compose.ui.util.fastMap
+import androidx.compose.ui.util.fastMaxBy
+import androidx.compose.ui.util.fastSumBy
+import kotlin.math.roundToInt
+
+@Composable
+internal fun InAppNotificationHostLayout(
+    behaviour: BannerInlineListScrollBehaviour,
+    scaffoldPaddingValues: PaddingValues,
+    bannerGlobal: @Composable () -> Unit,
+    bannerInlineList: @Composable () -> Unit,
+    modifier: Modifier = Modifier,
+    content: @Composable (PaddingValues) -> Unit,
+) {
+    var bannerInlineListHeight by remember { mutableFloatStateOf(0f) }
+    val scrollState = behaviour.state
+
+    LaunchedEffect(bannerInlineListHeight) {
+        // Update the banner inline list height whenever it changes.
+        if (bannerInlineListHeight > 0) {
+            val prevHeight = scrollState.bannerInlineListHeight
+            scrollState.bannerInlineListHeight = bannerInlineListHeight
+            // If any scroll is already present when the banner inline list gets displayed,
+            // we add that offset to the height offset.
+            if (scrollState.isBannerListOffScreen()) {
+                scrollState.adjustOffset(prevHeight)
+            }
+        }
+    }
+
+    SubcomposeLayout(
+        modifier = modifier.nestedScroll(behaviour.connection),
+    ) { constraints ->
+        val layoutWidth = constraints.maxWidth
+        val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
+
+        // Measuring Banner Global
+        val bannerGlobalPlaceables = subcomposeAndMeasureBannerGlobal(bannerGlobal, looseConstraints)
+        val bannerGlobalHeight = bannerGlobalPlaceables.fastMaxBy { it.height }?.height ?: 0
+
+        // Measuring Banner Inline list
+        val bannerInlineListPlaceables = subcomposeAndMeasureBannerInlineList(bannerInlineList, looseConstraints)
+        bannerInlineListHeight = bannerInlineListPlaceables.fastSumBy { it.height }.toFloat()
+
+        // Measuring Body content, applying scaffold paddings
+        val mainContentPlaceables = subcomposeAndMeasureMainContent(
+            bannerGlobalHeight = bannerGlobalHeight,
+            scrollState = scrollState,
+            scaffoldPaddingValues = scaffoldPaddingValues,
+            content = content,
+            looseConstraints = looseConstraints,
+        )
+        val mainContentHeight = mainContentPlaceables.fastSumBy { it.height }
+
+        // In case the maxHeight is not defined (for example when no content is passed to the content lambda),
+        // we manually calculate the layout height to avoid a crash caused by the pre-condition check
+        // of the layout function.
+        val layoutHeight = if (constraints.maxHeight == Constraints.Infinity) {
+            bannerGlobalHeight + bannerInlineListHeight.roundToInt() + mainContentHeight
+        } else {
+            constraints.maxHeight
+        }
+
+        layout(layoutWidth, layoutHeight) {
+            // Set the body at 0, 0 as we are applying the positioning via
+            // PaddingValues.
+            mainContentPlaceables.fastForEach { it.placeRelative(x = 0, y = 0) }
+
+            // Move inline list, following the height's offset
+            val scaffoldTopPx = scaffoldPaddingValues.calculateTopPadding().toPx()
+            // To avoid the layout to jump when a new banner inline is displayed
+            // we only place the banner inline list if it is not off screen.
+            if (!scrollState.isBannerListOffScreen()) {
+                bannerInlineListPlaceables.fastForEach {
+                    it.placeRelative(
+                        x = 0,
+                        y = scrollState.calculateInlineOffsetY(
+                            scaffoldTopPadding = scaffoldTopPx,
+                            bannerGlobalHeight = bannerGlobalHeight,
+                        ),
+                    )
+                }
+            }
+
+            // Set fixed Banner Global
+            bannerGlobalPlaceables.fastForEach { it.placeRelative(x = 0, y = scaffoldTopPx.roundToInt()) }
+        }
+    }
+}
+
+/**
+ * Subcompose and Measure the Banner Global.
+ */
+private fun SubcomposeMeasureScope.subcomposeAndMeasureBannerGlobal(
+    bannerGlobal: @Composable (() -> Unit),
+    looseConstraints: Constraints,
+): List<Placeable> = subcompose(
+    slotId = InAppNotificationScaffoldContent.BannerGlobal,
+    content = bannerGlobal,
+).fastMap { it.measure(looseConstraints) }
+
+/**
+ * Subcompose and Measure the Banner Inline List.
+ */
+private fun SubcomposeMeasureScope.subcomposeAndMeasureBannerInlineList(
+    bannerInlineList: @Composable (() -> Unit),
+    looseConstraints: Constraints,
+): List<Placeable> = subcompose(
+    slotId = InAppNotificationScaffoldContent.BannerInlineList,
+    content = bannerInlineList,
+).fastMap { it.measure(looseConstraints) }
+
+/**
+ * Subcompose and measure the main content of the scaffold. It calculates the scroll offset
+ * and applies it to the main content via [PaddingValues], taking in consideration:
+ *
+ * - The Banner Global Height, if visible
+ * - The Banner Inline List Height, if visible
+ * - The Scaffold Padding Values.
+ */
+private fun SubcomposeMeasureScope.subcomposeAndMeasureMainContent(
+    bannerGlobalHeight: Int,
+    scrollState: BannerInlineListScrollState,
+    scaffoldPaddingValues: PaddingValues,
+    content: @Composable ((PaddingValues) -> Unit),
+    looseConstraints: Constraints,
+): List<Placeable> = subcompose(
+    slotId = InAppNotificationScaffoldContent.MainContent,
+) {
+    val layoutDirection = (this).layoutDirection
+    val extraTopPadding = (bannerGlobalHeight + scrollState.calculateExtraPadding()).toDp()
+    val innerPadding = PaddingValues(
+        top = scaffoldPaddingValues.calculateTopPadding() + extraTopPadding,
+        bottom = scaffoldPaddingValues.calculateBottomPadding(),
+        start = scaffoldPaddingValues.calculateStartPadding(layoutDirection),
+        end = scaffoldPaddingValues.calculateEndPadding(layoutDirection),
+    )
+
+    content(innerPadding)
+}.fastMap { it.measure(looseConstraints) }
+
+private enum class InAppNotificationScaffoldContent {
+    BannerGlobal,
+    BannerInlineList,
+    MainContent,
+}
diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/action/NotificationAction.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/action/NotificationAction.kt
index adb1836fc84..35111a9921d 100644
--- a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/action/NotificationAction.kt
+++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/action/NotificationAction.kt
@@ -11,6 +11,7 @@ import net.thunderbird.feature.notification.api.ui.action.icon.Retry
 import net.thunderbird.feature.notification.api.ui.action.icon.UpdateServerSettings
 import net.thunderbird.feature.notification.api.ui.icon.NotificationIcon
 import net.thunderbird.feature.notification.resources.api.Res
+import net.thunderbird.feature.notification.resources.api.banner_inline_notification_open_notifications
 import net.thunderbird.feature.notification.resources.api.notification_action_archive
 import net.thunderbird.feature.notification.resources.api.notification_action_delete
 import net.thunderbird.feature.notification.resources.api.notification_action_mark_as_read
@@ -107,6 +108,14 @@ sealed class NotificationAction {
         override val titleResource: StringResource = Res.string.notification_action_retry
     }
 
+    /**
+     * Action to open the notification centre in the app.
+     */
+    data object OpenNotificationCentre : NotificationAction() {
+        override val icon: NotificationIcon? = null
+        override val titleResource: StringResource = Res.string.banner_inline_notification_open_notifications
+    }
+
     /**
      * Represents a custom notification action.
      *
diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/host/InAppNotificationHostStateHolder.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/host/InAppNotificationHostStateHolder.kt
index 2cc3e9f9ffb..03af639f4d8 100644
--- a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/host/InAppNotificationHostStateHolder.kt
+++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/host/InAppNotificationHostStateHolder.kt
@@ -134,7 +134,7 @@ class InAppNotificationHostStateHolder(private val enabled: ImmutableSet<Display
 }
 
 @Composable
-fun rememberInAppNotificationHostState(
+fun rememberInAppNotificationHostStateHolder(
     enabled: ImmutableSet<DisplayInAppNotificationFlag> = DisplayInAppNotificationFlag.AllNotifications,
 ): InAppNotificationHostStateHolder {
     return remember { InAppNotificationHostStateHolder(enabled) }
diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/host/visual/InAppNotificationVisual.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/host/visual/InAppNotificationVisual.kt
index b5ff99ad26c..0536942ea5c 100644
--- a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/host/visual/InAppNotificationVisual.kt
+++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/host/visual/InAppNotificationVisual.kt
@@ -11,6 +11,7 @@ import net.thunderbird.feature.notification.api.ui.action.NotificationAction
 import net.thunderbird.feature.notification.api.ui.host.visual.BannerInlineVisual.Companion.MAX_SUPPORTING_TEXT_LENGTH
 import net.thunderbird.feature.notification.api.ui.host.visual.BannerInlineVisual.Companion.MAX_TITLE_LENGTH
 import net.thunderbird.feature.notification.api.ui.style.InAppNotificationStyle
+import net.thunderbird.feature.notification.api.ui.style.SnackbarDuration
 
 sealed interface InAppNotificationVisual
 
@@ -170,7 +171,7 @@ data class BannerInlineVisual(
 data class SnackbarVisual(
     val message: String,
     val action: NotificationAction?,
-    val duration: Duration,
+    val duration: SnackbarDuration,
 ) : InAppNotificationVisual {
     internal companion object {
         /**
diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/style/InAppNotificationStyle.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/style/InAppNotificationStyle.kt
index 5ecb3b2d9e6..530ccbadcb8 100644
--- a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/style/InAppNotificationStyle.kt
+++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/style/InAppNotificationStyle.kt
@@ -1,7 +1,5 @@
 package net.thunderbird.feature.notification.api.ui.style
 
-import kotlin.time.Duration
-import kotlin.time.Duration.Companion.seconds
 import net.thunderbird.feature.notification.api.ui.style.builder.InAppNotificationStyleBuilder
 
 /**
@@ -35,7 +33,7 @@ sealed interface InAppNotificationStyle {
      * @see [InAppNotificationStyleBuilder.snackbar]
      */
     data class SnackbarNotification(
-        val duration: Duration = 10.seconds,
+        val duration: SnackbarDuration = SnackbarDuration.Short,
     ) : InAppNotificationStyle
 
     /**
@@ -44,6 +42,8 @@ sealed interface InAppNotificationStyle {
     data object DialogNotification : InAppNotificationStyle
 }
 
+enum class SnackbarDuration { Short, Long, Indefinite }
+
 /**
  * Configures the in-app notification style.
  *
diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/style/builder/InAppNotificationStyleBuilder.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/style/builder/InAppNotificationStyleBuilder.kt
index d32e96bbb06..81671b0509d 100644
--- a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/style/builder/InAppNotificationStyleBuilder.kt
+++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/style/builder/InAppNotificationStyleBuilder.kt
@@ -1,13 +1,12 @@
 package net.thunderbird.feature.notification.api.ui.style.builder
 
-import kotlin.time.Duration
-import kotlin.time.Duration.Companion.seconds
 import net.thunderbird.feature.notification.api.ui.style.InAppNotificationStyle
 import net.thunderbird.feature.notification.api.ui.style.InAppNotificationStyle.BannerGlobalNotification
 import net.thunderbird.feature.notification.api.ui.style.InAppNotificationStyle.BannerInlineNotification
 import net.thunderbird.feature.notification.api.ui.style.InAppNotificationStyle.DialogNotification
 import net.thunderbird.feature.notification.api.ui.style.InAppNotificationStyle.SnackbarNotification
 import net.thunderbird.feature.notification.api.ui.style.NotificationStyleMarker
+import net.thunderbird.feature.notification.api.ui.style.SnackbarDuration
 
 /**
  * Builder for creating [InAppNotificationStyle] instances.
@@ -89,7 +88,7 @@ class InAppNotificationStyleBuilder internal constructor() {
      * [BannerGlobalNotification] for that context)
      */
     @NotificationStyleMarker
-    fun snackbar(duration: Duration = 10.seconds) {
+    fun snackbar(duration: SnackbarDuration = SnackbarDuration.Short) {
         checkSingleStyleEntry<SnackbarNotification>()
         styles += SnackbarNotification(duration)
     }


Test patch:
diff --git a/core/ui/compose/testing/src/main/kotlin/app/k9mail/core/ui/compose/testing/ComposeTest.kt b/core/ui/compose/testing/src/main/kotlin/app/k9mail/core/ui/compose/testing/ComposeTest.kt
index 62849a69d9a..fee2bce9701 100644
--- a/core/ui/compose/testing/src/main/kotlin/app/k9mail/core/ui/compose/testing/ComposeTest.kt
+++ b/core/ui/compose/testing/src/main/kotlin/app/k9mail/core/ui/compose/testing/ComposeTest.kt
@@ -15,6 +15,9 @@ import androidx.compose.ui.test.onNodeWithText
 import androidx.compose.ui.test.onRoot
 import androidx.test.espresso.Espresso
 import app.k9mail.core.ui.compose.theme2.thunderbird.ThunderbirdTheme2
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
+import kotlinx.coroutines.test.runTest
 import org.junit.Rule
 import org.junit.runner.RunWith
 import org.robolectric.RobolectricTestRunner
@@ -31,6 +34,13 @@ open class ComposeTest {
     fun runComposeTest(testContent: ComposeContentTestRule.() -> Unit) = with(composeTestRule) {
         testContent()
     }
+
+    fun runComposeTestSuspend(
+        context: CoroutineContext = EmptyCoroutineContext,
+        testContent: suspend ComposeContentTestRule.() -> Unit,
+    ) = runTest(context) {
+        composeTestRule.testContent()
+    }
 }
 
 /**
diff --git a/feature/notification/api/src/androidUnitTest/kotlin/net/thunderbird/feature/notification/api/ui/BannerGlobalNotificationHostTest.kt b/feature/notification/api/src/androidUnitTest/kotlin/net/thunderbird/feature/notification/api/ui/BannerGlobalNotificationHostTest.kt
index a92cb0b3f45..569a53aac80 100644
--- a/feature/notification/api/src/androidUnitTest/kotlin/net/thunderbird/feature/notification/api/ui/BannerGlobalNotificationHostTest.kt
+++ b/feature/notification/api/src/androidUnitTest/kotlin/net/thunderbird/feature/notification/api/ui/BannerGlobalNotificationHostTest.kt
@@ -7,7 +7,6 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.semantics.Role
 import androidx.compose.ui.semantics.SemanticsProperties
 import androidx.compose.ui.test.SemanticsMatcher
-import androidx.compose.ui.test.SemanticsNodeInteraction
 import androidx.compose.ui.test.assert
 import androidx.compose.ui.test.assertCountEquals
 import androidx.compose.ui.test.assertIsDisplayed
@@ -19,9 +18,7 @@ import androidx.compose.ui.test.hasTextExactly
 import androidx.compose.ui.test.onChild
 import androidx.compose.ui.test.onChildren
 import androidx.compose.ui.test.onFirst
-import androidx.compose.ui.test.onRoot
 import androidx.compose.ui.test.performClick
-import androidx.compose.ui.test.printToString
 import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonText
 import app.k9mail.core.ui.compose.testing.ComposeTest
 import app.k9mail.core.ui.compose.testing.onNodeWithTag
@@ -33,8 +30,9 @@ import kotlin.test.Test
 import net.thunderbird.core.ui.compose.common.modifier.testTagAsResourceId
 import net.thunderbird.feature.notification.api.NotificationSeverity
 import net.thunderbird.feature.notification.api.ui.action.NotificationAction
-import net.thunderbird.feature.notification.api.ui.host.rememberInAppNotificationHostState
+import net.thunderbird.feature.notification.api.ui.host.rememberInAppNotificationHostStateHolder
 import net.thunderbird.feature.notification.api.ui.style.inAppNotificationStyles
+import net.thunderbird.feature.notification.api.ui.util.printSemanticTree
 import net.thunderbird.feature.notification.testing.fake.FakeInAppOnlyNotification
 import net.thunderbird.feature.notification.testing.fake.ui.action.createFakeNotificationAction
 
@@ -358,7 +356,7 @@ class BannerGlobalNotificationHostTest : ComposeTest() {
         onActionClick: (NotificationAction) -> Unit,
         modifier: Modifier = Modifier,
     ) {
-        val state = rememberInAppNotificationHostState()
+        val state = rememberInAppNotificationHostStateHolder()
         Column(modifier = modifier) {
             ButtonText(
                 text = "Trigger Notification",
@@ -385,11 +383,4 @@ class BannerGlobalNotificationHostTest : ComposeTest() {
         inAppNotificationStyles = inAppNotificationStyles { bannerGlobal() },
         actions = action?.let { setOf(it) }.orEmpty(),
     )
-
-    private fun printSemanticTree(root: SemanticsNodeInteraction = composeTestRule.onRoot(useUnmergedTree = true)) {
-        println()
-        println("Semantic tree:")
-        println(root.printToString())
-        println()
-    }
 }
diff --git a/feature/notification/api/src/androidUnitTest/kotlin/net/thunderbird/feature/notification/api/ui/BannerInlineNotificationListHostTest.kt b/feature/notification/api/src/androidUnitTest/kotlin/net/thunderbird/feature/notification/api/ui/BannerInlineNotificationListHostTest.kt
index 42529586138..c2ebcfb56ec 100644
--- a/feature/notification/api/src/androidUnitTest/kotlin/net/thunderbird/feature/notification/api/ui/BannerInlineNotificationListHostTest.kt
+++ b/feature/notification/api/src/androidUnitTest/kotlin/net/thunderbird/feature/notification/api/ui/BannerInlineNotificationListHostTest.kt
@@ -9,22 +9,15 @@ import androidx.compose.ui.platform.LocalInspectionMode
 import androidx.compose.ui.semantics.Role
 import androidx.compose.ui.semantics.SemanticsProperties
 import androidx.compose.ui.test.SemanticsMatcher
-import androidx.compose.ui.test.SemanticsNodeInteraction
-import androidx.compose.ui.test.SemanticsNodeInteractionCollection
 import androidx.compose.ui.test.assertCountEquals
 import androidx.compose.ui.test.assertIsDisplayed
 import androidx.compose.ui.test.assertIsNotDisplayed
 import androidx.compose.ui.test.filterToOne
 import androidx.compose.ui.test.hasClickAction
-import androidx.compose.ui.test.hasTestTag
 import androidx.compose.ui.test.hasTextExactly
-import androidx.compose.ui.test.onChildAt
 import androidx.compose.ui.test.onChildren
-import androidx.compose.ui.test.onRoot
 import androidx.compose.ui.test.performClick
-import androidx.compose.ui.test.printToString
 import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonText
-import app.k9mail.core.ui.compose.designsystem.organism.banner.BannerNotificationCardDefaults.TEST_TAG_BANNER_INLINE_CARD_ACTION_ROW
 import app.k9mail.core.ui.compose.testing.ComposeTest
 import app.k9mail.core.ui.compose.testing.onNodeWithTag
 import app.k9mail.core.ui.compose.testing.onNodeWithText
@@ -32,10 +25,16 @@ import app.k9mail.core.ui.compose.testing.setContentWithTheme
 import assertk.assertThat
 import assertk.assertions.isEqualTo
 import kotlin.test.Test
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toPersistentList
 import net.thunderbird.core.ui.compose.common.modifier.testTagAsResourceId
 import net.thunderbird.feature.notification.api.ui.action.NotificationAction
-import net.thunderbird.feature.notification.api.ui.host.rememberInAppNotificationHostState
+import net.thunderbird.feature.notification.api.ui.host.rememberInAppNotificationHostStateHolder
 import net.thunderbird.feature.notification.api.ui.style.inAppNotificationStyles
+import net.thunderbird.feature.notification.api.ui.util.assertBannerInline
+import net.thunderbird.feature.notification.api.ui.util.assertBannerInlineList
+import net.thunderbird.feature.notification.api.ui.util.printSemanticTree
 import net.thunderbird.feature.notification.testing.fake.FakeInAppOnlyNotification
 import net.thunderbird.feature.notification.testing.fake.ui.action.createFakeNotificationAction
 import org.jetbrains.compose.resources.PreviewContextConfigurationEffect
@@ -54,7 +53,7 @@ class BannerInlineNotificationListHostTest : ComposeTest() {
         mainClock.autoAdvance = false
         setContentWithTheme {
             Column {
-                val state = rememberInAppNotificationHostState()
+                val state = rememberInAppNotificationHostStateHolder()
                 ButtonText(
                     text = "Trigger Notification",
                     onClick = {
@@ -78,37 +77,28 @@ class BannerInlineNotificationListHostTest : ComposeTest() {
 
         // Assert
         printSemanticTree()
-        onNodeWithTag(BannerInlineNotificationListHostDefaults.TEST_TAG_HOST_PARENT)
-            .assertIsDisplayed()
-
-        val listHost = onNodeWithTag(
-            BannerInlineNotificationListHostDefaults.TEST_TAG_BANNER_INLINE_LIST,
-            useUnmergedTree = true,
-        ).assertIsDisplayed()
-
-        listHost
-            .onChildren()
-            .assertCountEquals(1)
+        assertBannerInlineList(size = 1) {
+            assertIsDisplayed()
+            assertBannerInline(
+                index = 0,
+                title = title,
+                supportingText = supportingText,
+                assertActions = {
+                    assertCountEquals(1)
+                    val actionButton = filterToOne(
+                        matcher = SemanticsMatcher.expectValue(
+                            key = SemanticsProperties.Role,
+                            expectedValue = Role.Button,
+                        ) and hasClickAction(),
+                    ).assertIsDisplayed()
 
-        listHost.assertBannerInline(
-            index = 0,
-            title = title,
-            supportingText = supportingText,
-            assertActions = {
-                assertCountEquals(1)
-                val actionButton = filterToOne(
-                    matcher = SemanticsMatcher.expectValue(
-                        key = SemanticsProperties.Role,
-                        expectedValue = Role.Button,
-                    ) and hasClickAction(),
-                ).assertIsDisplayed()
-
-                actionButton
-                    .onChildren()
-                    .filterToOne(hasTextExactly(action.title))
-                    .assertIsDisplayed()
-            },
-        )
+                    actionButton
+                        .onChildren()
+                        .filterToOne(hasTextExactly(action.title))
+                        .assertIsDisplayed()
+                },
+            )
+        }
 
         onNodeWithTag(BannerInlineNotificationListHostDefaults.TEST_TAG_CHECK_ERROR_NOTIFICATIONS)
             .assertIsNotDisplayed()
@@ -120,7 +110,7 @@ class BannerInlineNotificationListHostTest : ComposeTest() {
             // Arrange
             val notification1 = createNotification(title = "Notification 1", supportingText = "The supporting text")
             val notification2 = createNotification(title = "Notification 2", supportingText = "The supporting text")
-            val notifications = listOf(notification1, notification2)
+            val notifications = persistentListOf(notification1, notification2)
 
             mainClock.autoAdvance = false
             setContentWithPreviewAndResources {
@@ -135,31 +125,22 @@ class BannerInlineNotificationListHostTest : ComposeTest() {
 
             // Assert
             printSemanticTree()
-            onNodeWithTag(BannerInlineNotificationListHostDefaults.TEST_TAG_HOST_PARENT)
-                .assertIsDisplayed()
-
-            val listHost = onNodeWithTag(
-                BannerInlineNotificationListHostDefaults.TEST_TAG_BANNER_INLINE_LIST,
-                useUnmergedTree = true,
-            ).assertIsDisplayed()
-
-            listHost
-                .onChildren()
-                .assertCountEquals(2)
-
-            listHost.assertBannerInline(
-                index = 0,
-                title = notification1.title,
-                supportingText = requireNotNull(notification1.contentText),
-                assertActions = { assertCountEquals(2) },
-            )
+            assertBannerInlineList(size = 2) {
+                assertIsDisplayed()
+                assertBannerInline(
+                    index = 0,
+                    title = notification1.title,
+                    supportingText = requireNotNull(notification1.contentText),
+                    assertActions = { assertCountEquals(2) },
+                )
 
-            listHost.assertBannerInline(
-                index = 1,
-                title = notification2.title,
-                supportingText = requireNotNull(notification2.contentText),
-                assertActions = { assertCountEquals(2) },
-            )
+                assertBannerInline(
+                    index = 1,
+                    title = notification2.title,
+                    supportingText = requireNotNull(notification2.contentText),
+                    assertActions = { assertCountEquals(2) },
+                )
+            }
         }
 
     @Test
@@ -169,7 +150,7 @@ class BannerInlineNotificationListHostTest : ComposeTest() {
             val notification1 = createNotification(title = "Notification 1", supportingText = "The supporting text")
             val notification2 = createNotification(title = "Notification 2", supportingText = "The supporting text")
             val notification3 = createNotification(title = "Notification 3", supportingText = "The supporting text")
-            val notifications = listOf(notification1, notification2, notification3)
+            val notifications = persistentListOf(notification1, notification2, notification3)
             mainClock.autoAdvance = false
             setContentWithPreviewAndResources {
                 TestSubject(notifications = notifications)
@@ -183,44 +164,35 @@ class BannerInlineNotificationListHostTest : ComposeTest() {
 
             // Assert
             printSemanticTree()
-            onNodeWithTag(BannerInlineNotificationListHostDefaults.TEST_TAG_HOST_PARENT)
-                .assertIsDisplayed()
-
-            val listHost = onNodeWithTag(
-                BannerInlineNotificationListHostDefaults.TEST_TAG_BANNER_INLINE_LIST,
-                useUnmergedTree = true,
-            ).assertIsDisplayed()
-
-            listHost
-                .onChildren()
-                .assertCountEquals(2)
-
-            listHost.assertBannerInline(
-                index = 0,
-                title = notification1.title,
-                supportingText = requireNotNull(notification1.contentText),
-                assertActions = { assertCountEquals(2) },
-            )
-
-            listHost.assertBannerInline(
-                index = 1,
-                title = "Check Error Notifications",
-                supportingText = "Some messages need your attention.",
-                assertActions = {
-                    assertCountEquals(1)
-                    val actionButton = filterToOne(
-                        matcher = SemanticsMatcher.expectValue(
-                            key = SemanticsProperties.Role,
-                            expectedValue = Role.Button,
-                        ) and hasClickAction(),
-                    ).assertIsDisplayed()
+            assertBannerInlineList(size = 2) {
+                assertIsDisplayed()
+                assertBannerInline(
+                    index = 0,
+                    title = notification1.title,
+                    supportingText = requireNotNull(notification1.contentText),
+                    assertActions = { assertCountEquals(2) },
+                )
 
-                    actionButton
-                        .onChildren()
-                        .filterToOne(hasTextExactly("Open notifications"))
-                        .assertIsDisplayed()
-                },
-            )
+                assertBannerInline(
+                    index = 1,
+                    title = "Check Error Notifications",
+                    supportingText = "Some messages need your attention.",
+                    assertActions = {
+                        assertCountEquals(1)
+                        val actionButton = filterToOne(
+                            matcher = SemanticsMatcher.expectValue(
+                                key = SemanticsProperties.Role,
+                                expectedValue = Role.Button,
+                            ) and hasClickAction(),
+                        ).assertIsDisplayed()
+
+                        actionButton
+                            .onChildren()
+                            .filterToOne(hasTextExactly("Open notifications"))
+                            .assertIsDisplayed()
+                    },
+                )
+            }
         }
 
     @Test
@@ -234,7 +206,7 @@ class BannerInlineNotificationListHostTest : ComposeTest() {
         val actionClicked = mutableStateOf<NotificationAction?>(value = null)
         setContentWithTheme {
             TestSubject(
-                notifications = listOf(notification),
+                notifications = persistentListOf(notification),
                 onActionClick = { notification -> actionClicked.value = notification },
             )
         }
@@ -247,24 +219,15 @@ class BannerInlineNotificationListHostTest : ComposeTest() {
 
         // Assert
         printSemanticTree()
-        onNodeWithTag(BannerInlineNotificationListHostDefaults.TEST_TAG_HOST_PARENT)
-            .assertIsDisplayed()
-
-        val listHost = onNodeWithTag(
-            BannerInlineNotificationListHostDefaults.TEST_TAG_BANNER_INLINE_LIST,
-            useUnmergedTree = true,
-        ).assertIsDisplayed()
-
-        listHost
-            .onChildren()
-            .assertCountEquals(1)
-
-        listHost.assertBannerInline(
-            index = 0,
-            title = title,
-            supportingText = supportingText,
-            assertActions = { assertCountEquals(1) },
-        )
+        assertBannerInlineList(size = 1) {
+            assertIsDisplayed()
+            assertBannerInline(
+                index = 0,
+                title = title,
+                supportingText = supportingText,
+                assertActions = { assertCountEquals(1) },
+            )
+        }
 
         assertThat(actionClicked.value)
             .isEqualTo(action)
@@ -279,7 +242,7 @@ class BannerInlineNotificationListHostTest : ComposeTest() {
                     title = "Notification $index",
                     supportingText = "The supporting text",
                 )
-            }
+            }.toPersistentList()
             val openErrorNotificationsActionTitle = "Open notifications"
             val openErrorNotificationsClicked = mutableStateOf(false)
             mainClock.autoAdvance = false
@@ -298,85 +261,46 @@ class BannerInlineNotificationListHostTest : ComposeTest() {
 
             // Assert
             printSemanticTree()
-            onNodeWithTag(BannerInlineNotificationListHostDefaults.TEST_TAG_HOST_PARENT)
-                .assertIsDisplayed()
-
-            val listHost = onNodeWithTag(
-                BannerInlineNotificationListHostDefaults.TEST_TAG_BANNER_INLINE_LIST,
-                useUnmergedTree = true,
-            ).assertIsDisplayed()
-
-            listHost
-                .onChildren()
-                .assertCountEquals(2)
-
-            listHost.assertBannerInline(
-                index = 0,
-                title = notifications.first().title,
-                supportingText = requireNotNull(notifications.first().contentText),
-                assertActions = { assertCountEquals(2) },
-            )
-
-            listHost.assertBannerInline(
-                index = 1,
-                title = "Check Error Notifications",
-                supportingText = "Some messages need your attention.",
-                assertActions = {
-                    assertCountEquals(1)
-                    val actionButton = filterToOne(
-                        matcher = SemanticsMatcher.expectValue(
-                            key = SemanticsProperties.Role,
-                            expectedValue = Role.Button,
-                        ) and hasClickAction(),
-                    ).assertIsDisplayed()
+            assertBannerInlineList(size = 2) {
+                assertIsDisplayed()
+                assertBannerInline(
+                    index = 0,
+                    title = notifications.first().title,
+                    supportingText = requireNotNull(notifications.first().contentText),
+                    assertActions = { assertCountEquals(2) },
+                )
 
-                    actionButton
-                        .onChildren()
-                        .filterToOne(hasTextExactly(openErrorNotificationsActionTitle))
-                        .assertIsDisplayed()
-                },
-            )
+                assertBannerInline(
+                    index = 1,
+                    title = "Check Error Notifications",
+                    supportingText = "Some messages need your attention.",
+                    assertActions = {
+                        assertCountEquals(1)
+                        val actionButton = filterToOne(
+                            matcher = SemanticsMatcher.expectValue(
+                                key = SemanticsProperties.Role,
+                                expectedValue = Role.Button,
+                            ) and hasClickAction(),
+                        ).assertIsDisplayed()
+
+                        actionButton
+                            .onChildren()
+                            .filterToOne(hasTextExactly(openErrorNotificationsActionTitle))
+                            .assertIsDisplayed()
+                    },
+                )
+            }
         }
 
-    private fun printSemanticTree(root: SemanticsNodeInteraction = composeTestRule.onRoot(useUnmergedTree = true)) {
-        println("-----")
-        println("Semantic tree:")
-        println(root.printToString())
-        println("-----")
-        println()
-    }
-
-    private fun SemanticsNodeInteraction.assertBannerInline(
-        index: Int,
-        title: String,
-        supportingText: String,
-        assertActions: SemanticsNodeInteractionCollection.() -> Unit = {},
-    ) {
-        val banner = onChildAt(index)
-        val children = banner.onChildren()
-        children
-            .filterToOne(hasTextExactly(title))
-            .assertIsDisplayed()
-
-        children
-            .filterToOne(hasTextExactly(supportingText))
-            .assertIsDisplayed()
-
-        children
-            .filterToOne(hasTestTag(TEST_TAG_BANNER_INLINE_CARD_ACTION_ROW))
-            .onChildren()
-            .assertActions()
-    }
-
     @Composable
     private fun TestSubject(
-        notifications: List<FakeInAppOnlyNotification>,
+        notifications: ImmutableList<FakeInAppOnlyNotification>,
         modifier: Modifier = Modifier,
         onActionClick: (NotificationAction) -> Unit = {},
         onOpenErrorNotificationsClick: () -> Unit = {},
     ) {
         Column(modifier = modifier) {
-            val state = rememberInAppNotificationHostState()
+            val state = rememberInAppNotificationHostStateHolder()
             ButtonText(
                 text = "Trigger Notification",
                 onClick = {
diff --git a/feature/notification/api/src/androidUnitTest/kotlin/net/thunderbird/feature/notification/api/ui/InAppNotificationScaffoldTest.kt b/feature/notification/api/src/androidUnitTest/kotlin/net/thunderbird/feature/notification/api/ui/InAppNotificationScaffoldTest.kt
new file mode 100644
index 00000000000..1cb2fed0158
--- /dev/null
+++ b/feature/notification/api/src/androidUnitTest/kotlin/net/thunderbird/feature/notification/api/ui/InAppNotificationScaffoldTest.kt
@@ -0,0 +1,674 @@
+package net.thunderbird.feature.notification.api.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalInspectionMode
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.assertCountEquals
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsNotDisplayed
+import androidx.compose.ui.test.filterToOne
+import androidx.compose.ui.test.hasClickAction
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.hasTextExactly
+import androidx.compose.ui.test.junit4.ComposeContentTestRule
+import androidx.compose.ui.test.onChildren
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performScrollToNode
+import app.k9mail.core.ui.compose.common.koin.koinPreview
+import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyLarge
+import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodySmall
+import app.k9mail.core.ui.compose.testing.ComposeTest
+import app.k9mail.core.ui.compose.testing.setContentWithTheme
+import app.k9mail.core.ui.compose.theme2.MainTheme
+import assertk.assertThat
+import assertk.assertions.isEqualTo
+import assertk.assertions.isNotNull
+import kotlin.test.Test
+import kotlinx.collections.immutable.persistentSetOf
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import net.thunderbird.feature.notification.api.receiver.InAppNotificationEvent
+import net.thunderbird.feature.notification.api.receiver.InAppNotificationReceiver
+import net.thunderbird.feature.notification.api.ui.action.NotificationAction
+import net.thunderbird.feature.notification.api.ui.style.inAppNotificationStyles
+import net.thunderbird.feature.notification.api.ui.util.assertBannerInline
+import net.thunderbird.feature.notification.api.ui.util.assertBannerInlineList
+import net.thunderbird.feature.notification.api.ui.util.printSemanticTree
+import net.thunderbird.feature.notification.testing.fake.FakeInAppOnlyNotification
+import net.thunderbird.feature.notification.testing.fake.ui.action.createFakeNotificationAction
+import org.jetbrains.compose.resources.PreviewContextConfigurationEffect
+
+@Suppress("MaxLineLength")
+class InAppNotificationScaffoldTest : ComposeTest() {
+    // region [ content lambda with scroll verification ]
+    @Test
+    fun `InAppNotificationScaffold should be displayed`() = runComposeTestSuspend {
+        // Arrange & Act
+        setTestSubjectContent {
+            InAppNotificationScaffold {
+                TextBodySmall("Scaffold")
+            }
+        }
+
+        // Assert
+        onNodeWithTag(InAppNotificationScaffoldDefaults.TEST_TAG_INNER_SCAFFOLD)
+            .assertExists()
+        onNodeWithTag(InAppNotificationScaffoldDefaults.TEST_TAG_IN_APP_NOTIFICATION_HOST)
+            .assertExists()
+        onNodeWithTag(InAppNotificationScaffoldDefaults.TEST_TAG_SNACKBAR_HOST)
+            .assertIsNotDisplayed()
+    }
+
+    @Test
+    fun `InAppNotificationScaffold should support Column with verticalScroll modifier`() = runComposeTestSuspend {
+        // Arrange
+        val contentTag = "content_tag"
+        val firstElementTag = "first_element_tag"
+        val lastElementTag = "last_element_tag"
+        val size = 200
+        setTestSubjectContent {
+            InAppNotificationScaffold { paddingValues ->
+                Column(
+                    modifier = Modifier
+                        .fillMaxSize()
+                        .verticalScroll(rememberScrollState())
+                        .padding(paddingValues)
+                        .testTag(contentTag),
+                    verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.double),
+                ) {
+                    repeat(size) { index ->
+                        TextBodyLarge(
+                            text = "Element $index",
+                            modifier = when (index) {
+                                0 -> Modifier.testTag(firstElementTag)
+                                size - 1 -> Modifier.testTag(lastElementTag)
+                                else -> Modifier
+                            },
+                        )
+                    }
+                }
+            }
+        }
+
+        // Act
+        onNodeWithTag(contentTag).performScrollToNode(hasTestTag(lastElementTag))
+
+        // Assert
+        onNodeWithTag(InAppNotificationScaffoldDefaults.TEST_TAG_INNER_SCAFFOLD)
+            .assertExists()
+        onNodeWithTag(InAppNotificationScaffoldDefaults.TEST_TAG_IN_APP_NOTIFICATION_HOST)
+            .assertExists()
+        onNodeWithTag(InAppNotificationScaffoldDefaults.TEST_TAG_SNACKBAR_HOST)
+            .assertIsNotDisplayed()
+
+        printSemanticTree()
+        onNodeWithTag(firstElementTag).assertIsNotDisplayed()
+        onNodeWithTag(lastElementTag).assertIsDisplayed()
+    }
+
+    @Test
+    fun `InAppNotificationScaffold should support LazyColumn`() = runComposeTestSuspend {
+        // Arrange
+        val contentTag = "content_tag"
+        val firstElementTag = "first_element_tag"
+        val lastElementTag = "last_element_tag"
+        val size = 200
+        setTestSubjectContent {
+            InAppNotificationScaffold { paddingValues ->
+                LazyColumn(
+                    modifier = Modifier
+                        .fillMaxSize()
+                        .padding(paddingValues)
+                        .testTag(contentTag),
+                    verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.double),
+                ) {
+                    items(size) { index ->
+                        TextBodyLarge(
+                            text = "Element $index",
+                            modifier = when (index) {
+                                0 -> Modifier.testTag(firstElementTag)
+                                size - 1 -> Modifier.testTag(lastElementTag)
+                                else -> Modifier
+                            },
+                        )
+                    }
+                }
+            }
+        }
+
+        // Act
+        onNodeWithTag(contentTag).performScrollToNode(hasTestTag(lastElementTag))
+
+        // Assert
+        onNodeWithTag(InAppNotificationScaffoldDefaults.TEST_TAG_INNER_SCAFFOLD)
+            .assertExists()
+        onNodeWithTag(InAppNotificationScaffoldDefaults.TEST_TAG_IN_APP_NOTIFICATION_HOST)
+            .assertExists()
+        onNodeWithTag(InAppNotificationScaffoldDefaults.TEST_TAG_SNACKBAR_HOST)
+            .assertIsNotDisplayed()
+
+        printSemanticTree()
+        onNodeWithTag(firstElementTag).assertIsNotDisplayed()
+        onNodeWithTag(lastElementTag).assertIsDisplayed()
+    }
+    // endregion [ content lambda with scroll verification ]
+
+    // region [ Banner Global Notification verification ]
+    @Test
+    fun `InAppNotificationScaffold should display BannerGlobalHost when Show event with bannerGlobal in-app notification is triggered`() =
+        runComposeTestSuspend {
+            // Arrange
+            val event = InAppNotificationEvent.Show(
+                notification = FakeInAppOnlyNotification(
+                    inAppNotificationStyles = inAppNotificationStyles { bannerGlobal() },
+                ),
+            )
+            val receiver = FakeInAppNotificationReceiver()
+            setTestSubjectContent(inAppNotificationReceiver = receiver) {
+                InAppNotificationScaffold {
+                    TextBodyLarge(text = "Content")
+                }
+            }
+
+            // Pre-Act Assert
+            assertIdleState()
+
+            // Act
+            printSemanticTree()
+            receiver.triggerEvent(event)
+            printSemanticTree()
+
+            // Assert
+            onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_HOST)
+                .assertIsDisplayed()
+            onNodeWithTag(BannerInlineNotificationListHostDefaults.TEST_TAG_HOST_PARENT)
+                .assertIsNotDisplayed()
+        }
+
+    @Test
+    fun `InAppNotificationScaffold should call onNotificationActionClick when banner global action is clicked`() =
+        runComposeTestSuspend {
+            // Arrange
+            val actionTitle = "The action"
+            val action = createFakeNotificationAction(actionTitle)
+            val event = InAppNotificationEvent.Show(
+                notification = FakeInAppOnlyNotification(
+                    inAppNotificationStyles = inAppNotificationStyles { bannerGlobal() },
+                    actions = setOf(action),
+                ),
+            )
+            val receiver = FakeInAppNotificationReceiver()
+            val clickedAction = mutableStateOf<NotificationAction?>(value = null)
+            setTestSubjectContent(inAppNotificationReceiver = receiver) {
+                InAppNotificationScaffold(
+                    onNotificationActionClick = { clickedAction.value = it },
+                ) {
+                    TextBodyLarge(text = "Content")
+                }
+            }
+
+            // Pre-Act Assert
+            assertIdleState()
+
+            // Act (Phase 1)
+            printSemanticTree()
+            receiver.triggerEvent(event)
+            printSemanticTree()
+
+            // Assert (Phase 1)
+            onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_HOST)
+                .assertIsDisplayed()
+            onNodeWithTag(BannerInlineNotificationListHostDefaults.TEST_TAG_HOST_PARENT)
+                .assertIsNotDisplayed()
+
+            // Act (Phase 2)
+            onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_BANNER_GLOBAL_ACTION)
+                .performClick()
+
+            // Assert (Phase 2)
+            assertThat(clickedAction.value)
+                .isNotNull()
+                .given { action ->
+                    assertThat(action.resolveTitle())
+                        .isEqualTo(actionTitle)
+                }
+        }
+
+    @Test
+    fun `InAppNotificationScaffold should display the most priority banner global notification when multiple banner global notifications are triggered`() =
+        runComposeTestSuspend {
+            // Arrange
+            val event = InAppNotificationEvent.Show(
+                notification = FakeInAppOnlyNotification(
+                    inAppNotificationStyles = inAppNotificationStyles { bannerGlobal() },
+                ),
+            )
+            val receiver = FakeInAppNotificationReceiver()
+            setTestSubjectContent(inAppNotificationReceiver = receiver) {
+                InAppNotificationScaffold {
+                    TextBodyLarge(text = "Content")
+                }
+            }
+
+            // Pre-Act Assert
+            assertIdleState()
+
+            // Act
+            // TODO(#9572): If global is already present, show the one with the highest priority
+            //              show the previous one back once the higher priority has fixed and the
+            //              other wasn't
+
+            // Assert
+        }
+
+    @Test
+    fun `InAppNotificationScaffold should not display BannerGlobalHost when Show event with bannerInline in-app notification is triggered`() =
+        runComposeTestSuspend {
+            // Arrange
+            val event = InAppNotificationEvent.Show(
+                notification = FakeInAppOnlyNotification(
+                    inAppNotificationStyles = inAppNotificationStyles { bannerInline() },
+                    actions = setOf(createFakeNotificationAction("Action")),
+                ),
+            )
+            val receiver = FakeInAppNotificationReceiver()
+            setTestSubjectContent(inAppNotificationReceiver = receiver) {
+                InAppNotificationScaffold {
+                    TextBodyLarge(text = "Content")
+                }
+            }
+
+            // Pre-Act Assert
+            assertIdleState()
+
+            // Act
+            printSemanticTree()
+            receiver.triggerEvent(event)
+            printSemanticTree()
+
+            // Assert
+            onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_HOST)
+                .assertIsNotDisplayed()
+        }
+    // endregion [ Banner Global Notification verification ]
+
+    // region [ Banner Inline List Notification verification ]
+    @Test
+    fun `InAppNotificationScaffold should display BannerInlineListHost when Show event with bannerInline in-app notification is triggered`() =
+        runComposeTestSuspend {
+            // Arrange
+            val notification = FakeInAppOnlyNotification(
+                title = "The notification",
+                contentText = "The content",
+                inAppNotificationStyles = inAppNotificationStyles { bannerInline() },
+                actions = setOf(createFakeNotificationAction("Action")),
+            )
+            val event = InAppNotificationEvent.Show(notification = notification)
+            val receiver = FakeInAppNotificationReceiver()
+            setTestSubjectContent(inAppNotificationReceiver = receiver) {
+                InAppNotificationScaffold {
+                    TextBodyLarge(text = "Content")
+                }
+            }
+
+            // Pre-Act Assert
+            assertIdleState()
+
+            // Act
+            printSemanticTree()
+            receiver.triggerEvent(event)
+            printSemanticTree()
+
+            // Assert
+            onNodeWithTag(BannerInlineNotificationListHostDefaults.TEST_TAG_HOST_PARENT)
+                .assertIsDisplayed()
+
+            assertBannerInlineList(size = 1) {
+                assertIsDisplayed()
+                assertBannerInline(
+                    index = 0,
+                    title = notification.title,
+                    supportingText = requireNotNull(notification.contentText),
+                )
+            }
+        }
+
+    @Test
+    fun `InAppNotificationScaffold should display BannerInlineListHost with check error notifications when more than 3 Show event with bannerInline in-app notification is triggered`() =
+        runComposeTestSuspend {
+            mainClock.autoAdvance = false
+            // Arrange
+            val notification = FakeInAppOnlyNotification(
+                title = "The notification",
+                contentText = "The content",
+                inAppNotificationStyles = inAppNotificationStyles { bannerInline() },
+                actions = setOf(createFakeNotificationAction("Action")),
+            )
+            val event = InAppNotificationEvent.Show(notification = notification)
+            val receiver = FakeInAppNotificationReceiver()
+            setTestSubjectContent(inAppNotificationReceiver = receiver) {
+                InAppNotificationScaffold { TextBodyLarge(text = "Content") }
+            }
+
+            // Pre-Act Assert
+            assertIdleState()
+
+            // Act
+            printSemanticTree()
+            receiver.triggerEvent(event)
+            mainClock.advanceTimeBy(milliseconds = 1000L)
+            repeat(times = 10) {
+                receiver.triggerEvent(
+                    InAppNotificationEvent.Show(
+                        notification = FakeInAppOnlyNotification(
+                            title = "Notification $it",
+                            inAppNotificationStyles = inAppNotificationStyles { bannerInline() },
+                            actions = setOf(createFakeNotificationAction("Action")),
+                        ),
+                    ),
+                )
+                mainClock.advanceTimeBy(1000L)
+            }
+            printSemanticTree()
+
+            // Assert
+            onNodeWithTag(BannerInlineNotificationListHostDefaults.TEST_TAG_HOST_PARENT)
+                .assertIsDisplayed()
+
+            assertBannerInlineList(size = 2) {
+                assertIsDisplayed()
+                assertBannerInline(
+                    index = 0,
+                    title = notification.title,
+                    supportingText = requireNotNull(notification.contentText),
+                )
+                assertBannerInline(
+                    index = 1,
+                    title = "Check Error Notifications",
+                    supportingText = "Some messages need your attention.",
+                    assertActions = {
+                        assertCountEquals(1)
+                        val actionButton = filterToOne(
+                            matcher = SemanticsMatcher.expectValue(
+                                key = SemanticsProperties.Role,
+                                expectedValue = Role.Button,
+                            ) and hasClickAction(),
+                        ).assertIsDisplayed()
+
+                        actionButton
+                            .onChildren()
+                            .filterToOne(hasTextExactly("Open notifications"))
+                            .assertIsDisplayed()
+                    },
+                )
+            }
+        }
+
+    @Test
+    fun `InAppNotificationScaffold should call onNotificationActionClick when banner inline list action is clicked`() =
+        runComposeTestSuspend {
+            // Arrange
+            val actionTitle = "The action"
+            val action = createFakeNotificationAction(actionTitle)
+            val event = InAppNotificationEvent.Show(
+                notification = FakeInAppOnlyNotification(
+                    inAppNotificationStyles = inAppNotificationStyles { bannerInline() },
+                    actions = setOf(action),
+                ),
+            )
+            val receiver = FakeInAppNotificationReceiver()
+            val clickedAction = mutableStateOf<NotificationAction?>(value = null)
+            setTestSubjectContent(inAppNotificationReceiver = receiver) {
+                InAppNotificationScaffold(
+                    onNotificationActionClick = { clickedAction.value = it },
+                ) {
+                    TextBodyLarge(text = "Content")
+                }
+            }
+
+            // Pre-Act Assert
+            assertIdleState()
+
+            // Act (Phase 1)
+            printSemanticTree()
+            receiver.triggerEvent(event)
+            printSemanticTree()
+
+            // Assert (Phase 1)
+            onNodeWithTag(BannerInlineNotificationListHostDefaults.TEST_TAG_HOST_PARENT)
+                .assertIsDisplayed()
+
+            // Act (Phase 2)
+            onNodeWithTag(
+                BannerInlineNotificationListHostDefaults.testTagBannerInlineListItemAction(
+                    index = 0,
+                    actionIndex = 0,
+                ),
+            ).performClick()
+
+            // Assert (Phase 2)
+            assertThat(clickedAction.value)
+                .isNotNull()
+                .given { action ->
+                    assertThat(action.resolveTitle())
+                        .isEqualTo(actionTitle)
+                }
+        }
+
+    @Test
+    fun `InAppNotificationScaffold should call onNotificationActionClick when check notifications error action is clicked`() =
+        runComposeTestSuspend {
+            // Arrange
+            mainClock.autoAdvance = false
+            val receiver = FakeInAppNotificationReceiver()
+            val clickedAction = mutableStateOf<NotificationAction?>(value = null)
+            setTestSubjectContent(inAppNotificationReceiver = receiver) {
+                InAppNotificationScaffold(
+                    onNotificationActionClick = { clickedAction.value = it },
+                ) {
+                    TextBodyLarge(text = "Content")
+                }
+            }
+
+            // Pre-Act Assert
+            assertIdleState()
+
+            // Act (Phase 1)
+            printSemanticTree()
+            repeat(times = 10) {
+                receiver.triggerEvent(
+                    InAppNotificationEvent.Show(
+                        notification = FakeInAppOnlyNotification(
+                            title = "Notification $it",
+                            inAppNotificationStyles = inAppNotificationStyles { bannerInline() },
+                            actions = setOf(createFakeNotificationAction("Action")),
+                        ),
+                    ),
+                )
+                mainClock.advanceTimeBy(1000L)
+            }
+            printSemanticTree()
+
+            // Assert (Phase 1)
+            onNodeWithTag(BannerInlineNotificationListHostDefaults.TEST_TAG_HOST_PARENT)
+                .assertIsDisplayed()
+
+            assertBannerInlineList(size = 2)
+
+            // Act (Phase 2)
+            onNodeWithTag(BannerInlineNotificationListHostDefaults.TEST_TAG_CHECK_ERROR_NOTIFICATIONS_ACTION)
+                .performClick()
+
+            // Assert (Phase 2)
+            assertThat(clickedAction.value)
+                .isNotNull()
+                .isEqualTo(NotificationAction.OpenNotificationCentre)
+        }
+    // endregion [ Banner Inline List Notification verification ]
+
+    // region [ DisplayInAppNotificationFlag verification ]
+    @Test
+    fun `InAppNotificationScaffold should not display BannerGlobalHost when display flag BannerGlobalNotifications is not enabled`() =
+        runComposeTestSuspend {
+            // Arrange
+            val event = InAppNotificationEvent.Show(
+                notification = FakeInAppOnlyNotification(
+                    inAppNotificationStyles = inAppNotificationStyles { bannerGlobal() },
+                ),
+            )
+            val receiver = FakeInAppNotificationReceiver()
+            setTestSubjectContent(inAppNotificationReceiver = receiver) {
+                InAppNotificationScaffold(
+                    enabled = persistentSetOf(), // Empty set will disable all display flags
+                ) {
+                    TextBodyLarge(text = "Content")
+                }
+            }
+
+            // Pre-Act Assert
+            assertIdleState()
+
+            // Act
+            printSemanticTree()
+            receiver.triggerEvent(event)
+            printSemanticTree()
+
+            // Pre-Act Assert
+            assertIdleState()
+
+            // Assert
+            onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_HOST)
+                .assertExists()
+                .assertIsNotDisplayed()
+        }
+
+    @Test
+    fun `InAppNotificationScaffold should not display BannerInlineListHost when display flag BannerInlineNotifications is not enabled`() =
+        runComposeTestSuspend {
+            // Arrange
+            val event = InAppNotificationEvent.Show(
+                notification = FakeInAppOnlyNotification(
+                    inAppNotificationStyles = inAppNotificationStyles { bannerInline() },
+                    actions = setOf(createFakeNotificationAction("Action")),
+                ),
+            )
+            val receiver = FakeInAppNotificationReceiver()
+            setTestSubjectContent(inAppNotificationReceiver = receiver) {
+                InAppNotificationScaffold(
+                    enabled = persistentSetOf(), // Empty set will disable all display flags
+                ) {
+                    TextBodyLarge(text = "Content")
+                }
+            }
+
+            // Pre-Act Assert
+            assertIdleState()
+
+            // Act
+            printSemanticTree()
+            receiver.triggerEvent(event)
+            printSemanticTree()
+
+            // Assert
+            onNodeWithTag(BannerInlineNotificationListHostDefaults.TEST_TAG_HOST_PARENT)
+                .assertExists()
+                .assertIsNotDisplayed()
+        }
+
+    @Test
+    fun `InAppNotificationScaffold should not display Snackbar when display flag SnackbarNotifications is not enabled`() =
+        runComposeTestSuspend {
+            // Arrange
+            val event = InAppNotificationEvent.Show(
+                notification = FakeInAppOnlyNotification(
+                    inAppNotificationStyles = inAppNotificationStyles { snackbar() },
+                    actions = setOf(createFakeNotificationAction("Action")),
+                ),
+            )
+            val receiver = FakeInAppNotificationReceiver()
+            setTestSubjectContent(inAppNotificationReceiver = receiver) {
+                InAppNotificationScaffold(
+                    enabled = persistentSetOf(), // Empty set will disable all display flags
+                ) {
+                    TextBodyLarge(text = "Content")
+                }
+            }
+
+            // Pre-Act Assert
+            assertIdleState()
+
+            // Act
+            printSemanticTree()
+            receiver.triggerEvent(event)
+            printSemanticTree()
+
+            // Assert
+            onNodeWithTag(InAppNotificationScaffoldDefaults.TEST_TAG_SNACKBAR_HOST)
+                .assertExists()
+                .assertIsNotDisplayed()
+        }
+    // endregion [ DisplayInAppNotificationFlag verification ]
+
+    private fun ComposeContentTestRule.assertIdleState() {
+        onNodeWithTag(InAppNotificationScaffoldDefaults.TEST_TAG_INNER_SCAFFOLD)
+            .assertExists()
+        onNodeWithTag(InAppNotificationScaffoldDefaults.TEST_TAG_IN_APP_NOTIFICATION_HOST)
+            .assertExists()
+        onNodeWithTag(InAppNotificationScaffoldDefaults.TEST_TAG_SNACKBAR_HOST)
+            .assertExists()
+            .assertIsNotDisplayed()
+        onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_HOST)
+            .assertExists()
+            .assertIsNotDisplayed()
+        onNodeWithTag(BannerInlineNotificationListHostDefaults.TEST_TAG_HOST_PARENT)
+            .assertExists()
+            .assertIsNotDisplayed()
+    }
+
+    private fun ComposeTest.setTestSubjectContent(
+        inAppNotificationReceiver: InAppNotificationReceiver = FakeInAppNotificationReceiver(),
+        content: @Composable () -> Unit,
+    ) {
+        setContentWithTheme {
+            koinPreview {
+                single<InAppNotificationReceiver> { inAppNotificationReceiver }
+            } WithContent {
+                // https://github.com/robolectric/robolectric/issues/9603
+                // https://youtrack.jetbrains.com/issue/CMP-6612/Support-non-compose-UI-tests-with-resources
+                CompositionLocalProvider(LocalInspectionMode provides true) {
+                    PreviewContextConfigurationEffect()
+                    content()
+                }
+            }
+        }
+    }
+}
+
+private class FakeInAppNotificationReceiver(
+    initialEvents: List<InAppNotificationEvent> = emptyList(),
+) : InAppNotificationReceiver {
+    private val _events = MutableSharedFlow<InAppNotificationEvent>(replay = 1)
+    override val events: SharedFlow<InAppNotificationEvent> = _events
+
+    init {
+        initialEvents.forEach { event -> _events.tryEmit(event) }
+    }
+
+    suspend fun triggerEvent(event: InAppNotificationEvent) {
+        _events.emit(event)
+    }
+}
diff --git a/feature/notification/api/src/androidUnitTest/kotlin/net/thunderbird/feature/notification/api/ui/util/BannerInlineListAssert.kt b/feature/notification/api/src/androidUnitTest/kotlin/net/thunderbird/feature/notification/api/ui/util/BannerInlineListAssert.kt
new file mode 100644
index 00000000000..cd1938ac5c8
--- /dev/null
+++ b/feature/notification/api/src/androidUnitTest/kotlin/net/thunderbird/feature/notification/api/ui/util/BannerInlineListAssert.kt
@@ -0,0 +1,53 @@
+package net.thunderbird.feature.notification.api.ui.util
+
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.SemanticsNodeInteractionCollection
+import androidx.compose.ui.test.assertCountEquals
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.filterToOne
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.hasTextExactly
+import androidx.compose.ui.test.onChildAt
+import androidx.compose.ui.test.onChildren
+import app.k9mail.core.ui.compose.designsystem.organism.banner.BannerNotificationCardDefaults.TEST_TAG_BANNER_INLINE_CARD_ACTION_ROW
+import app.k9mail.core.ui.compose.testing.ComposeTest
+import app.k9mail.core.ui.compose.testing.onNodeWithTag
+import net.thunderbird.feature.notification.api.ui.BannerInlineNotificationListHostDefaults
+
+internal fun ComposeTest.assertBannerInlineList(
+    size: Int,
+    assertListHost: SemanticsNodeInteraction.() -> Unit = { assertIsDisplayed() },
+) {
+    val listHost = onNodeWithTag(
+        BannerInlineNotificationListHostDefaults.TEST_TAG_BANNER_INLINE_LIST,
+        useUnmergedTree = true,
+    )
+
+    listHost
+        .onChildren()
+        .assertCountEquals(size)
+
+    listHost.assertListHost()
+}
+
+internal fun SemanticsNodeInteraction.assertBannerInline(
+    index: Int,
+    title: String,
+    supportingText: String,
+    assertActions: SemanticsNodeInteractionCollection.() -> Unit = {},
+) {
+    val banner = onChildAt(index)
+    val children = banner.onChildren()
+    children
+        .filterToOne(hasTextExactly(title))
+        .assertIsDisplayed()
+
+    children
+        .filterToOne(hasTextExactly(supportingText))
+        .assertIsDisplayed()
+
+    children
+        .filterToOne(hasTestTag(TEST_TAG_BANNER_INLINE_CARD_ACTION_ROW))
+        .onChildren()
+        .assertActions()
+}
diff --git a/feature/notification/api/src/androidUnitTest/kotlin/net/thunderbird/feature/notification/api/ui/util/PrintSemanticTree.kt b/feature/notification/api/src/androidUnitTest/kotlin/net/thunderbird/feature/notification/api/ui/util/PrintSemanticTree.kt
new file mode 100644
index 00000000000..65414890b3e
--- /dev/null
+++ b/feature/notification/api/src/androidUnitTest/kotlin/net/thunderbird/feature/notification/api/ui/util/PrintSemanticTree.kt
@@ -0,0 +1,16 @@
+package net.thunderbird.feature.notification.api.ui.util
+
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.onRoot
+import androidx.compose.ui.test.printToString
+import app.k9mail.core.ui.compose.testing.ComposeTest
+
+internal fun ComposeTest.printSemanticTree(
+    root: SemanticsNodeInteraction = composeTestRule.onRoot(useUnmergedTree = true),
+) {
+    println("-----")
+    println("Semantic tree:")
+    println(root.printToString())
+    println("-----")
+    println()
+}
diff --git a/feature/notification/api/src/commonTest/kotlin/net/thunderbird/feature/notification/api/ui/host/InAppNotificationHostStateHolderTest.kt b/feature/notification/api/src/commonTest/kotlin/net/thunderbird/feature/notification/api/ui/host/InAppNotificationHostStateHolderTest.kt
index 5d3ee1ee755..6c65eea4c2c 100644
--- a/feature/notification/api/src/commonTest/kotlin/net/thunderbird/feature/notification/api/ui/host/InAppNotificationHostStateHolderTest.kt
+++ b/feature/notification/api/src/commonTest/kotlin/net/thunderbird/feature/notification/api/ui/host/InAppNotificationHostStateHolderTest.kt
@@ -17,8 +17,6 @@ import assertk.assertions.isTrue
 import assertk.assertions.prop
 import kotlin.test.Test
 import kotlin.test.assertFails
-import kotlin.time.Duration.Companion.hours
-import kotlin.time.Duration.Companion.seconds
 import kotlinx.collections.immutable.persistentSetOf
 import kotlinx.coroutines.test.runTest
 import net.thunderbird.feature.notification.api.NotificationSeverity
@@ -29,6 +27,7 @@ import net.thunderbird.feature.notification.api.ui.host.visual.BannerInlineVisua
 import net.thunderbird.feature.notification.api.ui.host.visual.BannerInlineVisual.Companion.MAX_TITLE_LENGTH
 import net.thunderbird.feature.notification.api.ui.host.visual.InAppNotificationHostState
 import net.thunderbird.feature.notification.api.ui.host.visual.SnackbarVisual
+import net.thunderbird.feature.notification.api.ui.style.SnackbarDuration
 import net.thunderbird.feature.notification.api.ui.style.inAppNotificationStyles
 import net.thunderbird.feature.notification.testing.fake.FakeInAppOnlyNotification
 import net.thunderbird.feature.notification.testing.fake.ui.action.createFakeNotificationAction
@@ -582,7 +581,7 @@ class InAppNotificationHostStateHolderTest {
             val expectedContentText = "expected text"
             val expectedAction = createFakeNotificationAction()
             val expectedSeverity = NotificationSeverity.Warning
-            val expectedDuration = 1.hours
+            val expectedDuration = SnackbarDuration.Long
             val notification = FakeInAppOnlyNotification(
                 contentText = expectedContentText,
                 inAppNotificationStyles = inAppNotificationStyles { snackbar(expectedDuration) },
@@ -619,7 +618,7 @@ class InAppNotificationHostStateHolderTest {
             val expectedContentText = "expected text"
             val expectedAction = createFakeNotificationAction()
             val expectedSeverity = NotificationSeverity.Warning
-            val expectedDuration = 1.hours
+            val expectedDuration = SnackbarDuration.Long
             val notification = FakeInAppOnlyNotification(
                 contentText = expectedContentText,
                 inAppNotificationStyles = inAppNotificationStyles { snackbar(expectedDuration) },
@@ -874,7 +873,7 @@ class InAppNotificationHostStateHolderTest {
     fun `dismiss should remove snackbar notification given a SnackbarVisual`() = runTest {
         // Arrange
         val notification = FakeInAppOnlyNotification(
-            inAppNotificationStyles = inAppNotificationStyles { snackbar(10.seconds) },
+            inAppNotificationStyles = inAppNotificationStyles { snackbar(SnackbarDuration.Short) },
             actions = setOf(createFakeNotificationAction()),
         )
         val visual = requireNotNull(SnackbarVisual.from(notification))
diff --git a/feature/notification/api/src/commonTest/kotlin/net/thunderbird/feature/notification/api/ui/style/InAppNotificationStyleTest.kt b/feature/notification/api/src/commonTest/kotlin/net/thunderbird/feature/notification/api/ui/style/InAppNotificationStyleTest.kt
index a8d498bec98..498f72534c0 100644
--- a/feature/notification/api/src/commonTest/kotlin/net/thunderbird/feature/notification/api/ui/style/InAppNotificationStyleTest.kt
+++ b/feature/notification/api/src/commonTest/kotlin/net/thunderbird/feature/notification/api/ui/style/InAppNotificationStyleTest.kt
@@ -6,9 +6,6 @@ import assertk.assertions.hasMessage
 import assertk.assertions.isInstanceOf
 import kotlin.test.Test
 import kotlin.test.assertFails
-import kotlin.time.Duration.Companion.hours
-import kotlin.time.Duration.Companion.minutes
-import kotlin.time.Duration.Companion.seconds
 
 @Suppress("MaxLineLength")
 class InAppNotificationStyleTest {
@@ -59,7 +56,7 @@ class InAppNotificationStyleTest {
     @Test
     fun `inAppNotificationStyle dsl should create a snackbar with 30 seconds duration in-app notification style`() {
         // Arrange
-        val duration = 30.seconds
+        val duration = SnackbarDuration.Short
         val expectedStyles = arrayOf<InAppNotificationStyle>(InAppNotificationStyle.SnackbarNotification(duration))
 
         // Act
@@ -165,8 +162,8 @@ class InAppNotificationStyleTest {
         val actual = assertFails {
             inAppNotificationStyles {
                 snackbar()
-                snackbar(duration = 1.minutes)
-                snackbar(duration = 1.hours)
+                snackbar(duration = SnackbarDuration.Short)
+                snackbar(duration = SnackbarDuration.Long)
             }
         }