Skip to content

Commit 4cf2e93

Browse files
committed
feat(in-app-notification): implement priority queue for banner global
1 parent 511a81b commit 4cf2e93

File tree

5 files changed

+186
-23
lines changed

5 files changed

+186
-23
lines changed

feature/notification/api/src/androidUnitTest/kotlin/net/thunderbird/feature/notification/api/ui/InAppNotificationScaffoldTest.kt

Lines changed: 96 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@ import androidx.compose.ui.test.SemanticsMatcher
1919
import androidx.compose.ui.test.assertCountEquals
2020
import androidx.compose.ui.test.assertIsDisplayed
2121
import androidx.compose.ui.test.assertIsNotDisplayed
22+
import androidx.compose.ui.test.assertTextEquals
2223
import androidx.compose.ui.test.filterToOne
2324
import androidx.compose.ui.test.hasClickAction
2425
import androidx.compose.ui.test.hasTestTag
2526
import androidx.compose.ui.test.hasTextExactly
2627
import androidx.compose.ui.test.junit4.ComposeContentTestRule
28+
import androidx.compose.ui.test.onChild
2729
import androidx.compose.ui.test.onChildren
2830
import androidx.compose.ui.test.onNodeWithTag
2931
import androidx.compose.ui.test.performClick
@@ -41,9 +43,11 @@ import kotlin.test.Test
4143
import kotlinx.collections.immutable.persistentSetOf
4244
import kotlinx.coroutines.flow.MutableSharedFlow
4345
import kotlinx.coroutines.flow.SharedFlow
46+
import net.thunderbird.feature.notification.api.NotificationSeverity
4447
import net.thunderbird.feature.notification.api.receiver.InAppNotificationEvent
4548
import net.thunderbird.feature.notification.api.receiver.InAppNotificationReceiver
4649
import net.thunderbird.feature.notification.api.ui.action.NotificationAction
50+
import net.thunderbird.feature.notification.api.ui.style.NotificationPriority
4751
import net.thunderbird.feature.notification.api.ui.style.inAppNotificationStyle
4852
import net.thunderbird.feature.notification.api.ui.util.assertBannerInline
4953
import net.thunderbird.feature.notification.api.ui.util.assertBannerInlineList
@@ -252,11 +256,19 @@ class InAppNotificationScaffoldTest : ComposeTest() {
252256
fun `InAppNotificationScaffold should display the most priority banner global notification when multiple banner global notifications are triggered`() =
253257
runComposeTestSuspend {
254258
// Arrange
255-
val event = InAppNotificationEvent.Show(
256-
notification = FakeInAppOnlyNotification(
257-
inAppNotificationStyle = inAppNotificationStyle { bannerGlobal() },
258-
),
259+
val lowerPriorityNotificationText = "lower priority notification"
260+
val lowerPriorityNotification = FakeInAppOnlyNotification(
261+
contentText = lowerPriorityNotificationText,
262+
severity = NotificationSeverity.Warning,
263+
inAppNotificationStyle = inAppNotificationStyle { bannerGlobal(priority = NotificationPriority.Min) },
259264
)
265+
val higherPriorityNotificationText = "higher priority notification"
266+
val higherPriorityNotification = FakeInAppOnlyNotification(
267+
contentText = higherPriorityNotificationText,
268+
severity = NotificationSeverity.Warning,
269+
inAppNotificationStyle = inAppNotificationStyle { bannerGlobal(priority = NotificationPriority.Max) },
270+
)
271+
260272
val receiver = FakeInAppNotificationReceiver()
261273
setTestSubjectContent(inAppNotificationReceiver = receiver) {
262274
InAppNotificationScaffold {
@@ -267,12 +279,87 @@ class InAppNotificationScaffoldTest : ComposeTest() {
267279
// Pre-Act Assert
268280
assertIdleState()
269281

270-
// Act
271-
// TODO(#9572): If global is already present, show the one with the highest priority
272-
// show the previous one back once the higher priority has fixed and the
273-
// other wasn't
282+
// Act (Phase 1)
283+
printSemanticTree()
284+
receiver.triggerEvent(InAppNotificationEvent.Show(notification = lowerPriorityNotification))
285+
printSemanticTree()
274286

275-
// Assert
287+
// Assert (Phase 1)
288+
onNodeWithTag(BannerInlineNotificationListHostDefaults.TEST_TAG_HOST_PARENT)
289+
.assertIsNotDisplayed()
290+
onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_WARNING_BANNER)
291+
.assertIsDisplayed()
292+
.onChild()
293+
.assertTextEquals(lowerPriorityNotificationText)
294+
295+
// Act (Phase 2)
296+
printSemanticTree()
297+
receiver.triggerEvent(InAppNotificationEvent.Show(notification = higherPriorityNotification))
298+
printSemanticTree()
299+
300+
// Assert (Phase 2)
301+
onNodeWithTag(BannerInlineNotificationListHostDefaults.TEST_TAG_HOST_PARENT)
302+
.assertIsNotDisplayed()
303+
onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_WARNING_BANNER)
304+
.assertIsDisplayed()
305+
.onChild()
306+
.assertTextEquals(higherPriorityNotificationText)
307+
}
308+
309+
@Test
310+
fun `InAppNotificationScaffold should display the previous banner global notification when higher priority banner global notification is dismissed`() =
311+
runComposeTestSuspend {
312+
// Arrange
313+
val lowerPriorityNotificationText = "lower priority notification"
314+
val lowerPriorityNotification = FakeInAppOnlyNotification(
315+
contentText = lowerPriorityNotificationText,
316+
severity = NotificationSeverity.Warning,
317+
inAppNotificationStyle = inAppNotificationStyle { bannerGlobal(priority = NotificationPriority.Min) },
318+
)
319+
val higherPriorityNotificationText = "higher priority notification"
320+
val higherPriorityNotification = FakeInAppOnlyNotification(
321+
contentText = higherPriorityNotificationText,
322+
severity = NotificationSeverity.Warning,
323+
inAppNotificationStyle = inAppNotificationStyle { bannerGlobal(priority = NotificationPriority.Max) },
324+
)
325+
326+
val receiver = FakeInAppNotificationReceiver()
327+
setTestSubjectContent(inAppNotificationReceiver = receiver) {
328+
InAppNotificationScaffold {
329+
TextBodyLarge(text = "Content")
330+
}
331+
}
332+
333+
// Pre-Act Assert
334+
assertIdleState()
335+
336+
// Act (Phase 1)
337+
printSemanticTree(prefixLabel = "before first show event")
338+
receiver.triggerEvent(InAppNotificationEvent.Show(notification = lowerPriorityNotification))
339+
printSemanticTree(prefixLabel = "before second show event")
340+
receiver.triggerEvent(InAppNotificationEvent.Show(notification = higherPriorityNotification))
341+
printSemanticTree(prefixLabel = "after events triggered")
342+
343+
// Assert (Phase 1)
344+
onNodeWithTag(BannerInlineNotificationListHostDefaults.TEST_TAG_HOST_PARENT)
345+
.assertIsNotDisplayed()
346+
onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_WARNING_BANNER)
347+
.assertIsDisplayed()
348+
.onChild()
349+
.assertTextEquals(higherPriorityNotificationText)
350+
351+
// Act (Phase 2)
352+
printSemanticTree(prefixLabel = "before dismiss event")
353+
receiver.triggerEvent(InAppNotificationEvent.Dismiss(notification = higherPriorityNotification))
354+
printSemanticTree(prefixLabel = "after dismiss event triggered")
355+
356+
// Assert (Phase 2)
357+
onNodeWithTag(BannerInlineNotificationListHostDefaults.TEST_TAG_HOST_PARENT)
358+
.assertIsNotDisplayed()
359+
onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_WARNING_BANNER)
360+
.assertIsDisplayed()
361+
.onChild()
362+
.assertTextEquals(lowerPriorityNotificationText)
276363
}
277364

278365
@Test

feature/notification/api/src/androidUnitTest/kotlin/net/thunderbird/feature/notification/api/ui/util/PrintSemanticTree.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import app.k9mail.core.ui.compose.testing.ComposeTest
77

88
internal fun ComposeTest.printSemanticTree(
99
root: SemanticsNodeInteraction = composeTestRule.onRoot(useUnmergedTree = true),
10+
prefixLabel: String = "",
1011
) {
1112
println("-----")
12-
println("Semantic tree:")
13+
println("$prefixLabel Semantic tree:")
1314
println(root.printToString())
1415
println("-----")
1516
println()

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

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
1010
import kotlinx.coroutines.flow.StateFlow
1111
import kotlinx.coroutines.flow.asStateFlow
1212
import kotlinx.coroutines.flow.update
13+
import net.thunderbird.core.common.collections.maxPriorityQueueOf
1314
import net.thunderbird.feature.notification.api.content.InAppNotification
1415
import net.thunderbird.feature.notification.api.ui.host.visual.BannerGlobalVisual
1516
import net.thunderbird.feature.notification.api.ui.host.visual.BannerInlineVisual
@@ -22,20 +23,17 @@ class InAppNotificationHostStateHolder(private val enabled: ImmutableSet<Display
2223
private val internalState =
2324
MutableStateFlow<InAppNotificationHostStateImpl>(value = InAppNotificationHostStateImpl())
2425
internal val currentInAppNotificationHostState: StateFlow<InAppNotificationHostState> = internalState.asStateFlow()
26+
private val bannerGlobalVisuals = maxPriorityQueueOf<BannerGlobalVisual>()
2527

2628
fun showInAppNotification(
2729
notification: InAppNotification,
2830
) {
2931
val newData = notification.toInAppNotificationData()
30-
// TODO(#9572): If global is already present, show the one with the highest priority
31-
// show the previous one back once the higher priority has fixed and the
32-
// other wasn't
33-
internalState.update {
34-
newData.bannerGlobalVisual.showIfNeeded(
35-
ifFlagEnabled = DisplayInAppNotificationFlag.BannerGlobalNotifications,
36-
select = { bannerGlobalVisual },
37-
transformIfDifferent = { copy(bannerGlobalVisual = it) },
38-
)
32+
if (isEnabled(DisplayInAppNotificationFlag.BannerGlobalNotifications)) {
33+
newData.bannerGlobalVisual?.let { bannerGlobalVisual ->
34+
bannerGlobalVisuals.add(bannerGlobalVisual)
35+
internalState.update { it.copy(bannerGlobalVisual = bannerGlobalVisuals.peek()) }
36+
}
3937
}
4038
internalState.update {
4139
newData.bannerInlineVisuals.showIfNeeded()
@@ -108,7 +106,11 @@ class InAppNotificationHostStateHolder(private val enabled: ImmutableSet<Display
108106
fun dismiss(visual: InAppNotificationVisual) {
109107
internalState.update { current ->
110108
current.copy(
111-
bannerGlobalVisual = visual.nullIfDifferent(otherwise = current.bannerGlobalVisual),
109+
bannerGlobalVisual = if (visual is BannerGlobalVisual && bannerGlobalVisuals.remove(visual)) {
110+
bannerGlobalVisuals.peek()
111+
} else {
112+
current.bannerGlobalVisual
113+
},
112114
bannerInlineVisuals = (visual as? BannerInlineVisual)?.let { bannerInlineVisual ->
113115
(current.bannerInlineVisuals - bannerInlineVisual).toPersistentSet()
114116
} ?: current.bannerInlineVisuals,
@@ -132,7 +134,6 @@ class InAppNotificationHostStateHolder(private val enabled: ImmutableSet<Display
132134
override val bannerGlobalVisual: BannerGlobalVisual? = null,
133135
override val bannerInlineVisuals: ImmutableSet<BannerInlineVisual> = persistentSetOf(),
134136
override val snackbarVisual: SnackbarVisual? = null,
135-
private val onDismissVisual: (InAppNotificationVisual) -> Unit = {},
136137
) : InAppNotificationHostState
137138

138139
private fun InAppNotification.toInAppNotificationData(): InAppNotificationHostState =

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import net.thunderbird.feature.notification.api.ui.action.NotificationAction
1010
import net.thunderbird.feature.notification.api.ui.host.visual.BannerInlineVisual.Companion.MAX_SUPPORTING_TEXT_LENGTH
1111
import net.thunderbird.feature.notification.api.ui.host.visual.BannerInlineVisual.Companion.MAX_TITLE_LENGTH
1212
import net.thunderbird.feature.notification.api.ui.style.InAppNotificationStyle
13+
import net.thunderbird.feature.notification.api.ui.style.NotificationPriority
1314
import net.thunderbird.feature.notification.api.ui.style.SnackbarDuration
1415

1516
sealed interface InAppNotificationVisual
@@ -34,8 +35,10 @@ data class BannerGlobalVisual(
3435
val message: CharSequence,
3536
val severity: NotificationSeverity,
3637
val action: NotificationAction?,
37-
val priority: Int,
38-
) : InAppNotificationVisual {
38+
val priority: NotificationPriority,
39+
) : InAppNotificationVisual, Comparable<BannerGlobalVisual> {
40+
override fun compareTo(other: BannerGlobalVisual): Int = priority.compareTo(other.priority)
41+
3942
internal companion object {
4043
/**
4144
* Creates a [BannerGlobalVisual] from an [InAppNotification].

feature/notification/api/src/commonTest/kotlin/net/thunderbird/feature/notification/api/ui/host/InAppNotificationHostStateHolderTest.kt

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import net.thunderbird.feature.notification.api.ui.host.visual.BannerInlineVisua
2727
import net.thunderbird.feature.notification.api.ui.host.visual.BannerInlineVisual.Companion.MAX_TITLE_LENGTH
2828
import net.thunderbird.feature.notification.api.ui.host.visual.InAppNotificationHostState
2929
import net.thunderbird.feature.notification.api.ui.host.visual.SnackbarVisual
30+
import net.thunderbird.feature.notification.api.ui.style.NotificationPriority
3031
import net.thunderbird.feature.notification.api.ui.style.SnackbarDuration
3132
import net.thunderbird.feature.notification.api.ui.style.inAppNotificationStyle
3233
import net.thunderbird.feature.notification.testing.fake.FakeInAppOnlyNotification
@@ -130,6 +131,42 @@ class InAppNotificationHostStateHolderTest {
130131
}
131132
}
132133

134+
@Test
135+
fun `showInAppNotification should show higher priority bannerGlobal when multiple bannerGlobal notifications are visible`() =
136+
runTest {
137+
// Arrange
138+
val lowerPriorityNotificationText = "lower priority notification"
139+
val lowerPriorityNotification = FakeInAppOnlyNotification(
140+
contentText = lowerPriorityNotificationText,
141+
severity = NotificationSeverity.Warning,
142+
inAppNotificationStyle = inAppNotificationStyle { bannerGlobal(priority = NotificationPriority.Min) },
143+
)
144+
val higherPriorityNotificationText = "higher priority notification"
145+
val higherPriorityNotification = FakeInAppOnlyNotification(
146+
contentText = higherPriorityNotificationText,
147+
severity = NotificationSeverity.Warning,
148+
inAppNotificationStyle = inAppNotificationStyle { bannerGlobal(priority = NotificationPriority.Max) },
149+
)
150+
val flags = persistentSetOf(
151+
DisplayInAppNotificationFlag.BannerGlobalNotifications,
152+
)
153+
val testSubject = InAppNotificationHostStateHolder(enabled = flags)
154+
155+
// Act
156+
testSubject.showInAppNotification(lowerPriorityNotification)
157+
testSubject.showInAppNotification(higherPriorityNotification)
158+
159+
// Assert
160+
testSubject.currentInAppNotificationHostState.test {
161+
val state = awaitItem()
162+
assertThat(state)
163+
.prop(InAppNotificationHostState::bannerGlobalVisual)
164+
.isNotNull()
165+
.prop(BannerGlobalVisual::message)
166+
.isEqualTo(higherPriorityNotificationText)
167+
}
168+
}
169+
133170
@Test
134171
fun `showInAppNotification throws IllegalStateException when InAppNotification has BannerGlobalNotification style but has multiple actions`() =
135172
runTest {
@@ -769,6 +806,40 @@ class InAppNotificationHostStateHolderTest {
769806
}
770807
}
771808

809+
@Test
810+
fun `dismiss should not remove bannerGlobal notification given multiple banner global present and lower priority notification is dimissed`() = runTest {
811+
// Arrange
812+
val lowerPriorityNotificationText = "lower priority notification"
813+
val lowerPriorityNotification = FakeInAppOnlyNotification(
814+
contentText = lowerPriorityNotificationText,
815+
severity = NotificationSeverity.Warning,
816+
inAppNotificationStyle = inAppNotificationStyle { bannerGlobal(priority = NotificationPriority.Min) },
817+
)
818+
val higherPriorityNotificationText = "higher priority notification"
819+
val higherPriorityNotification = FakeInAppOnlyNotification(
820+
contentText = higherPriorityNotificationText,
821+
severity = NotificationSeverity.Warning,
822+
inAppNotificationStyle = inAppNotificationStyle { bannerGlobal(priority = NotificationPriority.Max) },
823+
)
824+
val flags = persistentSetOf(DisplayInAppNotificationFlag.BannerGlobalNotifications)
825+
val testSubject = InAppNotificationHostStateHolder(enabled = flags)
826+
testSubject.showInAppNotification(lowerPriorityNotification)
827+
testSubject.showInAppNotification(higherPriorityNotification)
828+
829+
// Act
830+
testSubject.dismiss(lowerPriorityNotification)
831+
832+
// Assert
833+
testSubject.currentInAppNotificationHostState.test {
834+
val state = awaitItem()
835+
assertThat(state)
836+
.prop(InAppNotificationHostState::bannerGlobalVisual)
837+
.isNotNull()
838+
.prop(BannerGlobalVisual::message)
839+
.isEqualTo(higherPriorityNotificationText)
840+
}
841+
}
842+
772843
@Test
773844
fun `dismiss should remove bannerInline notification given a BannerInlineVisual`() = runTest {
774845
// Arrange

0 commit comments

Comments
 (0)