Send failure verified user : resolve ui and logic

This commit is contained in:
ganfra 2024-09-13 11:44:19 +02:00
parent 416810acca
commit ff368b4072
29 changed files with 1103 additions and 85 deletions

View file

@ -30,6 +30,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.sendfailure.resolve.ResolveVerifiedUserSendFailureState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
@ -95,6 +96,7 @@ class MessagesPresenter @AssistedInject constructor(
private val reactionSummaryPresenter: ReactionSummaryPresenter,
private val readReceiptBottomSheetPresenter: ReadReceiptBottomSheetPresenter,
private val pinnedMessagesBannerPresenter: Presenter<PinnedMessagesBannerState>,
private val resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState>,
private val networkMonitor: NetworkMonitor,
private val snackbarDispatcher: SnackbarDispatcher,
private val dispatchers: CoroutineDispatchers,
@ -128,6 +130,7 @@ class MessagesPresenter @AssistedInject constructor(
val reactionSummaryState = reactionSummaryPresenter.present()
val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present()
val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present()
val resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailurePresenter.present()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
@ -227,6 +230,7 @@ class MessagesPresenter @AssistedInject constructor(
appName = buildMeta.applicationName,
callState = callState,
pinnedMessagesBannerState = pinnedMessagesBannerState,
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
eventSink = { handleEvents(it) }
)
}

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.sendfailure.resolve.ResolveVerifiedUserSendFailureState
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
@ -47,6 +48,7 @@ data class MessagesState(
val callState: RoomCallState,
val appName: String,
val pinnedMessagesBannerState: PinnedMessagesBannerState,
val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState,
val eventSink: (MessagesEvents) -> Unit
)

View file

@ -10,6 +10,8 @@ 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.sendfailure.resolve.ResolveVerifiedUserSendFailureState
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.aResolveVerifiedUserSendFailureState
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
@ -113,6 +115,7 @@ fun aMessagesState(
enableVoiceMessages: Boolean = true,
callState: RoomCallState = RoomCallState.ENABLED,
pinnedMessagesBannerState: PinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(),
resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(),
eventSink: (MessagesEvents) -> Unit = {},
) = MessagesState(
roomId = RoomId("!id:domain"),
@ -137,6 +140,7 @@ fun aMessagesState(
callState = callState,
appName = "Element",
pinnedMessagesBannerState = pinnedMessagesBannerState,
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
eventSink = eventSink,
)

View file

@ -57,6 +57,8 @@ 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.sendfailure.resolve.ResolveVerifiedUserSendFailureEvents
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureView
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
@ -242,8 +244,12 @@ fun MessagesView(
},
onEmojiReactionClick = ::onEmojiReactionClick,
onVerifiedUserSendFailureClick = { event ->
state.resolveVerifiedUserSendFailureState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(event))
},
)
}
ResolveVerifiedUserSendFailureView(
state = state.resolveVerifiedUserSendFailureState,
)
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
@ -36,7 +38,6 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
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.preferences.api.store.AppPreferencesStore
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -57,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)
@ -116,10 +118,10 @@ class DefaultActionListPresenter @AssistedInject constructor(
isEventPinned = pinnedEventIds.contains(timelineItem.eventId),
)
val verifiedUserSendFailure = buildVerifiedUserSendFailure(timelineItem)
val verifiedUserSendFailure = userSendFailureFactory.create(timelineItem.localSendState)
val displayEmojiReactions = usersEventPermissions.canSendReaction && timelineItem.content.canReact()
if (actions.isNotEmpty() || displayEmojiReactions || verifiedUserSendFailure != ActionListState.VerifiedUserSendFailure.None) {
if (actions.isNotEmpty() || displayEmojiReactions || verifiedUserSendFailure != VerifiedUserSendFailure.None) {
target.value = ActionListState.Target.Success(
event = timelineItem,
displayEmojiReactions = displayEmojiReactions,
@ -131,32 +133,6 @@ class DefaultActionListPresenter @AssistedInject constructor(
}
}
private suspend fun buildVerifiedUserSendFailure(
timelineItem: TimelineItem.Event,
): ActionListState.VerifiedUserSendFailure {
return when (val sendState = timelineItem.localSendState) {
is LocalEventSendState.Failed.VerifiedUserHasUnsignedDevice -> {
val userId = sendState.devices.keys.firstOrNull()
if (userId == null) {
ActionListState.VerifiedUserSendFailure.None
} else {
val displayName = room.userDisplayName(userId).getOrNull() ?: userId.value
ActionListState.VerifiedUserSendFailure.UnsignedDevice(displayName)
}
}
is LocalEventSendState.Failed.VerifiedUserChangedIdentity -> {
val userId = sendState.users.firstOrNull()
if (userId == null) {
ActionListState.VerifiedUserSendFailure.None
} else {
val displayName = room.userDisplayName(userId).getOrNull() ?: userId.value
ActionListState.VerifiedUserSendFailure.ChangedIdentity(displayName)
}
}
else -> ActionListState.VerifiedUserSendFailure.None
}
}
private fun buildActions(
timelineItem: TimelineItem.Event,
usersEventPermissions: UserEventPermissions,

View file

@ -7,12 +7,10 @@
package io.element.android.features.messages.impl.actionlist
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.res.stringResource
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 io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
@Immutable
@ -31,20 +29,4 @@ data class ActionListState(
val actions: ImmutableList<TimelineItemAction>,
) : Target
}
@Immutable
sealed interface VerifiedUserSendFailure {
data object None : VerifiedUserSendFailure
data class UnsignedDevice(val displayName: String) : VerifiedUserSendFailure
data class ChangedIdentity(val displayName: String) : VerifiedUserSendFailure
@Composable
fun formatted(): String {
return when (this) {
is None -> ""
is UnsignedDevice -> stringResource(CommonStrings.screen_timeline_item_menu_send_failure_unsigned_device, displayName)
is ChangedIdentity -> stringResource(CommonStrings.screen_timeline_item_menu_send_failure_changed_identity, displayName)
}
}
}
}

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
@ -18,8 +20,6 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -37,7 +37,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
reactionsState = reactionsState
),
displayEmojiReactions = true,
verifiedUserSendFailure = ActionListState.VerifiedUserSendFailure.None,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
)
),
@ -50,7 +50,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
reactionsState = reactionsState,
),
displayEmojiReactions = true,
verifiedUserSendFailure = ActionListState.VerifiedUserSendFailure.None,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
)
),
@ -60,7 +60,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
reactionsState = reactionsState
),
displayEmojiReactions = true,
verifiedUserSendFailure = ActionListState.VerifiedUserSendFailure.None,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
)
),
@ -70,7 +70,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
reactionsState = reactionsState
),
displayEmojiReactions = true,
verifiedUserSendFailure = ActionListState.VerifiedUserSendFailure.None,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
)
),
@ -80,7 +80,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
reactionsState = reactionsState
),
displayEmojiReactions = true,
verifiedUserSendFailure = ActionListState.VerifiedUserSendFailure.None,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
)
),
@ -90,7 +90,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
reactionsState = reactionsState
),
displayEmojiReactions = true,
verifiedUserSendFailure = ActionListState.VerifiedUserSendFailure.None,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
)
),
@ -100,7 +100,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
reactionsState = reactionsState
),
displayEmojiReactions = true,
verifiedUserSendFailure = ActionListState.VerifiedUserSendFailure.None,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
)
),
@ -110,7 +110,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
reactionsState = reactionsState
),
displayEmojiReactions = false,
verifiedUserSendFailure = ActionListState.VerifiedUserSendFailure.None,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
),
),
@ -120,7 +120,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
reactionsState = reactionsState
),
displayEmojiReactions = false,
verifiedUserSendFailure = ActionListState.VerifiedUserSendFailure.None,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemPollActionList(),
),
),
@ -131,7 +131,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
messageShield = MessageShield.UnknownDevice(isCritical = true)
),
displayEmojiReactions = true,
verifiedUserSendFailure = ActionListState.VerifiedUserSendFailure.None,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(),
)
),
@ -139,7 +139,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
target = ActionListState.Target.Success(
event = aTimelineItemEvent(),
displayEmojiReactions = true,
verifiedUserSendFailure = ActionListState.VerifiedUserSendFailure.UnsignedDevice(displayName = "Alice"),
verifiedUserSendFailure = anUnsignedDeviceSendFailure(),
actions = aTimelineItemActionList(),
)
),

View file

@ -47,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
@ -196,7 +200,7 @@ private fun SheetContent(
HorizontalDivider()
}
}
if (target.verifiedUserSendFailure != ActionListState.VerifiedUserSendFailure.None) {
if (target.verifiedUserSendFailure != None) {
item {
VerifiedUserSendFailureView(
sendFailure = target.verifiedUserSendFailure,
@ -362,10 +366,19 @@ private fun EmojiReactionsRow(
@Composable
private fun VerifiedUserSendFailureView(
sendFailure: ActionListState.VerifiedUserSendFailure,
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)
@ -374,7 +387,7 @@ private fun VerifiedUserSendFailureView(
trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChevronRight())),
headlineContent = {
Text(
text = sendFailure.formatted(),
text = sendFailure.headline(),
style = ElementTheme.typography.fontBodySmMedium,
)
},

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()
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 -> ""
}
}
@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 -> ""
}
}
@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 -> ""
}
}
@PreviewsDayNight
@Composable
internal fun ResolveVerifiedUserSendFailureViewPreview(
@PreviewParameter(ResolveVerifiedUserSendFailureStateProvider::class) state: ResolveVerifiedUserSendFailureState
) = ElementPreview {
ResolveVerifiedUserSendFailureView(state)
}

View file

@ -0,0 +1,60 @@
/*
* 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
/**
* 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()
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()
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,64 @@
/*
* 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
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>
}