Merge pull request #3461 from element-hq/feature/fga/send_failure_identity_changes

Require acknowledgement to send to a verified user if their identity changed or if a device is unverified.
This commit is contained in:
ganfra 2024-09-16 16:00:18 +02:00 committed by GitHub
commit 60365e46a5
50 changed files with 1355 additions and 87 deletions

View file

@ -241,6 +241,9 @@ fun MessagesView(
state.customReactionState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event))
},
onEmojiReactionClick = ::onEmojiReactionClick,
onVerifiedUserSendFailureClick = { event ->
state.timelineState.eventSink(TimelineEvents.ComputeVerifiedUserSendFailure(event))
},
)
CustomReactionBottomSheet(

View file

@ -22,6 +22,8 @@ import io.element.android.features.messages.api.pinned.IsPinnedMessagesFeatureEn
import io.element.android.features.messages.impl.UserEventPermissions
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.sendfailure.VerifiedUserSendFailure
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
@ -56,6 +58,7 @@ class DefaultActionListPresenter @AssistedInject constructor(
private val appPreferencesStore: AppPreferencesStore,
private val isPinnedMessagesFeatureEnabled: IsPinnedMessagesFeatureEnabled,
private val room: MatrixRoom,
private val userSendFailureFactory: VerifiedUserSendFailureFactory,
) : ActionListPresenter {
@AssistedFactory
@ContributesBinding(RoomScope::class)
@ -115,12 +118,14 @@ class DefaultActionListPresenter @AssistedInject constructor(
isEventPinned = pinnedEventIds.contains(timelineItem.eventId),
)
val displayEmojiReactions = usersEventPermissions.canSendReaction &&
timelineItem.content.canReact()
if (actions.isNotEmpty() || displayEmojiReactions) {
val verifiedUserSendFailure = userSendFailureFactory.create(timelineItem.localSendState)
val displayEmojiReactions = usersEventPermissions.canSendReaction && timelineItem.content.canReact()
if (actions.isNotEmpty() || displayEmojiReactions || verifiedUserSendFailure != VerifiedUserSendFailure.None) {
target.value = ActionListState.Target.Success(
event = timelineItem,
displayEmojiReactions = displayEmojiReactions,
verifiedUserSendFailure = verifiedUserSendFailure,
actions = actions.toImmutableList()
)
} else {

View file

@ -9,6 +9,7 @@ package io.element.android.features.messages.impl.actionlist
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import kotlinx.collections.immutable.ImmutableList
@ -17,12 +18,14 @@ data class ActionListState(
val target: Target,
val eventSink: (ActionListEvents) -> Unit,
) {
@Immutable
sealed interface Target {
data object None : Target
data class Loading(val event: TimelineItem.Event) : Target
data class Success(
val event: TimelineItem.Event,
val displayEmojiReactions: Boolean,
val verifiedUserSendFailure: VerifiedUserSendFailure,
val actions: ImmutableList<TimelineItemAction>,
) : Target
}

View file

@ -9,6 +9,8 @@ package io.element.android.features.messages.impl.actionlist
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.anUnsignedDeviceSendFailure
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAudioContent
@ -35,6 +37,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
reactionsState = reactionsState
),
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
)
),
@ -47,6 +50,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
reactionsState = reactionsState,
),
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
)
),
@ -56,6 +60,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
reactionsState = reactionsState
),
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
)
),
@ -65,6 +70,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
reactionsState = reactionsState
),
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
)
),
@ -74,6 +80,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
reactionsState = reactionsState
),
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
)
),
@ -83,6 +90,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
reactionsState = reactionsState
),
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
)
),
@ -92,6 +100,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
reactionsState = reactionsState
),
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
)
),
@ -101,6 +110,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
reactionsState = reactionsState
),
displayEmojiReactions = false,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
),
),
@ -110,6 +120,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
reactionsState = reactionsState
),
displayEmojiReactions = false,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemPollActionList(),
),
),
@ -120,6 +131,15 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
messageShield = MessageShield.UnknownDevice(isCritical = true)
),
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
)
),
anActionListState().copy(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(),
displayEmojiReactions = true,
verifiedUserSendFailure = anUnsignedDeviceSendFailure(),
actions = aTimelineItemActionList(),
)
),

View file

