Render PinViolation above the composer.
This commit is contained in:
parent
81fc52dcfa
commit
c69e5f47e5
18 changed files with 509 additions and 1 deletions
|
|
@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
||||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
|
import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor
|
||||||
|
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStatePresenter
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||||
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
|
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
|
||||||
|
|
@ -91,6 +92,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
private val voiceMessageComposerPresenter: Presenter<VoiceMessageComposerState>,
|
private val voiceMessageComposerPresenter: Presenter<VoiceMessageComposerState>,
|
||||||
timelinePresenterFactory: TimelinePresenter.Factory,
|
timelinePresenterFactory: TimelinePresenter.Factory,
|
||||||
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
|
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
|
||||||
|
private val identityChangeStatePresenter: IdentityChangeStatePresenter,
|
||||||
private val actionListPresenterFactory: ActionListPresenter.Factory,
|
private val actionListPresenterFactory: ActionListPresenter.Factory,
|
||||||
private val customReactionPresenter: Presenter<CustomReactionState>,
|
private val customReactionPresenter: Presenter<CustomReactionState>,
|
||||||
private val reactionSummaryPresenter: Presenter<ReactionSummaryState>,
|
private val reactionSummaryPresenter: Presenter<ReactionSummaryState>,
|
||||||
|
|
@ -125,6 +127,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
val voiceMessageComposerState = voiceMessageComposerPresenter.present()
|
val voiceMessageComposerState = voiceMessageComposerPresenter.present()
|
||||||
val timelineState = timelinePresenter.present()
|
val timelineState = timelinePresenter.present()
|
||||||
val timelineProtectionState = timelineProtectionPresenter.present()
|
val timelineProtectionState = timelineProtectionPresenter.present()
|
||||||
|
val identityChangeState = identityChangeStatePresenter.present()
|
||||||
val actionListState = actionListPresenter.present()
|
val actionListState = actionListPresenter.present()
|
||||||
val customReactionState = customReactionPresenter.present()
|
val customReactionState = customReactionPresenter.present()
|
||||||
val reactionSummaryState = reactionSummaryPresenter.present()
|
val reactionSummaryState = reactionSummaryPresenter.present()
|
||||||
|
|
@ -217,6 +220,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
voiceMessageComposerState = voiceMessageComposerState,
|
voiceMessageComposerState = voiceMessageComposerState,
|
||||||
timelineState = timelineState,
|
timelineState = timelineState,
|
||||||
timelineProtectionState = timelineProtectionState,
|
timelineProtectionState = timelineProtectionState,
|
||||||
|
identityChangeState = identityChangeState,
|
||||||
actionListState = actionListState,
|
actionListState = actionListState,
|
||||||
customReactionState = customReactionState,
|
customReactionState = customReactionState,
|
||||||
reactionSummaryState = reactionSummaryState,
|
reactionSummaryState = reactionSummaryState,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ package io.element.android.features.messages.impl
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||||
|
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||||
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
|
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineState
|
import io.element.android.features.messages.impl.timeline.TimelineState
|
||||||
|
|
@ -34,6 +35,7 @@ data class MessagesState(
|
||||||
val voiceMessageComposerState: VoiceMessageComposerState,
|
val voiceMessageComposerState: VoiceMessageComposerState,
|
||||||
val timelineState: TimelineState,
|
val timelineState: TimelineState,
|
||||||
val timelineProtectionState: TimelineProtectionState,
|
val timelineProtectionState: TimelineProtectionState,
|
||||||
|
val identityChangeState: IdentityChangeState,
|
||||||
val actionListState: ActionListState,
|
val actionListState: ActionListState,
|
||||||
val customReactionState: CustomReactionState,
|
val customReactionState: CustomReactionState,
|
||||||
val reactionSummaryState: ReactionSummaryState,
|
val reactionSummaryState: ReactionSummaryState,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ package io.element.android.features.messages.impl
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||||
import io.element.android.features.messages.impl.actionlist.anActionListState
|
import io.element.android.features.messages.impl.actionlist.anActionListState
|
||||||
|
import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState
|
||||||
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
|
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||||
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
|
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
|
||||||
|
|
@ -125,6 +126,7 @@ fun aMessagesState(
|
||||||
composerState = composerState,
|
composerState = composerState,
|
||||||
voiceMessageComposerState = voiceMessageComposerState,
|
voiceMessageComposerState = voiceMessageComposerState,
|
||||||
timelineProtectionState = timelineProtectionState,
|
timelineProtectionState = timelineProtectionState,
|
||||||
|
identityChangeState = anIdentityChangeState(),
|
||||||
timelineState = timelineState,
|
timelineState = timelineState,
|
||||||
readReceiptBottomSheetState = readReceiptBottomSheetState,
|
readReceiptBottomSheetState = readReceiptBottomSheetState,
|
||||||
actionListState = actionListState,
|
actionListState = actionListState,
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListView
|
import io.element.android.features.messages.impl.actionlist.ActionListView
|
||||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||||
import io.element.android.features.messages.impl.attachments.Attachment
|
import io.element.android.features.messages.impl.attachments.Attachment
|
||||||
|
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStateView
|
||||||
import io.element.android.features.messages.impl.messagecomposer.AttachmentsBottomSheet
|
import io.element.android.features.messages.impl.messagecomposer.AttachmentsBottomSheet
|
||||||
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
|
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
||||||
|
|
@ -103,6 +104,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
|
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -448,6 +450,11 @@ private fun MessagesViewComposerBottomSheetContents(
|
||||||
state.composerState.eventSink(MessageComposerEvents.InsertSuggestion(it))
|
state.composerState.eventSink(MessageComposerEvents.InsertSuggestion(it))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
// Do not show the identity change if user is composing a Rich message or is seeing suggestion(s).
|
||||||
|
if (state.composerState.suggestions.isEmpty() &&
|
||||||
|
state.composerState.textEditorState is TextEditorState.Markdown) {
|
||||||
|
IdentityChangeStateView(state.identityChangeState)
|
||||||
|
}
|
||||||
MessageComposerView(
|
MessageComposerView(
|
||||||
state = state.composerState,
|
state = state.composerState,
|
||||||
voiceMessageState = state.voiceMessageComposerState,
|
voiceMessageState = state.voiceMessageComposerState,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.crypto.identity
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
|
||||||
|
sealed interface IdentityChangeEvent {
|
||||||
|
data class Submit(val userId: UserId) : IdentityChangeEvent
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.crypto.identity
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
|
data class IdentityChangeState(
|
||||||
|
val roomMemberIdentityStateChanges: ImmutableList<RoomMemberIdentityStateChange>,
|
||||||
|
val eventSink: (IdentityChangeEvent) -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RoomMemberIdentityStateChange(
|
||||||
|
val roomMember: RoomMember,
|
||||||
|
val identityState: IdentityState,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.crypto.identity
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
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.RoomMember
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||||
|
import io.element.android.libraries.matrix.api.room.roomMembers
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class IdentityChangeStatePresenter @Inject constructor(
|
||||||
|
private val room: MatrixRoom,
|
||||||
|
) : Presenter<IdentityChangeState> {
|
||||||
|
@Composable
|
||||||
|
override fun present(): IdentityChangeState {
|
||||||
|
val roomMemberIdentityStateChange = remember {
|
||||||
|
mutableStateOf(emptyList<RoomMemberIdentityStateChange>())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the ignored alert locally for now
|
||||||
|
val ignoredUserIdChange = rememberSaveable {
|
||||||
|
mutableStateOf(emptyList<UserId>())
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
observeRoomMemberIdentityStateChange(roomMemberIdentityStateChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleEvent(event: IdentityChangeEvent) {
|
||||||
|
when (event) {
|
||||||
|
is IdentityChangeEvent.Submit -> {
|
||||||
|
ignoredUserIdChange.value += event.userId
|
||||||
|
// TODO notify the SDK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return IdentityChangeState(
|
||||||
|
roomMemberIdentityStateChanges = roomMemberIdentityStateChange.value
|
||||||
|
.filter { it.roomMember.userId !in ignoredUserIdChange.value }
|
||||||
|
.toImmutableList(),
|
||||||
|
eventSink = ::handleEvent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CoroutineScope.observeRoomMemberIdentityStateChange(roomMemberIdentityStateChange: MutableState<List<RoomMemberIdentityStateChange>>) {
|
||||||
|
combine(room.identityStateChangesFlow, room.membersStateFlow) { IdentityStateChanges, membersState ->
|
||||||
|
IdentityStateChanges.map { IdentityStateChange ->
|
||||||
|
val member = membersState.roomMembers()
|
||||||
|
?.firstOrNull { roomMember -> roomMember.userId == IdentityStateChange.userId }
|
||||||
|
?: createDefaultRoomMemberForIdentityChange(IdentityStateChange.userId)
|
||||||
|
RoomMemberIdentityStateChange(
|
||||||
|
roomMember = member,
|
||||||
|
identityState = IdentityStateChange.identityState,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.onEach { roomMemberIdentityStateChanges ->
|
||||||
|
roomMemberIdentityStateChange.value = roomMemberIdentityStateChanges
|
||||||
|
}
|
||||||
|
.launchIn(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a default [RoomMember] for identity change events.
|
||||||
|
* In this case, only the userId will be used for rendering, other fields are not used, but keep them
|
||||||
|
* as close as possible to the actual data.
|
||||||
|
*/
|
||||||
|
private fun createDefaultRoomMemberForIdentityChange(userId: UserId): RoomMember {
|
||||||
|
return RoomMember(
|
||||||
|
userId = userId,
|
||||||
|
displayName = null,
|
||||||
|
avatarUrl = null,
|
||||||
|
membership = RoomMembershipState.JOIN,
|
||||||
|
isNameAmbiguous = false,
|
||||||
|
powerLevel = 0,
|
||||||
|
normalizedPowerLevel = 0,
|
||||||
|
isIgnored = false,
|
||||||
|
role = RoomMember.Role.USER,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.crypto.identity
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.features.messages.impl.typing.aTypingRoomMember
|
||||||
|
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
||||||
|
class IdentityChangeStateProvider : PreviewParameterProvider<IdentityChangeState> {
|
||||||
|
override val values: Sequence<IdentityChangeState>
|
||||||
|
get() = sequenceOf(
|
||||||
|
anIdentityChangeState(),
|
||||||
|
anIdentityChangeState(
|
||||||
|
roomMemberIdentityStateChanges = listOf(
|
||||||
|
RoomMemberIdentityStateChange(
|
||||||
|
roomMember = aTypingRoomMember(displayName = "Alice"),
|
||||||
|
identityState = IdentityState.PinViolation,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun anIdentityChangeState(
|
||||||
|
roomMemberIdentityStateChanges: List<RoomMemberIdentityStateChange> = emptyList(),
|
||||||
|
) = IdentityChangeState(
|
||||||
|
roomMemberIdentityStateChanges = roomMemberIdentityStateChanges.toImmutableList(),
|
||||||
|
eventSink = {},
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.crypto.identity
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||||
|
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun IdentityChangeStateView(
|
||||||
|
state: IdentityChangeState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
// Pick the first identity change to PinViolation
|
||||||
|
val identityChange = state.roomMemberIdentityStateChanges.firstOrNull {
|
||||||
|
// For now only render PinViolation
|
||||||
|
it.identityState == IdentityState.PinViolation
|
||||||
|
}
|
||||||
|
if (identityChange != null) {
|
||||||
|
ComposerAlertMolecule(
|
||||||
|
modifier = modifier,
|
||||||
|
avatar = identityChange.roomMember.getAvatarData(AvatarSize.ComposerAlert),
|
||||||
|
content = buildAnnotatedString {
|
||||||
|
val coloredPart = stringResource(CommonStrings.action_learn_more)
|
||||||
|
val fullText = stringResource(
|
||||||
|
CommonStrings.crypto_identity_change_pin_violation,
|
||||||
|
identityChange.roomMember.disambiguatedDisplayName,
|
||||||
|
coloredPart,
|
||||||
|
)
|
||||||
|
val startIndex = fullText.indexOf(coloredPart)
|
||||||
|
append(fullText)
|
||||||
|
addStyle(
|
||||||
|
style = SpanStyle(
|
||||||
|
textDecoration = TextDecoration.Underline,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
),
|
||||||
|
start = startIndex,
|
||||||
|
end = startIndex + coloredPart.length,
|
||||||
|
)
|
||||||
|
addStringAnnotation(
|
||||||
|
tag = "LEARN_MORE",
|
||||||
|
annotation = "TODO",
|
||||||
|
start = startIndex,
|
||||||
|
end = startIndex + coloredPart.length
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSubmitClick = { state.eventSink(IdentityChangeEvent.Submit(identityChange.roomMember.userId)) },
|
||||||
|
isCritical = identityChange.identityState == IdentityState.VerificationViolation,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun IdentityChangeStateViewPreview(
|
||||||
|
@PreviewParameter(IdentityChangeStateProvider::class) state: IdentityChangeState,
|
||||||
|
) = ElementPreview {
|
||||||
|
IdentityChangeStateView(
|
||||||
|
state = state,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.crypto.identity
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import io.element.android.features.messages.impl.MessagesView
|
||||||
|
import io.element.android.features.messages.impl.aMessagesState
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun MessagesViewWithIdentityChangePreview(
|
||||||
|
@PreviewParameter(IdentityChangeStateProvider::class) identityChangeState: IdentityChangeState
|
||||||
|
) = ElementPreview {
|
||||||
|
MessagesView(
|
||||||
|
state = aMessagesState().copy(identityChangeState = identityChangeState),
|
||||||
|
onBackClick = {},
|
||||||
|
onRoomDetailsClick = {},
|
||||||
|
onEventClick = { false },
|
||||||
|
onUserDataClick = {},
|
||||||
|
onLinkClick = {},
|
||||||
|
onPreviewAttachments = {},
|
||||||
|
onSendLocationClick = {},
|
||||||
|
onCreatePollClick = {},
|
||||||
|
onJoinCallClick = {},
|
||||||
|
onViewAllPinnedMessagesClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||||
import io.element.android.features.messages.impl.actionlist.FakeActionListPresenter
|
import io.element.android.features.messages.impl.actionlist.FakeActionListPresenter
|
||||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||||
|
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStatePresenter
|
||||||
import io.element.android.features.messages.impl.fixtures.aMessageEvent
|
import io.element.android.features.messages.impl.fixtures.aMessageEvent
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||||
|
|
@ -1016,6 +1017,9 @@ class MessagesPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val featureFlagService = FakeFeatureFlagService()
|
val featureFlagService = FakeFeatureFlagService()
|
||||||
|
val identityChangeStatePresenter = IdentityChangeStatePresenter(
|
||||||
|
room = matrixRoom,
|
||||||
|
)
|
||||||
return MessagesPresenter(
|
return MessagesPresenter(
|
||||||
room = matrixRoom,
|
room = matrixRoom,
|
||||||
composerPresenter = messageComposerPresenter,
|
composerPresenter = messageComposerPresenter,
|
||||||
|
|
@ -1026,6 +1030,7 @@ class MessagesPresenterTest {
|
||||||
customReactionPresenter = { aCustomReactionState() },
|
customReactionPresenter = { aCustomReactionState() },
|
||||||
reactionSummaryPresenter = { aReactionSummaryState() },
|
reactionSummaryPresenter = { aReactionSummaryState() },
|
||||||
readReceiptBottomSheetPresenter = { aReadReceiptBottomSheetState() },
|
readReceiptBottomSheetPresenter = { aReadReceiptBottomSheetState() },
|
||||||
|
identityChangeStatePresenter = identityChangeStatePresenter,
|
||||||
pinnedMessagesBannerPresenter = { aLoadedPinnedMessagesBannerState() },
|
pinnedMessagesBannerPresenter = { aLoadedPinnedMessagesBannerState() },
|
||||||
networkMonitor = FakeNetworkMonitor(),
|
networkMonitor = FakeNetworkMonitor(),
|
||||||
snackbarDispatcher = SnackbarDispatcher(),
|
snackbarDispatcher = SnackbarDispatcher(),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.crypto.identity
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.messages.impl.typing.aTypingRoomMember
|
||||||
|
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||||
|
import io.element.android.libraries.matrix.api.encryption.identity.IdentityStateChange
|
||||||
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
|
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||||
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
|
import io.element.android.tests.testutils.test
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class IdentityChangeStatePresenterTest {
|
||||||
|
@get:Rule
|
||||||
|
val warmUpRule = WarmUpRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - initial state`() = runTest {
|
||||||
|
val presenter = createIdentityChangeStatePresenter()
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.roomMemberIdentityStateChanges).isEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - when the room emits identity change, the presenter emits new state`() = runTest {
|
||||||
|
val room = FakeMatrixRoom()
|
||||||
|
val presenter = createIdentityChangeStatePresenter(room)
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.roomMemberIdentityStateChanges).isEmpty()
|
||||||
|
room.emitIdentityStateChanges(
|
||||||
|
listOf(
|
||||||
|
IdentityStateChange(
|
||||||
|
userId = A_USER_ID_2,
|
||||||
|
identityState = IdentityState.PinViolation,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val finalItem = awaitItem()
|
||||||
|
assertThat(finalItem.roomMemberIdentityStateChanges).hasSize(1)
|
||||||
|
val value = finalItem.roomMemberIdentityStateChanges.first()
|
||||||
|
assertThat(value.roomMember.userId).isEqualTo(A_USER_ID_2)
|
||||||
|
assertThat(value.identityState).isEqualTo(IdentityState.PinViolation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - when the room emits identity change, the presenter emits new state with member details`() =
|
||||||
|
runTest {
|
||||||
|
val room = FakeMatrixRoom().apply {
|
||||||
|
givenRoomMembersState(
|
||||||
|
MatrixRoomMembersState.Ready(
|
||||||
|
listOf(
|
||||||
|
aTypingRoomMember(
|
||||||
|
A_USER_ID_2,
|
||||||
|
displayName = "Alice",
|
||||||
|
),
|
||||||
|
).toImmutableList()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val presenter = createIdentityChangeStatePresenter(room)
|
||||||
|
presenter.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.roomMemberIdentityStateChanges).isEmpty()
|
||||||
|
room.emitIdentityStateChanges(
|
||||||
|
listOf(
|
||||||
|
IdentityStateChange(
|
||||||
|
userId = A_USER_ID_2,
|
||||||
|
identityState = IdentityState.PinViolation,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val finalItem = awaitItem()
|
||||||
|
assertThat(finalItem.roomMemberIdentityStateChanges).hasSize(1)
|
||||||
|
val value = finalItem.roomMemberIdentityStateChanges.first()
|
||||||
|
assertThat(value.roomMember.userId).isEqualTo(A_USER_ID_2)
|
||||||
|
assertThat(value.roomMember.displayName).isEqualTo("Alice")
|
||||||
|
assertThat(value.identityState).isEqualTo(IdentityState.PinViolation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createIdentityChangeStatePresenter(
|
||||||
|
room: MatrixRoom = FakeMatrixRoom(),
|
||||||
|
): IdentityChangeStatePresenter {
|
||||||
|
return IdentityChangeStatePresenter(
|
||||||
|
room = room,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.api.encryption.identity
|
||||||
|
|
||||||
|
enum class IdentityState {
|
||||||
|
/** The user is verified with us */
|
||||||
|
Verified,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Either this is the first identity we have seen for this user, or the
|
||||||
|
* user has acknowledged a change of identity explicitly e.g. by
|
||||||
|
* clicking OK on a notification.
|
||||||
|
*/
|
||||||
|
Pinned,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's identity has changed since it was pinned. The user should be
|
||||||
|
* notified about this and given the opportunity to acknowledge the
|
||||||
|
* change, which will make the new identity pinned.
|
||||||
|
*/
|
||||||
|
PinViolation,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's identity has changed, and before that it was verified. This
|
||||||
|
* is a serious problem. The user can either verify again to make this
|
||||||
|
* identity verified, or withdraw verification to make it pinned.
|
||||||
|
*/
|
||||||
|
VerificationViolation,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.api.encryption.identity
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
|
||||||
|
data class IdentityStateChange(
|
||||||
|
val userId: UserId,
|
||||||
|
val identityState: IdentityState,
|
||||||
|
)
|
||||||
|
|
@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
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.UniqueId
|
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||||
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.encryption.identity.IdentityStateChange
|
||||||
import io.element.android.libraries.matrix.api.media.AudioInfo
|
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
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
|
||||||
|
|
@ -52,6 +53,7 @@ interface MatrixRoom : Closeable {
|
||||||
|
|
||||||
val roomInfoFlow: Flow<MatrixRoomInfo>
|
val roomInfoFlow: Flow<MatrixRoomInfo>
|
||||||
val roomTypingMembersFlow: Flow<List<UserId>>
|
val roomTypingMembersFlow: Flow<List<UserId>>
|
||||||
|
val identityStateChangesFlow: Flow<List<IdentityStateChange>>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A one-to-one is a room with exactly 2 members.
|
* A one-to-one is a room with exactly 2 members.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.impl.mapper
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||||
|
import org.matrix.rustcomponents.sdk.IdentityState as RustIdentityState
|
||||||
|
|
||||||
|
fun RustIdentityState.map(): IdentityState = when (this) {
|
||||||
|
RustIdentityState.VERIFIED -> IdentityState.Verified
|
||||||
|
RustIdentityState.PINNED -> IdentityState.Pinned
|
||||||
|
RustIdentityState.PIN_VIOLATION -> IdentityState.PinViolation
|
||||||
|
RustIdentityState.VERIFICATION_VIOLATION -> IdentityState.VerificationViolation
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
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.UniqueId
|
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||||
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.encryption.identity.IdentityStateChange
|
||||||
import io.element.android.libraries.matrix.api.media.AudioInfo
|
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
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
|
||||||
|
|
@ -43,6 +44,7 @@ import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
||||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
|
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
|
||||||
|
import io.element.android.libraries.matrix.impl.mapper.map
|
||||||
import io.element.android.libraries.matrix.impl.room.draft.into
|
import io.element.android.libraries.matrix.impl.room.draft.into
|
||||||
import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher
|
import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher
|
||||||
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
|
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
|
||||||
|
|
@ -69,6 +71,7 @@ import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener
|
||||||
import org.matrix.rustcomponents.sdk.RoomInfo
|
import org.matrix.rustcomponents.sdk.RoomInfo
|
||||||
import org.matrix.rustcomponents.sdk.RoomInfoListener
|
import org.matrix.rustcomponents.sdk.RoomInfoListener
|
||||||
import org.matrix.rustcomponents.sdk.RoomListItem
|
import org.matrix.rustcomponents.sdk.RoomListItem
|
||||||
|
|
@ -82,6 +85,7 @@ import timber.log.Timber
|
||||||
import uniffi.matrix_sdk.RoomPowerLevelChanges
|
import uniffi.matrix_sdk.RoomPowerLevelChanges
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
import org.matrix.rustcomponents.sdk.IdentityStatusChange as RustIdentityStateChange
|
||||||
import org.matrix.rustcomponents.sdk.Room as InnerRoom
|
import org.matrix.rustcomponents.sdk.Room as InnerRoom
|
||||||
import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
|
import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
|
||||||
|
|
||||||
|
|
@ -130,6 +134,23 @@ class RustMatrixRoom(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override val identityStateChangesFlow: Flow<List<IdentityStateChange>> = mxCallbackFlow {
|
||||||
|
val initial = emptyList<IdentityStateChange>()
|
||||||
|
channel.trySend(initial)
|
||||||
|
innerRoom.subscribeToIdentityStatusChanges(object : IdentityStatusChangeListener {
|
||||||
|
override fun call(identityStatusChange: List<RustIdentityStateChange>) {
|
||||||
|
channel.trySend(
|
||||||
|
identityStatusChange.map {
|
||||||
|
IdentityStateChange(
|
||||||
|
userId = UserId(it.userId),
|
||||||
|
identityState = it.changedTo.map(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Create a dispatcher for all room methods...
|
// Create a dispatcher for all room methods...
|
||||||
private val roomDispatcher = coroutineDispatchers.io.limitedParallelism(32)
|
private val roomDispatcher = coroutineDispatchers.io.limitedParallelism(32)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
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.UniqueId
|
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||||
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.encryption.identity.IdentityStateChange
|
||||||
import io.element.android.libraries.matrix.api.media.AudioInfo
|
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
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
|
||||||
|
|
@ -137,7 +138,7 @@ class FakeMatrixRoom(
|
||||||
private val subscribeToSyncLambda: () -> Unit = { lambdaError() },
|
private val subscribeToSyncLambda: () -> Unit = { lambdaError() },
|
||||||
private val ignoreDeviceTrustAndResendResult: (Map<UserId, List<DeviceId>>, TransactionId) -> Result<Unit> = { _, _ -> lambdaError() },
|
private val ignoreDeviceTrustAndResendResult: (Map<UserId, List<DeviceId>>, TransactionId) -> Result<Unit> = { _, _ -> lambdaError() },
|
||||||
private val withdrawVerificationAndResendResult: (List<UserId>, TransactionId) -> Result<Unit> = { _, _ -> lambdaError() },
|
private val withdrawVerificationAndResendResult: (List<UserId>, TransactionId) -> Result<Unit> = { _, _ -> lambdaError() },
|
||||||
) : MatrixRoom {
|
) : MatrixRoom {
|
||||||
private val _roomInfoFlow: MutableSharedFlow<MatrixRoomInfo> = MutableSharedFlow(replay = 1)
|
private val _roomInfoFlow: MutableSharedFlow<MatrixRoomInfo> = MutableSharedFlow(replay = 1)
|
||||||
override val roomInfoFlow: Flow<MatrixRoomInfo> = _roomInfoFlow
|
override val roomInfoFlow: Flow<MatrixRoomInfo> = _roomInfoFlow
|
||||||
|
|
||||||
|
|
@ -152,6 +153,13 @@ class FakeMatrixRoom(
|
||||||
_roomTypingMembersFlow.tryEmit(typingMembers)
|
_roomTypingMembersFlow.tryEmit(typingMembers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val _identityStateChangesFlow: MutableSharedFlow<List<IdentityStateChange>> = MutableSharedFlow(replay = 1)
|
||||||
|
override val identityStateChangesFlow: Flow<List<IdentityStateChange>> = _identityStateChangesFlow
|
||||||
|
|
||||||
|
fun emitIdentityStateChanges(identityStateChanges: List<IdentityStateChange>) {
|
||||||
|
_identityStateChangesFlow.tryEmit(identityStateChanges)
|
||||||
|
}
|
||||||
|
|
||||||
override val membersStateFlow: MutableStateFlow<MatrixRoomMembersState> = MutableStateFlow(MatrixRoomMembersState.Unknown)
|
override val membersStateFlow: MutableStateFlow<MatrixRoomMembersState> = MutableStateFlow(MatrixRoomMembersState.Unknown)
|
||||||
|
|
||||||
override val roomNotificationSettingsStateFlow: MutableStateFlow<MatrixRoomNotificationSettingsState> =
|
override val roomNotificationSettingsStateFlow: MutableStateFlow<MatrixRoomNotificationSettingsState> =
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue