Merge pull request #1610 from vector-im/dla/feature/custom_room_notification_settings_list

Custom room notification settings list
This commit is contained in:
David Langley 2023-10-24 23:02:16 +01:00 committed by GitHub
commit 86cd274b3a
76 changed files with 1279 additions and 156 deletions

View file

@ -197,7 +197,9 @@ class LoggedInFlowNode @AssistedInject constructor(
) : NavTarget ) : NavTarget
@Parcelize @Parcelize
data object Settings : NavTarget data class Settings(
val initialElement: PreferencesEntryPoint.InitialTarget = PreferencesEntryPoint.InitialTarget.Root
) : NavTarget
@Parcelize @Parcelize
data object CreateRoom : NavTarget data object CreateRoom : NavTarget
@ -227,7 +229,7 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
override fun onSettingsClicked() { override fun onSettingsClicked() {
backstack.push(NavTarget.Settings) backstack.push(NavTarget.Settings())
} }
override fun onCreateRoomClicked() { override fun onCreateRoomClicked() {
@ -260,11 +262,15 @@ class LoggedInFlowNode @AssistedInject constructor(
override fun onForwardedToSingleRoom(roomId: RoomId) { override fun onForwardedToSingleRoom(roomId: RoomId) {
coroutineScope.launch { attachRoom(roomId) } coroutineScope.launch { attachRoom(roomId) }
} }
override fun onOpenGlobalNotificationSettings() {
backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.NotificationSettings))
}
} }
val inputs = RoomFlowNode.Inputs(roomId = navTarget.roomId, initialElement = navTarget.initialElement) val inputs = RoomFlowNode.Inputs(roomId = navTarget.roomId, initialElement = navTarget.initialElement)
createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs, callback)) createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs, callback))
} }
NavTarget.Settings -> { is NavTarget.Settings -> {
val callback = object : PreferencesEntryPoint.Callback { val callback = object : PreferencesEntryPoint.Callback {
override fun onOpenBugReport() { override fun onOpenBugReport() {
plugins<Callback>().forEach { it.onOpenBugReport() } plugins<Callback>().forEach { it.onOpenBugReport() }
@ -273,8 +279,14 @@ class LoggedInFlowNode @AssistedInject constructor(
override fun onVerifyClicked() { override fun onVerifyClicked() {
backstack.push(NavTarget.VerifySession) backstack.push(NavTarget.VerifySession)
} }
override fun onOpenRoomNotificationSettings(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId, initialElement = RoomLoadedFlowNode.NavTarget.RoomNotificationSettings))
}
} }
preferencesEntryPoint.nodeBuilder(this, buildContext) val inputs = PreferencesEntryPoint.Params(navTarget.initialElement)
return preferencesEntryPoint.nodeBuilder(this, buildContext)
.params(inputs)
.callback(callback) .callback(callback)
.build() .build()
} }

View file

@ -75,6 +75,7 @@ class RoomLoadedFlowNode @AssistedInject constructor(
interface Callback : Plugin { interface Callback : Plugin {
fun onForwardedToSingleRoom(roomId: RoomId) fun onForwardedToSingleRoom(roomId: RoomId)
fun onOpenGlobalNotificationSettings()
} }
data class Inputs( data class Inputs(
@ -128,6 +129,18 @@ class RoomLoadedFlowNode @AssistedInject constructor(
} }
} }
private fun createRoomDetailsNode(buildContext: BuildContext, initialTarget: RoomDetailsEntryPoint.InitialTarget): Node {
val callback = object : RoomDetailsEntryPoint.Callback {
override fun onOpenGlobalNotificationSettings() {
callbacks.forEach { it.onOpenGlobalNotificationSettings() }
}
}
return roomDetailsEntryPoint.nodeBuilder(this, buildContext)
.params(RoomDetailsEntryPoint.Params(initialTarget))
.callback(callback)
.build()
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
NavTarget.Messages -> { NavTarget.Messages -> {
@ -147,12 +160,13 @@ class RoomLoadedFlowNode @AssistedInject constructor(
messagesEntryPoint.createNode(this, buildContext, callback) messagesEntryPoint.createNode(this, buildContext, callback)
} }
NavTarget.RoomDetails -> { NavTarget.RoomDetails -> {
val inputs = RoomDetailsEntryPoint.Inputs(RoomDetailsEntryPoint.InitialTarget.RoomDetails) createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomDetails)
roomDetailsEntryPoint.createNode(this, buildContext, inputs, emptyList())
} }
is NavTarget.RoomMemberDetails -> { is NavTarget.RoomMemberDetails -> {
val inputs = RoomDetailsEntryPoint.Inputs(RoomDetailsEntryPoint.InitialTarget.RoomMemberDetails(navTarget.userId)) createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomMemberDetails(navTarget.userId))
roomDetailsEntryPoint.createNode(this, buildContext, inputs, emptyList()) }
NavTarget.RoomNotificationSettings -> {
createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomNotificationSettings)
} }
} }
} }
@ -166,6 +180,9 @@ class RoomLoadedFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data class RoomMemberDetails(val userId: UserId) : NavTarget data class RoomMemberDetails(val userId: UserId) : NavTarget
@Parcelize
data object RoomNotificationSettings : NavTarget
} }
@Composable @Composable

View file

@ -71,14 +71,22 @@ class RoomFlowNodeTest {
var nodeId: String? = null var nodeId: String? = null
override fun createNode( override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): RoomDetailsEntryPoint.NodeBuilder {
parentNode: Node, return object : RoomDetailsEntryPoint.NodeBuilder {
buildContext: BuildContext,
inputs: RoomDetailsEntryPoint.Inputs, override fun params(params: RoomDetailsEntryPoint.Params): RoomDetailsEntryPoint.NodeBuilder {
plugins: List<Plugin> return this
): Node { }
return node(buildContext) {}.also {
nodeId = it.id override fun callback(callback: RoomDetailsEntryPoint.Callback): RoomDetailsEntryPoint.NodeBuilder {
return this
}
override fun build(): Node {
return node(buildContext) {}.also {
nodeId = it.id
}
}
} }
} }
} }

View file

@ -15,6 +15,7 @@
*/ */
plugins { plugins {
id("io.element.android-library") id("io.element.android-library")
id("kotlin-parcelize")
} }
android { android {

View file

@ -16,16 +16,30 @@
package io.element.android.features.preferences.api package io.element.android.features.preferences.api
import android.os.Parcelable
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.parcelize.Parcelize
interface PreferencesEntryPoint : FeatureEntryPoint { interface PreferencesEntryPoint : FeatureEntryPoint {
sealed interface InitialTarget : Parcelable {
@Parcelize
data object Root : InitialTarget
@Parcelize
data object NotificationSettings : InitialTarget
}
data class Params(val initialElement: InitialTarget) : NodeInputs
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder { interface NodeBuilder {
fun params(params: Params): NodeBuilder
fun callback(callback: Callback): NodeBuilder fun callback(callback: Callback): NodeBuilder
fun build(): Node fun build(): Node
} }
@ -33,5 +47,6 @@ interface PreferencesEntryPoint : FeatureEntryPoint {
interface Callback : Plugin { interface Callback : Plugin {
fun onOpenBugReport() fun onOpenBugReport()
fun onVerifyClicked() fun onVerifyClicked()
fun onOpenRoomNotificationSettings(roomId: RoomId)
} }
} }

View file

@ -31,6 +31,11 @@ class DefaultPreferencesEntryPoint @Inject constructor() : PreferencesEntryPoint
return object : PreferencesEntryPoint.NodeBuilder { return object : PreferencesEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>() val plugins = ArrayList<Plugin>()
override fun params(params: PreferencesEntryPoint.Params): PreferencesEntryPoint.NodeBuilder {
plugins += params
return this
}
override fun callback(callback: PreferencesEntryPoint.Callback): PreferencesEntryPoint.NodeBuilder { override fun callback(callback: PreferencesEntryPoint.Callback): PreferencesEntryPoint.NodeBuilder {
plugins += callback plugins += callback
return this return this
@ -42,3 +47,8 @@ class DefaultPreferencesEntryPoint @Inject constructor() : PreferencesEntryPoint
} }
} }
} }
internal fun PreferencesEntryPoint.InitialTarget.toNavTarget() = when (this) {
is PreferencesEntryPoint.InitialTarget.Root -> PreferencesFlowNode.NavTarget.Root
is PreferencesEntryPoint.InitialTarget.NotificationSettings -> PreferencesFlowNode.NavTarget.NotificationSettings
}

View file

