Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve UX on notification setting changes #3022

Merged
merged 5 commits into from
Jun 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1647.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Improve UX on notification setting changes.
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.architecture.runUpdatingStateNoSuccess
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
Expand Down Expand Up @@ -74,7 +74,7 @@ class NotificationSettingsPresenter @Inject constructor(

LaunchedEffect(Unit) {
fetchSettings(matrixSettings)
observeNotificationSettings(matrixSettings)
observeNotificationSettings(matrixSettings, changeNotificationSettingAction)
}

// List of PushProvider -> Distributor
Expand Down Expand Up @@ -172,11 +172,15 @@ class NotificationSettingsPresenter @Inject constructor(
}

@OptIn(FlowPreview::class)
private fun CoroutineScope.observeNotificationSettings(target: MutableState<NotificationSettingsState.MatrixSettings>) {
private fun CoroutineScope.observeNotificationSettings(
target: MutableState<NotificationSettingsState.MatrixSettings>,
changeNotificationSettingAction: MutableState<AsyncAction<Unit>>,
) {
notificationSettingsService.notificationSettingsChangeFlow
.debounce(0.5.seconds)
.onEach {
fetchSettings(target)
changeNotificationSettingAction.value = AsyncAction.Uninitialized
}
.launchIn(this)
}
Expand Down Expand Up @@ -238,21 +242,21 @@ class NotificationSettingsPresenter @Inject constructor(
}

private fun CoroutineScope.setAtRoomNotificationsEnabled(enabled: Boolean, action: MutableState<AsyncAction<Unit>>) = launch {
suspend {
notificationSettingsService.setRoomMentionEnabled(enabled).getOrThrow()
}.runCatchingUpdatingState(action)
action.runUpdatingStateNoSuccess {
notificationSettingsService.setRoomMentionEnabled(enabled)
}
}

private fun CoroutineScope.setCallNotificationsEnabled(enabled: Boolean, action: MutableState<AsyncAction<Unit>>) = launch {
suspend {
notificationSettingsService.setCallEnabled(enabled).getOrThrow()
}.runCatchingUpdatingState(action)
action.runUpdatingStateNoSuccess {
notificationSettingsService.setCallEnabled(enabled)
}
}

private fun CoroutineScope.setInviteForMeNotificationsEnabled(enabled: Boolean, action: MutableState<AsyncAction<Unit>>) = launch {
suspend {
notificationSettingsService.setInviteForMeEnabled(enabled).getOrThrow()
}.runCatchingUpdatingState(action)
action.runUpdatingStateNoSuccess {
notificationSettingsService.setInviteForMeEnabled(enabled)
}
}

private fun CoroutineScope.setNotificationsEnabled(userPushStore: UserPushStore, enabled: Boolean) = launch {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.architecture.runUpdatingStateNoSuccess
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
Expand Down Expand Up @@ -73,7 +73,7 @@ class EditDefaultNotificationSettingPresenter @AssistedInject constructor(
val localCoroutineScope = rememberCoroutineScope()
LaunchedEffect(Unit) {
fetchSettings(mode)
observeNotificationSettings(mode)
observeNotificationSettings(mode, changeNotificationSettingAction)
observeRoomSummaries(roomsWithUserDefinedMode)
displayMentionsOnlyDisclaimer = !notificationSettingsService.canHomeServerPushEncryptedEventsToDevice().getOrDefault(true)
}
Expand Down Expand Up @@ -102,11 +102,15 @@ class EditDefaultNotificationSettingPresenter @AssistedInject constructor(
}

@OptIn(FlowPreview::class)
private fun CoroutineScope.observeNotificationSettings(mode: MutableState<RoomNotificationMode?>) {
private fun CoroutineScope.observeNotificationSettings(
mode: MutableState<RoomNotificationMode?>,
changeNotificationSettingAction: MutableState<AsyncAction<Unit>>,
) {
notificationSettingsService.notificationSettingsChangeFlow
.debounce(0.5.seconds)
.onEach {
fetchSettings(mode)
changeNotificationSettingAction.value = AsyncAction.Uninitialized
}
.launchIn(this)
}
Expand Down Expand Up @@ -139,10 +143,12 @@ class EditDefaultNotificationSettingPresenter @AssistedInject constructor(
}

private fun CoroutineScope.setDefaultNotificationMode(mode: RoomNotificationMode, action: MutableState<AsyncAction<Unit>>) = launch {
suspend {
action.runUpdatingStateNoSuccess {
// On modern clients, we don't have different settings for encrypted and non-encrypted rooms (Legacy clients did).
notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = true, mode = mode, isOneToOne = isOneToOne).getOrThrow()
notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = false, mode = mode, isOneToOne = isOneToOne).getOrThrow()
}.runCatchingUpdatingState(action)
notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = true, mode = mode, isOneToOne = isOneToOne)
.map {
notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = false, mode = mode, isOneToOne = isOneToOne)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ package io.element.android.features.roomdetails.impl.notificationsettings
import io.element.android.libraries.matrix.api.room.RoomNotificationMode

sealed interface RoomNotificationSettingsEvents {
data class RoomNotificationModeChanged(val mode: RoomNotificationMode) : RoomNotificationSettingsEvents
data class ChangeRoomNotificationMode(val mode: RoomNotificationMode) : RoomNotificationSettingsEvents
data class SetNotificationMode(val isDefault: Boolean) : RoomNotificationSettingsEvents
data object DeleteCustomNotification : RoomNotificationSettingsEvents
data object ClearSetNotificationError : RoomNotificationSettingsEvents
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.coroutine.suspendWithMinimumDuration
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.RoomNotificationMode
Expand Down Expand Up @@ -95,7 +96,7 @@ class RoomNotificationSettingsPresenter @AssistedInject constructor(

fun handleEvents(event: RoomNotificationSettingsEvents) {
when (event) {
is RoomNotificationSettingsEvents.RoomNotificationModeChanged -> {
is RoomNotificationSettingsEvents.ChangeRoomNotificationMode -> {
localCoroutineScope.setRoomNotificationMode(event.mode, pendingRoomNotificationMode, pendingSetDefault, setNotificationSettingAction)
}
is RoomNotificationSettingsEvents.SetNotificationMode -> {
Expand Down Expand Up @@ -171,7 +172,7 @@ class RoomNotificationSettingsPresenter @AssistedInject constructor(
pendingDefaultState: MutableState<Boolean?>,
action: MutableState<AsyncAction<Unit>>
) = launch {
suspend {
suspendWithMinimumDuration {
pendingModeState.value = mode
pendingDefaultState.value = false
val result = notificationSettingsService.setRoomNotificationMode(room.roomId, mode)
Expand All @@ -187,7 +188,7 @@ class RoomNotificationSettingsPresenter @AssistedInject constructor(
action: MutableState<AsyncAction<Unit>>,
pendingDefaultState: MutableState<Boolean?>
) = launch {
suspend {
suspendWithMinimumDuration {
pendingDefaultState.value = true
val result = notificationSettingsService.restoreDefaultRoomNotificationMode(room.roomId)
if (result.isFailure) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@
enabled = !state.displayIsDefault.orTrue(),
displayMentionsOnlyDisclaimer = state.displayMentionsOnlyDisclaimer,
onSelectOption = {
state.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(it.mode))
state.eventSink(RoomNotificationSettingsEvents.ChangeRoomNotificationMode(it.mode))

Check warning on line 152 in features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt

View check run for this annotation

Codecov / codecov/patch

features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt#L152

Added line #L152 was not covered by tests
},
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
enabled = !state.displayIsDefault.orTrue(),
displayMentionsOnlyDisclaimer = state.displayMentionsOnlyDisclaimer,
onSelectOption = {
state.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(it.mode))
state.eventSink(RoomNotificationSettingsEvents.ChangeRoomNotificationMode(it.mode))

Check warning on line 71 in features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt

View check run for this annotation

Codecov / codecov/patch

features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt#L71

Added line #L71 was not covered by tests
},
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ class RoomNotificationSettingsPresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY))
awaitItem().eventSink(RoomNotificationSettingsEvents.ChangeRoomNotificationMode(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY))
val updatedState = consumeItemsUntilPredicate {
it.roomNotificationSettings.dataOrNull()?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
}.last()
Expand Down Expand Up @@ -129,7 +129,7 @@ class RoomNotificationSettingsPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY))
initialState.eventSink(RoomNotificationSettingsEvents.ChangeRoomNotificationMode(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY))
initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(true))
val defaultState = consumeItemsUntilPredicate {
it.roomNotificationSettings.dataOrNull()?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
Expand All @@ -148,7 +148,7 @@ class RoomNotificationSettingsPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY))
initialState.eventSink(RoomNotificationSettingsEvents.ChangeRoomNotificationMode(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY))
initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(true))
val failedState = consumeItemsUntilPredicate {
it.restoreDefaultAction.isFailure()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,24 @@ suspend inline fun <T> MutableState<AsyncAction<T>>.runUpdatingState(
resultBlock = resultBlock,
)

/**
* Run the given block and update the state accordingly, using only Loading and Failure states.
* It's up to the caller to manage the Success state.
*/
@OptIn(ExperimentalContracts::class)
inline fun <T> MutableState<AsyncAction<T>>.runUpdatingStateNoSuccess(
resultBlock: () -> Result<Unit>,
): Result<Unit> {
contract {
callsInPlace(resultBlock, InvocationKind.EXACTLY_ONCE)
}
value = AsyncAction.Loading
return resultBlock()
.onFailure { failure ->
value = AsyncAction.Failure(failure)
}
}

/**
* Calls the specified [Result]-returning function [resultBlock]
* encapsulating its progress and return value into an [AsyncAction] while
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.element.android.libraries.core.coroutine

import kotlinx.coroutines.delay
import kotlin.system.measureTimeMillis

fun suspendWithMinimumDuration(
minimumDurationMillis: Long = 500,
block: suspend () -> Unit
) = suspend {
val duration = measureTimeMillis {
block()
}
delay(minimumDurationMillis - duration)
}
Loading