Add room notification settings (#807)

* Add room notification settings

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
Co-authored-by: Jorge Martín <jorgem@element.io>
Co-authored-by: Benoit Marty <benoit@matrix.org>
Co-authored-by: David Langley <langley.dave@gmail.com>
This commit is contained in:
Yoan Pintas 2023-09-07 08:24:34 +00:00 committed by GitHub
parent 34a2a0f99c
commit 4a870fc4a5
68 changed files with 1381 additions and 70 deletions

2
changelog.d/506.feature Normal file
View file

@ -0,0 +1,2 @@
Add a "Mute" shortcut icon and a "Notifications" section in the room details screen

View file

@ -59,7 +59,6 @@ import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.Async 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.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.components.avatar.AvatarData 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.avatar.AvatarSize
@ -215,7 +214,8 @@ class MessagesPresenter @AssistedInject constructor(
} }
private fun CoroutineScope.reinviteOtherUser(inviteProgress: MutableState<Async<Unit>>) = launch(dispatchers.io) { private fun CoroutineScope.reinviteOtherUser(inviteProgress: MutableState<Async<Unit>>) = launch(dispatchers.io) {
suspend { inviteProgress.value = Async.Loading()
runCatching {
room.updateMembers() room.updateMembers()
val memberList = when (val memberState = room.membersStateFlow.value) { val memberList = when (val memberState = room.membersStateFlow.value) {
@ -228,7 +228,14 @@ class MessagesPresenter @AssistedInject constructor(
room.inviteUserById(member.userId).onFailure { t -> room.inviteUserById(member.userId).onFailure { t ->
Timber.e(t, "Failed to reinvite DM partner") Timber.e(t, "Failed to reinvite DM partner")
}.getOrThrow() }.getOrThrow()
}.runCatchingUpdatingState(inviteProgress) }.fold(
onSuccess = {
inviteProgress.value = Async.Success(Unit)
},
onFailure = {
inviteProgress.value = Async.Failure(it)
}
)
} }
private suspend fun handleActionRedact(event: TimelineItem.Event) { private suspend fun handleActionRedact(event: TimelineItem.Event) {

View file

@ -51,6 +51,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.components.avatar.AvatarData 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.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
@ -80,6 +81,7 @@ import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds
class MessagesPresenterTest { class MessagesPresenterTest {
@ -380,13 +382,13 @@ class MessagesPresenterTest {
assertThat(initialState.showReinvitePrompt).isFalse() assertThat(initialState.showReinvitePrompt).isFalse()
// When the input field is focused we show the alert // When the input field is focused we show the alert
initialState.composerState.eventSink(MessageComposerEvents.FocusChanged(true)) initialState.composerState.eventSink(MessageComposerEvents.FocusChanged(true))
val focusedState = consumeItemsUntilPredicate { state -> val focusedState = consumeItemsUntilPredicate(timeout = 250.milliseconds) { state ->
state.showReinvitePrompt state.showReinvitePrompt
}.last() }.last()
assertThat(focusedState.showReinvitePrompt).isTrue() assertThat(focusedState.showReinvitePrompt).isTrue()
// If it's dismissed then we stop showing the alert // If it's dismissed then we stop showing the alert
initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Cancel)) initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Cancel))
val dismissedState = consumeItemsUntilPredicate { state -> val dismissedState = consumeItemsUntilPredicate(timeout = 250.milliseconds) { state ->
!state.showReinvitePrompt !state.showReinvitePrompt
}.last() }.last()
assertThat(dismissedState.showReinvitePrompt).isFalse() assertThat(dismissedState.showReinvitePrompt).isFalse()
@ -470,7 +472,9 @@ class MessagesPresenterTest {
val initialState = consumeItemsUntilTimeout().last() val initialState = consumeItemsUntilTimeout().last()
initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite)) initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite))
skipItems(1) skipItems(1)
val loadingState = awaitItem() val loadingState = consumeItemsUntilPredicate { state ->
state.inviteProgress.isLoading()
}.last()
assertThat(loadingState.inviteProgress.isLoading()).isTrue() assertThat(loadingState.inviteProgress.isLoading()).isTrue()
val newState = awaitItem() val newState = awaitItem()
assertThat(newState.inviteProgress.isSuccess()).isTrue() assertThat(newState.inviteProgress.isSuccess()).isTrue()
@ -595,7 +599,7 @@ class MessagesPresenterTest {
appCoroutineScope = this, appCoroutineScope = this,
room = matrixRoom, room = matrixRoom,
mediaPickerProvider = FakePickerProvider(), mediaPickerProvider = FakePickerProvider(),
featureFlagService = FakeFeatureFlagService(), featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.NotificationSettings.key to true)),
localMediaFactory = FakeLocalMediaFactory(mockMediaUrl), localMediaFactory = FakeLocalMediaFactory(mockMediaUrl),
mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom), mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom),
snackbarDispatcher = SnackbarDispatcher(), snackbarDispatcher = SnackbarDispatcher(),

View file

@ -42,6 +42,7 @@ dependencies {
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.mediapickers.api) implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.featureflag.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)
@ -59,6 +60,7 @@ dependencies {
testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.mediapickers.test) testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.libraries.usersearch.test) testImplementation(projects.libraries.usersearch.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
testImplementation(projects.features.leaveroom.fake) testImplementation(projects.features.leaveroom.fake)

View file

@ -18,4 +18,6 @@ package io.element.android.features.roomdetails.impl
sealed interface RoomDetailsEvent { sealed interface RoomDetailsEvent {
data object LeaveRoom : RoomDetailsEvent data object LeaveRoom : RoomDetailsEvent
data object MuteNotification : RoomDetailsEvent
data object UnmuteNotification : RoomDetailsEvent
} }

View file

@ -33,6 +33,7 @@ import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditNode
import io.element.android.features.roomdetails.impl.invite.RoomInviteMembersNode import io.element.android.features.roomdetails.impl.invite.RoomInviteMembersNode
import io.element.android.features.roomdetails.impl.members.RoomMemberListNode import io.element.android.features.roomdetails.impl.members.RoomMemberListNode
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsNode import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsNode
import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsNode
import io.element.android.libraries.architecture.BackstackNode 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
@ -66,6 +67,9 @@ class RoomDetailsFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data object InviteMembers : NavTarget data object InviteMembers : NavTarget
@Parcelize
object RoomNotificationSettings : NavTarget
@Parcelize @Parcelize
data class RoomMemberDetails(val roomMemberId: UserId) : NavTarget data class RoomMemberDetails(val roomMemberId: UserId) : NavTarget
} }
@ -85,6 +89,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
override fun openInviteMembers() { override fun openInviteMembers() {
backstack.push(NavTarget.InviteMembers) backstack.push(NavTarget.InviteMembers)
} }
override fun openRoomNotificationSettings() {
backstack.push(NavTarget.RoomNotificationSettings)
}
} }
createNode<RoomDetailsNode>(buildContext, listOf(roomDetailsCallback)) createNode<RoomDetailsNode>(buildContext, listOf(roomDetailsCallback))
} }
@ -110,6 +118,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
createNode<RoomInviteMembersNode>(buildContext) createNode<RoomInviteMembersNode>(buildContext)
} }
NavTarget.RoomNotificationSettings -> {
createNode<RoomNotificationSettingsNode>(buildContext)
}
is NavTarget.RoomMemberDetails -> { is NavTarget.RoomMemberDetails -> {
val plugins = listOf(RoomMemberDetailsNode.RoomMemberDetailsInput(navTarget.roomMemberId)) val plugins = listOf(RoomMemberDetailsNode.RoomMemberDetailsInput(navTarget.roomMemberId))
createNode<RoomMemberDetailsNode>(buildContext, plugins) createNode<RoomMemberDetailsNode>(buildContext, plugins)

View file

@ -51,6 +51,7 @@ class RoomDetailsNode @AssistedInject constructor(
fun openRoomMemberList() fun openRoomMemberList()
fun openInviteMembers() fun openInviteMembers()
fun editRoomDetails() fun editRoomDetails()
fun openRoomNotificationSettings()
} }
private val callbacks = plugins<Callback>() private val callbacks = plugins<Callback>()
@ -67,6 +68,10 @@ class RoomDetailsNode @AssistedInject constructor(
callbacks.forEach { it.openRoomMemberList() } callbacks.forEach { it.openRoomMemberList() }
} }
private fun openRoomNotificationSettings() {
callbacks.forEach { it.openRoomNotificationSettings() }
}
private fun invitePeople() { private fun invitePeople() {
callbacks.forEach { it.openInviteMembers() } callbacks.forEach { it.openInviteMembers() }
} }
@ -133,6 +138,7 @@ class RoomDetailsNode @AssistedInject constructor(
onShareRoom = ::onShareRoom, onShareRoom = ::onShareRoom,
onShareMember = ::onShareMember, onShareMember = ::onShareMember,
openRoomMemberList = ::openRoomMemberList, openRoomMemberList = ::openRoomMemberList,
openRoomNotificationSettings = ::openRoomNotificationSettings,
invitePeople = ::invitePeople, invitePeople = ::invitePeople,
) )
} }

View file