@ -27,6 +27,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
@ -46,6 +47,10 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure.ChangedIdentity
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure.None
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure.UnsignedDevice
import io.element.android.features.messages.impl.timeline.components.MessageShieldView
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
@ -90,6 +95,7 @@ fun ActionListView(
onSelectAction: (action: TimelineItemAction, TimelineItem.Event) -> Unit,
onEmojiReactionClick: (String, TimelineItem.Event) -> Unit,
onCustomReactionClick: (TimelineItem.Event) -> Unit,
onVerifiedUserSendFailureClick: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier,
) {
val sheetState = rememberModalBottomSheetState()
@ -126,6 +132,14 @@ fun ActionListView(
state.eventSink(ActionListEvents.Clear)
}
fun onVerifiedUserSendFailureClick() {
if (targetItem == null) return
sheetState.hide(coroutineScope) {
state.eventSink(ActionListEvents.Clear)
onVerifiedUserSendFailureClick(targetItem)
}
}
if (targetItem != null) {
ModalBottomSheet(
sheetState = sheetState,
@ -137,6 +151,7 @@ fun ActionListView(
onActionClick = ::onItemActionClick,
onEmojiReactionClick = ::onEmojiReactionClick,
onCustomReactionClick = ::onCustomReactionClick,
onVerifiedUserSendFailureClick = ::onVerifiedUserSendFailureClick,
modifier = Modifier
.navigationBarsPadding()
.imePadding()
@ -151,6 +166,7 @@ private fun SheetContent(
onActionClick: (TimelineItemAction) -> Unit,
onEmojiReactionClick: (String) -> Unit,
onCustomReactionClick: () -> Unit,
onVerifiedUserSendFailureClick: () -> Unit,
modifier: Modifier = Modifier,
) {
when (val target = state.target) {
@ -184,6 +200,16 @@ private fun SheetContent(
HorizontalDivider()
}
}
if (target.verifiedUserSendFailure != None) {
item {
VerifiedUserSendFailureView(
sendFailure = target.verifiedUserSendFailure,
modifier = Modifier.fillMaxWidth(),
onClick = onVerifiedUserSendFailureClick
)
HorizontalDivider()
}
}
if (target.displayEmojiReactions) {
item {
EmojiReactionsRow(
@ -338,6 +364,42 @@ private fun EmojiReactionsRow(
}
}
@Composable
private fun VerifiedUserSendFailureView(
sendFailure: VerifiedUserSendFailure,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
@Composable
fun VerifiedUserSendFailure.headline(): String {
return when (this) {
is None -> ""
is UnsignedDevice -> stringResource(CommonStrings.screen_timeline_item_menu_send_failure_unsigned_device, userDisplayName)
is ChangedIdentity -> stringResource(CommonStrings.screen_timeline_item_menu_send_failure_changed_identity, userDisplayName)
}
}
ListItem(
modifier = modifier
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Error())),
trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChevronRight())),
headlineContent = {
Text(
text = sendFailure.headline(),
style = ElementTheme.typography.fontBodySmMedium,
)
},
colors = ListItemDefaults.colors(
containerColor = Color.Transparent,
leadingIconColor = ElementTheme.colors.iconCriticalPrimary,
trailingIconColor = ElementTheme.colors.iconPrimary,
headlineColor = ElementTheme.colors.textCriticalPrimary,
),
)
}
@Composable
private fun EmojiButton(
emoji: String,
@ -387,5 +449,6 @@ internal fun SheetContentPreview(
onActionClick = {},
onEmojiReactionClick = {},
onCustomReactionClick = {},
onVerifiedUserSendFailureClick = {},
)
}

View file

@ -0,0 +1,23 @@
/*
* 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.sendfailure
import androidx.compose.runtime.Immutable
@Immutable
sealed interface VerifiedUserSendFailure {
data object None : VerifiedUserSendFailure
data class UnsignedDevice(
val userDisplayName: String,
) : VerifiedUserSendFailure
data class ChangedIdentity(
val userDisplayName: String,
) : VerifiedUserSendFailure
}

View file

@ -0,0 +1,42 @@
/*
* 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.sendfailure
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import javax.inject.Inject
class VerifiedUserSendFailureFactory @Inject constructor(
private val room: MatrixRoom,
) {
suspend fun create(
sendState: LocalEventSendState?,
): VerifiedUserSendFailure {
return when (sendState) {
is LocalEventSendState.Failed.VerifiedUserHasUnsignedDevice -> {
val userId = sendState.devices.keys.firstOrNull()
if (userId == null) {
VerifiedUserSendFailure.None
} else {
val displayName = room.userDisplayName(userId).getOrNull() ?: userId.value
VerifiedUserSendFailure.UnsignedDevice(displayName)
}
}
is LocalEventSendState.Failed.VerifiedUserChangedIdentity -> {
val userId = sendState.users.firstOrNull()
if (userId == null) {
VerifiedUserSendFailure.None
} else {
val displayName = room.userDisplayName(userId).getOrNull() ?: userId.value
VerifiedUserSendFailure.ChangedIdentity(displayName)
}
}
else -> VerifiedUserSendFailure.None
}
}
}

View file

@ -0,0 +1,20 @@
/*
* 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.sendfailure.resolve
import io.element.android.features.messages.impl.timeline.model.TimelineItem
sealed interface ResolveVerifiedUserSendFailureEvents {
data class ComputeForMessage(
val messageEvent: TimelineItem.Event,
) : ResolveVerifiedUserSendFailureEvents
data object ResolveAndResend : ResolveVerifiedUserSendFailureEvents
data object Retry : ResolveVerifiedUserSendFailureEvents
data object Dismiss : ResolveVerifiedUserSendFailureEvents
}

View file

@ -0,0 +1,95 @@
/*
* 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.sendfailure.resolve
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import kotlinx.coroutines.launch
import javax.inject.Inject
class ResolveVerifiedUserSendFailurePresenter @Inject constructor(
private val room: MatrixRoom,
private val verifiedUserSendFailureFactory: VerifiedUserSendFailureFactory,
) : Presenter<ResolveVerifiedUserSendFailureState> {
@Composable
override fun present(): ResolveVerifiedUserSendFailureState {
var resolver by remember {
mutableStateOf<VerifiedUserSendFailureResolver?>(null)
}
val verifiedUserSendFailure by produceState<VerifiedUserSendFailure>(VerifiedUserSendFailure.None, resolver?.currentSendFailure?.value) {
val currentSendFailure = resolver?.currentSendFailure?.value
value = verifiedUserSendFailureFactory.create(currentSendFailure)
}
val resolveAction = remember {
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
}
val retryAction = remember {
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
}
val coroutineScope = rememberCoroutineScope()
fun handleEvents(event: ResolveVerifiedUserSendFailureEvents) {
when (event) {
is ResolveVerifiedUserSendFailureEvents.ComputeForMessage -> {
val sendState = event.messageEvent.localSendState as? LocalEventSendState.Failed.VerifiedUser
val transactionId = event.messageEvent.transactionId
resolver = if (sendState != null && transactionId != null) {
VerifiedUserSendFailureResolver(
room = room,
transactionId = transactionId,
iterator = VerifiedUserSendFailureIterator.from(sendState)
)
} else {
null
}
}
ResolveVerifiedUserSendFailureEvents.Dismiss -> {
resolver = null
}
ResolveVerifiedUserSendFailureEvents.Retry -> {
coroutineScope.launch {
resolver?.run {
runUpdatingState(retryAction) {
resend()
}
}
}
}
ResolveVerifiedUserSendFailureEvents.ResolveAndResend -> {
coroutineScope.launch {
resolver?.run {
runUpdatingState(resolveAction) {
resolveAndResend()
}
}
}
}
}
}
return ResolveVerifiedUserSendFailureState(
verifiedUserSendFailure = verifiedUserSendFailure,
resolveAction = resolveAction.value,
retryAction = retryAction.value,
eventSink = ::handleEvents
)
}
}

View file

@ -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.features.messages.impl.crypto.sendfailure.resolve
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
import io.element.android.libraries.architecture.AsyncAction
data class ResolveVerifiedUserSendFailureState(
val verifiedUserSendFailure: VerifiedUserSendFailure,
val resolveAction: AsyncAction<Unit>,
val retryAction: AsyncAction<Unit>,
val eventSink: (ResolveVerifiedUserSendFailureEvents) -> Unit
)

View file

@ -0,0 +1,45 @@
/*
* 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.sendfailure.resolve
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
import io.element.android.libraries.architecture.AsyncAction
open class ResolveVerifiedUserSendFailureStateProvider : PreviewParameterProvider<ResolveVerifiedUserSendFailureState> {
override val values: Sequence<ResolveVerifiedUserSendFailureState>
get() = sequenceOf(
aResolveVerifiedUserSendFailureState(),
aResolveVerifiedUserSendFailureState(
verifiedUserSendFailure = anUnsignedDeviceSendFailure()
),
aResolveVerifiedUserSendFailureState(
verifiedUserSendFailure = aChangedIdentitySendFailure()
)
)
}
fun aResolveVerifiedUserSendFailureState(
verifiedUserSendFailure: VerifiedUserSendFailure = VerifiedUserSendFailure.None,
resolveAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
retryAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (ResolveVerifiedUserSendFailureEvents) -> Unit = {}
) = ResolveVerifiedUserSendFailureState(
verifiedUserSendFailure = verifiedUserSendFailure,
resolveAction = resolveAction,
retryAction = retryAction,
eventSink = eventSink
)
fun anUnsignedDeviceSendFailure(userDisplayName: String = "Alice") = VerifiedUserSendFailure.UnsignedDevice(
userDisplayName = userDisplayName,
)
fun aChangedIdentitySendFailure(userDisplayName: String = "Alice") = VerifiedUserSendFailure.ChangedIdentity(
userDisplayName = userDisplayName,
)

View file

@ -0,0 +1,156 @@
/*
* 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.sendfailure.resolve
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ResolveVerifiedUserSendFailureView(
state: ResolveVerifiedUserSendFailureState,
modifier: Modifier = Modifier,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var showSheet by remember { mutableStateOf(false) }
fun dismiss() {
state.eventSink(ResolveVerifiedUserSendFailureEvents.Dismiss)
}
fun onRetryClick() {
state.eventSink(ResolveVerifiedUserSendFailureEvents.Retry)
}
fun onResolveAndResendClick() {
state.eventSink(ResolveVerifiedUserSendFailureEvents.ResolveAndResend)
}
LaunchedEffect(state.verifiedUserSendFailure) {
if (state.verifiedUserSendFailure is VerifiedUserSendFailure.None) {
sheetState.hide()
showSheet = false
} else {
showSheet = true
}
}
Box(modifier = modifier) {
if (showSheet) {
ModalBottomSheet(
modifier = Modifier
.systemBarsPadding()
.navigationBarsPadding(),
sheetState = sheetState,
onDismissRequest = ::dismiss,
) {
IconTitleSubtitleMolecule(
modifier = Modifier.padding(24.dp),
title = state.verifiedUserSendFailure.title(),
subTitle = state.verifiedUserSendFailure.subtitle(),
iconImageVector = CompoundIcons.Error(),
iconTint = ElementTheme.colors.iconCriticalPrimary,
iconBackgroundTint = ElementTheme.colors.bgCriticalSubtle,
)
ButtonColumnMolecule(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp),
) {
Button(
modifier = Modifier.fillMaxWidth(),
text = state.verifiedUserSendFailure.resolveAction(),
showProgress = state.resolveAction.isLoading(),
onClick = ::onResolveAndResendClick
)
OutlinedButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(id = CommonStrings.action_retry),
showProgress = state.retryAction.isLoading(),
onClick = ::onRetryClick
)
TextButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(id = CommonStrings.action_cancel_for_now),
onClick = ::dismiss,
)
}
}
}
}
}
@Composable
private fun VerifiedUserSendFailure.title(): String {
return when (this) {
is VerifiedUserSendFailure.UnsignedDevice -> stringResource(id = CommonStrings.screen_resolve_send_failure_unsigned_device_title, userDisplayName)
is VerifiedUserSendFailure.ChangedIdentity -> stringResource(
id = CommonStrings.screen_resolve_send_failure_changed_identity_title,
userDisplayName
)
VerifiedUserSendFailure.None -> error("This method should never be called for this state")
}
}
@Composable
private fun VerifiedUserSendFailure.subtitle(): String {
return when (this) {
is VerifiedUserSendFailure.UnsignedDevice -> stringResource(
id = CommonStrings.screen_resolve_send_failure_unsigned_device_subtitle,
userDisplayName,
userDisplayName,
)
is VerifiedUserSendFailure.ChangedIdentity -> stringResource(
id = CommonStrings.screen_resolve_send_failure_changed_identity_subtitle,
userDisplayName
)
VerifiedUserSendFailure.None -> error("This method should never be called for this state")
}
}
@Composable
private fun VerifiedUserSendFailure.resolveAction(): String {
return when (this) {
is VerifiedUserSendFailure.UnsignedDevice -> stringResource(id = CommonStrings.screen_resolve_send_failure_unsigned_device_primary_button_title)
is VerifiedUserSendFailure.ChangedIdentity -> stringResource(id = CommonStrings.screen_resolve_send_failure_changed_identity_primary_button_title)
VerifiedUserSendFailure.None -> error("This method should never be called for this state")
}
}
@PreviewsDayNight
@Composable
internal fun ResolveVerifiedUserSendFailureViewPreview(
@PreviewParameter(ResolveVerifiedUserSendFailureStateProvider::class) state: ResolveVerifiedUserSendFailureState
) = ElementPreview {
ResolveVerifiedUserSendFailureView(state)
}

View file

@ -0,0 +1,73 @@
/*
* 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.sendfailure.resolve
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import timber.log.Timber
/**
* Iterator for [LocalEventSendState.Failed.VerifiedUser]
* Allow to iterate through the internal state of the failure.
* This is useful to allow solving the failure step by step (e.g. for each user).
*/
interface VerifiedUserSendFailureIterator : Iterator<LocalEventSendState.Failed.VerifiedUser> {
companion object {
fun from(failure: LocalEventSendState.Failed.VerifiedUser): VerifiedUserSendFailureIterator {
return when (failure) {
is LocalEventSendState.Failed.VerifiedUserHasUnsignedDevice -> UnsignedDeviceSendFailureIterator(failure)
is LocalEventSendState.Failed.VerifiedUserChangedIdentity -> ChangedIdentitySendFailureIterator(failure)
}
}
}
}
class UnsignedDeviceSendFailureIterator(
failure: LocalEventSendState.Failed.VerifiedUserHasUnsignedDevice
) : VerifiedUserSendFailureIterator {
private val iterator = failure.devices.iterator()
init {
if (!hasNext()) {
Timber.w("Got $failure without any devices, shouldn't happen.")
}
}
override fun hasNext(): Boolean {
return iterator.hasNext()
}
override fun next(): LocalEventSendState.Failed.VerifiedUser {
val (userId, deviceIds) = iterator.next()
return LocalEventSendState.Failed.VerifiedUserHasUnsignedDevice(
mapOf(userId to deviceIds)
)
}
}
class ChangedIdentitySendFailureIterator(
failure: LocalEventSendState.Failed.VerifiedUserChangedIdentity
) : VerifiedUserSendFailureIterator {
private val iterator = failure.users.iterator()
init {
if (!hasNext()) {
Timber.w("Got $failure without any users, shouldn't happen.")
}
}
override fun hasNext(): Boolean {
return iterator.hasNext()
}
override fun next(): LocalEventSendState.Failed.VerifiedUser {
val userId = iterator.next()
return LocalEventSendState.Failed.VerifiedUserChangedIdentity(
listOf(userId)
)
}
}

View file

@ -0,0 +1,70 @@
/*
* 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.sendfailure.resolve
import androidx.compose.runtime.mutableStateOf
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import timber.log.Timber
/**
* This class is responsible for resolving and resending a failed message sent to a verified user.
* It also allow to resend the message without resolving the failure, for example if the user has in the meantime verified their device again.
* It's using the [VerifiedUserSendFailureIterator] to iterate over the different failures (ie. the different users concerned by the failure).
* This way, the user can resolve and resend the message for each user concerned, one by one.
*/
class VerifiedUserSendFailureResolver(
private val room: MatrixRoom,
private val transactionId: TransactionId,
private val iterator: VerifiedUserSendFailureIterator,
) {
val currentSendFailure = mutableStateOf<LocalEventSendState.Failed.VerifiedUser?>(null)
init {
if (iterator.hasNext()) {
currentSendFailure.value = iterator.next()
}
}
suspend fun resend(): Result<Unit> {
return room.retrySendMessage(transactionId)
.onSuccess {
Timber.d("Succeed to resend message with transactionId: $transactionId")
currentSendFailure.value = null
}
.onFailure {
Timber.e(it, "Failed to resend message with transactionId: $transactionId")
}
}
suspend fun resolveAndResend(): Result<Unit> {
return when (val failure = currentSendFailure.value) {
is LocalEventSendState.Failed.VerifiedUserHasUnsignedDevice -> {
room.ignoreDeviceTrustAndResend(failure.devices, transactionId)
}
is LocalEventSendState.Failed.VerifiedUserChangedIdentity -> {
room.withdrawVerificationAndResend(failure.users, transactionId)
}
else -> {
Result.failure(IllegalStateException("Unknown send failure type"))
}
}.onSuccess {
Timber.d("Succeed to resolve and resend message with transactionId: $transactionId")
if (iterator.hasNext()) {
val failure = iterator.next()
currentSendFailure.value = failure
} else {
currentSendFailure.value = null
Timber.d("No more failure to resolve for transactionId: $transactionId")
}
}.onFailure {
Timber.e(it, "Failed to resolve and resend message with transactionId: $transactionId")
}
}
}

View file

@ -10,6 +10,8 @@ package io.element.android.features.messages.impl.di
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailurePresenter
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerPresenter
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.libraries.architecture.Presenter
@ -20,4 +22,7 @@ import io.element.android.libraries.di.RoomScope
interface MessagesModule {
@Binds
fun bindPinnedMessagesBannerPresenter(presenter: PinnedMessagesBannerPresenter): Presenter<PinnedMessagesBannerState>
@Binds
fun bindResolveVerifiedUserSendFailurePresenter(presenter: ResolveVerifiedUserSendFailurePresenter): Presenter<ResolveVerifiedUserSendFailureState>
}

View file

@ -181,6 +181,7 @@ private fun PinnedMessagesListLoaded(
onSelectAction = ::onActionSelected,
onCustomReactionClick = {},
onEmojiReactionClick = { _, _ -> },
onVerifiedUserSendFailureClick = {}
)
LazyColumn(
modifier = modifier.fillMaxSize(),
@ -199,19 +200,18 @@ private fun PinnedMessagesListLoaded(
renderReadReceipts = false,
isLastOutgoingMessage = false,
focusedEventId = null,
onClick = onEventClick,
onLongClick = ::onMessageLongClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onClick = onEventClick,
onLongClick = ::onMessageLongClick,
inReplyToClick = {},
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },
onMoreReactionsClick = {},
onReadReceiptClick = {},
eventSink = {},
onSwipeToReply = {},
onJoinCallClick = {},
onShieldClick = {},
eventSink = {},
eventContentView = { event, contentModifier, onContentLayoutChange ->
TimelineItemEventContentViewWrapper(
event = event,

View file

@ -7,6 +7,7 @@
package io.element.android.features.messages.impl.timeline
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
@ -19,7 +20,6 @@ sealed interface TimelineEvents {
data object OnFocusEventRender : TimelineEvents
data object JumpToLive : TimelineEvents
data class ShowShieldDialog(val messageShield: MessageShield) : TimelineEvents
data object HideShieldDialog : TimelineEvents
/**
@ -27,6 +27,8 @@ sealed interface TimelineEvents {
*/
sealed interface EventFromTimelineItem : TimelineEvents
data class ComputeVerifiedUserSendFailure(val event: TimelineItem.Event) : EventFromTimelineItem
data class ShowShieldDialog(val messageShield: MessageShield) : EventFromTimelineItem
data class LoadMore(val direction: Timeline.PaginationDirection) : EventFromTimelineItem
/**

View file

@ -21,6 +21,8 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureEvents
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig
import io.element.android.features.messages.impl.timeline.model.NewEventState
@ -66,6 +68,7 @@ class TimelinePresenter @AssistedInject constructor(
private val endPollAction: EndPollAction,
private val sessionPreferencesStore: SessionPreferencesStore,
private val timelineController: TimelineController,
private val resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState>,
) : Presenter<TimelineState> {
@AssistedFactory
interface Factory {
@ -101,6 +104,7 @@ class TimelinePresenter @AssistedInject constructor(
val newEventState = remember { mutableStateOf(NewEventState.None) }
val messageShield: MutableState<MessageShield?> = remember { mutableStateOf(null) }
val resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailurePresenter.present()
val isSendPublicReadReceiptsEnabled by sessionPreferencesStore.isSendPublicReadReceiptsEnabled().collectAsState(initial = true)
val renderReadReceipts by sessionPreferencesStore.isRenderReadReceiptsEnabled().collectAsState(initial = true)
val isLive by timelineController.isLive().collectAsState(initial = true)
@ -156,6 +160,9 @@ class TimelinePresenter @AssistedInject constructor(
}
TimelineEvents.HideShieldDialog -> messageShield.value = null
is TimelineEvents.ShowShieldDialog -> messageShield.value = event.messageShield
is TimelineEvents.ComputeVerifiedUserSendFailure -> {
resolveVerifiedUserSendFailureState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(event.event))
}
}
}
@ -232,6 +239,7 @@ class TimelinePresenter @AssistedInject constructor(
isLive = isLive,
focusRequestState = focusRequestState.value,
messageShield = messageShield.value,
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
eventSink = { handleEvents(it) }
)
}

View file

@ -8,6 +8,7 @@
package io.element.android.features.messages.impl.timeline
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState
import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.matrix.api.core.EventId
@ -25,6 +26,7 @@ data class TimelineState(
val focusRequestState: FocusRequestState,
// If not null, info will be rendered in a dialog
val messageShield: MessageShield?,
val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState,
val eventSink: (TimelineEvents) -> Unit,
) {
val hasAnyEvent = timelineItems.any { it is TimelineItem.Event }

View file

@ -7,6 +7,8 @@
package io.element.android.features.messages.impl.timeline
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.aResolveVerifiedUserSendFailureState
import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData
import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
@ -44,6 +46,7 @@ fun aTimelineState(
focusedEventIndex: Int = -1,
isLive: Boolean = true,
messageShield: MessageShield? = null,
resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(),
eventSink: (TimelineEvents) -> Unit = {},
): TimelineState {
val focusedEventId = timelineItems.filterIsInstance<TimelineItem.Event>().getOrNull(focusedEventIndex)?.eventId
@ -60,6 +63,7 @@ fun aTimelineState(
isLive = isLive,
focusRequestState = focusRequestState,
messageShield = messageShield,
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
eventSink = eventSink,
)
}

View file

@ -48,6 +48,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureView
import io.element.android.features.messages.impl.timeline.components.TimelineItemRow
import io.element.android.features.messages.impl.timeline.components.toText
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
@ -127,8 +128,8 @@ fun TimelineView(
Box(modifier) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.nestedScroll(nestedScrollConnection),
.fillMaxSize()
.nestedScroll(nestedScrollConnection),
state = lazyListState,
reverseLayout = useReverseLayout,
contentPadding = PaddingValues(vertical = 8.dp),
@ -150,19 +151,18 @@ fun TimelineView(
isLastOutgoingMessage = (timelineItem as? TimelineItem.Event)?.isMine == true &&
state.timelineItems.first().identifier() == timelineItem.identifier(),
focusedEventId = state.focusedEventId,
onClick = onMessageClick,
onLongClick = onMessageLongClick,
onShieldClick = ::onShieldClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onClick = onMessageClick,
onLongClick = onMessageLongClick,
inReplyToClick = ::inReplyToClick,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
eventSink = state.eventSink,
onSwipeToReply = onSwipeToReply,
onJoinCallClick = onJoinCallClick,
eventSink = state.eventSink,
)
}
}
@ -186,6 +186,8 @@ fun TimelineView(
}
}
ResolveVerifiedUserSendFailureView(state = state.resolveVerifiedUserSendFailureState)
MessageShieldDialog(state)
}
@ -267,8 +269,8 @@ private fun BoxScope.TimelineScrollHelper(
// Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered
isVisible = !canAutoScroll || forceJumpToBottomVisibility || !isLive,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 12.dp),
.align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 12.dp),
onClick = { jumpToBottom() },
)
}
@ -295,8 +297,8 @@ private fun JumpToBottomButton(
) {
Icon(
modifier = Modifier
.size(24.dp)
.rotate(90f),
.size(24.dp)
.rotate(90f),
imageVector = CompoundIcons.ArrowRight(),
contentDescription = stringResource(id = CommonStrings.a11y_jump_to_bottom)
)

View file

@ -28,9 +28,8 @@ internal fun ATimelineItemEventRow(
isHighlighted = isHighlighted,
onClick = {},
onLongClick = {},
onShieldClick = {},
onUserDataClick = {},
onLinkClick = {},
onUserDataClick = {},
inReplyToClick = {},
onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> },

View file

@ -23,6 +23,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.isEdited
import io.element.android.libraries.core.bool.orFalse
@ -31,14 +32,13 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.timeline.item.event.isCritical
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun TimelineEventTimestampView(
event: TimelineItem.Event,
onShieldClick: (MessageShield) -> Unit,
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
modifier: Modifier = Modifier,
) {
val formattedTime = event.sentTime
@ -48,8 +48,8 @@ fun TimelineEventTimestampView(
val tint = if (hasError || hasEncryptionCritical) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.secondary
Row(
modifier = Modifier
.padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing))
.then(modifier),
.padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing))
.then(modifier),
verticalAlignment = Alignment.CenterVertically,
) {
if (isMessageEdited) {
@ -66,12 +66,17 @@ fun TimelineEventTimestampView(
color = tint,
)
if (hasError) {
val isVerifiedUserSendFailure = event.localSendState is LocalEventSendState.Failed.VerifiedUser
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = CompoundIcons.Error(),
contentDescription = stringResource(id = CommonStrings.common_sending_failed),
tint = tint,
modifier = Modifier.size(15.dp, 18.dp),
modifier = Modifier
.size(15.dp, 18.dp)
.clickable(isVerifiedUserSendFailure) {
eventSink(TimelineEvents.ComputeVerifiedUserSendFailure(event))
},
)
}
event.messageShield?.let { shield ->
@ -80,8 +85,10 @@ fun TimelineEventTimestampView(
imageVector = shield.toIcon(),
contentDescription = shield.toText(),
modifier = Modifier
.size(15.dp)
.clickable { onShieldClick(shield) },
.size(15.dp)
.clickable {
eventSink(TimelineEvents.ShowShieldDialog(shield))
},
tint = shield.toIconColor(),
)
Spacer(modifier = Modifier.width(4.dp))
@ -94,7 +101,7 @@ fun TimelineEventTimestampView(
internal fun TimelineEventTimestampViewPreview(@PreviewParameter(TimelineItemEventForTimestampViewProvider::class) event: TimelineItem.Event) = ElementPreview {
TimelineEventTimestampView(
event = event,
onShieldClick = {},
eventSink = {},
)
}

View file

@ -81,7 +81,6 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
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.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToView
@ -110,7 +109,6 @@ fun TimelineItemEventRow(
isHighlighted: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
onShieldClick: (MessageShield) -> Unit,
onLinkClick: (String) -> Unit,
onUserDataClick: (UserId) -> Unit,
inReplyToClick: (EventId) -> Unit,
@ -161,6 +159,17 @@ fun TimelineItemEventRow(
ReplySwipeIndicator({ offset / 120 })
}
TimelineItemEventRowContent(
event = event,
isHighlighted = isHighlighted,
timelineRoomInfo = timelineRoomInfo,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
inReplyToClick = ::inReplyToClick,
onUserDataClick = ::onUserDataClick,
onReactionClick = { emoji -> onReactionClick(emoji, event) },
onReactionLongClick = { emoji -> onReactionLongClick(emoji, event) },
onMoreReactionsClick = { onMoreReactionsClick(event) },
modifier = Modifier
.absoluteOffset { IntOffset(x = offset.roundToInt(), y = 0) }
.draggable(
@ -176,18 +185,7 @@ fun TimelineItemEventRow(
},
state = state.draggableState,
),
event = event,
isHighlighted = isHighlighted,
timelineRoomInfo = timelineRoomInfo,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
onShieldClick = onShieldClick,
inReplyToClick = ::inReplyToClick,
onUserDataClick = ::onUserDataClick,
onReactionClick = { emoji -> onReactionClick(emoji, event) },
onReactionLongClick = { emoji -> onReactionLongClick(emoji, event) },
onMoreReactionsClick = { onMoreReactionsClick(event) },
eventSink = eventSink,
eventContentView = eventContentView,
)
}
@ -200,12 +198,12 @@ fun TimelineItemEventRow(
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
onShieldClick = onShieldClick,
inReplyToClick = ::inReplyToClick,
onUserDataClick = ::onUserDataClick,
onReactionClick = { emoji -> onReactionClick(emoji, event) },
onReactionLongClick = { emoji -> onReactionLongClick(emoji, event) },
onMoreReactionsClick = { onMoreReactionsClick(event) },
eventSink = eventSink,
eventContentView = eventContentView,
)
}
@ -255,12 +253,12 @@ private fun TimelineItemEventRowContent(
interactionSource: MutableInteractionSource,
onClick: () -> Unit,
onLongClick: () -> Unit,
onShieldClick: (MessageShield) -> Unit,
inReplyToClick: () -> Unit,
onUserDataClick: () -> Unit,
onReactionClick: (emoji: String) -> Unit,
onReactionLongClick: (emoji: String) -> Unit,
onMoreReactionsClick: (event: TimelineItem.Event) -> Unit,
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
modifier: Modifier = Modifier,
eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit,
) {
@ -322,9 +320,9 @@ private fun TimelineItemEventRowContent(
) {
MessageEventBubbleContent(
event = event,
onShieldClick = onShieldClick,
onMessageLongClick = onLongClick,
inReplyToClick = inReplyToClick,
eventSink = eventSink,
eventContentView = eventContentView,
)
}
@ -382,9 +380,9 @@ private fun MessageSenderInformation(
@Composable
private fun MessageEventBubbleContent(
event: TimelineItem.Event,
onShieldClick: (MessageShield) -> Unit,
onMessageLongClick: () -> Unit,
inReplyToClick: () -> Unit,
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
@SuppressLint("ModifierParameter")
// need to rename this modifier to prevent linter false positives
@Suppress("ModifierNaming")
@ -422,7 +420,7 @@ private fun MessageEventBubbleContent(
@Composable
fun WithTimestampLayout(
timestampPosition: TimestampPosition,
onShieldClick: (MessageShield) -> Unit,
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
modifier: Modifier = Modifier,
canShrinkContent: Boolean = false,
content: @Composable (onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit) -> Unit,
@ -433,7 +431,7 @@ private fun MessageEventBubbleContent(
content {}
TimelineEventTimestampView(
event = event,
onShieldClick = onShieldClick,
eventSink = eventSink,
modifier = Modifier
// Outer padding
.padding(horizontal = 4.dp, vertical = 4.dp)
@ -454,7 +452,7 @@ private fun MessageEventBubbleContent(
overlay = {
TimelineEventTimestampView(
event = event,
onShieldClick = onShieldClick,
eventSink = eventSink,
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
)
@ -465,7 +463,7 @@ private fun MessageEventBubbleContent(
content {}
TimelineEventTimestampView(
event = event,
onShieldClick = onShieldClick,
eventSink = eventSink,
modifier = Modifier
.align(Alignment.End)
.padding(horizontal = 8.dp, vertical = 4.dp)
@ -513,7 +511,7 @@ private fun MessageEventBubbleContent(
val contentWithTimestamp = @Composable {
WithTimestampLayout(
timestampPosition = timestampPosition,
onShieldClick = onShieldClick,
eventSink = eventSink,
canShrinkContent = canShrinkContent,
modifier = timestampLayoutModifier,
content = { onContentLayoutChange ->

View file

@ -29,7 +29,6 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
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.timeline.item.event.MessageShield
@Composable
fun TimelineItemGroupedEventsRow(
@ -40,7 +39,6 @@ fun TimelineItemGroupedEventsRow(
focusedEventId: EventId?,
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
onShieldClick: (MessageShield) -> Unit,
inReplyToClick: (EventId) -> Unit,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit,
@ -77,7 +75,6 @@ fun TimelineItemGroupedEventsRow(
isLastOutgoingMessage = isLastOutgoingMessage,
onClick = onClick,
onLongClick = onLongClick,
onShieldClick = onShieldClick,
inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
@ -102,7 +99,6 @@ private fun TimelineItemGroupedEventsRowContent(
isLastOutgoingMessage: Boolean,
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
onShieldClick: (MessageShield) -> Unit,
inReplyToClick: (EventId) -> Unit,
onUserDataClick: (UserId) -> Unit,
onLinkClick: (String) -> Unit,
@ -143,19 +139,18 @@ private fun TimelineItemGroupedEventsRowContent(
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
focusedEventId = focusedEventId,
onClick = onClick,
onLongClick = onLongClick,
onShieldClick = onShieldClick,
inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onClick = onClick,
onLongClick = onLongClick,
inReplyToClick = inReplyToClick,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
onMoreReactionsClick = onMoreReactionsClick,
onReadReceiptClick = onReadReceiptClick,
eventSink = eventSink,
onSwipeToReply = {},
onJoinCallClick = {},
eventSink = eventSink,
eventContentView = eventContentView,
)
}
@ -188,7 +183,6 @@ internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPrevi
isLastOutgoingMessage = false,
onClick = {},
onLongClick = {},
onShieldClick = {},
inReplyToClick = {},
onUserDataClick = {},
onLinkClick = {},
@ -213,7 +207,6 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi
isLastOutgoingMessage = false,
onClick = {},
onLongClick = {},
onShieldClick = {},
inReplyToClick = {},
onUserDataClick = {},
onLinkClick = {},

View file

@ -30,7 +30,6 @@ import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.designsystem.theme.highlightedMessageBackgroundColor
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.timeline.item.event.MessageShield
@Composable
internal fun TimelineItemRow(
@ -43,7 +42,6 @@ internal fun TimelineItemRow(
onLinkClick: (String) -> Unit,
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
onShieldClick: (MessageShield) -> Unit,
inReplyToClick: (EventId) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
@ -115,9 +113,8 @@ internal fun TimelineItemRow(
isHighlighted = timelineItem.isEvent(focusedEventId),
onClick = { onClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) },
onShieldClick = onShieldClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,
onUserDataClick = onUserDataClick,
inReplyToClick = inReplyToClick,
onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick,
@ -141,7 +138,6 @@ internal fun TimelineItemRow(
focusedEventId = focusedEventId,
onClick = onClick,
onLongClick = onLongClick,
onShieldClick = onShieldClick,
inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick,
onLinkClick = onLinkClick,

View file

@ -16,6 +16,7 @@ import com.google.common.truth.Truth.assertThat
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.sendfailure.resolve.aResolveVerifiedUserSendFailureState
import io.element.android.features.messages.impl.draft.FakeComposerDraftService
import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator
@ -1035,6 +1036,7 @@ class MessagesPresenterTest {
sessionPreferencesStore = sessionPreferencesStore,
timelineItemIndexer = TimelineItemIndexer(),
timelineController = TimelineController(matrixRoom),
resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() },
)
val timelinePresenterFactory = object : TimelinePresenter.Factory {
override fun create(navigator: MessagesNavigator): TimelinePresenter {

View file

@ -34,6 +34,8 @@ 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.model.TimelineItemAction
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.aChangedIdentitySendFailure
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerItem
import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMessagesBannerState
@ -329,6 +331,7 @@ class MessagesViewTest {
event = timelineItem,
displayEmojiReactions = true,
actions = persistentListOf(TimelineItemAction.Edit),
verifiedUserSendFailure = VerifiedUserSendFailure.None,
)
),
)
@ -399,6 +402,7 @@ class MessagesViewTest {
target = ActionListState.Target.Success(
event = timelineItem,
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(TimelineItemAction.Edit),
),
),
@ -416,6 +420,32 @@ class MessagesViewTest {
eventsRecorder.assertSingle(CustomReactionEvents.ShowCustomReactionSheet(timelineItem))
}
@Test
fun `clicking on verified user send failure from action list emits the expected Event`() {
val eventsRecorder = EventsRecorder<TimelineEvents>()
val state = aMessagesState()
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
val stateWithActionListState = state.copy(
actionListState = anActionListState(
target = ActionListState.Target.Success(
event = timelineItem,
displayEmojiReactions = true,
verifiedUserSendFailure = aChangedIdentitySendFailure(),
actions = persistentListOf(),
),
),
timelineState = aTimelineState(eventSink = eventsRecorder)
)
rule.setMessagesView(
state = stateWithActionListState,
)
val verifiedUserSendFailure = rule.activity.getString(CommonStrings.screen_timeline_item_menu_send_failure_changed_identity, "Alice")
rule.onNodeWithText(verifiedUserSendFailure).performClick()
// Give time for the close animation to complete
rule.mainClock.advanceTimeBy(milliseconds = 1_000)
eventsRecorder.assertSingle(TimelineEvents.ComputeVerifiedUserSendFailure(timelineItem))
}
@Test
fun `clicking on a custom emoji emits the expected Events`() {
val aUnicode = "🙈"

View file

@ -14,6 +14,8 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.aUserEventPermissions
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.sendfailure.VerifiedUserSendFailure
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory
import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent
@ -25,8 +27,10 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
@ -79,6 +83,7 @@ class ActionListPresenterTest {
ActionListState.Target.Success(
event = messageEvent,
displayEmojiReactions = false,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.ViewSource,
)
@ -120,6 +125,7 @@ class ActionListPresenterTest {
ActionListState.Target.Success(
event = messageEvent,
displayEmojiReactions = false,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.ViewSource,
)
@ -161,6 +167,7 @@ class ActionListPresenterTest {
ActionListState.Target.Success(
event = messageEvent,
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
@ -208,6 +215,7 @@ class ActionListPresenterTest {
ActionListState.Target.Success(
event = messageEvent,
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Forward,
TimelineItemAction.Pin,
@ -252,6 +260,7 @@ class ActionListPresenterTest {
ActionListState.Target.Success(
event = messageEvent,
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
@ -298,6 +307,7 @@ class ActionListPresenterTest {
ActionListState.Target.Success(
event = messageEvent,
displayEmojiReactions = false,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
@ -345,6 +355,7 @@ class ActionListPresenterTest {
ActionListState.Target.Success(
event = messageEvent,
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
@ -392,6 +403,7 @@ class ActionListPresenterTest {
ActionListState.Target.Success(
event = messageEvent,
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
@ -439,6 +451,7 @@ class ActionListPresenterTest {
ActionListState.Target.Success(
event = messageEvent,
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
@ -484,6 +497,7 @@ class ActionListPresenterTest {
ActionListState.Target.Success(
event = stateEvent,
displayEmojiReactions = false,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.ViewSource,
)
@ -553,6 +567,7 @@ class ActionListPresenterTest {
ActionListState.Target.Success(
event = messageEvent,
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
@ -599,6 +614,7 @@ class ActionListPresenterTest {
ActionListState.Target.Success(
event = messageEvent,
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
@ -652,6 +668,7 @@ class ActionListPresenterTest {
ActionListState.Target.Success(
event = messageEvent,
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
@ -748,6 +765,7 @@ class ActionListPresenterTest {
ActionListState.Target.Success(
event = messageEvent,
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Edit,
TimelineItemAction.Copy,
@ -787,6 +805,7 @@ class ActionListPresenterTest {
ActionListState.Target.Success(
event = messageEvent,
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Edit,
@ -829,6 +848,7 @@ class ActionListPresenterTest {
ActionListState.Target.Success(
event = messageEvent,
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.EndPoll,
@ -870,6 +890,7 @@ class ActionListPresenterTest {
ActionListState.Target.Success(
event = messageEvent,
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Pin,
@ -910,6 +931,7 @@ class ActionListPresenterTest {
ActionListState.Target.Success(
event = messageEvent,
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
@ -949,6 +971,7 @@ class ActionListPresenterTest {
ActionListState.Target.Success(
event = messageEvent,
displayEmojiReactions = false,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.ViewSource
)
@ -956,6 +979,32 @@ class ActionListPresenterTest {
)
}
}
@Test
fun `present - compute for verified user send failure`() = runTest {
val room = FakeMatrixRoom(
userDisplayNameResult = { Result.success("Alice") }
)
val presenter = createActionListPresenter(isDeveloperModeEnabled = false, isPinFeatureEnabled = false, room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
sendState = LocalEventSendState.Failed.VerifiedUserChangedIdentity(users = listOf(A_USER_ID)),
)
initialState.eventSink.invoke(
ActionListEvents.ComputeForMessage(
event = messageEvent,
userEventPermissions = aUserEventPermissions(),
)
)
skipItems(1)
val successState = awaitItem()
val target = successState.target as ActionListState.Target.Success
assertThat(target.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.ChangedIdentity(userDisplayName = "Alice"))
}
}
}
private fun createActionListPresenter(
@ -968,6 +1017,7 @@ private fun createActionListPresenter(
postProcessor = TimelineItemActionPostProcessor.Default,
appPreferencesStore = preferencesStore,
isPinnedMessagesFeatureEnabled = { isPinFeatureEnabled },
room = room
room = room,
userSendFailureFactory = VerifiedUserSendFailureFactory(room)
)
}

View file

@ -0,0 +1,353 @@
/*
* 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.sendfailure.resolve
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure
import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory
import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_TRANSACTION_ID
import io.element.android.libraries.matrix.test.A_USER_ID
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.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class ResolveVerifiedUserSendFailurePresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = createResolveVerifiedUserSendFailurePresenter()
presenter.test {
val initialState = awaitItem()
assertThat(initialState.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None)
}
}
@Test
fun `present - remote message scenario`() = runTest {
val presenter = createResolveVerifiedUserSendFailurePresenter()
presenter.test {
val sentMessage = aMessageEvent()
val initialState = awaitItem()
assertThat(initialState.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None)
initialState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(sentMessage))
ensureAllEventsConsumed()
}
}
@Test
fun `present - sent message scenario`() = runTest {
val presenter = createResolveVerifiedUserSendFailurePresenter()
presenter.test {
val sentMessage = aMessageEvent(
sendState = LocalEventSendState.Sent(AN_EVENT_ID)
)
val initialState = awaitItem()
assertThat(initialState.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None)
initialState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(sentMessage))
ensureAllEventsConsumed()
}
}
@Test
fun `present - unknown failed message scenario`() = runTest {
val presenter = createResolveVerifiedUserSendFailurePresenter()
presenter.test {
val failedMessage = aMessageEvent(
sendState = LocalEventSendState.Failed.Unknown("")
)
val initialState = awaitItem()
assertThat(initialState.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None)
initialState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(failedMessage))
ensureAllEventsConsumed()
}
}
@Test
fun `present - verified user unsigned device failure dismiss scenario`() = runTest {
val room = FakeMatrixRoom(
userDisplayNameResult = { userId ->
Result.success(userId.value)
},
)
val presenter = createResolveVerifiedUserSendFailurePresenter(room)
presenter.test {
val failedMessage = aVerifiedUserHasUnsignedDeviceFailedMessage()
val initialState = awaitItem()
assertThat(initialState.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None)
initialState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(failedMessage))
skipItems(1)
awaitItem().also { state ->
assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.UnsignedDevice(A_USER_ID.value))
state.eventSink(ResolveVerifiedUserSendFailureEvents.Dismiss)
}
skipItems(1)
awaitItem().also { state ->
assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None)
}
ensureAllEventsConsumed()
}
}
@Test
fun `present - verified user unsigned device failure retry scenario`() = runTest {
val room = FakeMatrixRoom(
userDisplayNameResult = { userId ->
Result.success(userId.value)
},
retrySendMessageResult = {
Result.success(Unit)
},
)
val presenter = createResolveVerifiedUserSendFailurePresenter(room)
presenter.test {
val failedMessage = aVerifiedUserHasUnsignedDeviceFailedMessage()
val initialState = awaitItem()
assertThat(initialState.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None)
initialState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(failedMessage))
skipItems(1)
awaitItem().also { state ->
assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.UnsignedDevice(A_USER_ID.value))
state.eventSink(ResolveVerifiedUserSendFailureEvents.Retry)
}
awaitItem().also { state ->
assertThat(state.retryAction).isEqualTo(AsyncAction.Loading)
}
skipItems(2)
awaitItem().also { state ->
assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None)
assertThat(state.retryAction).isEqualTo(AsyncAction.Success(Unit))
}
ensureAllEventsConsumed()
}
}
@Test
fun `present - verified user unsigned device failure resolve and resend scenario`() = runTest {
val room = FakeMatrixRoom(
userDisplayNameResult = { userId ->
Result.success(userId.value)
},
ignoreDeviceTrustAndResendResult = { _, _ ->
Result.success(Unit)
},
)
val presenter = createResolveVerifiedUserSendFailurePresenter(room)
presenter.test {
val failedMessage = aVerifiedUserHasUnsignedDeviceFailedMessage()
val initialState = awaitItem()
assertThat(initialState.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None)
initialState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(failedMessage))
skipItems(1)
awaitItem().also { state ->
assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.UnsignedDevice(A_USER_ID.value))
state.eventSink(ResolveVerifiedUserSendFailureEvents.ResolveAndResend)
}
awaitItem().also { state ->
assertThat(state.resolveAction).isEqualTo(AsyncAction.Loading)
}
// This should move to the next user
skipItems(2)
awaitItem().also { state ->
assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.UnsignedDevice(A_USER_ID_2.value))
assertThat(state.resolveAction).isEqualTo(AsyncAction.Success(Unit))
state.eventSink(ResolveVerifiedUserSendFailureEvents.ResolveAndResend)
}
skipItems(3)
awaitItem().also { state ->
assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None)
}
ensureAllEventsConsumed()
}
}
@Test
fun `present - verified user unsigned device failure resolve and resend scenario with error`() = runTest {
val room = FakeMatrixRoom(
userDisplayNameResult = { userId ->
Result.success(userId.value)
},
ignoreDeviceTrustAndResendResult = { _, _ ->
Result.failure(Exception())
},
)
val presenter = createResolveVerifiedUserSendFailurePresenter(room)
presenter.test {
val failedMessage = aVerifiedUserHasUnsignedDeviceFailedMessage()
val initialState = awaitItem()
assertThat(initialState.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None)
initialState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(failedMessage))
skipItems(1)
awaitItem().also { state ->
assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.UnsignedDevice(A_USER_ID.value))
state.eventSink(ResolveVerifiedUserSendFailureEvents.ResolveAndResend)
}
awaitItem().also { state ->
assertThat(state.resolveAction).isEqualTo(AsyncAction.Loading)
}
awaitItem().also { state ->
assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.UnsignedDevice(A_USER_ID.value))
assertThat(state.resolveAction).isInstanceOf(AsyncAction.Failure::class.java)
}
ensureAllEventsConsumed()
}
}
@Test
fun `present - verified user changed identity failure retry scenario`() = runTest {
val room = FakeMatrixRoom(
userDisplayNameResult = { userId ->
Result.success(userId.value)
},
retrySendMessageResult = {
Result.success(Unit)
},
)
val presenter = createResolveVerifiedUserSendFailurePresenter(room)
presenter.test {
val failedMessage = aVerifiedUserChangedIdentityMessage()
val initialState = awaitItem()
assertThat(initialState.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None)
initialState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(failedMessage))
skipItems(1)
awaitItem().also { state ->
assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.ChangedIdentity(A_USER_ID.value))
state.eventSink(ResolveVerifiedUserSendFailureEvents.Retry)
}
awaitItem().also { state ->
assertThat(state.retryAction).isEqualTo(AsyncAction.Loading)
}
skipItems(2)
awaitItem().also { state ->
assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None)
assertThat(state.retryAction).isEqualTo(AsyncAction.Success(Unit))
}
ensureAllEventsConsumed()
}
}
@Test
fun `present - verified user changed identity failure resolve and resend scenario`() = runTest {
val room = FakeMatrixRoom(
userDisplayNameResult = { userId ->
Result.success(userId.value)
},
withdrawVerificationAndResendResult = { _, _ ->
Result.success(Unit)
},
)
val presenter = createResolveVerifiedUserSendFailurePresenter(room)
presenter.test {
val failedMessage = aVerifiedUserChangedIdentityMessage()
val initialState = awaitItem()
assertThat(initialState.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None)
initialState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(failedMessage))
skipItems(1)
awaitItem().also { state ->
assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.ChangedIdentity(A_USER_ID.value))
state.eventSink(ResolveVerifiedUserSendFailureEvents.ResolveAndResend)
}
awaitItem().also { state ->
assertThat(state.resolveAction).isEqualTo(AsyncAction.Loading)
}
// This should move to the next user
skipItems(2)
awaitItem().also { state ->
assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.ChangedIdentity(A_USER_ID_2.value))
assertThat(state.resolveAction).isEqualTo(AsyncAction.Success(Unit))
state.eventSink(ResolveVerifiedUserSendFailureEvents.ResolveAndResend)
}
skipItems(3)
awaitItem().also { state ->
assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None)
}
ensureAllEventsConsumed()
}
}
@Test
fun `present - verified user changed identity failure resolve and resend scenario with error`() = runTest {
val room = FakeMatrixRoom(
userDisplayNameResult = { userId ->
Result.success(userId.value)
},
withdrawVerificationAndResendResult = { _, _ ->
Result.failure(Exception())
},
)
val presenter = createResolveVerifiedUserSendFailurePresenter(room)
presenter.test {
val failedMessage = aVerifiedUserChangedIdentityMessage()
val initialState = awaitItem()
assertThat(initialState.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None)
initialState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(failedMessage))
skipItems(1)
awaitItem().also { state ->
assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.ChangedIdentity(A_USER_ID.value))
state.eventSink(ResolveVerifiedUserSendFailureEvents.ResolveAndResend)
}
awaitItem().also { state ->
assertThat(state.resolveAction).isEqualTo(AsyncAction.Loading)
}
awaitItem().also { state ->
assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.ChangedIdentity(A_USER_ID.value))
assertThat(state.resolveAction).isInstanceOf(AsyncAction.Failure::class.java)
}
ensureAllEventsConsumed()
}
}
private fun aVerifiedUserHasUnsignedDeviceFailedMessage(): TimelineItem.Event {
return aMessageEvent(
transactionId = A_TRANSACTION_ID,
sendState = LocalEventSendState.Failed.VerifiedUserHasUnsignedDevice(
mapOf(
A_USER_ID to emptyList(),
A_USER_ID_2 to emptyList()
)
)
)
}
private fun aVerifiedUserChangedIdentityMessage(): TimelineItem.Event {
return aMessageEvent(
transactionId = A_TRANSACTION_ID,
sendState = LocalEventSendState.Failed.VerifiedUserChangedIdentity(
listOf(A_USER_ID, A_USER_ID_2)
)
)
}
private fun createResolveVerifiedUserSendFailurePresenter(
room: MatrixRoom = FakeMatrixRoom(),
): ResolveVerifiedUserSendFailurePresenter {
return ResolveVerifiedUserSendFailurePresenter(
room = room,
verifiedUserSendFailureFactory = VerifiedUserSendFailureFactory(room),
)
}
}

View file

@ -0,0 +1,61 @@
/*
* 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.sendfailure.resolve
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ResolveVerifiedUserSendFailureViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on resolve and resend emit the expected event`() {
val eventsRecorder = EventsRecorder<ResolveVerifiedUserSendFailureEvents>()
rule.setResolveVerifiedUserSendFailureView(
state = aResolveVerifiedUserSendFailureState(
verifiedUserSendFailure = aChangedIdentitySendFailure(),
eventSink = eventsRecorder,
),
)
rule.clickOn(res = CommonStrings.screen_resolve_send_failure_changed_identity_primary_button_title)
eventsRecorder.assertSingle(ResolveVerifiedUserSendFailureEvents.ResolveAndResend)
}
@Test
fun `clicking on retry emit the expected event`() {
val eventsRecorder = EventsRecorder<ResolveVerifiedUserSendFailureEvents>()
rule.setResolveVerifiedUserSendFailureView(
state = aResolveVerifiedUserSendFailureState(
verifiedUserSendFailure = aChangedIdentitySendFailure(),
eventSink = eventsRecorder,
),
)
rule.clickOn(res = CommonStrings.action_retry)
eventsRecorder.assertSingle(ResolveVerifiedUserSendFailureEvents.Retry)
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setResolveVerifiedUserSendFailureView(
state: ResolveVerifiedUserSendFailureState,
) {
setContent {
ResolveVerifiedUserSendFailureView(state = state)
}
}
}

View file

@ -13,6 +13,7 @@ import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.FakeMessagesNavigator
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.aResolveVerifiedUserSendFailureState
import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator
import io.element.android.features.messages.impl.timeline.components.aCriticalShield
@ -680,6 +681,7 @@ import kotlin.time.Duration.Companion.seconds
sessionPreferencesStore = sessionPreferencesStore,
timelineItemIndexer = timelineItemIndexer,
timelineController = TimelineController(room),
resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() },
)
}
}