Show new notification sound banner logic
This commit is contained in:
parent
71d2c1d9df
commit
4475ed0d37
11 changed files with 150 additions and 2 deletions
|
|
@ -251,6 +251,12 @@ private fun RoomsViewList(
|
||||||
item {
|
item {
|
||||||
BatteryOptimizationBanner(state = state.batteryOptimizationState)
|
BatteryOptimizationBanner(state = state.batteryOptimizationState)
|
||||||
}
|
}
|
||||||
|
} else if (state.showNewNotificationSoundBanner) {
|
||||||
|
item {
|
||||||
|
NewNotificationSoundBanner(
|
||||||
|
onDismissClick = { updatedEventSink(RoomListEvents.DismissNewNotificationSoundBanner) },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,17 +26,22 @@ open class RoomListContentStateProvider : PreviewParameterProvider<RoomListConte
|
||||||
aSkeletonContentState(),
|
aSkeletonContentState(),
|
||||||
anEmptyContentState(),
|
anEmptyContentState(),
|
||||||
anEmptyContentState(securityBannerState = SecurityBannerState.SetUpRecovery),
|
anEmptyContentState(securityBannerState = SecurityBannerState.SetUpRecovery),
|
||||||
|
aRoomsContentState(
|
||||||
|
showNewNotificationSoundBanner = true,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun aRoomsContentState(
|
internal fun aRoomsContentState(
|
||||||
securityBannerState: SecurityBannerState = SecurityBannerState.None,
|
securityBannerState: SecurityBannerState = SecurityBannerState.None,
|
||||||
|
showNewNotificationSoundBanner: Boolean = false,
|
||||||
summaries: ImmutableList<RoomListRoomSummary> = aRoomListRoomSummaryList(),
|
summaries: ImmutableList<RoomListRoomSummary> = aRoomListRoomSummaryList(),
|
||||||
fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(),
|
fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(),
|
||||||
batteryOptimizationState: BatteryOptimizationState = aBatteryOptimizationState(),
|
batteryOptimizationState: BatteryOptimizationState = aBatteryOptimizationState(),
|
||||||
seenRoomInvites: Set<RoomId> = emptySet(),
|
seenRoomInvites: Set<RoomId> = emptySet(),
|
||||||
) = RoomListContentState.Rooms(
|
) = RoomListContentState.Rooms(
|
||||||
securityBannerState = securityBannerState,
|
securityBannerState = securityBannerState,
|
||||||
|
showNewNotificationSoundBanner = showNewNotificationSoundBanner,
|
||||||
fullScreenIntentPermissionsState = fullScreenIntentPermissionsState,
|
fullScreenIntentPermissionsState = fullScreenIntentPermissionsState,
|
||||||
batteryOptimizationState = batteryOptimizationState,
|
batteryOptimizationState = batteryOptimizationState,
|
||||||
summaries = summaries,
|
summaries = summaries,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ sealed interface RoomListEvents {
|
||||||
data class UpdateVisibleRange(val range: IntRange) : RoomListEvents
|
data class UpdateVisibleRange(val range: IntRange) : RoomListEvents
|
||||||
data object DismissRequestVerificationPrompt : RoomListEvents
|
data object DismissRequestVerificationPrompt : RoomListEvents
|
||||||
data object DismissBanner : RoomListEvents
|
data object DismissBanner : RoomListEvents
|
||||||
|
data object DismissNewNotificationSoundBanner : RoomListEvents
|
||||||
data object ToggleSearchResults : RoomListEvents
|
data object ToggleSearchResults : RoomListEvents
|
||||||
data class ShowContextMenu(val roomSummary: RoomListRoomSummary) : RoomListEvents
|
data class ShowContextMenu(val roomSummary: RoomListRoomSummary) : RoomListEvents
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@ class RoomListPresenter(
|
||||||
}
|
}
|
||||||
|
|
||||||
var securityBannerDismissed by rememberSaveable { mutableStateOf(false) }
|
var securityBannerDismissed by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val showNewNotificationSoundBanner by appPreferencesStore.showNewNotificationSoundBanner().collectAsState(false)
|
||||||
|
|
||||||
// Avatar indicator
|
// Avatar indicator
|
||||||
val hideInvitesAvatar by client.rememberHideInvitesAvatar()
|
val hideInvitesAvatar by client.rememberHideInvitesAvatar()
|
||||||
|
|
@ -112,6 +113,9 @@ class RoomListPresenter(
|
||||||
}
|
}
|
||||||
RoomListEvents.DismissRequestVerificationPrompt -> securityBannerDismissed = true
|
RoomListEvents.DismissRequestVerificationPrompt -> securityBannerDismissed = true
|
||||||
RoomListEvents.DismissBanner -> securityBannerDismissed = true
|
RoomListEvents.DismissBanner -> securityBannerDismissed = true
|
||||||
|
RoomListEvents.DismissNewNotificationSoundBanner -> coroutineScope.launch {
|
||||||
|
appPreferencesStore.setShowNewNotificationSoundBanner(false)
|
||||||
|
}
|
||||||
RoomListEvents.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
|
RoomListEvents.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvents.ToggleSearchVisibility)
|
||||||
is RoomListEvents.ShowContextMenu -> {
|
is RoomListEvents.ShowContextMenu -> {
|
||||||
coroutineScope.showContextMenu(event, contextMenu)
|
coroutineScope.showContextMenu(event, contextMenu)
|
||||||
|
|
@ -141,7 +145,10 @@ class RoomListPresenter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val contentState = roomListContentState(securityBannerDismissed)
|
val contentState = roomListContentState(
|
||||||
|
securityBannerDismissed,
|
||||||
|
showNewNotificationSoundBanner,
|
||||||
|
)
|
||||||
|
|
||||||
val canReportRoom by produceState(false) { value = client.canReportRoom() }
|
val canReportRoom by produceState(false) { value = client.canReportRoom() }
|
||||||
|
|
||||||
|
|
@ -197,6 +204,7 @@ class RoomListPresenter(
|
||||||
@Composable
|
@Composable
|
||||||
private fun roomListContentState(
|
private fun roomListContentState(
|
||||||
securityBannerDismissed: Boolean,
|
securityBannerDismissed: Boolean,
|
||||||
|
showNewNotificationSoundBanner: Boolean,
|
||||||
): RoomListContentState {
|
): RoomListContentState {
|
||||||
val roomSummaries by produceState(initialValue = AsyncData.Loading()) {
|
val roomSummaries by produceState(initialValue = AsyncData.Loading()) {
|
||||||
roomListDataSource.allRooms.collect { value = AsyncData.Success(it) }
|
roomListDataSource.allRooms.collect { value = AsyncData.Success(it) }
|
||||||
|
|
@ -215,11 +223,14 @@ class RoomListPresenter(
|
||||||
val seenRoomInvites by remember { seenInvitesStore.seenRoomIds() }.collectAsState(emptySet())
|
val seenRoomInvites by remember { seenInvitesStore.seenRoomIds() }.collectAsState(emptySet())
|
||||||
val securityBannerState by rememberSecurityBannerState(securityBannerDismissed)
|
val securityBannerState by rememberSecurityBannerState(securityBannerDismissed)
|
||||||
return when {
|
return when {
|
||||||
showEmpty -> RoomListContentState.Empty(securityBannerState = securityBannerState)
|
showEmpty -> RoomListContentState.Empty(
|
||||||
|
securityBannerState = securityBannerState,
|
||||||
|
)
|
||||||
showSkeleton -> RoomListContentState.Skeleton(count = 16)
|
showSkeleton -> RoomListContentState.Skeleton(count = 16)
|
||||||
else -> {
|
else -> {
|
||||||
RoomListContentState.Rooms(
|
RoomListContentState.Rooms(
|
||||||
securityBannerState = securityBannerState,
|
securityBannerState = securityBannerState,
|
||||||
|
showNewNotificationSoundBanner = showNewNotificationSoundBanner,
|
||||||
fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(),
|
fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(),
|
||||||
batteryOptimizationState = batteryOptimizationPresenter.present(),
|
batteryOptimizationState = batteryOptimizationPresenter.present(),
|
||||||
summaries = roomSummaries.dataOrNull().orEmpty().toPersistentList(),
|
summaries = roomSummaries.dataOrNull().orEmpty().toPersistentList(),
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ sealed interface RoomListContentState {
|
||||||
val securityBannerState: SecurityBannerState,
|
val securityBannerState: SecurityBannerState,
|
||||||
val fullScreenIntentPermissionsState: FullScreenIntentPermissionsState,
|
val fullScreenIntentPermissionsState: FullScreenIntentPermissionsState,
|
||||||
val batteryOptimizationState: BatteryOptimizationState,
|
val batteryOptimizationState: BatteryOptimizationState,
|
||||||
|
val showNewNotificationSoundBanner: Boolean,
|
||||||
val summaries: ImmutableList<RoomListRoomSummary>,
|
val summaries: ImmutableList<RoomListRoomSummary>,
|
||||||
val seenRoomInvites: ImmutableSet<RoomId>,
|
val seenRoomInvites: ImmutableSet<RoomId>,
|
||||||
) : RoomListContentState
|
) : RoomListContentState
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ import io.element.android.tests.testutils.lambda.value
|
||||||
import io.element.android.tests.testutils.test
|
import io.element.android.tests.testutils.test
|
||||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.advanceTimeBy
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
|
@ -593,6 +594,38 @@ class RoomListPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - notification sound banner`() = runTest {
|
||||||
|
val subscribeToVisibleRoomsLambda = lambdaRecorder { _: List<RoomId> -> }
|
||||||
|
val roomListService = FakeRoomListService(subscribeToVisibleRoomsLambda = subscribeToVisibleRoomsLambda)
|
||||||
|
val matrixClient = FakeMatrixClient(
|
||||||
|
roomListService = roomListService,
|
||||||
|
)
|
||||||
|
val roomSummary = aRoomSummary(
|
||||||
|
currentUserMembership = CurrentUserMembership.INVITED
|
||||||
|
)
|
||||||
|
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
|
||||||
|
roomListService.postAllRooms(listOf(roomSummary))
|
||||||
|
val store = InMemoryAppPreferencesStore()
|
||||||
|
val presenter = createRoomListPresenter(
|
||||||
|
client = matrixClient,
|
||||||
|
appPreferencesStore = store,
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
assertThat(store.showNewNotificationSoundBanner().first()).isFalse()
|
||||||
|
skipItems(1)
|
||||||
|
val state = awaitItem()
|
||||||
|
assertThat(state.contentAsRooms().showNewNotificationSoundBanner).isFalse()
|
||||||
|
store.setShowNewNotificationSoundBanner(true)
|
||||||
|
assertThat(store.showNewNotificationSoundBanner().first()).isTrue()
|
||||||
|
assertThat(awaitItem().contentAsRooms().showNewNotificationSoundBanner).isTrue()
|
||||||
|
state.eventSink(RoomListEvents.DismissNewNotificationSoundBanner)
|
||||||
|
assertThat(awaitItem().contentAsRooms().showNewNotificationSoundBanner).isFalse()
|
||||||
|
// Ensure store has been updated
|
||||||
|
assertThat(store.showNewNotificationSoundBanner().first()).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun TestScope.createRoomListPresenter(
|
private fun TestScope.createRoomListPresenter(
|
||||||
client: MatrixClient = FakeMatrixClient(),
|
client: MatrixClient = FakeMatrixClient(),
|
||||||
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
|
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.migration.impl.migrations
|
||||||
|
|
||||||
|
import dev.zacsweers.metro.AppScope
|
||||||
|
import dev.zacsweers.metro.ContributesIntoSet
|
||||||
|
import dev.zacsweers.metro.Inject
|
||||||
|
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the new notification sound banner is displayed, but only on application upgrade.
|
||||||
|
*/
|
||||||
|
@ContributesIntoSet(AppScope::class)
|
||||||
|
@Inject
|
||||||
|
class AppMigration08(
|
||||||
|
private val appPreferencesStore: AppPreferencesStore,
|
||||||
|
) : AppMigration {
|
||||||
|
override val order: Int = 8
|
||||||
|
|
||||||
|
override suspend fun migrate(isFreshInstall: Boolean) {
|
||||||
|
if (!isFreshInstall) {
|
||||||
|
appPreferencesStore.setShowNewNotificationSoundBanner(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.migration.impl.migrations
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AppMigration08Test {
|
||||||
|
@Test
|
||||||
|
fun `migration on fresh install should not modify the store`() = runTest {
|
||||||
|
val store = InMemoryAppPreferencesStore()
|
||||||
|
assertThat(store.showNewNotificationSoundBanner().first()).isFalse()
|
||||||
|
val migration = AppMigration08(store)
|
||||||
|
migration.migrate(isFreshInstall = true)
|
||||||
|
assertThat(store.showNewNotificationSoundBanner().first()).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `migration on upgrade should modify the store`() = runTest {
|
||||||
|
val store = InMemoryAppPreferencesStore()
|
||||||
|
assertThat(store.showNewNotificationSoundBanner().first()).isFalse()
|
||||||
|
val migration = AppMigration08(store)
|
||||||
|
migration.migrate(isFreshInstall = false)
|
||||||
|
assertThat(store.showNewNotificationSoundBanner().first()).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -37,5 +37,8 @@ interface AppPreferencesStore {
|
||||||
suspend fun setTracingLogPacks(targets: Set<TraceLogPack>)
|
suspend fun setTracingLogPacks(targets: Set<TraceLogPack>)
|
||||||
fun getTracingLogPacksFlow(): Flow<Set<TraceLogPack>>
|
fun getTracingLogPacksFlow(): Flow<Set<TraceLogPack>>
|
||||||
|
|
||||||
|
suspend fun setShowNewNotificationSoundBanner(show: Boolean)
|
||||||
|
fun showNewNotificationSoundBanner(): Flow<Boolean>
|
||||||
|
|
||||||
suspend fun reset()
|
suspend fun reset()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ private val hideInviteAvatarsKey = booleanPreferencesKey("hideInviteAvatars")
|
||||||
private val timelineMediaPreviewValueKey = stringPreferencesKey("timelineMediaPreviewValue")
|
private val timelineMediaPreviewValueKey = stringPreferencesKey("timelineMediaPreviewValue")
|
||||||
private val logLevelKey = stringPreferencesKey("logLevel")
|
private val logLevelKey = stringPreferencesKey("logLevel")
|
||||||
private val traceLogPacksKey = stringPreferencesKey("traceLogPacks")
|
private val traceLogPacksKey = stringPreferencesKey("traceLogPacks")
|
||||||
|
private val showNewNotificationSoundBannerKey = booleanPreferencesKey("showNewNotificationSoundBanner")
|
||||||
|
|
||||||
@ContributesBinding(AppScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
@Inject
|
@Inject
|
||||||
|
|
@ -145,6 +146,19 @@ class DefaultAppPreferencesStore(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun setShowNewNotificationSoundBanner(show: Boolean) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
prefs[showNewNotificationSoundBannerKey] = show
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun showNewNotificationSoundBanner(): Flow<Boolean> {
|
||||||
|
return store.data.map { prefs ->
|
||||||
|
// Default is false, but a migration will set it to true on application upgrade (see AppMigration08)
|
||||||
|
prefs[showNewNotificationSoundBannerKey] ?: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun reset() {
|
override suspend fun reset() {
|
||||||
store.edit { it.clear() }
|
store.edit { it.clear() }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ class InMemoryAppPreferencesStore(
|
||||||
theme: String? = null,
|
theme: String? = null,
|
||||||
logLevel: LogLevel = LogLevel.INFO,
|
logLevel: LogLevel = LogLevel.INFO,
|
||||||
traceLockPacks: Set<TraceLogPack> = emptySet(),
|
traceLockPacks: Set<TraceLogPack> = emptySet(),
|
||||||
|
showNewNotificationSoundBanner: Boolean = false,
|
||||||
) : AppPreferencesStore {
|
) : AppPreferencesStore {
|
||||||
private val isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled)
|
private val isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled)
|
||||||
private val customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl)
|
private val customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl)
|
||||||
|
|
@ -30,6 +31,7 @@ class InMemoryAppPreferencesStore(
|
||||||
private val tracingLogPacks = MutableStateFlow(traceLockPacks)
|
private val tracingLogPacks = MutableStateFlow(traceLockPacks)
|
||||||
private val hideInviteAvatars = MutableStateFlow(hideInviteAvatars)
|
private val hideInviteAvatars = MutableStateFlow(hideInviteAvatars)
|
||||||
private val timelineMediaPreviewValue = MutableStateFlow(timelineMediaPreviewValue)
|
private val timelineMediaPreviewValue = MutableStateFlow(timelineMediaPreviewValue)
|
||||||
|
private val showNewNotificationSoundBanner = MutableStateFlow(showNewNotificationSoundBanner)
|
||||||
|
|
||||||
override suspend fun setDeveloperModeEnabled(enabled: Boolean) {
|
override suspend fun setDeveloperModeEnabled(enabled: Boolean) {
|
||||||
isDeveloperModeEnabled.value = enabled
|
isDeveloperModeEnabled.value = enabled
|
||||||
|
|
@ -91,6 +93,14 @@ class InMemoryAppPreferencesStore(
|
||||||
return tracingLogPacks
|
return tracingLogPacks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun setShowNewNotificationSoundBanner(show: Boolean) {
|
||||||
|
showNewNotificationSoundBanner.value = show
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun showNewNotificationSoundBanner(): Flow<Boolean> {
|
||||||
|
return showNewNotificationSoundBanner
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun reset() {
|
override suspend fun reset() {
|
||||||
// No op
|
// No op
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue