Fix switch and radio buttons toggling to invalid intermediate states.

This commit is contained in:
David Langley 2023-10-18 21:44:37 +01:00
parent 72e7d92571
commit aece3dcc7b
6 changed files with 134 additions and 50 deletions

View file

@ -22,5 +22,6 @@ sealed interface RoomNotificationSettingsEvents {
data class RoomNotificationModeChanged(val mode: RoomNotificationMode) : RoomNotificationSettingsEvents data class RoomNotificationModeChanged(val mode: RoomNotificationMode) : RoomNotificationSettingsEvents
data class SetNotificationMode(val isDefault: Boolean): RoomNotificationSettingsEvents data class SetNotificationMode(val isDefault: Boolean): RoomNotificationSettingsEvents
data object DeleteCustomNotification: RoomNotificationSettingsEvents data object DeleteCustomNotification: RoomNotificationSettingsEvents
data object ClearError: RoomNotificationSettingsEvents data object ClearSetNotificationError: RoomNotificationSettingsEvents
data object ClearRestoreDefaultError: RoomNotificationSettingsEvents
} }

View file

@ -19,8 +19,6 @@ package io.element.android.features.roomdetails.impl.notificationsettings
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@ -31,7 +29,7 @@ import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.roomNotificationSettings import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
@ -51,76 +49,136 @@ class RoomNotificationSettingsPresenter @Inject constructor(
mutableStateOf(null) mutableStateOf(null)
} }
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val changeNotificationSettingAction: MutableState<Async<Unit>> = remember { mutableStateOf(Async.Uninitialized) } val setNotificationSettingAction: MutableState<Async<Unit>> = remember { mutableStateOf(Async.Uninitialized) }
val deleteCustomNotificationSettingAction: MutableState<Async<Unit>> = remember { mutableStateOf(Async.Uninitialized) } val restoreDefaultAction: MutableState<Async<Unit>> = remember { mutableStateOf(Async.Uninitialized) }
val roomNotificationSettings: MutableState<Async<RoomNotificationSettings>> = remember {
mutableStateOf(Async.Uninitialized)
}
// We store state of which mode the user has set via the notification service before the new push settings have been updated.
// We show this state immediately to the user and debounce updates to notification settings to hide some invalid states returned
// by the rust sdk during these two events that cause the radio buttons ot toggle quickly back and forth.
// This is a client side work-around until bulk push rule updates are supported.
// ref: https://github.com/matrix-org/matrix-spec-proposals/pull/3934
val pendingRoomNotificationMode: MutableState<RoomNotificationMode?> = remember {
mutableStateOf(null)
}
// We store state of whether the user has set the notifications settings to default or custom via the notification service.
// We show this state immediately to the user and debounce updates to notification settings to hide some invalid states returned
// by the rust sdk during these two events that cause the switch ot toggle quickly back and forth.
// This is a client side work-around until bulk push rule updates are supported.
// ref: https://github.com/matrix-org/matrix-spec-proposals/pull/3934
val pendingSetDefault: MutableState<Boolean?> = remember {
mutableStateOf(null)
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
getDefaultRoomNotificationMode(defaultRoomNotificationMode) getDefaultRoomNotificationMode(defaultRoomNotificationMode)
room.updateRoomNotificationSettings() fetchNotificationSettings(pendingRoomNotificationMode, roomNotificationSettings)
observeNotificationSettings() observeNotificationSettings(pendingRoomNotificationMode, roomNotificationSettings)
} }
val roomNotificationSettingsState by room.roomNotificationSettingsStateFlow.collectAsState()
fun handleEvents(event: RoomNotificationSettingsEvents) { fun handleEvents(event: RoomNotificationSettingsEvents) {
when (event) { when (event) {
is RoomNotificationSettingsEvents.RoomNotificationModeChanged -> { is RoomNotificationSettingsEvents.RoomNotificationModeChanged -> {
localCoroutineScope.setRoomNotificationMode(event.mode, changeNotificationSettingAction) localCoroutineScope.setRoomNotificationMode(event.mode, pendingRoomNotificationMode, pendingSetDefault, setNotificationSettingAction)
} }
is RoomNotificationSettingsEvents.SetNotificationMode -> { is RoomNotificationSettingsEvents.SetNotificationMode -> {
if (event.isDefault) { if (event.isDefault) {
localCoroutineScope.restoreDefaultRoomNotificationMode(changeNotificationSettingAction) localCoroutineScope.restoreDefaultRoomNotificationMode(restoreDefaultAction, pendingSetDefault)
} else { } else {
defaultRoomNotificationMode.value?.let { defaultRoomNotificationMode.value?.let {
localCoroutineScope.setRoomNotificationMode(it, changeNotificationSettingAction) localCoroutineScope.setRoomNotificationMode(it, pendingRoomNotificationMode, pendingSetDefault, setNotificationSettingAction)
} }
} }
} }
is RoomNotificationSettingsEvents.DeleteCustomNotification -> { is RoomNotificationSettingsEvents.DeleteCustomNotification -> {
localCoroutineScope.restoreDefaultRoomNotificationMode(deleteCustomNotificationSettingAction) localCoroutineScope.restoreDefaultRoomNotificationMode(restoreDefaultAction, pendingSetDefault)
} }
RoomNotificationSettingsEvents.ClearError -> { RoomNotificationSettingsEvents.ClearSetNotificationError -> {
changeNotificationSettingAction.value = Async.Uninitialized setNotificationSettingAction.value = Async.Uninitialized
}
RoomNotificationSettingsEvents.ClearRestoreDefaultError -> {
restoreDefaultAction.value = Async.Uninitialized
} }
} }
} }
return RoomNotificationSettingsState( return RoomNotificationSettingsState(
roomName = room.displayName, roomName = room.displayName,
roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(), roomNotificationSettings = roomNotificationSettings.value,
pendingRoomNotificationMode = pendingRoomNotificationMode.value,
pendingSetDefault = pendingSetDefault.value,
defaultRoomNotificationMode = defaultRoomNotificationMode.value, defaultRoomNotificationMode = defaultRoomNotificationMode.value,
changeNotificationSettingAction = changeNotificationSettingAction.value, setNotificationSettingAction = setNotificationSettingAction.value,
deleteCustomNotificationSettingAction = deleteCustomNotificationSettingAction.value, restoreDefaultAction = restoreDefaultAction.value,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
private fun CoroutineScope.observeNotificationSettings() { private fun CoroutineScope.observeNotificationSettings(
pendingModeState: MutableState<RoomNotificationMode?>,
roomNotificationSettings: MutableState<Async<RoomNotificationSettings>>
) {
notificationSettingsService.notificationSettingsChangeFlow notificationSettingsService.notificationSettingsChangeFlow
.debounce(0.5.seconds) .debounce(0.5.seconds)
.onEach { .onEach {
room.updateRoomNotificationSettings() fetchNotificationSettings(pendingModeState, roomNotificationSettings)
} }
.launchIn(this) .launchIn(this)
} }
private fun CoroutineScope.getDefaultRoomNotificationMode(defaultRoomNotificationMode: MutableState<RoomNotificationMode?>) = launch { private fun CoroutineScope.fetchNotificationSettings(
pendingModeState: MutableState<RoomNotificationMode?>,
roomNotificationSettings: MutableState<Async<RoomNotificationSettings>>
) = launch {
suspend {
pendingModeState.value = null
notificationSettingsService.getRoomNotificationSettings(room.roomId, room.isEncrypted, room.isOneToOne).getOrThrow()
}.runCatchingUpdatingState(roomNotificationSettings)
}
private fun CoroutineScope.getDefaultRoomNotificationMode(
defaultRoomNotificationMode: MutableState<RoomNotificationMode?>
) = launch {
defaultRoomNotificationMode.value = notificationSettingsService.getDefaultRoomNotificationMode( defaultRoomNotificationMode.value = notificationSettingsService.getDefaultRoomNotificationMode(
room.isEncrypted, room.isEncrypted,
room.isOneToOne room.isOneToOne
).getOrThrow() ).getOrThrow()
} }
private fun CoroutineScope.setRoomNotificationMode(mode: RoomNotificationMode, action: MutableState<Async<Unit>>) = launch { private fun CoroutineScope.setRoomNotificationMode(
mode: RoomNotificationMode,
pendingModeState: MutableState<RoomNotificationMode?>,
pendingDefaultState: MutableState<Boolean?>,
action: MutableState<Async<Unit>>
) = launch {
suspend { suspend {
notificationSettingsService.setRoomNotificationMode(room.roomId, mode).getOrThrow() pendingModeState.value = mode
pendingDefaultState.value = false
val result = notificationSettingsService.setRoomNotificationMode(room.roomId, mode)
if (result.isFailure) {
pendingModeState.value = null
pendingDefaultState.value = null
}
result.getOrThrow()
}.runCatchingUpdatingState(action) }.runCatchingUpdatingState(action)
} }
private fun CoroutineScope.restoreDefaultRoomNotificationMode(action: MutableState<Async<Unit>>) = launch { private fun CoroutineScope.restoreDefaultRoomNotificationMode(
action: MutableState<Async<Unit>>,
pendingDefaultState: MutableState<Boolean?>
) = launch {
suspend { suspend {
notificationSettingsService.restoreDefaultRoomNotificationMode(room.roomId).getOrThrow() pendingDefaultState.value = true
val result = notificationSettingsService.restoreDefaultRoomNotificationMode(room.roomId)
if (result.isFailure) {
pendingDefaultState.value = null
}
result.getOrThrow()
}.runCatchingUpdatingState(action) }.runCatchingUpdatingState(action)
} }
} }