@ -43,6 +43,7 @@ import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -52,7 +53,7 @@ class PreferencesFlowNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
) : BackstackNode<PreferencesFlowNode.NavTarget>( ) : BackstackNode<PreferencesFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Root, initialElement = plugins.filterIsInstance<PreferencesEntryPoint.Params>().first().initialElement.toNavTarget(),
savedStateMap = buildContext.savedStateMap, savedStateMap = buildContext.savedStateMap,
), ),
buildContext = buildContext, buildContext = buildContext,
@ -152,8 +153,13 @@ class PreferencesFlowNode @AssistedInject constructor(
createNode<NotificationSettingsNode>(buildContext, listOf(notificationSettingsCallback)) createNode<NotificationSettingsNode>(buildContext, listOf(notificationSettingsCallback))
} }
is NavTarget.EditDefaultNotificationSetting -> { is NavTarget.EditDefaultNotificationSetting -> {
val callback = object : EditDefaultNotificationSettingNode.Callback {
override fun openRoomNotificationSettings(roomId: RoomId) {
plugins<PreferencesEntryPoint.Callback>().forEach { it.onOpenRoomNotificationSettings(roomId) }
}
}
val input = EditDefaultNotificationSettingNode.Inputs(navTarget.isOneToOne) val input = EditDefaultNotificationSettingNode.Inputs(navTarget.isOneToOne)
createNode<EditDefaultNotificationSettingNode>(buildContext, plugins = listOf(input)) createNode<EditDefaultNotificationSettingNode>(buildContext, plugins = listOf(input, callback))
} }
NavTarget.AdvancedSettings -> { NavTarget.AdvancedSettings -> {
createNode<AdvancedSettingsNode>(buildContext) createNode<AdvancedSettingsNode>(buildContext)

View file

@ -24,4 +24,5 @@ sealed interface NotificationSettingsEvents {
data class SetCallNotificationsEnabled(val enabled: Boolean) : NotificationSettingsEvents data class SetCallNotificationsEnabled(val enabled: Boolean) : NotificationSettingsEvents
data object FixConfigurationMismatch : NotificationSettingsEvents data object FixConfigurationMismatch : NotificationSettingsEvents
data object ClearConfigurationMismatchError : NotificationSettingsEvents data object ClearConfigurationMismatchError : NotificationSettingsEvents
data object ClearNotificationChangeError : NotificationSettingsEvents
} }

View file

@ -23,7 +23,9 @@ import androidx.compose.runtime.collectAsState
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
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
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.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
@ -50,6 +52,7 @@ class NotificationSettingsPresenter @Inject constructor(
val systemNotificationsEnabled: MutableState<Boolean> = remember { val systemNotificationsEnabled: MutableState<Boolean> = remember {
mutableStateOf(systemNotificationsEnabledProvider.notificationsEnabled()) mutableStateOf(systemNotificationsEnabledProvider.notificationsEnabled())
} }
val changeNotificationSettingAction: MutableState<Async<Unit>> = remember { mutableStateOf(Async.Uninitialized) }
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val appNotificationsEnabled = userPushStore val appNotificationsEnabled = userPushStore
@ -67,8 +70,12 @@ class NotificationSettingsPresenter @Inject constructor(
fun handleEvents(event: NotificationSettingsEvents) { fun handleEvents(event: NotificationSettingsEvents) {
when (event) { when (event) {
is NotificationSettingsEvents.SetAtRoomNotificationsEnabled -> localCoroutineScope.setAtRoomNotificationsEnabled(event.enabled) is NotificationSettingsEvents.SetAtRoomNotificationsEnabled -> {
is NotificationSettingsEvents.SetCallNotificationsEnabled -> localCoroutineScope.setCallNotificationsEnabled(event.enabled) localCoroutineScope.setAtRoomNotificationsEnabled(event.enabled, changeNotificationSettingAction)
}
is NotificationSettingsEvents.SetCallNotificationsEnabled -> {
localCoroutineScope.setCallNotificationsEnabled(event.enabled, changeNotificationSettingAction)
}
is NotificationSettingsEvents.SetNotificationsEnabled -> localCoroutineScope.setNotificationsEnabled(userPushStore, event.enabled) is NotificationSettingsEvents.SetNotificationsEnabled -> localCoroutineScope.setNotificationsEnabled(userPushStore, event.enabled)
NotificationSettingsEvents.ClearConfigurationMismatchError -> { NotificationSettingsEvents.ClearConfigurationMismatchError -> {
matrixSettings.value = NotificationSettingsState.MatrixSettings.Invalid(fixFailed = false) matrixSettings.value = NotificationSettingsState.MatrixSettings.Invalid(fixFailed = false)
@ -77,6 +84,7 @@ class NotificationSettingsPresenter @Inject constructor(
NotificationSettingsEvents.RefreshSystemNotificationsEnabled -> { NotificationSettingsEvents.RefreshSystemNotificationsEnabled -> {
systemNotificationsEnabled.value = systemNotificationsEnabledProvider.notificationsEnabled() systemNotificationsEnabled.value = systemNotificationsEnabledProvider.notificationsEnabled()
} }
NotificationSettingsEvents.ClearNotificationChangeError -> changeNotificationSettingAction.value = Async.Uninitialized
} }
} }
@ -86,6 +94,7 @@ class NotificationSettingsPresenter @Inject constructor(
systemNotificationsEnabled = systemNotificationsEnabled.value, systemNotificationsEnabled = systemNotificationsEnabled.value,
appNotificationsEnabled = appNotificationsEnabled.value appNotificationsEnabled = appNotificationsEnabled.value
), ),
changeNotificationSettingAction = changeNotificationSettingAction.value,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }
@ -154,12 +163,16 @@ class NotificationSettingsPresenter @Inject constructor(
) )
} }
private fun CoroutineScope.setAtRoomNotificationsEnabled(enabled: Boolean) = launch { private fun CoroutineScope.setAtRoomNotificationsEnabled(enabled: Boolean, action: MutableState<Async<Unit>>) = launch {
notificationSettingsService.setRoomMentionEnabled(enabled) suspend {
notificationSettingsService.setRoomMentionEnabled(enabled).getOrThrow()
}.runCatchingUpdatingState(action)
} }
private fun CoroutineScope.setCallNotificationsEnabled(enabled: Boolean) = launch { private fun CoroutineScope.setCallNotificationsEnabled(enabled: Boolean, action: MutableState<Async<Unit>>) = launch {
notificationSettingsService.setCallEnabled(enabled) suspend {
notificationSettingsService.setCallEnabled(enabled).getOrThrow()
}.runCatchingUpdatingState(action)
} }
private fun CoroutineScope.setNotificationsEnabled(userPushStore: UserPushStore, enabled: Boolean) = launch { private fun CoroutineScope.setNotificationsEnabled(userPushStore: UserPushStore, enabled: Boolean) = launch {

View file

@ -17,12 +17,14 @@
package io.element.android.features.preferences.impl.notifications package io.element.android.features.preferences.impl.notifications
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
@Immutable @Immutable
data class NotificationSettingsState( data class NotificationSettingsState(
val matrixSettings: MatrixSettings, val matrixSettings: MatrixSettings,
val appSettings: AppSettings, val appSettings: AppSettings,
val changeNotificationSettingAction: Async<Unit>,
val eventSink: (NotificationSettingsEvents) -> Unit, val eventSink: (NotificationSettingsEvents) -> Unit,
) { ) {
sealed interface MatrixSettings { sealed interface MatrixSettings {

View file

@ -17,16 +17,21 @@
package io.element.android.features.preferences.impl.notifications package io.element.android.features.preferences.impl.notifications
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
open class NotificationSettingsStateProvider : PreviewParameterProvider<NotificationSettingsState> { open class NotificationSettingsStateProvider : PreviewParameterProvider<NotificationSettingsState> {
override val values: Sequence<NotificationSettingsState> override val values: Sequence<NotificationSettingsState>
get() = sequenceOf( get() = sequenceOf(
aNotificationSettingsState(), aNotificationSettingsState(),
aNotificationSettingsState(changeNotificationSettingAction = Async.Loading(Unit)),
aNotificationSettingsState(changeNotificationSettingAction = Async.Failure(Throwable("error"))),
) )
} }
fun aNotificationSettingsState() = NotificationSettingsState( fun aNotificationSettingsState(
changeNotificationSettingAction: Async<Unit> = Async.Uninitialized,
) = NotificationSettingsState(
matrixSettings = NotificationSettingsState.MatrixSettings.Valid( matrixSettings = NotificationSettingsState.MatrixSettings.Valid(
atRoomNotificationsEnabled = true, atRoomNotificationsEnabled = true,
callNotificationsEnabled = true, callNotificationsEnabled = true,
@ -37,5 +42,6 @@ fun aNotificationSettingsState() = NotificationSettingsState(
systemNotificationsEnabled = false, systemNotificationsEnabled = false,
appNotificationsEnabled = true, appNotificationsEnabled = true,
), ),
changeNotificationSettingAction = changeNotificationSettingAction,
eventSink = {} eventSink = {}
) )

View file

@ -34,6 +34,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import io.element.android.libraries.androidutils.system.startNotificationSettingsIntent import io.element.android.libraries.androidutils.system.startNotificationSettingsIntent
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
@ -87,9 +89,23 @@ fun NotificationSettingsView(
onGroupChatsClicked = { onOpenEditDefault(false) }, onGroupChatsClicked = { onOpenEditDefault(false) },
onDirectChatsClicked = { onOpenEditDefault(true) }, onDirectChatsClicked = { onOpenEditDefault(true) },
onMentionNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetAtRoomNotificationsEnabled(it)) }, onMentionNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetAtRoomNotificationsEnabled(it)) },
// TODO We are removing the call notification toggle until support for call notifications has been added
// onCallsNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetCallNotificationsEnabled(it)) }, // onCallsNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetCallNotificationsEnabled(it)) },
) )
} }
when (state.changeNotificationSettingAction) {
is Async.Loading -> {
ProgressDialog()
}
is Async.Failure -> {
ErrorDialog(
title = stringResource(CommonStrings.dialog_title_error),
content = stringResource(CommonStrings.screen_notification_settings_edit_failed_updating_default_mode),
onDismiss = { state.eventSink(NotificationSettingsEvents.ClearNotificationChangeError) },
)
}
else -> Unit
}
} }
} }
@ -101,6 +117,7 @@ private fun NotificationSettingsContentView(
onGroupChatsClicked: () -> Unit, onGroupChatsClicked: () -> Unit,
onDirectChatsClicked: () -> Unit, onDirectChatsClicked: () -> Unit,
onMentionNotificationsChanged: (Boolean) -> Unit, onMentionNotificationsChanged: (Boolean) -> Unit,
// TODO We are removing the call notification toggle until support for call notifications has been added
// onCallsNotificationsChanged: (Boolean) -> Unit, // onCallsNotificationsChanged: (Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -151,7 +168,7 @@ private fun NotificationSettingsContentView(
onCheckedChange = onMentionNotificationsChanged onCheckedChange = onMentionNotificationsChanged
) )
} }
// We are removing the call notification toggle until call support has been added // TODO We are removing the call notification toggle until support for call notifications has been added
// PreferenceCategory(title = stringResource(id = CommonStrings.screen_notification_settings_additional_settings_section_title)) { // PreferenceCategory(title = stringResource(id = CommonStrings.screen_notification_settings_additional_settings_section_title)) {
// PreferenceSwitch( // PreferenceSwitch(
// modifier = Modifier, // modifier = Modifier,

View file

@ -21,12 +21,14 @@ import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
class EditDefaultNotificationSettingNode @AssistedInject constructor( class EditDefaultNotificationSettingNode @AssistedInject constructor(
@ -35,20 +37,30 @@ class EditDefaultNotificationSettingNode @AssistedInject constructor(
presenterFactory: EditDefaultNotificationSettingPresenter.Factory presenterFactory: EditDefaultNotificationSettingPresenter.Factory
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun openRoomNotificationSettings(roomId: RoomId)
}
data class Inputs( data class Inputs(
val isOneToOne: Boolean val isOneToOne: Boolean
) : NodeInputs ) : NodeInputs
private val inputs = inputs<Inputs>() private val inputs = inputs<Inputs>()
private val callbacks = plugins<Callback>()
private val presenter = presenterFactory.create(inputs.isOneToOne) private val presenter = presenterFactory.create(inputs.isOneToOne)
private fun openRoomNotificationSettings(roomId: RoomId) {
callbacks.forEach { it.openRoomNotificationSettings(roomId) }
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
EditDefaultNotificationSettingView( EditDefaultNotificationSettingView(
state = state, state = state,
openRoomNotificationSettings = { openRoomNotificationSettings(it) },
onBackPressed = ::navigateUp, onBackPressed = ::navigateUp,
modifier = modifier modifier = modifier,
) )
} }
} }

View file

@ -25,20 +25,28 @@ import androidx.compose.runtime.rememberCoroutineScope
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.matrix.api.MatrixClient
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.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
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
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.text.Collator
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
class EditDefaultNotificationSettingPresenter @AssistedInject constructor( class EditDefaultNotificationSettingPresenter @AssistedInject constructor(
private val notificationSettingsService: NotificationSettingsService, private val notificationSettingsService: NotificationSettingsService,
@Assisted private val isOneToOne: Boolean, @Assisted private val isOneToOne: Boolean,
private val roomListService: RoomListService,
private val matrixClient: MatrixClient,
) : Presenter<EditDefaultNotificationSettingState> { ) : Presenter<EditDefaultNotificationSettingState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@ -50,21 +58,34 @@ class EditDefaultNotificationSettingPresenter @AssistedInject constructor(
val mode: MutableState<RoomNotificationMode?> = remember { val mode: MutableState<RoomNotificationMode?> = remember {
mutableStateOf(null) mutableStateOf(null)
} }
val changeNotificationSettingAction: MutableState<Async<Unit>> = remember { mutableStateOf(Async.Uninitialized) }
val roomsWithUserDefinedMode: MutableState<List<RoomSummary.Filled>> = remember {
mutableStateOf(listOf())
}
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
fetchSettings(mode) fetchSettings(mode)
observeNotificationSettings(mode) observeNotificationSettings(mode)
observeRoomSummaries(roomsWithUserDefinedMode)
} }
fun handleEvents(event: EditDefaultNotificationSettingStateEvents) { fun handleEvents(event: EditDefaultNotificationSettingStateEvents) {
when (event) { when (event) {
is EditDefaultNotificationSettingStateEvents.SetNotificationMode -> localCoroutineScope.setDefaultNotificationMode(event.mode) is EditDefaultNotificationSettingStateEvents.SetNotificationMode -> {
localCoroutineScope.setDefaultNotificationMode(event.mode, changeNotificationSettingAction)
}
EditDefaultNotificationSettingStateEvents.ClearError -> changeNotificationSettingAction.value = Async.Uninitialized
} }
} }
return EditDefaultNotificationSettingState( return EditDefaultNotificationSettingState(
isOneToOne = isOneToOne, isOneToOne = isOneToOne,
mode = mode.value, mode = mode.value,
roomsWithUserDefinedMode = roomsWithUserDefinedMode.value,
changeNotificationSettingAction = changeNotificationSettingAction.value,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }
@ -83,10 +104,39 @@ class EditDefaultNotificationSettingPresenter @AssistedInject constructor(
.launchIn(this) .launchIn(this)
} }
private fun CoroutineScope.setDefaultNotificationMode(mode: RoomNotificationMode) = launch { private fun CoroutineScope.observeRoomSummaries(roomsWithUserDefinedMode: MutableState<List<RoomSummary.Filled>>) {
// On modern clients, we don't have different settings for encrypted and non-encrypted rooms (Legacy clients did). roomListService.allRooms()
notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = true, mode = mode, isOneToOne = isOneToOne) .summaries
notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = false, mode = mode, isOneToOne = isOneToOne) .onEach {
updateRoomsWithUserDefinedMode(it, roomsWithUserDefinedMode)
}
.launchIn(this)
}
private fun CoroutineScope.updateRoomsWithUserDefinedMode(
summaries: List<RoomSummary>,
roomsWithUserDefinedMode: MutableState<List<RoomSummary.Filled>>
) = launch {
val roomWithUserDefinedRules: Set<String> = notificationSettingsService.getRoomsWithUserDefinedRules().getOrThrow().toSet()
val sortedSummaries = summaries
.filterIsInstance<RoomSummary.Filled>()
.filter {
val room = matrixClient.getRoom(it.details.roomId) ?: return@filter false
roomWithUserDefinedRules.contains(it.identifier()) && isOneToOne == room.isOneToOne
}
// locale sensitive sorting
.sortedWith(compareBy(Collator.getInstance()){ it.details.name })
roomsWithUserDefinedMode.value = sortedSummaries
}
private fun CoroutineScope.setDefaultNotificationMode(mode: RoomNotificationMode, action: MutableState<Async<Unit>>) = launch {
suspend {
// 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)
} }
} }

View file

@ -16,10 +16,14 @@
package io.element.android.features.preferences.impl.notifications.edit package io.element.android.features.preferences.impl.notifications.edit
import io.element.android.libraries.architecture.Async
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.roomlist.RoomSummary
data class EditDefaultNotificationSettingState( data class EditDefaultNotificationSettingState(
val isOneToOne: Boolean, val isOneToOne: Boolean,
val mode: RoomNotificationMode?, val mode: RoomNotificationMode?,
val roomsWithUserDefinedMode: List<RoomSummary.Filled>,
val changeNotificationSettingAction: Async<Unit>,
val eventSink: (EditDefaultNotificationSettingStateEvents) -> Unit, val eventSink: (EditDefaultNotificationSettingStateEvents) -> Unit,
) )

View file

@ -20,4 +20,5 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationMode
sealed interface EditDefaultNotificationSettingStateEvents { sealed interface EditDefaultNotificationSettingStateEvents {
data class SetNotificationMode(val mode: RoomNotificationMode): EditDefaultNotificationSettingStateEvents data class SetNotificationMode(val mode: RoomNotificationMode): EditDefaultNotificationSettingStateEvents
data object ClearError: EditDefaultNotificationSettingStateEvents
} }

View file

