Render PinViolation above the composer.

This commit is contained in:
Benoit Marty 2024-10-01 17:35:42 +02:00 committed by Benoit Marty
parent 81fc52dcfa
commit c69e5f47e5
18 changed files with 509 additions and 1 deletions

View file

@ -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.model.TimelineItemAction
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.MessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
@ -91,6 +92,7 @@ class MessagesPresenter @AssistedInject constructor(
private val voiceMessageComposerPresenter: Presenter<VoiceMessageComposerState>,
timelinePresenterFactory: TimelinePresenter.Factory,
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
private val identityChangeStatePresenter: IdentityChangeStatePresenter,
private val actionListPresenterFactory: ActionListPresenter.Factory,
private val customReactionPresenter: Presenter<CustomReactionState>,
private val reactionSummaryPresenter: Presenter<ReactionSummaryState>,
@ -125,6 +127,7 @@ class MessagesPresenter @AssistedInject constructor(
val voiceMessageComposerState = voiceMessageComposerPresenter.present()
val timelineState = timelinePresenter.present()
val timelineProtectionState = timelineProtectionPresenter.present()
val identityChangeState = identityChangeStatePresenter.present()
val actionListState = actionListPresenter.present()
val customReactionState = customReactionPresenter.present()
val reactionSummaryState = reactionSummaryPresenter.present()
@ -217,6 +220,7 @@ class MessagesPresenter @AssistedInject constructor(
voiceMessageComposerState = voiceMessageComposerState,
timelineState = timelineState,
timelineProtectionState = timelineProtectionState,
identityChangeState = identityChangeState,
actionListState = actionListState,
customReactionState = customReactionState,
reactionSummaryState = reactionSummaryState,

View file

@ -9,6 +9,7 @@ package io.element.android.features.messages.impl
import androidx.compose.runtime.Immutable
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.pinned.banner.PinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.TimelineState
@ -34,6 +35,7 @@ data class MessagesState(
val voiceMessageComposerState: VoiceMessageComposerState,
val timelineState: TimelineState,
val timelineProtectionState: TimelineProtectionState,
val identityChangeState: IdentityChangeState,
val actionListState: ActionListState,
val customReactionState: CustomReactionState,
val reactionSummaryState: ReactionSummaryState,

View file

@ -10,6 +10,7 @@ package io.element.android.features.messages.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
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.crypto.identity.anIdentityChangeState
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.aMessageComposerState
@ -125,6 +126,7 @@ fun aMessagesState(
composerState = composerState,
voiceMessageComposerState = voiceMessageComposerState,
timelineProtectionState = timelineProtectionState,
identityChangeState = anIdentityChangeState(),
timelineState = timelineState,
readReceiptBottomSheetState = readReceiptBottomSheetState,
actionListState = actionListState,

View file

@ -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.model.TimelineItemAction
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.AttachmentsState
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.matrix.api.core.EventId
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 kotlinx.collections.immutable.ImmutableList
import timber.log.Timber
@ -448,6 +450,11 @@ private fun MessagesViewComposerBottomSheetContents(
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(
state = state.composerState,
voiceMessageState = state.voiceMessageComposerState,

View file

@ -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
}

View file

@ -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,
)

View file

@ -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,
)
}

View file

@ -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 = {},
)

View file

@ -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,
)
}

View file

@ -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 = {},
)
}

View file

@ -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.FakeActionListPresenter
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.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
@ -1016,6 +1017,9 @@ class MessagesPresenterTest {
}
}
val featureFlagService = FakeFeatureFlagService()
val identityChangeStatePresenter = IdentityChangeStatePresenter(
room = matrixRoom,
)
return MessagesPresenter(
room = matrixRoom,
composerPresenter = messageComposerPresenter,
@ -1026,6 +1030,7 @@ class MessagesPresenterTest {
customReactionPresenter = { aCustomReactionState() },
reactionSummaryPresenter = { aReactionSummaryState() },
readReceiptBottomSheetPresenter = { aReadReceiptBottomSheetState() },
identityChangeStatePresenter = identityChangeStatePresenter,
pinnedMessagesBannerPresenter = { aLoadedPinnedMessagesBannerState() },
networkMonitor = FakeNetworkMonitor(),
snackbarDispatcher = SnackbarDispatcher(),

View file

@ -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,
)
}
}