@ -22,31 +22,55 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomPresenter import io.element.android.features.leaveroom.api.LeaveRoomPresenter
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.powerlevels.canInvite import io.element.android.libraries.matrix.api.room.powerlevels.canInvite
import io.element.android.libraries.matrix.api.room.powerlevels.canSendState import io.element.android.libraries.matrix.api.room.powerlevels.canSendState
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class RoomDetailsPresenter @Inject constructor( class RoomDetailsPresenter @Inject constructor(
private val client: MatrixClient,
private val room: MatrixRoom, private val room: MatrixRoom,
private val featureFlagService: FeatureFlagService,
private val notificationSettingsService: NotificationSettingsService,
private val roomMembersDetailsPresenterFactory: RoomMemberDetailsPresenter.Factory, private val roomMembersDetailsPresenterFactory: RoomMemberDetailsPresenter.Factory,
private val leaveRoomPresenter: LeaveRoomPresenter, private val leaveRoomPresenter: LeaveRoomPresenter,
private val dispatchers: CoroutineDispatchers,
) : Presenter<RoomDetailsState> { ) : Presenter<RoomDetailsState> {
@Composable @Composable
override fun present(): RoomDetailsState { override fun present(): RoomDetailsState {
val scope = rememberCoroutineScope()
val leaveRoomState = leaveRoomPresenter.present() val leaveRoomState = leaveRoomPresenter.present()
val canShowNotificationSettings = remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
canShowNotificationSettings.value = featureFlagService.isFeatureEnabled(FeatureFlags.NotificationSettings)
if (canShowNotificationSettings.value) {
room.updateRoomNotificationSettings()
observeNotificationSettings()
}
room.updateMembers() room.updateMembers()
} }
@ -69,10 +93,22 @@ class RoomDetailsPresenter @Inject constructor(
} }
} }
val roomNotificationSettingsState by room.roomNotificationSettingsStateFlow.collectAsState()
fun handleEvents(event: RoomDetailsEvent) { fun handleEvents(event: RoomDetailsEvent) {
when (event) { when (event) {
is RoomDetailsEvent.LeaveRoom -> RoomDetailsEvent.LeaveRoom ->
leaveRoomState.eventSink(LeaveRoomEvent.ShowConfirmation(room.roomId)) leaveRoomState.eventSink(LeaveRoomEvent.ShowConfirmation(room.roomId))
RoomDetailsEvent.MuteNotification -> {
scope.launch(dispatchers.io) {
client.notificationSettingsService().muteRoom(room.roomId)
}
}
RoomDetailsEvent.UnmuteNotification -> {
scope.launch(dispatchers.io) {
client.notificationSettingsService().unmuteRoom(room.roomId, room.isEncrypted, room.activeMemberCount)
}
}
} }
} }
@ -88,9 +124,11 @@ class RoomDetailsPresenter @Inject constructor(
isEncrypted = room.isEncrypted, isEncrypted = room.isEncrypted,
canInvite = canInvite, canInvite = canInvite,
canEdit = (canEditAvatar || canEditName || canEditTopic) && roomType == RoomDetailsType.Room, canEdit = (canEditAvatar || canEditName || canEditTopic) && roomType == RoomDetailsType.Room,
canShowNotificationSettings = canShowNotificationSettings.value,
roomType = roomType, roomType = roomType,
roomMemberDetailsState = roomMemberDetailsState, roomMemberDetailsState = roomMemberDetailsState,
leaveRoomState = leaveRoomState, leaveRoomState = leaveRoomState,
roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(),
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }
@ -122,4 +160,10 @@ class RoomDetailsPresenter @Inject constructor(
private fun getCanSendState(membersState: MatrixRoomMembersState, type: StateEventType) = produceState(false, membersState) { private fun getCanSendState(membersState: MatrixRoomMembersState, type: StateEventType) = produceState(false, membersState) {
value = room.canSendState(type).getOrElse { false } value = room.canSendState(type).getOrElse { false }
} }
private fun CoroutineScope.observeNotificationSettings() {
notificationSettingsService.notificationSettingsChangeFlow.onEach {
room.updateRoomNotificationSettings()
}.launchIn(this)
}
} }

View file

@ -19,6 +19,7 @@ package io.element.android.features.roomdetails.impl
import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
data class RoomDetailsState( data class RoomDetailsState(
val roomId: String, val roomId: String,
@ -32,7 +33,9 @@ data class RoomDetailsState(
val roomMemberDetailsState: RoomMemberDetailsState?, val roomMemberDetailsState: RoomMemberDetailsState?,
val canEdit: Boolean, val canEdit: Boolean,
val canInvite: Boolean, val canInvite: Boolean,
val canShowNotificationSettings: Boolean,
val leaveRoomState: LeaveRoomState, val leaveRoomState: LeaveRoomState,
val roomNotificationSettings: RoomNotificationSettings?,
val eventSink: (RoomDetailsEvent) -> Unit val eventSink: (RoomDetailsEvent) -> Unit
) )

View file

@ -22,6 +22,8 @@ import io.element.android.features.roomdetails.impl.members.details.aRoomMemberD
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState> { open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState> {
override val values: Sequence<RoomDetailsState> override val values: Sequence<RoomDetailsState>
@ -75,9 +77,11 @@ fun aRoomDetailsState() = RoomDetailsState(
isEncrypted = true, isEncrypted = true,
canInvite = false, canInvite = false,
canEdit = false, canEdit = false,
canShowNotificationSettings = true,
roomType = RoomDetailsType.Room, roomType = RoomDetailsType.Room,
roomMemberDetailsState = null, roomMemberDetailsState = null,
leaveRoomState = LeaveRoomState(), leaveRoomState = LeaveRoomState(),
roomNotificationSettings = RoomNotificationSettings(mode = RoomNotificationMode.MUTE, isDefault = false),
eventSink = {} eventSink = {}
) )

View file

@ -27,12 +27,15 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Notifications
import androidx.compose.material.icons.outlined.NotificationsOff
import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.PersonAddAlt import androidx.compose.material.icons.outlined.PersonAddAlt
import androidx.compose.material.icons.outlined.Share import androidx.compose.material.icons.outlined.Share
@ -75,6 +78,7 @@ 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
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
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
@ -87,6 +91,7 @@ fun RoomDetailsView(
onShareRoom: () -> Unit, onShareRoom: () -> Unit,
onShareMember: (RoomMember) -> Unit, onShareMember: (RoomMember) -> Unit,
openRoomMemberList: () -> Unit, openRoomMemberList: () -> Unit,
openRoomNotificationSettings: () -> Unit,
invitePeople: () -> Unit, invitePeople: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -120,7 +125,10 @@ fun RoomDetailsView(
roomName = state.roomName, roomName = state.roomName,
roomAlias = state.roomAlias roomAlias = state.roomAlias
) )
MainActionsSection(onShareRoom = onShareRoom) MainActionsSection(
state = state,
onShareRoom = onShareRoom
)
} }
is RoomDetailsType.Dm -> { is RoomDetailsType.Dm -> {
@ -142,6 +150,12 @@ fun RoomDetailsView(
) )
} }
if (state.canShowNotificationSettings && state.roomNotificationSettings != null) {
NotificationSection(
isDefaultMode = state.roomNotificationSettings.isDefault,
openRoomNotificationSettings = openRoomNotificationSettings)
}
if (state.roomType is RoomDetailsType.Room) { if (state.roomType is RoomDetailsType.Room) {
MembersSection( MembersSection(
memberCount = state.memberCount, memberCount = state.memberCount,
@ -211,8 +225,21 @@ internal fun RoomDetailsTopBar(
} }
@Composable @Composable
internal fun MainActionsSection(onShareRoom: () -> Unit, modifier: Modifier = Modifier) { internal fun MainActionsSection(state: RoomDetailsState, onShareRoom: () -> Unit, modifier: Modifier = Modifier) {
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
val roomNotificationSettings = state.roomNotificationSettings
if (state.canShowNotificationSettings && roomNotificationSettings != null) {
if (roomNotificationSettings.mode == RoomNotificationMode.MUTE) {
MainActionButton(title = stringResource(CommonStrings.common_unmute), icon = Icons.Outlined.NotificationsOff, onClick = {
state.eventSink(RoomDetailsEvent.UnmuteNotification)
})
} else {
MainActionButton(title = stringResource(CommonStrings.common_mute), icon = Icons.Outlined.Notifications, onClick = {
state.eventSink(RoomDetailsEvent.MuteNotification)
})
}
}
Spacer(modifier = Modifier.width(20.dp))
MainActionButton(title = stringResource(R.string.screen_room_details_share_room_title), icon = Icons.Outlined.Share, onClick = onShareRoom) MainActionButton(title = stringResource(R.string.screen_room_details_share_room_title), icon = Icons.Outlined.Share, onClick = onShareRoom)
} }
} }
@ -280,6 +307,27 @@ internal fun TopicSection(
} }
} }
@Composable
internal fun NotificationSection(
isDefaultMode: Boolean,
openRoomNotificationSettings: () -> Unit,
modifier: Modifier = Modifier
) {
val subtitle = if (isDefaultMode) {
stringResource(R.string.screen_room_details_notification_mode_default)
} else {
stringResource(R.string.screen_room_details_notification_mode_custom)
}
PreferenceCategory(modifier = modifier) {
PreferenceText(
title = stringResource(R.string.screen_room_details_notification_title),
subtitle = subtitle,
icon = Icons.Outlined.Notifications,
onClick = openRoomNotificationSettings,
)
}
}
@Composable @Composable
internal fun MembersSection( internal fun MembersSection(
memberCount: Long, memberCount: Long,
@ -352,6 +400,7 @@ private fun ContentToPreview(state: RoomDetailsState) {
onShareRoom = {}, onShareRoom = {},
onShareMember = {}, onShareMember = {},
openRoomMemberList = {}, openRoomMemberList = {},
openRoomNotificationSettings = {},
invitePeople = {}, invitePeople = {},
) )
} }

View file

@ -0,0 +1,24 @@
/*
* 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 io.element.android.libraries.matrix.api.room.RoomNotificationMode
sealed interface RoomNotificationSettingsEvents {
data class RoomNotificationModeChanged(val mode: RoomNotificationMode) : RoomNotificationSettingsEvents
data class SetNotificationMode(val isDefault: Boolean): RoomNotificationSettingsEvents
}

View file

@ -0,0 +1,52 @@
/*
* 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.features.roomdetails.impl.R
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
data class RoomNotificationSettingsItem(
val mode: RoomNotificationMode,
val title: String,
)
@Composable
fun roomNotificationSettingsItems(): ImmutableList<RoomNotificationSettingsItem> {
return RoomNotificationMode.values()
.map {
when (it) {
RoomNotificationMode.ALL_MESSAGES -> RoomNotificationSettingsItem(
mode = it,
title = stringResource(R.string.screen_room_notification_settings_mode_all_messages),
)
RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> RoomNotificationSettingsItem(
mode = it,
title = stringResource(R.string.screen_room_notification_settings_mode_mentions_and_keywords),
)
RoomNotificationMode.MUTE -> RoomNotificationSettingsItem(
mode = it,
title = stringResource(CommonStrings.common_mute),
)
}
}
.toImmutableList()
}

View file

@ -0,0 +1,57 @@
/*
* 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.Modifier
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.RoomScope
import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(RoomScope::class)
class RoomNotificationSettingsNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: RoomNotificationSettingsPresenter,
private val analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) {
init {
lifecycle.subscribe(
onResume = {
analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.RoomNotifications))
}
)
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
RoomNotificationSettingsView(
state = state,
modifier = modifier,
onBackPressed = this::navigateUp,
)
}
}

View file

@ -0,0 +1,97 @@
/*
* 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.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.selectable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.RadioButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.toEnabledColor
import io.element.android.libraries.theme.ElementTheme
@Composable
fun RoomNotificationSettingsOption(
roomNotificationSettingsItem: RoomNotificationSettingsItem,
modifier: Modifier = Modifier,
enabled: Boolean = true,
isSelected: Boolean = false,
onOptionSelected: (RoomNotificationSettingsItem) -> Unit = {},
) {
Row(
modifier
.fillMaxWidth()
.selectable(
selected = isSelected,
enabled = enabled,
onClick = { onOptionSelected(roomNotificationSettingsItem) },
role = Role.RadioButton,
)
.padding(8.dp),
) {
Column(
Modifier
.weight(1f)
.padding(horizontal = 8.dp)
.align(Alignment.CenterVertically)
) {
Text(
text = roomNotificationSettingsItem.title,
style = ElementTheme.typography.fontBodyLgRegular,
color = enabled.toEnabledColor(),
)
}
RadioButton(
modifier = Modifier
.align(Alignment.CenterVertically)
.size(48.dp),
selected = isSelected,
enabled = enabled,
onClick = null // null recommended for accessibility with screenreaders
)
}
}
@DayNightPreviews
@Composable
internal fun RoomPrivacyOptionLightPreview() = ElementPreview { ContentToPreview() }
@Composable
private fun ContentToPreview() {
Column {
RoomNotificationSettingsOption(
roomNotificationSettingsItem = roomNotificationSettingsItems().first(),
isSelected = true,
)
RoomNotificationSettingsOption(
roomNotificationSettingsItem = roomNotificationSettingsItems().last(),
isSelected = false,
enabled = false,
)
}
}

View file

@ -0,0 +1,108 @@
/*
* 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.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
class RoomNotificationSettingsPresenter @Inject constructor(
private val room: MatrixRoom,
private val notificationSettingsService: NotificationSettingsService,
) : Presenter<RoomNotificationSettingsState> {
@Composable
override fun present(): RoomNotificationSettingsState {
val defaultRoomNotificationMode: MutableState<RoomNotificationMode?> = rememberSaveable {
mutableStateOf(null)
}
val localCoroutineScope = rememberCoroutineScope()
LaunchedEffect(Unit) {
getDefaultRoomNotificationMode(defaultRoomNotificationMode)
observeNotificationSettings()
}
val roomNotificationSettingsState by room.roomNotificationSettingsStateFlow.collectAsState()
fun handleEvents(event: RoomNotificationSettingsEvents) {
when (event) {
is RoomNotificationSettingsEvents.RoomNotificationModeChanged -> {
localCoroutineScope.setRoomNotificationMode(event.mode)
}
is RoomNotificationSettingsEvents.SetNotificationMode -> {
if (event.isDefault) {
localCoroutineScope.restoreDefaultRoomNotificationMode()
} else {
defaultRoomNotificationMode.value?.let {
localCoroutineScope.setRoomNotificationMode(it)
}
}
}
}
}
return RoomNotificationSettingsState(
roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(),
defaultRoomNotificationMode = defaultRoomNotificationMode.value,
eventSink = ::handleEvents,
)
}
@OptIn(FlowPreview::class)
private fun CoroutineScope.observeNotificationSettings() {
notificationSettingsService.notificationSettingsChangeFlow
.debounce(0.5.seconds)
.onEach {
room.updateRoomNotificationSettings()
}
.launchIn(this)
}
private fun CoroutineScope.getDefaultRoomNotificationMode(defaultRoomNotificationMode: MutableState<RoomNotificationMode?>) = launch {
defaultRoomNotificationMode.value = notificationSettingsService.getDefaultRoomNotificationMode(
room.isEncrypted,
room.activeMemberCount
).getOrThrow()
}
private fun CoroutineScope.setRoomNotificationMode(mode: RoomNotificationMode) = launch {
notificationSettingsService.setRoomNotificationMode(room.roomId, mode)
}
private fun CoroutineScope.restoreDefaultRoomNotificationMode() = launch {
notificationSettingsService.restoreDefaultRoomNotificationMode(room.roomId)
}
}

View file

@ -0,0 +1,26 @@
/*
* 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 io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
data class RoomNotificationSettingsState(
val roomNotificationSettings: RoomNotificationSettings?,
val defaultRoomNotificationMode: RoomNotificationMode?,
val eventSink: (RoomNotificationSettingsEvents) -> Unit
)

View file

@ -0,0 +1,34 @@
/*
* 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.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
internal class RoomNotificationSettingsStateProvider : PreviewParameterProvider<RoomNotificationSettingsState> {
override val values: Sequence<RoomNotificationSettingsState>
get() = sequenceOf(
RoomNotificationSettingsState(
RoomNotificationSettings(
mode = RoomNotificationMode.MUTE,
isDefault = true),
RoomNotificationMode.ALL_MESSAGES,
eventSink = { },
),
)
}

View file

@ -0,0 +1,161 @@
/*
* 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.ExperimentalLayoutApi
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
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.core.bool.orTrue
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.PreferenceSwitch
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
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.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RoomNotificationSettingsView(
state: RoomNotificationSettingsState,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
) {
Scaffold(
modifier = modifier,
topBar = {
RoomNotificationSettingsTopBar(
onBackPressed = { onBackPressed() }
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxWidth()
.padding(padding)
.consumeWindowInsets(padding),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
val subtitle = 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)
null -> ""
}
PreferenceCategory(title = stringResource(id = R.string.screen_room_notification_settings_custom_settings_title)) {
PreferenceSwitch(
isChecked = state.roomNotificationSettings?.isDefault.orTrue(),
onCheckedChange = {
state.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(it))
},
title = "Match default setting",
subtitle = subtitle,
enabled = state.roomNotificationSettings != null
)
PreferenceText(
title = stringResource(id = R.string.screen_room_notification_settings_allow_custom),
subtitle = stringResource(id = R.string.screen_room_notification_settings_allow_custom_footnote),
enabled = state.roomNotificationSettings != null && !state.roomNotificationSettings.isDefault,
)
if (state.roomNotificationSettings != null) {
RoomNotificationSettingsOptions(
selected = state.roomNotificationSettings.mode,
enabled = !state.roomNotificationSettings.isDefault,
onOptionSelected = {
state.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(it.mode))
},
)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RoomNotificationSettingsTopBar(
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
) {
TopAppBar(
modifier = modifier,
title = {
Text(
text = stringResource(R.string.screen_room_details_notification_title),
style = ElementTheme.typography.aliasScreenTitle,
)
},
navigationIcon = { BackButton(onClick = onBackPressed) },
)
}
@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
)
}
}
}
@Preview
@Composable
internal fun RoomNotificationSettingsLightPreview(@PreviewParameter(RoomNotificationSettingsStateProvider::class) state: RoomNotificationSettingsState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
internal fun RoomNotificationSettingsDarkPreview(@PreviewParameter(RoomNotificationSettingsStateProvider::class) state: RoomNotificationSettingsState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: RoomNotificationSettingsState) {
RoomNotificationSettingsView(state)
}

View file

@ -23,6 +23,16 @@
<string name="screen_room_details_share_room_title">"Partager le salon"</string> <string name="screen_room_details_share_room_title">"Partager le salon"</string>
<string name="screen_room_details_updating_room">"Mise à jour du salon…"</string> <string name="screen_room_details_updating_room">"Mise à jour du salon…"</string>
<string name="screen_room_member_list_pending_header_title">"En attente"</string> <string name="screen_room_member_list_pending_header_title">"En attente"</string>
<string name="screen_room_notification_settings_allow_custom">"Autoriser les paramètres personnalisés"</string>
<string name="screen_room_notification_settings_allow_custom_footnote">"Activer cette option remplacera votre paramètre par défaut"</string>
<string name="screen_room_notification_settings_custom_settings_title">"Me notifier dans ce chat pour"</string>
<string name="screen_room_notification_settings_default_setting_footnote_content_link">"paramètres généraux"</string>
<string name="screen_room_notification_settings_default_setting_title">"Paramètre par défaut"</string>
<string name="screen_room_notification_settings_error_loading_settings">"Une erreur sest produite lors du chargement des paramètres de notification."</string>
<string name="screen_room_notification_settings_error_restoring_default">"Impossible de restaurer le mode par défaut, veuillez réessayer."</string>
<string name="screen_room_notification_settings_error_setting_mode">"Impossible de régler le mode, veuillez réessayer."</string>
<string name="screen_room_notification_settings_mode_all_messages">"Tous les messages"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Mentions et mots-clés uniquement"</string>
<string name="screen_dm_details_block_alert_action">"Bloquer"</string> <string name="screen_dm_details_block_alert_action">"Bloquer"</string>
<string name="screen_dm_details_block_alert_description">"Les utilisateurs bloqués ne pourront pas vous envoyer de messages et tous leurs messages seront masqués. Vous pouvez les débloquer à tout moment."</string> <string name="screen_dm_details_block_alert_description">"Les utilisateurs bloqués ne pourront pas vous envoyer de messages et tous leurs messages seront masqués. Vous pouvez les débloquer à tout moment."</string>
<string name="screen_dm_details_block_user">"Bloquer l\'utilisateur"</string> <string name="screen_dm_details_block_user">"Bloquer l\'utilisateur"</string>

View file

@ -25,6 +25,17 @@
<string name="screen_room_details_updating_room">"Обновление комнаты…"</string> <string name="screen_room_details_updating_room">"Обновление комнаты…"</string>
<string name="screen_room_member_list_pending_header_title">"В ожидании"</string> <string name="screen_room_member_list_pending_header_title">"В ожидании"</string>
<string name="screen_room_member_list_room_members_header_title">"Участники комнаты"</string> <string name="screen_room_member_list_room_members_header_title">"Участники комнаты"</string>
<string name="screen_room_notification_settings_allow_custom">"Разрешить пользовательские настройки"</string>
<string name="screen_room_notification_settings_allow_custom_footnote">"Включение этого параметра отменяет настройки по умолчанию"</string>
<string name="screen_room_notification_settings_custom_settings_title">"Уведомить меня в этом чате"</string>
<string name="screen_room_notification_settings_default_setting_footnote">"Вы можете изменить его в своем %1$s."</string>
<string name="screen_room_notification_settings_default_setting_footnote_content_link">"Основные Настройки"</string>
<string name="screen_room_notification_settings_default_setting_title">"Настройка по умолчанию"</string>
<string name="screen_room_notification_settings_error_loading_settings">"Произошла ошибка при загрузке настроек уведомлений."</string>
<string name="screen_room_notification_settings_error_restoring_default">"Не удалось восстановить режим по умолчанию, попробуйте еще раз."</string>
<string name="screen_room_notification_settings_error_setting_mode">"Не удалось настроить режим, попробуйте еще раз."</string>
<string name="screen_room_notification_settings_mode_all_messages">"Все сообщения"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Только упоминания и ключевые слова"</string>
<string name="screen_dm_details_block_alert_action">"Заблокировать"</string> <string name="screen_dm_details_block_alert_action">"Заблокировать"</string>
<string name="screen_dm_details_block_alert_description">"Заблокированные пользователи не смогут отправлять вам сообщения, а все их сообщения будут скрыты. Вы можете разблокировать их в любое время."</string> <string name="screen_dm_details_block_alert_description">"Заблокированные пользователи не смогут отправлять вам сообщения, а все их сообщения будут скрыты. Вы можете разблокировать их в любое время."</string>
<string name="screen_dm_details_block_user">"Заблокировать пользователя"</string> <string name="screen_dm_details_block_user">"Заблокировать пользователя"</string>

View file

@ -25,6 +25,17 @@
<string name="screen_room_details_updating_room">"Aktualizácia miestnosti…"</string> <string name="screen_room_details_updating_room">"Aktualizácia miestnosti…"</string>
<string name="screen_room_member_list_pending_header_title">"Čaká sa"</string> <string name="screen_room_member_list_pending_header_title">"Čaká sa"</string>
<string name="screen_room_member_list_room_members_header_title">"Členovia miestnosti"</string> <string name="screen_room_member_list_room_members_header_title">"Členovia miestnosti"</string>
<string name="screen_room_notification_settings_allow_custom">"Povoliť vlastné nastavenie"</string>
<string name="screen_room_notification_settings_allow_custom_footnote">"Zapnutím tohto nastavenia sa prepíše vaše predvolené nastavenie"</string>
<string name="screen_room_notification_settings_custom_settings_title">"Upozorniť ma v tejto konverzácii na"</string>
<string name="screen_room_notification_settings_default_setting_footnote">"Môžete to zmeniť vo svojich %1$s."</string>
<string name="screen_room_notification_settings_default_setting_footnote_content_link">"všeobecných nastaveniach"</string>
<string name="screen_room_notification_settings_default_setting_title">"Predvolené nastavenie"</string>
<string name="screen_room_notification_settings_error_loading_settings">"Pri načítavaní nastavení oznámení došlo k chybe."</string>
<string name="screen_room_notification_settings_error_restoring_default">"Nepodarilo sa obnoviť predvolený režim, skúste to prosím znova."</string>
<string name="screen_room_notification_settings_error_setting_mode">"Nepodarilo sa nastaviť režim, skúste to prosím znova."</string>
<string name="screen_room_notification_settings_mode_all_messages">"Všetky správy"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Iba zmienky a kľúčové slová"</string>
<string name="screen_dm_details_block_alert_action">"Zablokovať"</string> <string name="screen_dm_details_block_alert_action">"Zablokovať"</string>
<string name="screen_dm_details_block_alert_description">"Blokovaní používatelia vám nebudú môcť posielať správy a všetky ich správy budú skryté. Môžete ich kedykoľvek odblokovať."</string> <string name="screen_dm_details_block_alert_description">"Blokovaní používatelia vám nebudú môcť posielať správy a všetky ich správy budú skryté. Môžete ich kedykoľvek odblokovať."</string>
<string name="screen_dm_details_block_user">"Zablokovať používateľa"</string> <string name="screen_dm_details_block_user">"Zablokovať používateľa"</string>

View file

@ -18,6 +18,12 @@
<string name="screen_room_details_updating_room">"正在更新聊天室…"</string> <string name="screen_room_details_updating_room">"正在更新聊天室…"</string>
<string name="screen_room_member_list_pending_header_title">"待定"</string> <string name="screen_room_member_list_pending_header_title">"待定"</string>
<string name="screen_room_member_list_room_members_header_title">"聊天室成員"</string> <string name="screen_room_member_list_room_members_header_title">"聊天室成員"</string>
<string name="screen_room_notification_settings_default_setting_footnote_content_link">"全域設定"</string>
<string name="screen_room_notification_settings_default_setting_title">"預設"</string>
<string name="screen_room_notification_settings_error_restoring_default">"無法重設為預設模式,請再試一次。"</string>
<string name="screen_room_notification_settings_error_setting_mode">"無法設定模式,請再試一次。"</string>
<string name="screen_room_notification_settings_mode_all_messages">"所有訊息"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"只限提及與關鍵字"</string>
<string name="screen_dm_details_block_alert_action">"封鎖"</string> <string name="screen_dm_details_block_alert_action">"封鎖"</string>
<string name="screen_dm_details_block_user">"封鎖使用者"</string> <string name="screen_dm_details_block_user">"封鎖使用者"</string>
<string name="screen_dm_details_unblock_alert_action">"解除封鎖"</string> <string name="screen_dm_details_unblock_alert_action">"解除封鎖"</string>

View file

@ -24,6 +24,17 @@
<string name="screen_room_details_updating_room">"Updating room…"</string> <string name="screen_room_details_updating_room">"Updating room…"</string>
<string name="screen_room_member_list_pending_header_title">"Pending"</string> <string name="screen_room_member_list_pending_header_title">"Pending"</string>
<string name="screen_room_member_list_room_members_header_title">"Room members"</string> <string name="screen_room_member_list_room_members_header_title">"Room members"</string>
<string name="screen_room_notification_settings_allow_custom">"Allow custom setting"</string>
<string name="screen_room_notification_settings_allow_custom_footnote">"Turning this on will override your default setting"</string>
<string name="screen_room_notification_settings_custom_settings_title">"Notify me in this chat for"</string>
<string name="screen_room_notification_settings_default_setting_footnote">"You can change it in your %1$s."</string>
<string name="screen_room_notification_settings_default_setting_footnote_content_link">"global settings"</string>
<string name="screen_room_notification_settings_default_setting_title">"Default setting"</string>
<string name="screen_room_notification_settings_error_loading_settings">"An error occurred while loading notification settings."</string>
<string name="screen_room_notification_settings_error_restoring_default">"Failed restoring the default mode, please try again."</string>
<string name="screen_room_notification_settings_error_setting_mode">"Failed setting the mode, please try again."</string>
<string name="screen_room_notification_settings_mode_all_messages">"All messages"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Mentions and Keywords only"</string>
<string name="screen_dm_details_block_alert_action">"Block"</string> <string name="screen_dm_details_block_alert_action">"Block"</string>
<string name="screen_dm_details_block_alert_description">"Blocked users won\'t be able to send you messages and all their messages will be hidden. You can unblock them anytime."</string> <string name="screen_dm_details_block_alert_description">"Blocked users won\'t be able to send you messages and all their messages will be hidden. You can unblock them anytime."</string>
<string name="screen_dm_details_block_user">"Block user"</string> <string name="screen_dm_details_block_user">"Block user"</string>

View file

@ -29,22 +29,30 @@ import io.element.android.features.roomdetails.impl.RoomDetailsType
import io.element.android.features.roomdetails.impl.RoomTopicState import io.element.android.features.roomdetails.impl.RoomTopicState
import io.element.android.features.roomdetails.impl.members.aRoomMember import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
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.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.A_USER_ID_2
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.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
class RoomDetailsPresenterTests { class RoomDetailsPresenterTests {
@ -52,20 +60,37 @@ class RoomDetailsPresenterTests {
@Rule @Rule
@JvmField @JvmField
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
private fun aRoomDetailsPresenter(
room: MatrixRoom,
leaveRoomPresenter: LeaveRoomPresenter = LeaveRoomPresenterFake(),
dispatchers: CoroutineDispatchers,
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService()
): RoomDetailsPresenter {
val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService)
private fun aRoomDetailsPresenter(room: MatrixRoom, leaveRoomPresenter: LeaveRoomPresenter = LeaveRoomPresenterFake()): RoomDetailsPresenter {
val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory { val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory {
override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter { override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter {
return RoomMemberDetailsPresenter(FakeMatrixClient(), room, roomMemberId) return RoomMemberDetailsPresenter(matrixClient, room, roomMemberId)
} }
} }
return RoomDetailsPresenter(room, roomMemberDetailsPresenterFactory, leaveRoomPresenter) val featureFlagService = FakeFeatureFlagService(
mapOf(FeatureFlags.NotificationSettings.key to true)
)
return RoomDetailsPresenter(
matrixClient,
room,
featureFlagService,
matrixClient.notificationSettingsService(),
roomMemberDetailsPresenterFactory,
leaveRoomPresenter,
dispatchers
)
} }
@Test @Test
fun `present - initial state is created from room info`() = runTest { fun `present - initial state is created from room info`() = runTest {
val room = aMatrixRoom() val room = aMatrixRoom()
val presenter = aRoomDetailsPresenter(room) val presenter = aRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -84,7 +109,7 @@ class RoomDetailsPresenterTests {
@Test @Test
fun `present - initial state with no room name`() = runTest { fun `present - initial state with no room name`() = runTest {
val room = aMatrixRoom(name = null) val room = aMatrixRoom(name = null)
val presenter = aRoomDetailsPresenter(room) val presenter = aRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -106,7 +131,7 @@ class RoomDetailsPresenterTests {
val roomMembers = listOf(myRoomMember, otherRoomMember) val roomMembers = listOf(myRoomMember, otherRoomMember)
givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers)) givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers))
} }
val presenter = aRoomDetailsPresenter(room) val presenter = aRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -122,7 +147,7 @@ class RoomDetailsPresenterTests {
val room = aMatrixRoom().apply { val room = aMatrixRoom().apply {
givenCanInviteResult(Result.success(true)) givenCanInviteResult(Result.success(true))
} }
val presenter = aRoomDetailsPresenter(room) val presenter = aRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -140,11 +165,13 @@ class RoomDetailsPresenterTests {
val room = aMatrixRoom().apply { val room = aMatrixRoom().apply {
givenCanInviteResult(Result.success(false)) givenCanInviteResult(Result.success(false))
} }
val presenter = aRoomDetailsPresenter(room) val presenter = aRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
assertThat(awaitItem().canInvite).isFalse() assertThat(awaitItem().canInvite).isFalse()
cancelAndIgnoreRemainingEvents()
} }
} }
@ -153,11 +180,13 @@ class RoomDetailsPresenterTests {
val room = aMatrixRoom().apply { val room = aMatrixRoom().apply {
givenCanInviteResult(Result.failure(Throwable("Whoops"))) givenCanInviteResult(Result.failure(Throwable("Whoops")))
} }
val presenter = aRoomDetailsPresenter(room) val presenter = aRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
assertThat(awaitItem().canInvite).isFalse() assertThat(awaitItem().canInvite).isFalse()
cancelAndIgnoreRemainingEvents()
} }
} }
@ -169,7 +198,7 @@ class RoomDetailsPresenterTests {
givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.failure(Throwable("Whelp"))) givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.failure(Throwable("Whelp")))
givenCanInviteResult(Result.success(false)) givenCanInviteResult(Result.success(false))
} }
val presenter = aRoomDetailsPresenter(room) val presenter = aRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -198,7 +227,7 @@ class RoomDetailsPresenterTests {
givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.success(true)) givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.success(true))
givenCanInviteResult(Result.success(false)) givenCanInviteResult(Result.success(false))
} }
val presenter = aRoomDetailsPresenter(room) val presenter = aRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -227,7 +256,7 @@ class RoomDetailsPresenterTests {
givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(true)) givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(true))
} }
val presenter = aRoomDetailsPresenter(room) val presenter = aRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -248,7 +277,7 @@ class RoomDetailsPresenterTests {
givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.success(true)) givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.success(true))
givenCanInviteResult(Result.success(false)) givenCanInviteResult(Result.success(false))
} }
val presenter = aRoomDetailsPresenter(room) val presenter = aRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -269,12 +298,14 @@ class RoomDetailsPresenterTests {
givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.success(false)) givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.success(false))
givenCanInviteResult(Result.success(false)) givenCanInviteResult(Result.success(false))
} }
val presenter = aRoomDetailsPresenter(room) val presenter = aRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
// Initially false, and no further events // Initially false, and no further events
assertThat(awaitItem().canEdit).isFalse() assertThat(awaitItem().canEdit).isFalse()
cancelAndIgnoreRemainingEvents()
} }
} }
@ -285,12 +316,14 @@ class RoomDetailsPresenterTests {
givenCanInviteResult(Result.success(false)) givenCanInviteResult(Result.success(false))
} }
val presenter = aRoomDetailsPresenter(room) val presenter = aRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
// The initial state is "hidden" and no further state changes happen // The initial state is "hidden" and no further state changes happen
assertThat(awaitItem().roomTopic).isEqualTo(RoomTopicState.Hidden) assertThat(awaitItem().roomTopic).isEqualTo(RoomTopicState.Hidden)
cancelAndIgnoreRemainingEvents()
} }
} }
@ -301,7 +334,7 @@ class RoomDetailsPresenterTests {
givenCanInviteResult(Result.success(false)) givenCanInviteResult(Result.success(false))
} }
val presenter = aRoomDetailsPresenter(room) val presenter = aRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -319,7 +352,7 @@ class RoomDetailsPresenterTests {
fun `present - leave room event is passed on to leave room presenter`() = runTest { fun `present - leave room event is passed on to leave room presenter`() = runTest {
val leaveRoomPresenter = LeaveRoomPresenterFake() val leaveRoomPresenter = LeaveRoomPresenterFake()
val room = aMatrixRoom() val room = aMatrixRoom()
val presenter = aRoomDetailsPresenter(room, leaveRoomPresenter) val presenter = aRoomDetailsPresenter(room, leaveRoomPresenter, testCoroutineDispatchers())
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -330,6 +363,64 @@ class RoomDetailsPresenterTests {
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@Test
fun `present - notification mode changes`() = runTest {
val leaveRoomPresenter = LeaveRoomPresenterFake()
val notificationSettingsService = FakeNotificationSettingsService()
val room = aMatrixRoom(notificationSettingsService = notificationSettingsService)
val presenter = aRoomDetailsPresenter(room, leaveRoomPresenter, testCoroutineDispatchers(), notificationSettingsService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
notificationSettingsService.setRoomNotificationMode(room.roomId, RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
val updatedState = consumeItemsUntilPredicate {
it.roomNotificationSettings?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
}.last()
assertThat(updatedState.roomNotificationSettings?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - mute room notifications`() = runTest {
val leaveRoomPresenter = LeaveRoomPresenterFake()
val notificationSettingsService = FakeNotificationSettingsService(initialMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
val room = aMatrixRoom(notificationSettingsService = notificationSettingsService)
val presenter = aRoomDetailsPresenter(room, leaveRoomPresenter, testCoroutineDispatchers(), notificationSettingsService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(RoomDetailsEvent.MuteNotification)
val updatedState = consumeItemsUntilPredicate(timeout = 250.milliseconds) {
it.roomNotificationSettings?.mode == RoomNotificationMode.MUTE
}.last()
assertThat(updatedState.roomNotificationSettings?.mode).isEqualTo(RoomNotificationMode.MUTE)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - unmute room notifications`() = runTest {
val leaveRoomPresenter = LeaveRoomPresenterFake()
val notificationSettingsService = FakeNotificationSettingsService(
initialMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
initialDefaultMode = RoomNotificationMode.ALL_MESSAGES
)
val room = aMatrixRoom(notificationSettingsService = notificationSettingsService)
val presenter = aRoomDetailsPresenter(room, leaveRoomPresenter, testCoroutineDispatchers(), notificationSettingsService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(RoomDetailsEvent.UnmuteNotification)
val updatedState = consumeItemsUntilPredicate {
it.roomNotificationSettings?.mode == RoomNotificationMode.ALL_MESSAGES
}.last()
assertThat(updatedState.roomNotificationSettings?.mode).isEqualTo(RoomNotificationMode.ALL_MESSAGES)
cancelAndIgnoreRemainingEvents()
}
}
} }
fun aMatrixRoom( fun aMatrixRoom(
@ -341,6 +432,7 @@ fun aMatrixRoom(
isEncrypted: Boolean = true, isEncrypted: Boolean = true,
isPublic: Boolean = true, isPublic: Boolean = true,
isDirect: Boolean = false, isDirect: Boolean = false,
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService()
) = FakeMatrixRoom( ) = FakeMatrixRoom(
roomId = roomId, roomId = roomId,
name = name, name = name,
@ -350,5 +442,6 @@ fun aMatrixRoom(
isEncrypted = isEncrypted, isEncrypted = isEncrypted,
isPublic = isPublic, isPublic = isPublic,
isDirect = isDirect, isDirect = isDirect,
notificationSettingsService = notificationSettingsService
) )

View file

@ -0,0 +1,83 @@
/*
* 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.notificationsettings
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth
import io.element.android.features.roomdetails.aMatrixRoom
import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsEvents
import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsPresenter
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.test.A_ROOM_NOTIFICATION_MODE
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import kotlinx.coroutines.test.runTest
import org.junit.Test
class RoomNotificationSettingsPresenterTests {
@Test
fun `present - initial state is created from room info`() = runTest {
val presenter = aNotificationPresenter
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
Truth.assertThat(initialState.roomNotificationSettings).isNull()
Truth.assertThat(initialState.defaultRoomNotificationMode).isNull()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - notification mode changed`() = runTest {
val presenter = aNotificationPresenter
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY))
val updatedState = consumeItemsUntilPredicate {
it.roomNotificationSettings?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
}.last()
Truth.assertThat(updatedState.roomNotificationSettings?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
}
}
@Test
fun `present - notification settings restore default`() = runTest {
val presenter = aNotificationPresenter
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY))
initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(true))
val defaultState = consumeItemsUntilPredicate {
it.roomNotificationSettings?.mode == A_ROOM_NOTIFICATION_MODE
}.last()
Truth.assertThat(defaultState.roomNotificationSettings?.mode).isEqualTo(A_ROOM_NOTIFICATION_MODE)
}
}
private val aNotificationPresenter: RoomNotificationSettingsPresenter get() {
val room = aMatrixRoom()
return RoomNotificationSettingsPresenter(
room = room,
notificationSettingsService = room.notificationSettingsService
)
}
}

View file

@ -39,6 +39,8 @@ import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.toEnabledColor
import io.element.android.libraries.designsystem.toSecondaryEnabledColor
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
/** /**
@ -48,6 +50,7 @@ import io.element.android.libraries.theme.ElementTheme
fun PreferenceText( fun PreferenceText(
title: String, title: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true,
subtitle: String? = null, subtitle: String? = null,
currentValue: String? = null, currentValue: String? = null,
loadingCurrentValue: Boolean = false, loadingCurrentValue: Boolean = false,
@ -68,8 +71,9 @@ fun PreferenceText(
) { ) {
PreferenceIcon( PreferenceIcon(
icon = icon, icon = icon,
enabled = enabled,
isVisible = showIconAreaIfNoIcon, isVisible = showIconAreaIfNoIcon,
tintColor = tintColor ?: ElementTheme.materialColors.secondary tintColor = tintColor ?: enabled.toSecondaryEnabledColor(),
) )
Column( Column(
modifier = Modifier modifier = Modifier
@ -79,13 +83,13 @@ fun PreferenceText(
Text( Text(
style = ElementTheme.typography.fontBodyLgRegular, style = ElementTheme.typography.fontBodyLgRegular,
text = title, text = title,
color = tintColor ?: ElementTheme.materialColors.primary, color = tintColor ?: enabled.toEnabledColor(),
) )
if (subtitle != null) { if (subtitle != null) {
Text( Text(
style = ElementTheme.typography.fontBodyMdRegular, style = ElementTheme.typography.fontBodyMdRegular,
text = subtitle, text = subtitle,
color = tintColor ?: ElementTheme.materialColors.secondary, color = tintColor ?: enabled.toSecondaryEnabledColor(),
) )
} }
} }
@ -96,7 +100,7 @@ fun PreferenceText(
.padding(start = 16.dp, end = 8.dp), .padding(start = 16.dp, end = 8.dp),
text = currentValue, text = currentValue,
style = ElementTheme.typography.fontBodyXsMedium, style = ElementTheme.typography.fontBodyXsMedium,
color = ElementTheme.materialColors.secondary, color = enabled.toSecondaryEnabledColor(),
) )
} else if (loadingCurrentValue) { } else if (loadingCurrentValue) {
CircularProgressIndicator( CircularProgressIndicator(
@ -135,6 +139,13 @@ private fun ContentToPreview() {
icon = Icons.Default.BugReport, icon = Icons.Default.BugReport,
currentValue = "123", currentValue = "123",
) )
PreferenceText(
title = "Title",
subtitle = "Some content",
icon = Icons.Default.BugReport,
currentValue = "123",
enabled = false,
)
PreferenceText( PreferenceText(
title = "Title", title = "Title",
subtitle = "Some content", subtitle = "Some content",

View file

@ -30,5 +30,11 @@ enum class FeatureFlags(
key = "feature.polls", key = "feature.polls",
title = "Polls", title = "Polls",
description = "Create poll and render poll events in the timeline", description = "Create poll and render poll events in the timeline",
defaultValue = false,
),
NotificationSettings(
key = "feature.notificationsettings",
title = "Show notification settings",
defaultValue = false,
), ),
} }

View file

@ -31,6 +31,7 @@ class BuildtimeFeatureFlagProvider @Inject constructor() :
when (feature) { when (feature) {
FeatureFlags.LocationSharing -> true FeatureFlags.LocationSharing -> true
FeatureFlags.Polls -> false FeatureFlags.Polls -> false
FeatureFlags.NotificationSettings -> false
} }
} else { } else {
false false

View file

@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.notification.NotificationService import io.element.android.libraries.matrix.api.notification.NotificationService
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.pusher.PushersService
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.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
@ -49,6 +50,7 @@ interface MatrixClient : Closeable {
fun sessionVerificationService(): SessionVerificationService fun sessionVerificationService(): SessionVerificationService
fun pushersService(): PushersService fun pushersService(): PushersService
fun notificationService(): NotificationService fun notificationService(): NotificationService
fun notificationSettingsService(): NotificationSettingsService
suspend fun getCacheSize(): Long suspend fun getCacheSize(): Long
/** /**

View file

@ -0,0 +1,36 @@
/*
* 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.libraries.matrix.api.notificationsettings
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.MatrixRoomNotificationSettingsState
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
import kotlinx.coroutines.flow.SharedFlow
interface NotificationSettingsService {
/**
* State of the current room notification settings flow ([MatrixRoomNotificationSettingsState.Unknown] if not started).
*/
val notificationSettingsChangeFlow : SharedFlow<Unit>
suspend fun getRoomNotificationSettings(roomId: RoomId, isEncrypted: Boolean, membersCount: Long): Result<RoomNotificationSettings>
suspend fun getDefaultRoomNotificationMode(isEncrypted: Boolean, membersCount: Long): Result<RoomNotificationMode>
suspend fun setRoomNotificationMode(roomId: RoomId, mode: RoomNotificationMode): Result<Unit>
suspend fun restoreDefaultRoomNotificationMode(roomId: RoomId): Result<Unit>
suspend fun muteRoom(roomId: RoomId): Result<Unit>
suspend fun unmuteRoom(roomId: RoomId, isEncrypted: Boolean, membersCount: Long): Result<Unit>
}

View file

@ -56,11 +56,15 @@ interface MatrixRoom : Closeable {
*/ */
val membersStateFlow: StateFlow<MatrixRoomMembersState> val membersStateFlow: StateFlow<MatrixRoomMembersState>
val roomNotificationSettingsStateFlow: StateFlow<MatrixRoomNotificationSettingsState>
/** /**
* Try to load the room members and update the membersFlow. * Try to load the room members and update the membersFlow.
*/ */
suspend fun updateMembers(): Result<Unit> suspend fun updateMembers(): Result<Unit>
suspend fun updateRoomNotificationSettings(): Result<Unit>
val syncUpdateFlow: StateFlow<Long> val syncUpdateFlow: StateFlow<Long>
val timeline: MatrixTimeline val timeline: MatrixTimeline

View file

@ -0,0 +1,33 @@
/*
* 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.libraries.matrix.api.room
sealed interface MatrixRoomNotificationSettingsState {
object Unknown : MatrixRoomNotificationSettingsState
data class Pending(val prevRoomNotificationSettings: RoomNotificationSettings? = null) : MatrixRoomNotificationSettingsState
data class Error(val failure: Throwable, val prevRoomNotificationSettings: RoomNotificationSettings? = null) : MatrixRoomNotificationSettingsState
data class Ready(val roomNotificationSettings: RoomNotificationSettings) : MatrixRoomNotificationSettingsState
}
fun MatrixRoomNotificationSettingsState.roomNotificationSettings(): RoomNotificationSettings? {
return when (this) {
is MatrixRoomNotificationSettingsState.Ready -> roomNotificationSettings
is MatrixRoomNotificationSettingsState.Pending -> prevRoomNotificationSettings
is MatrixRoomNotificationSettingsState.Error -> prevRoomNotificationSettings
else -> null
}
}

View file

@ -0,0 +1,26 @@
/*
* 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.libraries.matrix.api.room
data class RoomNotificationSettings(
val mode: RoomNotificationMode,
val isDefault: Boolean,
)
enum class RoomNotificationMode {
ALL_MESSAGES, MENTIONS_AND_KEYWORDS_ONLY, MUTE
}

View file

@ -29,6 +29,7 @@ import io.element.android.libraries.matrix.api.createroom.RoomPreset
import io.element.android.libraries.matrix.api.createroom.RoomVisibility import io.element.android.libraries.matrix.api.createroom.RoomVisibility
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.notification.NotificationService import io.element.android.libraries.matrix.api.notification.NotificationService
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.pusher.PushersService
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.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
@ -42,6 +43,7 @@ import io.element.android.libraries.matrix.impl.core.toProgressWatcher
import io.element.android.libraries.matrix.impl.mapper.toSessionData import io.element.android.libraries.matrix.impl.mapper.toSessionData
import io.element.android.libraries.matrix.impl.media.RustMediaLoader import io.element.android.libraries.matrix.impl.media.RustMediaLoader
import io.element.android.libraries.matrix.impl.notification.RustNotificationService import io.element.android.libraries.matrix.impl.notification.RustNotificationService
import io.element.android.libraries.matrix.impl.notificationsettings.RustNotificationSettingsService
import io.element.android.libraries.matrix.impl.pushers.RustPushersService import io.element.android.libraries.matrix.impl.pushers.RustPushersService
import io.element.android.libraries.matrix.impl.room.RoomContentForwarder import io.element.android.libraries.matrix.impl.room.RoomContentForwarder
import io.element.android.libraries.matrix.impl.room.RustMatrixRoom import io.element.android.libraries.matrix.impl.room.RustMatrixRoom
@ -99,6 +101,7 @@ class RustMatrixClient constructor(
client = client, client = client,
dispatchers = dispatchers, dispatchers = dispatchers,
) )
private val notificationProcessSetup = NotificationProcessSetup.SingleProcess(syncService) private val notificationProcessSetup = NotificationProcessSetup.SingleProcess(syncService)
private val notificationClient = client.notificationClient(notificationProcessSetup) private val notificationClient = client.notificationClient(notificationProcessSetup)
.use { builder -> .use { builder ->
@ -106,8 +109,10 @@ class RustMatrixClient constructor(
.filterByPushRules() .filterByPushRules()
.finish() .finish()
} }
private val notificationSettings = client.getNotificationSettings()
private val notificationService = RustNotificationService(sessionId, notificationClient, dispatchers, clock) private val notificationService = RustNotificationService(sessionId, notificationClient, dispatchers, clock)
private val notificationSettingsService = RustNotificationSettingsService(notificationSettings, dispatchers)
private val isLoggingOut = AtomicBoolean(false) private val isLoggingOut = AtomicBoolean(false)
@ -173,6 +178,7 @@ class RustMatrixClient constructor(
sessionId = sessionId, sessionId = sessionId,
roomListItem = roomListItem, roomListItem = roomListItem,
innerRoom = fullRoom, innerRoom = fullRoom,
roomNotificationSettingsService = notificationSettingsService,
sessionCoroutineScope = sessionCoroutineScope, sessionCoroutineScope = sessionCoroutineScope,
coroutineDispatchers = dispatchers, coroutineDispatchers = dispatchers,
systemClock = clock, systemClock = clock,
@ -277,9 +283,13 @@ class RustMatrixClient constructor(
override fun notificationService(): NotificationService = notificationService override fun notificationService(): NotificationService = notificationService
override fun notificationSettingsService(): NotificationSettingsService = notificationSettingsService
override fun close() { override fun close() {
sessionCoroutineScope.cancel() sessionCoroutineScope.cancel()
client.setDelegate(null) client.setDelegate(null)
notificationSettings.setDelegate(null)
notificationSettings.destroy()
verificationService.destroy() verificationService.destroy()
syncService.destroy() syncService.destroy()
innerRoomListService.destroy() innerRoomListService.destroy()

View file

@ -22,6 +22,7 @@ import dagger.Provides
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
@ -34,6 +35,11 @@ object SessionMatrixModule {
return matrixClient.sessionVerificationService() return matrixClient.sessionVerificationService()
} }
@Provides
fun providesNotificationSettingsService(matrixClient: MatrixClient): NotificationSettingsService {
return matrixClient.notificationSettingsService()
}
@Provides @Provides
fun provideRoomMembershipObserver(matrixClient: MatrixClient): RoomMembershipObserver { fun provideRoomMembershipObserver(matrixClient: MatrixClient): RoomMembershipObserver {
return matrixClient.roomMembershipObserver() return matrixClient.roomMembershipObserver()

View file

@ -0,0 +1,44 @@
/*
* 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.libraries.matrix.impl.notificationsettings
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
import org.matrix.rustcomponents.sdk.RoomNotificationMode as RustRoomNotificationMode
import org.matrix.rustcomponents.sdk.RoomNotificationSettings as RustRoomNotificationSettings
object RoomNotificationSettingsMapper {
fun map(roomNotificationSettings: RustRoomNotificationSettings): RoomNotificationSettings =
RoomNotificationSettings(
mode = mapMode(roomNotificationSettings.mode),
isDefault = roomNotificationSettings.isDefault
)
fun mapMode(mode: RustRoomNotificationMode): RoomNotificationMode =
when (mode) {
RustRoomNotificationMode.ALL_MESSAGES -> RoomNotificationMode.ALL_MESSAGES
RustRoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
RustRoomNotificationMode.MUTE -> RoomNotificationMode.MUTE
}
fun mapMode(mode: RoomNotificationMode): RustRoomNotificationMode =
when (mode) {
RoomNotificationMode.ALL_MESSAGES -> RustRoomNotificationMode.ALL_MESSAGES
RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> RustRoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
RoomNotificationMode.MUTE -> RustRoomNotificationMode.MUTE
}
}

View file

@ -0,0 +1,86 @@
/*
* 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.libraries.matrix.impl.notificationsettings
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
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.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.NotificationSettings
import org.matrix.rustcomponents.sdk.NotificationSettingsDelegate
class RustNotificationSettingsService(
private val notificationSettings: NotificationSettings,
private val dispatchers: CoroutineDispatchers,
) : NotificationSettingsService {
private val _notificationSettingsChangeFlow = MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
override val notificationSettingsChangeFlow: SharedFlow<Unit> = _notificationSettingsChangeFlow.asSharedFlow()
private var notificationSettingsDelegate = object : NotificationSettingsDelegate {
override fun settingsDidChange() {
_notificationSettingsChangeFlow.tryEmit(Unit)
}
}
init {
notificationSettings.setDelegate(notificationSettingsDelegate)
}
override suspend fun getRoomNotificationSettings(roomId: RoomId, isEncrypted: Boolean, membersCount: Long): Result<RoomNotificationSettings> =
runCatching {
notificationSettings.getRoomNotificationSettings(roomId.value, isEncrypted, isOneToOne(membersCount)).let(RoomNotificationSettingsMapper::map)
}
override suspend fun getDefaultRoomNotificationMode(isEncrypted: Boolean, membersCount: Long): Result<RoomNotificationMode> =
runCatching {
notificationSettings.getDefaultRoomNotificationMode(isEncrypted, isOneToOne(membersCount)).let(RoomNotificationSettingsMapper::mapMode)
}
override suspend fun setRoomNotificationMode(roomId: RoomId, mode: RoomNotificationMode): Result<Unit> = withContext(dispatchers.io) {
runCatching {
notificationSettings.setRoomNotificationMode(roomId.value, mode.let(RoomNotificationSettingsMapper::mapMode))
}
}
override suspend fun restoreDefaultRoomNotificationMode(roomId: RoomId): Result<Unit> = withContext(dispatchers.io) {
runCatching {
notificationSettings.restoreDefaultRoomNotificationMode(roomId.value)
}
}
override suspend fun muteRoom(roomId: RoomId): Result<Unit> = setRoomNotificationMode(roomId, RoomNotificationMode.MUTE)
override suspend fun unmuteRoom(roomId: RoomId, isEncrypted: Boolean, membersCount: Long) = withContext(dispatchers.io) {
runCatching {
notificationSettings.unmuteRoom(roomId.value, isEncrypted, isOneToOne(membersCount))
}
}
/**
* A one-to-one is a room with exactly 2 members.
* See [the Matrix spec](https://spec.matrix.org/latest/client-server-api/#default-underride-rules).
* @param membersCount The active members count in a room
*/
private fun isOneToOne(membersCount: Long) = membersCount == 2L
}