@ -0,0 +1,58 @@
/*
* Copyright (c) 2023 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.features.preferences.impl.notifications.edit
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails
open class EditDefaultNotificationSettingStateProvider: PreviewParameterProvider<EditDefaultNotificationSettingState> {
override val values: Sequence<EditDefaultNotificationSettingState>
get() = sequenceOf(
anEditDefaultNotificationSettingsState(),
anEditDefaultNotificationSettingsState(isOneToOne = true),
anEditDefaultNotificationSettingsState(changeNotificationSettingAction = Async.Loading(Unit)),
anEditDefaultNotificationSettingsState(changeNotificationSettingAction = Async.Failure(Throwable("error"))),
)
}
private fun anEditDefaultNotificationSettingsState(
isOneToOne: Boolean = false,
changeNotificationSettingAction: Async<Unit> = Async.Uninitialized
) = EditDefaultNotificationSettingState(
isOneToOne = isOneToOne,
mode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
roomsWithUserDefinedMode = listOf(aRoomSummary()),
changeNotificationSettingAction = changeNotificationSettingAction,
eventSink = {}
)
private fun aRoomSummary() = RoomSummary.Filled(
RoomSummaryDetails(
roomId = RoomId("!roomId:domain"),
name = "Room",
avatarURLString = null,
isDirect = false,
lastMessage = null,
lastMessageTimestamp = null,
unreadNotificationCount = 0,
notificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
)
)

View file

@ -21,8 +21,21 @@ import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.designsystem.components.preferences.PreferencePage import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -33,11 +46,12 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun EditDefaultNotificationSettingView( fun EditDefaultNotificationSettingView(
state: EditDefaultNotificationSettingState, state: EditDefaultNotificationSettingState,
openRoomNotificationSettings:(roomId: RoomId) -> Unit,
onBackPressed: () -> Unit, onBackPressed: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val title = if(state.isOneToOne) { val title = if (state.isOneToOne) {
CommonStrings.screen_notification_settings_direct_chats CommonStrings.screen_notification_settings_direct_chats
} else { } else {
CommonStrings.screen_notification_settings_group_chats CommonStrings.screen_notification_settings_group_chats
@ -51,7 +65,7 @@ fun EditDefaultNotificationSettingView(
// Only ALL_MESSAGES and MENTIONS_AND_KEYWORDS_ONLY are valid global defaults. // Only ALL_MESSAGES and MENTIONS_AND_KEYWORDS_ONLY are valid global defaults.
val validModes = listOf(RoomNotificationMode.ALL_MESSAGES, RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) val validModes = listOf(RoomNotificationMode.ALL_MESSAGES, RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
val categoryTitle = if(state.isOneToOne) { val categoryTitle = if (state.isOneToOne) {
CommonStrings.screen_notification_settings_edit_screen_direct_section_header CommonStrings.screen_notification_settings_edit_screen_direct_section_header
} else { } else {
CommonStrings.screen_notification_settings_edit_screen_group_section_header CommonStrings.screen_notification_settings_edit_screen_group_section_header
@ -70,6 +84,63 @@ fun EditDefaultNotificationSettingView(
} }
} }
} }
if (state.roomsWithUserDefinedMode.isNotEmpty()) {
PreferenceCategory(title = stringResource(id = CommonStrings.screen_notification_settings_edit_custom_settings_section_title)) {
state.roomsWithUserDefinedMode.forEach { summary ->
val subtitle = when (summary.details.notificationMode) {
RoomNotificationMode.ALL_MESSAGES -> stringResource(id = CommonStrings.screen_notification_settings_edit_mode_all_messages)
RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> {
stringResource(id = CommonStrings.screen_notification_settings_edit_mode_mentions_and_keywords)
}
RoomNotificationMode.MUTE -> stringResource(id = CommonStrings.common_mute)
null -> ""
}
val avatarData = AvatarData(
id = summary.identifier(),
name = summary.details.name,
url = summary.details.avatarURLString,
size = AvatarSize.CustomRoomNotificationSetting,
)
ListItem(
headlineContent = {
Text(text = summary.details.name)
},
supportingContent = {
Text(text = subtitle)
},
leadingContent = ListItemContent.Custom {
Avatar(avatarData = avatarData)
},
onClick = {
openRoomNotificationSettings(summary.details.roomId)
}
)
}
}
}
when (state.changeNotificationSettingAction) {
is Async.Loading -> {
ProgressDialog()
}
is Async.Failure -> {
ErrorDialog(
title = stringResource(CommonStrings.dialog_title_error),
content = stringResource(CommonStrings.screen_notification_settings_edit_failed_updating_default_mode),
onDismiss = { state.eventSink(EditDefaultNotificationSettingStateEvents.ClearError) },
)
}
else -> Unit
}
} }
} }
@PreviewsDayNight
@Composable
internal fun EditDefaultNotificationSettingViewPreview(
@PreviewParameter(EditDefaultNotificationSettingStateProvider::class) state: EditDefaultNotificationSettingState
) = ElementPreview {
EditDefaultNotificationSettingView(
state = state,
openRoomNotificationSettings = {},
onBackPressed = {},
)
}

View file

@ -23,7 +23,14 @@ import com.google.common.truth.Truth
import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingPresenter import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingPresenter
import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingStateEvents import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingStateEvents
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.roomlist.RoomSummary
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomSummaryDetail
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.consumeItemsUntilPredicate
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -32,7 +39,7 @@ class EditDefaultNotificationSettingsPresenterTests {
@Test @Test
fun `present - ensures initial state is correct`() = runTest { fun `present - ensures initial state is correct`() = runTest {
val notificationSettingsService = FakeNotificationSettingsService() val notificationSettingsService = FakeNotificationSettingsService()
val presenter = EditDefaultNotificationSettingPresenter(notificationSettingsService = notificationSettingsService, isOneToOne = false) val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -47,10 +54,32 @@ class EditDefaultNotificationSettingsPresenterTests {
} }
} }
@Test
fun `present - ensure list of rooms with user defined mode`() = runTest {
val room = FakeMatrixRoom()
val notificationSettingsService = FakeNotificationSettingsService(
initialRoomMode = RoomNotificationMode.ALL_MESSAGES,
initialRoomModeIsDefault = false
)
val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService).apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val roomListService = FakeRoomListService()
val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService, roomListService, matrixClient)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
roomListService.postAllRooms(listOf(RoomSummary.Filled(aRoomSummaryDetail(notificationMode = RoomNotificationMode.ALL_MESSAGES))))
val loadedState = consumeItemsUntilPredicate { state ->
state.roomsWithUserDefinedMode.any { it.details.notificationMode == RoomNotificationMode.ALL_MESSAGES }
}.last()
Truth.assertThat(loadedState.roomsWithUserDefinedMode.any { it.details.notificationMode == RoomNotificationMode.ALL_MESSAGES }).isTrue()
}
}
@Test @Test
fun `present - edit default notification setting`() = runTest { fun `present - edit default notification setting`() = runTest {
val notificationSettingsService = FakeNotificationSettingsService() val presenter = createEditDefaultNotificationSettingPresenter()
val presenter = EditDefaultNotificationSettingPresenter(notificationSettingsService = notificationSettingsService, isOneToOne = false)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -61,4 +90,39 @@ class EditDefaultNotificationSettingsPresenterTests {
Truth.assertThat(loadedState.mode).isEqualTo(RoomNotificationMode.ALL_MESSAGES) Truth.assertThat(loadedState.mode).isEqualTo(RoomNotificationMode.ALL_MESSAGES)
} }
} }
@Test
fun `present - edit default notification setting failed`() = runTest {
val notificationSettingsService = FakeNotificationSettingsService()
val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService)
notificationSettingsService.givenSetDefaultNotificationModeError(A_THROWABLE)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(EditDefaultNotificationSettingStateEvents.SetNotificationMode(RoomNotificationMode.ALL_MESSAGES))
val errorState = consumeItemsUntilPredicate {
it.changeNotificationSettingAction.isFailure()
}.last()
Truth.assertThat(errorState.changeNotificationSettingAction.isFailure()).isTrue()
errorState.eventSink(EditDefaultNotificationSettingStateEvents.ClearError)
val clearErrorState = consumeItemsUntilPredicate {
it.changeNotificationSettingAction.isUninitialized()
}.last()
Truth.assertThat(clearErrorState.changeNotificationSettingAction.isUninitialized()).isTrue()
}
}
private fun createEditDefaultNotificationSettingPresenter(
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
roomListService: FakeRoomListService = FakeRoomListService(),
matrixClient: FakeMatrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService)
): EditDefaultNotificationSettingPresenter {
return EditDefaultNotificationSettingPresenter(
notificationSettingsService = notificationSettingsService,
isOneToOne = false,
roomListService = roomListService,
matrixClient = matrixClient
)
}
} }

View file

@ -22,6 +22,7 @@ import app.cash.turbine.test
import com.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory import com.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory
import com.google.common.truth.Truth import com.google.common.truth.Truth
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.consumeItemsUntilPredicate
@ -187,6 +188,35 @@ class NotificationSettingsPresenterTests {
} }
} }
@Test
fun `present - clear notification settings change error`() = runTest {
val notificationSettingsService = FakeNotificationSettingsService()
val presenter = createNotificationSettingsPresenter(notificationSettingsService)
notificationSettingsService.givenSetAtRoomError(A_THROWABLE)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val loadedState = consumeItemsUntilPredicate {
(it.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid)?.atRoomNotificationsEnabled == false
}.last()
val validMatrixState = loadedState.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid
Truth.assertThat(validMatrixState?.atRoomNotificationsEnabled).isFalse()
loadedState.eventSink(NotificationSettingsEvents.SetAtRoomNotificationsEnabled(true))
val errorState = consumeItemsUntilPredicate {
it.changeNotificationSettingAction.isFailure()
}.last()
Truth.assertThat(errorState.changeNotificationSettingAction.isFailure()).isTrue()
errorState.eventSink(NotificationSettingsEvents.ClearNotificationChangeError)
val clearErrorState = consumeItemsUntilPredicate {
it.changeNotificationSettingAction.isUninitialized()
}.last()
Truth.assertThat(clearErrorState.changeNotificationSettingAction.isUninitialized()).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
private fun createNotificationSettingsPresenter( private fun createNotificationSettingsPresenter(
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService() notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService()
) : NotificationSettingsPresenter { ) : NotificationSettingsPresenter {

View file

@ -33,9 +33,22 @@ interface RoomDetailsEntryPoint : FeatureEntryPoint {
@Parcelize @Parcelize
data class RoomMemberDetails(val roomMemberId: UserId) : InitialTarget data class RoomMemberDetails(val roomMemberId: UserId) : InitialTarget
@Parcelize
data object RoomNotificationSettings : InitialTarget
} }
data class Inputs(val initialElement: InitialTarget) : NodeInputs data class Params(val initialElement: InitialTarget) : NodeInputs
fun createNode(parentNode: Node, buildContext: BuildContext, inputs: Inputs, plugins: List<Plugin>): Node interface Callback : Plugin {
fun onOpenGlobalNotificationSettings()
}
interface NodeBuilder {
fun params(params: Params): NodeBuilder
fun callback(callback: Callback): NodeBuilder
fun build(): Node
}
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
} }

View file

@ -44,6 +44,7 @@ dependencies {
implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.featureflag.api) implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.permissions.api) implementation(projects.libraries.permissions.api)
implementation(projects.libraries.preferences.api)
api(projects.features.roomdetails.api) api(projects.features.roomdetails.api)
api(projects.libraries.usersearch.api) api(projects.libraries.usersearch.api)
api(projects.services.apperror.api) api(projects.services.apperror.api)

View file

@ -29,17 +29,30 @@ import javax.inject.Inject
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultRoomDetailsEntryPoint @Inject constructor() : RoomDetailsEntryPoint { class DefaultRoomDetailsEntryPoint @Inject constructor() : RoomDetailsEntryPoint {
override fun createNode(
parentNode: Node, override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): RoomDetailsEntryPoint.NodeBuilder {
buildContext: BuildContext, return object : RoomDetailsEntryPoint.NodeBuilder {
inputs: RoomDetailsEntryPoint.Inputs, val plugins = ArrayList<Plugin>()
plugins: List<Plugin>
): Node { override fun params(params: RoomDetailsEntryPoint.Params): RoomDetailsEntryPoint.NodeBuilder {
return parentNode.createNode<RoomDetailsFlowNode>(buildContext, plugins + inputs) plugins += params
return this
}
override fun callback(callback: RoomDetailsEntryPoint.Callback): RoomDetailsEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun build(): Node {
return parentNode.createNode<RoomDetailsFlowNode>(buildContext, plugins)
}
}
} }
} }
internal fun InitialTarget.toNavTarget() = when (this) { internal fun InitialTarget.toNavTarget() = when (this) {
is InitialTarget.RoomDetails -> NavTarget.RoomDetails is InitialTarget.RoomDetails -> NavTarget.RoomDetails
is InitialTarget.RoomMemberDetails -> NavTarget.RoomMemberDetails(roomMemberId) is InitialTarget.RoomMemberDetails -> NavTarget.RoomMemberDetails(roomMemberId)
is InitialTarget.RoomNotificationSettings -> NavTarget.RoomNotificationSettings(showUserDefinedSettingStyle = true)
} }

View file

@ -23,6 +23,7 @@ import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted import dagger.assisted.Assisted
@ -47,7 +48,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
) : BackstackNode<RoomDetailsFlowNode.NavTarget>( ) : BackstackNode<RoomDetailsFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Inputs>().first().initialElement.toNavTarget(), initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Params>().first().initialElement.toNavTarget(),
savedStateMap = buildContext.savedStateMap, savedStateMap = buildContext.savedStateMap,
), ),
buildContext = buildContext, buildContext = buildContext,
@ -68,7 +69,13 @@ class RoomDetailsFlowNode @AssistedInject constructor(
data object InviteMembers : NavTarget data object InviteMembers : NavTarget
@Parcelize @Parcelize
data object RoomNotificationSettings : NavTarget data class RoomNotificationSettings(
/**
* When presented from outsite the context of the room, the rooms settings UI is different.
* Figma designs: https://www.figma.com/file/0MMNu7cTOzLOlWb7ctTkv3/Element-X?type=design&node-id=5199-198932&mode=design&t=fTTvpuxYFjewYQOe-0
*/
val showUserDefinedSettingStyle: Boolean
) : NavTarget
@Parcelize @Parcelize
data class RoomMemberDetails(val roomMemberId: UserId) : NavTarget data class RoomMemberDetails(val roomMemberId: UserId) : NavTarget
@ -91,7 +98,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
} }
override fun openRoomNotificationSettings() { override fun openRoomNotificationSettings() {
backstack.push(NavTarget.RoomNotificationSettings) backstack.push(NavTarget.RoomNotificationSettings(showUserDefinedSettingStyle = false))
} }
} }
createNode<RoomDetailsNode>(buildContext, listOf(roomDetailsCallback)) createNode<RoomDetailsNode>(buildContext, listOf(roomDetailsCallback))
@ -118,8 +125,14 @@ class RoomDetailsFlowNode @AssistedInject constructor(
createNode<RoomInviteMembersNode>(buildContext) createNode<RoomInviteMembersNode>(buildContext)
} }
NavTarget.RoomNotificationSettings -> { is NavTarget.RoomNotificationSettings -> {
createNode<RoomNotificationSettingsNode>(buildContext) val input = RoomNotificationSettingsNode.RoomNotificationSettingInput(navTarget.showUserDefinedSettingStyle)
val callback = object : RoomNotificationSettingsNode.Callback {
override fun openGlobalNotificationSettings() {
plugins<RoomDetailsEntryPoint.Callback>().forEach { it.onOpenGlobalNotificationSettings() }
}
}
createNode<RoomNotificationSettingsNode>(buildContext, listOf(input, callback))
} }
is NavTarget.RoomMemberDetails -> { is NavTarget.RoomMemberDetails -> {

View file

@ -21,4 +21,7 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationMode
sealed interface RoomNotificationSettingsEvents { 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 ClearSetNotificationError: RoomNotificationSettingsEvents
data object ClearRestoreDefaultError: RoomNotificationSettingsEvents
} }

View file

@ -22,10 +22,13 @@ import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
@ -33,10 +36,24 @@ import io.element.android.services.analytics.api.AnalyticsService
class RoomNotificationSettingsNode @AssistedInject constructor( class RoomNotificationSettingsNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val presenter: RoomNotificationSettingsPresenter, presenterFactory: RoomNotificationSettingsPresenter.Factory,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
data class RoomNotificationSettingInput(
val showUserDefinedSettingStyle: Boolean
) : NodeInputs
interface Callback : Plugin {
fun openGlobalNotificationSettings()
}
private val inputs = inputs<RoomNotificationSettingInput>()
private val callbacks = plugins<Callback>()
private fun openGlobalNotificationSettings() {
callbacks.forEach { it.openGlobalNotificationSettings() }
}
private val presenter = presenterFactory.create(inputs.showUserDefinedSettingStyle)
init { init {
lifecycle.subscribe( lifecycle.subscribe(
onResume = { onResume = {
@ -51,6 +68,7 @@ class RoomNotificationSettingsNode @AssistedInject constructor(
RoomNotificationSettingsView( RoomNotificationSettingsView(
state = state, state = state,
modifier = modifier, modifier = modifier,
onShowGlobalNotifications = this::openGlobalNotificationSettings,
onBackPressed = this::navigateUp, onBackPressed = this::navigateUp,
) )
} }

View file

@ -0,0 +1,43 @@
/*
* Copyright (c) 2023 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.features.roomdetails.impl.notificationsettings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
@Composable
fun RoomNotificationSettingsOptions(
selected: RoomNotificationMode?,
enabled: Boolean,
modifier: Modifier = Modifier,
onOptionSelected: (RoomNotificationSettingsItem) -> Unit = {},
) {
val items = roomNotificationSettingsItems()
Column(modifier = modifier.selectableGroup()) {
items.forEach { item ->
RoomNotificationSettingsOption(
roomNotificationSettingsItem = item,
isSelected = selected == item.mode,
onOptionSelected = onOptionSelected,
enabled = enabled
)
}
}
}

View file

@ -19,90 +19,176 @@ 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.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
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
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
class RoomNotificationSettingsPresenter @Inject constructor( class RoomNotificationSettingsPresenter @AssistedInject constructor(
private val room: MatrixRoom, private val room: MatrixRoom,
private val notificationSettingsService: NotificationSettingsService, private val notificationSettingsService: NotificationSettingsService,
@Assisted private val showUserDefinedSettingStyle: Boolean,
) : Presenter<RoomNotificationSettingsState> { ) : Presenter<RoomNotificationSettingsState> {
@AssistedFactory
interface Factory {
fun create(showUserDefinedSettingStyle: Boolean): RoomNotificationSettingsPresenter
}
@Composable @Composable
override fun present(): RoomNotificationSettingsState { override fun present(): RoomNotificationSettingsState {
val defaultRoomNotificationMode: MutableState<RoomNotificationMode?> = rememberSaveable { val defaultRoomNotificationMode: MutableState<RoomNotificationMode?> = rememberSaveable {
mutableStateOf(null) mutableStateOf(null)
} }
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val setNotificationSettingAction: 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)
observeNotificationSettings() fetchNotificationSettings(pendingRoomNotificationMode, roomNotificationSettings)
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) localCoroutineScope.setRoomNotificationMode(event.mode, pendingRoomNotificationMode, pendingSetDefault, setNotificationSettingAction)
} }
is RoomNotificationSettingsEvents.SetNotificationMode -> { is RoomNotificationSettingsEvents.SetNotificationMode -> {
if (event.isDefault) { if (event.isDefault) {
localCoroutineScope.restoreDefaultRoomNotificationMode() localCoroutineScope.restoreDefaultRoomNotificationMode(restoreDefaultAction, pendingSetDefault)
} else { } else {
defaultRoomNotificationMode.value?.let { defaultRoomNotificationMode.value?.let {
localCoroutineScope.setRoomNotificationMode(it) localCoroutineScope.setRoomNotificationMode(it, pendingRoomNotificationMode, pendingSetDefault, setNotificationSettingAction)
} }
} }
} }
is RoomNotificationSettingsEvents.DeleteCustomNotification -> {
localCoroutineScope.restoreDefaultRoomNotificationMode(restoreDefaultAction, pendingSetDefault)
}
RoomNotificationSettingsEvents.ClearSetNotificationError -> {
setNotificationSettingAction.value = Async.Uninitialized
}
RoomNotificationSettingsEvents.ClearRestoreDefaultError -> {
restoreDefaultAction.value = Async.Uninitialized
}
} }
} }
return RoomNotificationSettingsState( return RoomNotificationSettingsState(
roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(), showUserDefinedSettingStyle = showUserDefinedSettingStyle,
roomName = room.displayName,
roomNotificationSettings = roomNotificationSettings.value,
pendingRoomNotificationMode = pendingRoomNotificationMode.value,
pendingSetDefault = pendingSetDefault.value,
defaultRoomNotificationMode = defaultRoomNotificationMode.value, defaultRoomNotificationMode = defaultRoomNotificationMode.value,
setNotificationSettingAction = setNotificationSettingAction.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) = launch { private fun CoroutineScope.setRoomNotificationMode(
notificationSettingsService.setRoomNotificationMode(room.roomId, mode) mode: RoomNotificationMode,
pendingModeState: MutableState<RoomNotificationMode?>,
pendingDefaultState: MutableState<Boolean?>,
action: MutableState<Async<Unit>>
) = launch {
suspend {
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)
} }
private fun CoroutineScope.restoreDefaultRoomNotificationMode() = launch { private fun CoroutineScope.restoreDefaultRoomNotificationMode(
notificationSettingsService.restoreDefaultRoomNotificationMode(room.roomId) action: MutableState<Async<Unit>>,
pendingDefaultState: MutableState<Boolean?>
) = launch {
suspend {
pendingDefaultState.value = true
val result = notificationSettingsService.restoreDefaultRoomNotificationMode(room.roomId)
if (result.isFailure) {
pendingDefaultState.value = null
}
result.getOrThrow()
}.runCatchingUpdatingState(action)
} }
} }

View file

@ -16,11 +16,26 @@
package io.element.android.features.roomdetails.impl.notificationsettings package io.element.android.features.roomdetails.impl.notificationsettings
import io.element.android.libraries.architecture.Async
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
data class RoomNotificationSettingsState( data class RoomNotificationSettingsState(
val roomNotificationSettings: RoomNotificationSettings?, val showUserDefinedSettingStyle: Boolean,
val roomName: String,
val roomNotificationSettings: Async<RoomNotificationSettings>,
val pendingRoomNotificationMode: RoomNotificationMode?,
val pendingSetDefault: Boolean?,
val defaultRoomNotificationMode: RoomNotificationMode?, val defaultRoomNotificationMode: RoomNotificationMode?,
val setNotificationSettingAction: 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

@ -17,18 +17,38 @@
package io.element.android.features.roomdetails.impl.notificationsettings package io.element.android.features.roomdetails.impl.notificationsettings
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async
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
internal class RoomNotificationSettingsStateProvider : PreviewParameterProvider<RoomNotificationSettingsState> { internal class RoomNotificationSettingsStateProvider : PreviewParameterProvider<RoomNotificationSettingsState> {
override val values: Sequence<RoomNotificationSettingsState> override val values: Sequence<RoomNotificationSettingsState>
get() = sequenceOf( get() = sequenceOf(
RoomNotificationSettingsState( aRoomNotificationSettingsState(),
RoomNotificationSettings( aRoomNotificationSettingsState(isDefault = false),
mode = RoomNotificationMode.MUTE, aRoomNotificationSettingsState(setNotificationSettingAction = Async.Loading(Unit)),
isDefault = true), aRoomNotificationSettingsState(setNotificationSettingAction = Async.Failure(Throwable("error"))),
RoomNotificationMode.ALL_MESSAGES, aRoomNotificationSettingsState(restoreDefaultAction = Async.Loading(Unit)),
eventSink = { }, aRoomNotificationSettingsState(restoreDefaultAction = Async.Failure(Throwable("error"))),
),
) )
private fun aRoomNotificationSettingsState(
isDefault: Boolean = true,
setNotificationSettingAction: Async<Unit> = Async.Uninitialized,
restoreDefaultAction: Async<Unit> = Async.Uninitialized,
): RoomNotificationSettingsState {
return RoomNotificationSettingsState(
showUserDefinedSettingStyle = false,
roomName = "Room 1",
Async.Success(RoomNotificationSettings(
mode = RoomNotificationMode.MUTE,
isDefault = isDefault)),
pendingRoomNotificationMode = null,
pendingSetDefault = null,
defaultRoomNotificationMode = RoomNotificationMode.ALL_MESSAGES,
setNotificationSettingAction = setNotificationSettingAction,
restoreDefaultAction = restoreDefaultAction,
eventSink = { },
)
}
} }

View file

@ -18,25 +18,29 @@ package io.element.android.features.roomdetails.impl.notificationsettings
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.roomdetails.impl.R import io.element.android.features.roomdetails.impl.R
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.bool.orTrue import io.element.android.libraries.core.bool.orTrue
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart
import io.element.android.libraries.designsystem.theme.aliasScreenTitle import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
@ -45,11 +49,35 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun RoomNotificationSettingsView( fun RoomNotificationSettingsView(
state: RoomNotificationSettingsState, state: RoomNotificationSettingsState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onShowGlobalNotifications: () -> Unit = {},
onBackPressed: () -> Unit = {},
) {
if(state.showUserDefinedSettingStyle) {
UserDefinedRoomNotificationSettingsView(
state = state,
modifier = modifier,
onBackPressed = onBackPressed,
)
} else {
RoomSpecificNotificationSettingsView(
state = state,
modifier = modifier,
onShowGlobalNotifications = onShowGlobalNotifications,
onBackPressed = onBackPressed,
)
}
}
@Composable
private fun RoomSpecificNotificationSettingsView(
state: RoomNotificationSettingsState,
modifier: Modifier = Modifier,
onShowGlobalNotifications: () -> Unit = {},
onBackPressed: () -> Unit = {}, onBackPressed: () -> Unit = {},
) { ) {
Scaffold( Scaffold(
@ -67,41 +95,84 @@ fun RoomNotificationSettingsView(
.consumeWindowInsets(padding), .consumeWindowInsets(padding),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
val subtitle = when (state.defaultRoomNotificationMode) { val roomNotificationSettings = state.roomNotificationSettings.dataOrNull()
RoomNotificationMode.ALL_MESSAGES -> stringResource(id = R.string.screen_room_notification_settings_mode_all_messages) PreferenceSwitch(
RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> stringResource(id = R.string.screen_room_notification_settings_mode_mentions_and_keywords) isChecked = !state.displayIsDefault.orTrue(),
RoomNotificationMode.MUTE -> stringResource(id = CommonStrings.common_mute) onCheckedChange = {
null -> "" state.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(!it))
} },
title = stringResource(id = R.string.screen_room_notification_settings_allow_custom),
subtitle = stringResource(id = R.string.screen_room_notification_settings_allow_custom_footnote),
PreferenceCategory(title = stringResource(id = R.string.screen_room_notification_settings_custom_settings_title)) { enabled = roomNotificationSettings != null
PreferenceSwitch( )
isChecked = state.roomNotificationSettings?.isDefault.orTrue(), if (state.displayIsDefault.orTrue()) {
onCheckedChange = { PreferenceCategory(title = stringResource(id = R.string.screen_room_notification_settings_default_setting_title)) {
state.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(it)) val text = buildAnnotatedStringWithStyledPart(
}, R.string.screen_room_notification_settings_default_setting_footnote,
title = "Match default setting", R.string.screen_room_notification_settings_default_setting_footnote_content_link,
subtitle = subtitle, color = Color.Unspecified,
enabled = state.roomNotificationSettings != null underline = false,
) bold = true,
)
PreferenceText( ClickableText(
title = stringResource(id = R.string.screen_room_notification_settings_allow_custom), text = text,
subtitle = stringResource(id = R.string.screen_room_notification_settings_allow_custom_footnote), onClick = {
enabled = state.roomNotificationSettings != null && !state.roomNotificationSettings.isDefault, onShowGlobalNotifications()
) },
modifier = Modifier
if (state.roomNotificationSettings != null) { .padding(start = 16.dp, bottom = 16.dp, end = 16.dp),
style = ElementTheme.typography.fontBodyMdRegular
.copy(
color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.Center,
)
)
if(state.defaultRoomNotificationMode != null){
val defaultModeTitle = when (state.defaultRoomNotificationMode) {
RoomNotificationMode.ALL_MESSAGES -> stringResource(id = R.string.screen_room_notification_settings_mode_all_messages)
RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> {
stringResource(id = R.string.screen_room_notification_settings_mode_mentions_and_keywords)
}
RoomNotificationMode.MUTE -> stringResource(id = CommonStrings.common_mute)
}
RoomNotificationSettingsOption(
roomNotificationSettingsItem = RoomNotificationSettingsItem(state.defaultRoomNotificationMode, defaultModeTitle),
isSelected = true,
onOptionSelected = { },
enabled = true
)
}
}
} else {
PreferenceCategory(title = stringResource(id = R.string.screen_room_notification_settings_custom_settings_title)) {
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))
}, },)
)
} }
} }
when (state.setNotificationSettingAction) {
is Async.Loading -> {
ProgressDialog()
}
is Async.Failure -> {
ShowChangeNotificationSettingError(state, RoomNotificationSettingsEvents.ClearSetNotificationError)
}
else -> Unit
}
when (state.restoreDefaultAction) {
is Async.Loading -> {
ProgressDialog()
}
is Async.Failure -> {
ShowChangeNotificationSettingError(state, RoomNotificationSettingsEvents.ClearRestoreDefaultError)
}
else -> Unit
}
} }
} }
} }
@ -124,26 +195,6 @@ private fun RoomNotificationSettingsTopBar(
) )
} }
@Composable
private fun RoomNotificationSettingsOptions(
selected: RoomNotificationMode?,
enabled: Boolean,
modifier: Modifier = Modifier,
onOptionSelected: (RoomNotificationSettingsItem) -> Unit = {},
) {
val items = roomNotificationSettingsItems()
Column(modifier = modifier.selectableGroup()) {
items.forEach { item ->
RoomNotificationSettingsOption(
roomNotificationSettingsItem = item,
isSelected = selected == item.mode,
onOptionSelected = onOptionSelected,
enabled = enabled
)
}
}
}
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun RoomNotificationSettingsPreview( internal fun RoomNotificationSettingsPreview(

View file

@ -0,0 +1,31 @@
/*
* Copyright (c) 2023 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.features.roomdetails.impl.notificationsettings
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun ShowChangeNotificationSettingError(state: RoomNotificationSettingsState, event: RoomNotificationSettingsEvents) {
ErrorDialog(
title = stringResource(CommonStrings.dialog_title_error),
content = stringResource(CommonStrings.screen_notification_settings_edit_failed_updating_default_mode),
onDismiss = { state.eventSink(event) },
)
}

View file

@ -0,0 +1,43 @@
/*
* Copyright (c) 2023 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.features.roomdetails.impl.notificationsettings
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
internal class UserDefinedRoomNotificationSettingsStateProvider : PreviewParameterProvider<RoomNotificationSettingsState> {
override val values: Sequence<RoomNotificationSettingsState>
get() = sequenceOf(
RoomNotificationSettingsState(
showUserDefinedSettingStyle = false,
roomName = "Room 1",
Async.Success(
RoomNotificationSettings(
mode = RoomNotificationMode.MUTE,
isDefault = false)
),
pendingRoomNotificationMode = null,
pendingSetDefault = null,
defaultRoomNotificationMode = RoomNotificationMode.ALL_MESSAGES,
setNotificationSettingAction = Async.Uninitialized,
restoreDefaultAction = Async.Uninitialized,
eventSink = { },
),
)
}

View file

@ -0,0 +1,141 @@
/*
* Copyright (c) 2023 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.features.roomdetails.impl.notificationsettings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.roomdetails.impl.R
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.bool.orTrue
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.CommonDrawables
@Composable
fun UserDefinedRoomNotificationSettingsView(
state: RoomNotificationSettingsState,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
) {
Scaffold(
modifier = modifier,
topBar = {
UserDefinedRoomNotificationSettingsTopBar(
roomName = state.roomName,
onBackPressed = { onBackPressed() }
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxWidth()
.padding(padding)
.consumeWindowInsets(padding),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
val roomNotificationSettings = state.roomNotificationSettings.dataOrNull()
if (roomNotificationSettings != null && state.displayNotificationMode != null) {
RoomNotificationSettingsOptions(
selected = state.displayNotificationMode,
enabled = !state.displayIsDefault.orTrue(),
onOptionSelected = {
state.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(it.mode))
},
)
}
PreferenceText(
title = stringResource(R.string.screen_room_notification_settings_edit_remove_setting),
icon = ImageVector.vectorResource(CommonDrawables.ic_compound_delete),
tintColor = MaterialTheme.colorScheme.error,
onClick = {
state.eventSink(RoomNotificationSettingsEvents.DeleteCustomNotification)
}
)
when (state.setNotificationSettingAction) {
is Async.Loading -> {
ProgressDialog()
}
is Async.Failure -> {
ShowChangeNotificationSettingError(state, RoomNotificationSettingsEvents.ClearSetNotificationError)
}
else -> Unit
}
when (state.restoreDefaultAction) {
is Async.Loading -> {
ProgressDialog()
}
is Async.Failure -> {
ShowChangeNotificationSettingError(state, RoomNotificationSettingsEvents.ClearRestoreDefaultError)
}
is Async.Success -> {
LaunchedEffect(state.restoreDefaultAction) {
onBackPressed()
}
}
else -> Unit
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun UserDefinedRoomNotificationSettingsTopBar(
roomName: String,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
) {
TopAppBar(
modifier = modifier,
title = {
Text(
text = roomName,
)
},
navigationIcon = { BackButton(onClick = onBackPressed) },
)
}
@PreviewsDayNight
@Composable
internal fun UserDefinedRoomNotificationSettingsPreview(
@PreviewParameter(UserDefinedRoomNotificationSettingsStateProvider::class) state: RoomNotificationSettingsState
) = ElementPreview {
UserDefinedRoomNotificationSettingsView(state)
}

View file

@ -24,6 +24,9 @@ import io.element.android.features.roomdetails.aMatrixRoom
import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsEvents import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsEvents
import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsPresenter import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsPresenter
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.consumeItemsUntilPredicate
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -31,12 +34,12 @@ import org.junit.Test
class RoomNotificationSettingsPresenterTests { class RoomNotificationSettingsPresenterTests {
@Test @Test
fun `present - initial state is created from room info`() = runTest { fun `present - initial state is created from room info`() = runTest {
val presenter = aNotificationPresenter val presenter = createRoomNotificationSettingsPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
Truth.assertThat(initialState.roomNotificationSettings).isNull() Truth.assertThat(initialState.roomNotificationSettings.dataOrNull()).isNull()
Truth.assertThat(initialState.defaultRoomNotificationMode).isNull() Truth.assertThat(initialState.defaultRoomNotificationMode).isNull()
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
@ -44,21 +47,80 @@ class RoomNotificationSettingsPresenterTests {
@Test @Test
fun `present - notification mode changed`() = runTest { fun `present - notification mode changed`() = runTest {
val presenter = aNotificationPresenter val presenter = createRoomNotificationSettingsPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
awaitItem().eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)) awaitItem().eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY))
val updatedState = consumeItemsUntilPredicate { val updatedState = consumeItemsUntilPredicate {
it.roomNotificationSettings?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY it.roomNotificationSettings.dataOrNull()?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
}.last() }.last()
Truth.assertThat(updatedState.roomNotificationSettings?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) Truth.assertThat(updatedState.roomNotificationSettings.dataOrNull()?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - observe notification mode changed`() = runTest {
val notificationSettingsService = FakeNotificationSettingsService()
val presenter = createRoomNotificationSettingsPresenter(notificationSettingsService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
notificationSettingsService.setRoomNotificationMode(A_ROOM_ID, RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
val updatedState = consumeItemsUntilPredicate {
it.roomNotificationSettings.dataOrNull()?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
}.last()
Truth.assertThat(updatedState.roomNotificationSettings.dataOrNull()?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
}
}
@Test
fun `present - notification settings set custom failed`() = runTest {
val notificationSettingsService = FakeNotificationSettingsService()
notificationSettingsService.givenSetNotificationModeError(A_THROWABLE)
val presenter = createRoomNotificationSettingsPresenter(notificationSettingsService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(false))
val failedState = consumeItemsUntilPredicate {
it.setNotificationSettingAction.isFailure()
}.last()
Truth.assertThat(failedState.roomNotificationSettings.dataOrNull()?.isDefault).isTrue()
Truth.assertThat(failedState.pendingSetDefault).isNull()
Truth.assertThat(failedState.setNotificationSettingAction.isFailure()).isTrue()
failedState.eventSink(RoomNotificationSettingsEvents.ClearSetNotificationError)
val errorClearedState = consumeItemsUntilPredicate {
it.setNotificationSettingAction.isUninitialized()
}.last()
Truth.assertThat(errorClearedState.setNotificationSettingAction.isUninitialized()).isTrue()
}
}
@Test
fun `present - notification settings set custom`() = runTest {
val notificationSettingsService = FakeNotificationSettingsService()
val presenter = createRoomNotificationSettingsPresenter(notificationSettingsService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(false))
val defaultState = consumeItemsUntilPredicate {
it.roomNotificationSettings.dataOrNull()?.isDefault == false
}.last()
Truth.assertThat(defaultState.roomNotificationSettings.dataOrNull()?.isDefault).isFalse()
} }
} }
@Test @Test
fun `present - notification settings restore default`() = runTest { fun `present - notification settings restore default`() = runTest {
val presenter = aNotificationPresenter val presenter = createRoomNotificationSettingsPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -66,17 +128,45 @@ class RoomNotificationSettingsPresenterTests {
initialState.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)) initialState.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY))
initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(true)) initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(true))
val defaultState = consumeItemsUntilPredicate { val defaultState = consumeItemsUntilPredicate {
it.roomNotificationSettings?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY it.roomNotificationSettings.dataOrNull()?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
}.last() }.last()
Truth.assertThat(defaultState.roomNotificationSettings?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) Truth.assertThat(defaultState.roomNotificationSettings.dataOrNull()?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
cancelAndIgnoreRemainingEvents()
} }
} }
private val aNotificationPresenter: RoomNotificationSettingsPresenter get() { @Test
val room = aMatrixRoom() fun `present - notification settings restore default failed`() = runTest {
val notificationSettingsService = FakeNotificationSettingsService()
notificationSettingsService.givenRestoreDefaultNotificationModeError(A_THROWABLE)
val presenter = createRoomNotificationSettingsPresenter(notificationSettingsService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY))
initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(true))
val failedState = consumeItemsUntilPredicate {
it.restoreDefaultAction.isFailure()
}.last()
Truth.assertThat(failedState.restoreDefaultAction.isFailure()).isTrue()
failedState.eventSink(RoomNotificationSettingsEvents.ClearRestoreDefaultError)
val errorClearedState = consumeItemsUntilPredicate {
it.restoreDefaultAction.isUninitialized()
}.last()
Truth.assertThat(errorClearedState.restoreDefaultAction.isUninitialized()).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
private fun createRoomNotificationSettingsPresenter(
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService()
): RoomNotificationSettingsPresenter{
val room = aMatrixRoom(notificationSettingsService = notificationSettingsService)
return RoomNotificationSettingsPresenter( return RoomNotificationSettingsPresenter(
room = room, room = room,
notificationSettingsService = room.notificationSettingsService notificationSettingsService = notificationSettingsService,
showUserDefinedSettingStyle = false,
) )
} }
} }

View file

@ -46,4 +46,6 @@ enum class AvatarSize(val dp: Dp) {
EditRoomDetails(70.dp), EditRoomDetails(70.dp),
NotificationsOptIn(32.dp), NotificationsOptIn(32.dp),
CustomRoomNotificationSetting(36.dp)
} }

View file

@ -38,4 +38,5 @@ interface NotificationSettingsService {
suspend fun setRoomMentionEnabled(enabled: Boolean): Result<Unit> suspend fun setRoomMentionEnabled(enabled: Boolean): Result<Unit>
suspend fun isCallEnabled(): Result<Boolean> suspend fun isCallEnabled(): Result<Boolean>
suspend fun setCallEnabled(enabled: Boolean): Result<Unit> suspend fun setCallEnabled(enabled: Boolean): Result<Unit>
suspend fun getRoomsWithUserDefinedRules(): Result<List<String>>
} }

View file

@ -28,6 +28,8 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.NotificationSettings import org.matrix.rustcomponents.sdk.NotificationSettings
import org.matrix.rustcomponents.sdk.NotificationSettingsDelegate import org.matrix.rustcomponents.sdk.NotificationSettingsDelegate
import org.matrix.rustcomponents.sdk.NotificationSettingsException
import timber.log.Timber
class RustNotificationSettingsService( class RustNotificationSettingsService(
private val notificationSettings: NotificationSettings, private val notificationSettings: NotificationSettings,
@ -63,7 +65,13 @@ class RustNotificationSettingsService(
isOneToOne: Boolean isOneToOne: Boolean
): Result<Unit> = withContext(dispatchers.io) { ): Result<Unit> = withContext(dispatchers.io) {
runCatching { runCatching {
notificationSettings.setDefaultRoomNotificationMode(isEncrypted, isOneToOne, mode.let(RoomNotificationSettingsMapper::mapMode)) try {
notificationSettings.setDefaultRoomNotificationMode(isEncrypted, isOneToOne, mode.let(RoomNotificationSettingsMapper::mapMode))
} catch (exception: NotificationSettingsException.RuleNotFound) {
// `setDefaultRoomNotificationMode` updates multiple rules including unstable rules (e.g. the polls push rules defined in the MSC3930)
// since production home servers may not have these rules yet, we drop the RuleNotFound error
Timber.w("Unable to find the rule: ${exception.ruleId}")
}
} }
} }
@ -110,4 +118,9 @@ class RustNotificationSettingsService(
notificationSettings.setCallEnabled(enabled) notificationSettings.setCallEnabled(enabled)
} }
} }
override suspend fun getRoomsWithUserDefinedRules(): Result<List<String>> =
runCatching {
notificationSettings.getRoomsWithUserDefinedRules(enabled = true)
}
} }

View file

@ -20,12 +20,14 @@ import io.element.android.libraries.matrix.api.core.RoomId
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.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 io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NOTIFICATION_MODE import io.element.android.libraries.matrix.test.A_ROOM_NOTIFICATION_MODE
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
class FakeNotificationSettingsService( class FakeNotificationSettingsService(
initialRoomMode: RoomNotificationMode = A_ROOM_NOTIFICATION_MODE, initialRoomMode: RoomNotificationMode = A_ROOM_NOTIFICATION_MODE,
initialRoomModeIsDefault: Boolean = true,
initialGroupDefaultMode: RoomNotificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY, initialGroupDefaultMode: RoomNotificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
initialEncryptedGroupDefaultMode: RoomNotificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY, initialEncryptedGroupDefaultMode: RoomNotificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
initialOneToOneDefaultMode: RoomNotificationMode = RoomNotificationMode.ALL_MESSAGES, initialOneToOneDefaultMode: RoomNotificationMode = RoomNotificationMode.ALL_MESSAGES,
@ -37,16 +39,21 @@ class FakeNotificationSettingsService(
private var defaultOneToOneRoomNotificationMode: RoomNotificationMode = initialOneToOneDefaultMode private var defaultOneToOneRoomNotificationMode: RoomNotificationMode = initialOneToOneDefaultMode
private var defaultEncryptedOneToOneRoomNotificationMode: RoomNotificationMode = initialEncryptedOneToOneDefaultMode private var defaultEncryptedOneToOneRoomNotificationMode: RoomNotificationMode = initialEncryptedOneToOneDefaultMode
private var roomNotificationMode: RoomNotificationMode = initialRoomMode private var roomNotificationMode: RoomNotificationMode = initialRoomMode
private var roomNotificationModeIsDefault: Boolean = initialRoomModeIsDefault
private var callNotificationsEnabled = false private var callNotificationsEnabled = false
private var atRoomNotificationsEnabled = false private var atRoomNotificationsEnabled = false
private var setNotificationModeError: Throwable? = null
private var restoreDefaultNotificationModeError: Throwable? = null
private var setDefaultNotificationModeError: Throwable? = null
private var setAtRoomError: Throwable? = null
override val notificationSettingsChangeFlow: SharedFlow<Unit> override val notificationSettingsChangeFlow: SharedFlow<Unit>
get() = _notificationSettingsStateFlow get() = _notificationSettingsStateFlow
override suspend fun getRoomNotificationSettings(roomId: RoomId, isEncrypted: Boolean, isOneToOne: Boolean): Result<RoomNotificationSettings> { override suspend fun getRoomNotificationSettings(roomId: RoomId, isEncrypted: Boolean, isOneToOne: Boolean): Result<RoomNotificationSettings> {
return Result.success( return Result.success(
RoomNotificationSettings( RoomNotificationSettings(
mode = roomNotificationMode, mode = if(roomNotificationModeIsDefault) defaultEncryptedGroupRoomNotificationMode else roomNotificationMode,
isDefault = roomNotificationMode == defaultEncryptedGroupRoomNotificationMode isDefault = roomNotificationModeIsDefault
) )
) )
} }
@ -68,6 +75,10 @@ class FakeNotificationSettingsService(
} }
override suspend fun setDefaultRoomNotificationMode(isEncrypted: Boolean, mode: RoomNotificationMode, isOneToOne: Boolean): Result<Unit> { override suspend fun setDefaultRoomNotificationMode(isEncrypted: Boolean, mode: RoomNotificationMode, isOneToOne: Boolean): Result<Unit> {
val error = setDefaultNotificationModeError
if (error != null) {
return Result.failure(error)
}
if (isOneToOne) { if (isOneToOne) {
if (isEncrypted) { if (isEncrypted) {
defaultEncryptedOneToOneRoomNotificationMode = mode defaultEncryptedOneToOneRoomNotificationMode = mode
@ -86,12 +97,23 @@ class FakeNotificationSettingsService(
} }
override suspend fun setRoomNotificationMode(roomId: RoomId, mode: RoomNotificationMode): Result<Unit> { override suspend fun setRoomNotificationMode(roomId: RoomId, mode: RoomNotificationMode): Result<Unit> {
roomNotificationMode = mode val error = setNotificationModeError
_notificationSettingsStateFlow.emit(Unit) return if (error != null) {
return Result.success(Unit) Result.failure(error)
} else {
roomNotificationModeIsDefault = false
roomNotificationMode = mode
_notificationSettingsStateFlow.emit(Unit)
Result.success(Unit)
}
} }
override suspend fun restoreDefaultRoomNotificationMode(roomId: RoomId): Result<Unit> { override suspend fun restoreDefaultRoomNotificationMode(roomId: RoomId): Result<Unit> {
val error = restoreDefaultNotificationModeError
if (error != null) {
return Result.failure(error)
}
roomNotificationModeIsDefault = true
roomNotificationMode = defaultEncryptedGroupRoomNotificationMode roomNotificationMode = defaultEncryptedGroupRoomNotificationMode
_notificationSettingsStateFlow.emit(Unit) _notificationSettingsStateFlow.emit(Unit)
return Result.success(Unit) return Result.success(Unit)
@ -110,6 +132,10 @@ class FakeNotificationSettingsService(
} }
override suspend fun setRoomMentionEnabled(enabled: Boolean): Result<Unit> { override suspend fun setRoomMentionEnabled(enabled: Boolean): Result<Unit> {
val error = setAtRoomError
if (error != null) {
return Result.failure(error)
}
atRoomNotificationsEnabled = enabled atRoomNotificationsEnabled = enabled
return Result.success(Unit) return Result.success(Unit)
} }
@ -122,4 +148,25 @@ class FakeNotificationSettingsService(
callNotificationsEnabled = enabled callNotificationsEnabled = enabled
return Result.success(Unit) return Result.success(Unit)
} }
override suspend fun getRoomsWithUserDefinedRules(): Result<List<String>> {
return Result.success(if (roomNotificationModeIsDefault) listOf() else listOf(A_ROOM_ID.value))
}
fun givenSetNotificationModeError(throwable: Throwable?) {
setNotificationModeError = throwable
}
fun givenRestoreDefaultNotificationModeError(throwable: Throwable?) {
restoreDefaultNotificationModeError = throwable
}
fun givenSetAtRoomError(throwable: Throwable?) {
setAtRoomError = throwable
}
fun givenSetDefaultNotificationModeError(throwable: Throwable?) {
setDefaultNotificationModeError = throwable
}
} }

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:11aa649bb8e25975c79ebc8c20aa82153acac75b4087f99485c58bd604ba3f33
size 35947

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9bfce546aa824f56a1d89d638fc1a2ed6fc02da326edc5360d3618795c58545a
size 35875

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:addc7ec085394342bdd8f072f8d65ff25208bc1352b0fe1d3b9109bb4998424e
size 32286

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ca186fcad4d0a39f4f6108cd4c050e5ee692fca17168a1a44393b7aded6126dc
size 36827

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1df0f8620db5a3f751e37543f34785517703e4731af9e624dc50b91de79f46e9
size 33107

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a58daa364819cef83467a57974517118d683f6b47ae0ec19164dc0678cfa63c6
size 33064

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b551c64c9d99fbdf315662967f0374668e456738e1b2cab44bfc99726f22a60e
size 29033

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b0b0df0468cc575ff14b9dc07ca6d4a61bb19ad709ead861a412489499e1bfd5
size 32201

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5524d336f303c995801049bb32c2ae4ff4511cfe20ddc3709d1cd7a0632eeb8c
size 48900

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:80c8d43a01b2d39d5305635341329f863edea75d5adc0bf3989a63d9d38d46a0
size 48814

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c14d79d561656b6ac874d6cf54a731626cc221b302c99ca002cd25e9dd8c55ed
size 45047

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d3a3796c755d202361d4eacf2be151a9b579b588242d55706dbc35e26b13b081
size 44306

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f34dde086bbd14cc5cfeb0003d6bfd0d3868ecbc497956dc17794e69f6773279 oid sha256:3f2fc33febab98860da9b6591c4ae33dc9ba4fa28365520e4fcb08f8bfaf6ff8
size 40070 size 33828

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9d99786d84445bc42f7e7f7e414f973a04ba6684780f69eb1cfe60a09ff3ec6e
size 38017

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4993368d2f9e605674efdf3027314a312428ddbda78e3168612fd03de220a581
size 33874

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:315b8d8a5081cf2aeb3dcf50992863c1c0e3300d3825954e1a12908567843d58
size 41165

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4993368d2f9e605674efdf3027314a312428ddbda78e3168612fd03de220a581
size 33874

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:315b8d8a5081cf2aeb3dcf50992863c1c0e3300d3825954e1a12908567843d58
size 41165

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ef0545d55a8a78af5d8286fc590998c973cee986a3f6aff91850cf277d47a5e5 oid sha256:0cf8d93229fd5d8034dc7e2f68f9868fc42515c538098c89d4dddca814827bc3
size 36415 size 31448

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:13c5007d3a97734dca40a88f2312e59d09a8906deb71d16c0611df7dd25a6391
size 35124

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a2351abc0cbecc9ff503ba50a1d9c01eda5e771181335d5d1b0fc72f00b337c3
size 30586

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8bf1fc0c9605c5e8e18fb7a45ec3a7ed0ff2f9920b85582800098929f0521fbd
size 36512

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a2351abc0cbecc9ff503ba50a1d9c01eda5e771181335d5d1b0fc72f00b337c3
size 30586

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8bf1fc0c9605c5e8e18fb7a45ec3a7ed0ff2f9920b85582800098929f0521fbd
size 36512

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:372ff5eca3c8c9de6bb15d5ae9a55f1d8ec124c7e454d411be6bd28f78d3aed0
size 24324

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3795832f75ba48402f228dbd5be5dc05b10dab00242922fb861c0f77fcd391e4
size 22853

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2619a9a70eca12d5931ebc21adce48a1aed8383f35908bb09546f44b40f04543
size 23094

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9d7d71d58b250bdec2d9b6e1ed46c5e3ffd98ffeefcaf4267b9979970a89750b
size 22226

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e7bdea3caef3f1be9fac1fbb5511fe2d76b7985576c038f8c1d920615c3d49cd
size 25005