View file

@ -22,9 +22,19 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
data class RoomNotificationSettingsState( data class RoomNotificationSettingsState(
val roomName: String, val roomName: String,
val roomNotificationSettings: RoomNotificationSettings?, val roomNotificationSettings: Async<RoomNotificationSettings>,
val pendingRoomNotificationMode: RoomNotificationMode?,
val pendingSetDefault: Boolean?,
val defaultRoomNotificationMode: RoomNotificationMode?, val defaultRoomNotificationMode: RoomNotificationMode?,
val changeNotificationSettingAction: Async<Unit>, val setNotificationSettingAction: Async<Unit>,
val deleteCustomNotificationSettingAction: Async<Unit>, val restoreDefaultAction: Async<Unit>,
val eventSink: (RoomNotificationSettingsEvents) -> Unit val eventSink: (RoomNotificationSettingsEvents) -> Unit
) )
val RoomNotificationSettingsState.displayNotificationMode: RoomNotificationMode? get() {
return pendingRoomNotificationMode ?: roomNotificationSettings.dataOrNull()?.mode
}
val RoomNotificationSettingsState.displayIsDefault: Boolean? get() {
return pendingSetDefault ?: roomNotificationSettings.dataOrNull()?.isDefault
}

View file

@ -26,12 +26,14 @@ internal class RoomNotificationSettingsStateProvider : PreviewParameterProvider<
get() = sequenceOf( get() = sequenceOf(
RoomNotificationSettingsState( RoomNotificationSettingsState(
roomName = "Room 1", roomName = "Room 1",
RoomNotificationSettings( Async.Success(RoomNotificationSettings(
mode = RoomNotificationMode.MUTE, mode = RoomNotificationMode.MUTE,
isDefault = true), isDefault = true)),
RoomNotificationMode.ALL_MESSAGES, pendingRoomNotificationMode = null,
changeNotificationSettingAction = Async.Uninitialized, pendingSetDefault = null,
deleteCustomNotificationSettingAction = Async.Uninitialized, defaultRoomNotificationMode = RoomNotificationMode.ALL_MESSAGES,
setNotificationSettingAction = Async.Uninitialized,
restoreDefaultAction = Async.Uninitialized,
eventSink = { }, eventSink = { },
), ),
) )