View file

@ -33,16 +33,19 @@ import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.poll.PollKind import io.element.android.libraries.matrix.api.poll.PollKind
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.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MatrixRoomNotificationSettingsState
import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.room.roomMembers import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.item.event.EventType import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.matrix.impl.core.toProgressWatcher import io.element.android.libraries.matrix.impl.core.toProgressWatcher
import io.element.android.libraries.matrix.impl.media.MediaUploadHandlerImpl import io.element.android.libraries.matrix.impl.media.MediaUploadHandlerImpl
import io.element.android.libraries.matrix.impl.media.map import io.element.android.libraries.matrix.impl.media.map
import io.element.android.libraries.matrix.impl.poll.toInner import io.element.android.libraries.matrix.impl.poll.toInner
import io.element.android.libraries.matrix.impl.notificationsettings.RustNotificationSettingsService
import io.element.android.libraries.matrix.impl.room.location.toInner import io.element.android.libraries.matrix.impl.room.location.toInner
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
import io.element.android.libraries.matrix.impl.util.destroyAll import io.element.android.libraries.matrix.impl.util.destroyAll
@ -72,6 +75,7 @@ class RustMatrixRoom(
override val sessionId: SessionId, override val sessionId: SessionId,
private val roomListItem: RoomListItem, private val roomListItem: RoomListItem,
private val innerRoom: Room, private val innerRoom: Room,
private val roomNotificationSettingsService: RustNotificationSettingsService,
sessionCoroutineScope: CoroutineScope, sessionCoroutineScope: CoroutineScope,
private val coroutineDispatchers: CoroutineDispatchers, private val coroutineDispatchers: CoroutineDispatchers,
private val systemClock: SystemClock, private val systemClock: SystemClock,
@ -90,6 +94,10 @@ class RustMatrixRoom(
private val roomCoroutineScope = sessionCoroutineScope.childScope(coroutineDispatchers.main, "RoomScope-$roomId") private val roomCoroutineScope = sessionCoroutineScope.childScope(coroutineDispatchers.main, "RoomScope-$roomId")
private val _membersStateFlow = MutableStateFlow<MatrixRoomMembersState>(MatrixRoomMembersState.Unknown) private val _membersStateFlow = MutableStateFlow<MatrixRoomMembersState>(MatrixRoomMembersState.Unknown)
private val _syncUpdateFlow = MutableStateFlow(0L) private val _syncUpdateFlow = MutableStateFlow(0L)
private val _roomNotificationSettingsStateFlow = MutableStateFlow<MatrixRoomNotificationSettingsState>(MatrixRoomNotificationSettingsState.Unknown)
override val roomNotificationSettingsStateFlow: StateFlow<MatrixRoomNotificationSettingsState> = _roomNotificationSettingsStateFlow
private val _timeline by lazy { private val _timeline by lazy {
RustMatrixTimeline( RustMatrixTimeline(
matrixRoom = this, matrixRoom = this,
@ -197,6 +205,22 @@ class RustMatrixRoom(
} }
} }
override suspend fun updateRoomNotificationSettings(): Result<Unit> = withContext(coroutineDispatchers.io) {
val currentState = _roomNotificationSettingsStateFlow.value
val currentRoomNotificationSettings = currentState.roomNotificationSettings()
_roomNotificationSettingsStateFlow.value = MatrixRoomNotificationSettingsState.Pending(prevRoomNotificationSettings = currentRoomNotificationSettings)
runCatching {
roomNotificationSettingsService.getRoomNotificationSettings(roomId, isEncrypted, activeMemberCount).getOrThrow()
}.map {
_roomNotificationSettingsStateFlow.value = MatrixRoomNotificationSettingsState.Ready(it)
}.onFailure {
_roomNotificationSettingsStateFlow.value = MatrixRoomNotificationSettingsState.Error(
prevRoomNotificationSettings = currentRoomNotificationSettings,
failure = it
)
}
}
override suspend fun userAvatarUrl(userId: UserId): Result<String?> = withContext(roomDispatcher) { override suspend fun userAvatarUrl(userId: UserId): Result<String?> = withContext(roomDispatcher) {
runCatching { runCatching {
innerRoom.memberAvatarUrl(userId.value) innerRoom.memberAvatarUrl(userId.value)

View file

@ -24,6 +24,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.notification.NotificationService import io.element.android.libraries.matrix.api.notification.NotificationService
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.pusher.PushersService
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.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
@ -33,6 +34,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.test.media.FakeMediaLoader import io.element.android.libraries.matrix.test.media.FakeMediaLoader
import io.element.android.libraries.matrix.test.notification.FakeNotificationService import io.element.android.libraries.matrix.test.notification.FakeNotificationService
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.pushers.FakePushersService import io.element.android.libraries.matrix.test.pushers.FakePushersService
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
@ -50,6 +52,7 @@ class FakeMatrixClient(
private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(), private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(),
private val pushersService: FakePushersService = FakePushersService(), private val pushersService: FakePushersService = FakePushersService(),
private val notificationService: FakeNotificationService = FakeNotificationService(), private val notificationService: FakeNotificationService = FakeNotificationService(),
private val notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
private val syncService: FakeSyncService = FakeSyncService(), private val syncService: FakeSyncService = FakeSyncService(),
private val accountManagementUrlString: Result<String?> = Result.success(null), private val accountManagementUrlString: Result<String?> = Result.success(null),
) : MatrixClient { ) : MatrixClient {
@ -142,6 +145,7 @@ class FakeMatrixClient(
override fun pushersService(): PushersService = pushersService override fun pushersService(): PushersService = pushersService
override fun notificationService(): NotificationService = notificationService override fun notificationService(): NotificationService = notificationService
override fun notificationSettingsService(): NotificationSettingsService = notificationSettingsService
override fun roomMembershipObserver(): RoomMembershipObserver { override fun roomMembershipObserver(): RoomMembershipObserver {
return RoomMembershipObserver() return RoomMembershipObserver()

View file

@ -24,6 +24,8 @@ import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.TransactionId import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
const val A_USER_NAME = "alice" const val A_USER_NAME = "alice"
const val A_PASSWORD = "password" const val A_PASSWORD = "password"
@ -59,6 +61,8 @@ const val A_HOMESERVER_URL_2 = "matrix-client.org"
val A_HOMESERVER = MatrixHomeServerDetails(A_HOMESERVER_URL, supportsPasswordLogin = true, supportsOidcLogin = false) val A_HOMESERVER = MatrixHomeServerDetails(A_HOMESERVER_URL, supportsPasswordLogin = true, supportsOidcLogin = false)
val A_HOMESERVER_OIDC = MatrixHomeServerDetails(A_HOMESERVER_URL, supportsPasswordLogin = false, supportsOidcLogin = true) val A_HOMESERVER_OIDC = MatrixHomeServerDetails(A_HOMESERVER_URL, supportsPasswordLogin = false, supportsOidcLogin = true)
val A_ROOM_NOTIFICATION_MODE = RoomNotificationMode.MUTE
val A_ROOM_NOTIFICATION_SETTINGS = RoomNotificationSettings(mode = A_ROOM_NOTIFICATION_MODE, isDefault = false)
const val AN_AVATAR_URL = "mxc://data" const val AN_AVATAR_URL = "mxc://data"

View file

@ -0,0 +1,64 @@
/*
* 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.libraries.matrix.test.notificationsettings
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.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
import io.element.android.libraries.matrix.test.A_ROOM_NOTIFICATION_MODE
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
class FakeNotificationSettingsService(
initialMode: RoomNotificationMode = A_ROOM_NOTIFICATION_MODE,
initialDefaultMode: RoomNotificationMode = A_ROOM_NOTIFICATION_MODE
) : NotificationSettingsService {
private var _roomNotificationSettingsStateFlow = MutableStateFlow(Unit)
private var defaultRoomNotificationMode: RoomNotificationMode = initialDefaultMode
private var roomNotificationMode: RoomNotificationMode = initialMode
override val notificationSettingsChangeFlow: SharedFlow<Unit>
get() = _roomNotificationSettingsStateFlow
override suspend fun getRoomNotificationSettings(roomId: RoomId, isEncrypted: Boolean, membersCount: Long): Result<RoomNotificationSettings> {
return Result.success(RoomNotificationSettings(mode = roomNotificationMode, isDefault = roomNotificationMode == defaultRoomNotificationMode))
}
override suspend fun getDefaultRoomNotificationMode(isEncrypted: Boolean, membersCount: Long): Result<RoomNotificationMode> {
return Result.success(defaultRoomNotificationMode)
}
override suspend fun setRoomNotificationMode(roomId: RoomId, mode: RoomNotificationMode): Result<Unit> {
roomNotificationMode = mode
_roomNotificationSettingsStateFlow.emit(Unit)
return Result.success(Unit)
}
override suspend fun restoreDefaultRoomNotificationMode(roomId: RoomId): Result<Unit> {
roomNotificationMode = defaultRoomNotificationMode
_roomNotificationSettingsStateFlow.emit(Unit)
return Result.success(Unit)
}
override suspend fun muteRoom(roomId: RoomId): Result<Unit> {
return setRoomNotificationMode(roomId, RoomNotificationMode.MUTE)
}
override suspend fun unmuteRoom(roomId: RoomId, isEncrypted: Boolean, membersCount: Long): Result<Unit> {
return restoreDefaultRoomNotificationMode(roomId)
}
}

View file

@ -27,16 +27,19 @@ import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaUploadHandler import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.poll.PollKind import io.element.android.libraries.matrix.api.poll.PollKind
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.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.MatrixRoomNotificationSettingsState
import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import io.element.android.tests.testutils.simulateLongTask import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -58,6 +61,7 @@ class FakeMatrixRoom(
override val isDirect: Boolean = false, override val isDirect: Boolean = false,
override val joinedMemberCount: Long = 123L, override val joinedMemberCount: Long = 123L,
override val activeMemberCount: Long = 234L, override val activeMemberCount: Long = 234L,
val notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(),
private val matrixTimeline: MatrixTimeline = FakeMatrixTimeline(), private val matrixTimeline: MatrixTimeline = FakeMatrixTimeline(),
canRedact: Boolean = false, canRedact: Boolean = false,
) : MatrixRoom { ) : MatrixRoom {
@ -136,10 +140,19 @@ class FakeMatrixRoom(
override val membersStateFlow: MutableStateFlow<MatrixRoomMembersState> = MutableStateFlow(MatrixRoomMembersState.Unknown) override val membersStateFlow: MutableStateFlow<MatrixRoomMembersState> = MutableStateFlow(MatrixRoomMembersState.Unknown)
override val roomNotificationSettingsStateFlow: MutableStateFlow<MatrixRoomNotificationSettingsState> =
MutableStateFlow(MatrixRoomNotificationSettingsState.Unknown)
override suspend fun updateMembers(): Result<Unit> = simulateLongTask { override suspend fun updateMembers(): Result<Unit> = simulateLongTask {
updateMembersResult updateMembersResult
} }
override suspend fun updateRoomNotificationSettings(): Result<Unit> = simulateLongTask {
val notificationSettings = notificationSettingsService.getRoomNotificationSettings(roomId, isEncrypted, activeMemberCount).getOrThrow()
roomNotificationSettingsStateFlow.value = MatrixRoomNotificationSettingsState.Ready(notificationSettings)
return Result.success(Unit)
}
override val syncUpdateFlow: StateFlow<Long> = MutableStateFlow(0L) override val syncUpdateFlow: StateFlow<Long> = MutableStateFlow(0L)
override val timeline: MatrixTimeline = matrixTimeline override val timeline: MatrixTimeline = matrixTimeline

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="Use_an_identity_server_to_invite_by_email.__default_Use_the_default____defaultIdentityServerName_s___default__or_manage_in__settings_Settings__settings_._web">"Use an identity server to invite by email. "<default>"Use the default (%(defaultIdentityServerName)s)"</default>" or manage in "<settings>"Settings"</settings>"."</string>
<string name="a11y_hide_password">"Hide password"</string> <string name="a11y_hide_password">"Hide password"</string>
<string name="a11y_notifications_mentions_only">"Mentions only"</string> <string name="a11y_notifications_mentions_only">"Mentions only"</string>
<string name="a11y_notifications_muted">"Muted"</string> <string name="a11y_notifications_muted">"Muted"</string>
@ -161,6 +162,10 @@
<string name="leave_room_alert_private_subtitle">"Are you sure that you want to leave this room? This room is not public and you won\'t be able to rejoin without an invite."</string> <string name="leave_room_alert_private_subtitle">"Are you sure that you want to leave this room? This room is not public and you won\'t be able to rejoin without an invite."</string>
<string name="leave_room_alert_subtitle">"Are you sure that you want to leave the room?"</string> <string name="leave_room_alert_subtitle">"Are you sure that you want to leave the room?"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string> <string name="login_initial_device_name_android">"%1$s Android"</string>
<plurals name="__count_s_rooms_web">
<item quantity="one">"%(count)s room"</item>
<item quantity="other">"%(count)s rooms"</item>
</plurals>
<plurals name="common_member_count"> <plurals name="common_member_count">
<item quantity="one">"%1$d member"</item> <item quantity="one">"%1$d member"</item>
<item quantity="other">"%1$d members"</item> <item quantity="other">"%1$d members"</item>

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6edcc452c76049a978ceb97b46776446790dbdbe30eef1693fafda089cdc3ee7 oid sha256:49bd51a32dbd3615364a6039163a36c08d0096a8c511a7084e21b7c1780131db
size 55527 size 52810

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:20e630b0f3bb9fddd5f11e4e9f2a92b5d35c4bbe182662e8d0fde27451297006 oid sha256:2c61e5d0eabb1f9cae83c3df0f73f14545e0c61bd7393d6e2862e0f48b9e3d7a
size 45247 size 51272

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:553b498247e9da02ad037716a67c080a3da2fb07602b0c0c44de8f16a4891eda oid sha256:adf48ade91c22ce7f98bf2773ec4e14ce0c290f7ba25e8c76d9f722677c46277
size 46086 size 44388

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d91c291503dc745092b132fec62208e73dcd031822ca21a66a7fb1f48ecfa6e1 oid sha256:262ba970549c3c137ab90ffc607279479dfc4fbf86468e18b08ea6c5ef5b0710
size 49316 size 53634

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:efda43a25fdc2a21a4ad3767b14b4c7a308003f9bcb9e67d64520db13c83f0fd oid sha256:49d92579bffadd59f89e79217b5e6148bbfeda0c5750a06ad7aa36f8125d6d29
size 61061 size 50616

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:31f2d59ad03a4d9ba8efd5f146668162f3c2095ac90e16dd3634d3e817f7263d oid sha256:b742f507ba11be8bdf733e5e751fc5d9fe1c0cc2eb070abf9abcffb605783ab3
size 61238 size 52151

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:31f2d59ad03a4d9ba8efd5f146668162f3c2095ac90e16dd3634d3e817f7263d oid sha256:b742f507ba11be8bdf733e5e751fc5d9fe1c0cc2eb070abf9abcffb605783ab3
size 61238 size 52151

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:fba2e76faa5915acf1e9d2ede6b58e1ab73e287aefa6e5ffc643be527350d987 oid sha256:467ca1b17857b053881cb9999c3d6b099949c6b4279188da57383fdb9083041f
size 50147 size 53946

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1089845cd143d07e0d6a363a9dc490ab6f5ae4711fc99843c01e4f074dbdeb98 oid sha256:d8e1ede8c0ea34595e90b2031c9607b99e72c3d7d1e9a778bbfbece98eb23875
size 55780 size 53035

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:59054f5692d41c0d6fe65c6f1bc9d0c4e1e3149aedd987e9335fabff7b46d85f oid sha256:a9f1d200ef2c93d557a67a29700918bd70439c7d7d8d247d60473b26f0c44eb3
size 57237 size 53851

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6f65151d983057ca185c53b4045fb33675f12730fee1b40e3df6d269e2290a05 oid sha256:b06e1652745ceff912734b9bf74543c95f4d420eaf3aec4b388dd101ea1428f8
size 47290 size 53838

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:de6a5ac50e8b30364b9e630d2edbe49c18cc3684da080e2167f41bd689f17700 oid sha256:5d4a381a3c99a7f04a11a2e93cd7c4145fa0bd33a1f7461038b3918c5cd94571
size 48369 size 46676

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:edcf03466e38ce3906583f2701f45d2a9538a6fd7e9d1c9d4822a037b833a512 oid sha256:61add95d28096c65f94764df109d253c7b589058cea324d908616312e7dffc3e
size 50185 size 54723

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:670f05e0c2861234708c7dee4d919a977bbd3aec3e965db631505957871f19fa oid sha256:3197e871e4b4c39dc714610b54e351c4061149bfc0b5511f78a0c73e8ec2236b
size 62904 size 51824

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9553c9ef84cd1f2e890290e093cb0006f03d1a2f36de5e2b7d104d0eb102b2e2 oid sha256:a0cf7d5d0c91aa7946586e5258077b07b329f387f46b0df0a29c49cc211ed584
size 63045 size 54021

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9553c9ef84cd1f2e890290e093cb0006f03d1a2f36de5e2b7d104d0eb102b2e2 oid sha256:a0cf7d5d0c91aa7946586e5258077b07b329f387f46b0df0a29c49cc211ed584
size 63045 size 54021

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:7ab533a35fbbba8b45d94bcfb0637d0f021ee2ef71230754ed418702ee18f11d oid sha256:cd4be4f49a78aba83c4b5f9c350f8b64cfe96d50890df16154cd7b0e20546d25
size 51182 size 55020

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9ec34df75877b7050ad874905926f5d35fee29d1f8324437b502d7152ef87cdc oid sha256:da79bef2bd54301f2724269719d93d1d9212fd17b9dc0d2a7ba935693c6170ac
size 57494 size 54107

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:08af9a706d579b4632c70265b6f1529008bd483cb4924c341e3a9f0172cc8cd4 oid sha256:bd05144d4b3527a44e1c9f3fb92d227ccc38fd7afce5436f32b59f14f0b54e27
size 39251 size 39028

View file

@ -92,7 +92,8 @@
"includeRegex": [ "includeRegex": [
"screen_room_details_.*", "screen_room_details_.*",
"screen_room_member_list_.*", "screen_room_member_list_.*",
"screen_dm_details_.*" "screen_dm_details_.*",
"screen_room_notification_settings_.*"
] ]
}, },
{ {