View file

@ -75,27 +75,29 @@ fun RoomNotificationSettingsView(
null -> "" null -> ""
} }
val roomNotificationSettings = state.roomNotificationSettings.dataOrNull()
PreferenceCategory(title = stringResource(id = R.string.screen_room_notification_settings_custom_settings_title)) { PreferenceCategory(title = stringResource(id = R.string.screen_room_notification_settings_custom_settings_title)) {
PreferenceSwitch( PreferenceSwitch(
isChecked = state.roomNotificationSettings?.isDefault.orTrue(), isChecked = state.displayIsDefault.orTrue(),
onCheckedChange = { onCheckedChange = {
state.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(it)) state.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(it))
}, },
title = "Match default setting", title = "Match default setting",
subtitle = subtitle, subtitle = subtitle,
enabled = state.roomNotificationSettings != null enabled = roomNotificationSettings != null
) )
PreferenceText( PreferenceText(
title = stringResource(id = R.string.screen_room_notification_settings_allow_custom), title = stringResource(id = R.string.screen_room_notification_settings_allow_custom),
subtitle = stringResource(id = R.string.screen_room_notification_settings_allow_custom_footnote), subtitle = stringResource(id = R.string.screen_room_notification_settings_allow_custom_footnote),
enabled = state.roomNotificationSettings != null && !state.roomNotificationSettings.isDefault, enabled = !state.displayIsDefault.orTrue(),
) )
if (state.roomNotificationSettings != null) { if (roomNotificationSettings != null && state.displayNotificationMode != null) {
RoomNotificationSettingsOptions( RoomNotificationSettingsOptions(
selected = state.roomNotificationSettings.mode, selected = state.displayNotificationMode,
enabled = !state.roomNotificationSettings.isDefault, enabled = !state.displayIsDefault.orTrue(),
onOptionSelected = { onOptionSelected = {
state.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(it.mode)) state.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(it.mode))
}, },
@ -103,12 +105,22 @@ fun RoomNotificationSettingsView(
} }
} }
when (state.changeNotificationSettingAction) { when (state.setNotificationSettingAction) {
is Async.Loading -> { is Async.Loading -> {
ProgressDialog() ProgressDialog()
} }
is Async.Failure -> { is Async.Failure -> {
ShowChangeNotificationSettingError(state) ShowChangeNotificationSettingError(state, RoomNotificationSettingsEvents.ClearSetNotificationError)
}
else -> Unit
}
when (state.restoreDefaultAction) {
is Async.Loading -> {
ProgressDialog()
}
is Async.Failure -> {
ShowChangeNotificationSettingError(state, RoomNotificationSettingsEvents.ClearRestoreDefaultError)
} }
else -> Unit else -> Unit
} }
@ -155,11 +167,11 @@ fun RoomNotificationSettingsOptions(
} }
@Composable @Composable
fun ShowChangeNotificationSettingError(state: RoomNotificationSettingsState) { fun ShowChangeNotificationSettingError(state: RoomNotificationSettingsState, event: RoomNotificationSettingsEvents) {
ErrorDialog( ErrorDialog(
title = stringResource(CommonStrings.dialog_title_error), title = stringResource(CommonStrings.dialog_title_error),
content = stringResource(CommonStrings.screen_notification_settings_edit_failed_updating_default_mode), content = stringResource(CommonStrings.screen_notification_settings_edit_failed_updating_default_mode),
onDismiss = { state.eventSink(RoomNotificationSettingsEvents.ClearError) }, onDismiss = { state.eventSink(event) },
) )
} }

View file

@ -62,10 +62,11 @@ fun UserDefinedRoomNotificationSettingsView(
.consumeWindowInsets(padding), .consumeWindowInsets(padding),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
if (state.roomNotificationSettings != null) { val roomNotificationSettings = state.roomNotificationSettings.dataOrNull()
if (roomNotificationSettings != null && state.displayNotificationMode != null) {
RoomNotificationSettingsOptions( RoomNotificationSettingsOptions(
selected = state.roomNotificationSettings.mode, selected = state.displayNotificationMode,
enabled = !state.roomNotificationSettings.isDefault, enabled = roomNotificationSettings.isDefault,
onOptionSelected = { onOptionSelected = {
state.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(it.mode)) state.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(it.mode))
}, },
@ -81,7 +82,7 @@ fun UserDefinedRoomNotificationSettingsView(
} }
) )
when (state.changeNotificationSettingAction) { when (state.setNotificationSettingAction) {
is Async.Loading -> { is Async.Loading -> {
ProgressDialog() ProgressDialog()
} }
@ -91,7 +92,7 @@ fun UserDefinedRoomNotificationSettingsView(
else -> Unit else -> Unit
} }
when (state.deleteCustomNotificationSettingAction) { when (state.restoreDefaultAction) {
is Async.Loading -> { is Async.Loading -> {
ProgressDialog() ProgressDialog()
} }
@ -99,7 +100,7 @@ fun UserDefinedRoomNotificationSettingsView(
ShowChangeNotificationSettingError(state) ShowChangeNotificationSettingError(state)
} }
is Async.Success -> { is Async.Success -> {
LaunchedEffect(state.deleteCustomNotificationSettingAction) { LaunchedEffect(state.restoreDefaultAction) {
onBackPressed() onBackPressed()
} }
} }