Send failure verified user : resolve ui and logic
This commit is contained in:
parent
416810acca
commit
ff368b4072
29 changed files with 1103 additions and 85 deletions
|
|
@ -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) }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -1062,6 +1063,7 @@ class MessagesPresenterTest {
|
|||
reactionSummaryPresenter = reactionSummaryPresenter,
|
||||
readReceiptBottomSheetPresenter = readReceiptBottomSheetPresenter,
|
||||
pinnedMessagesBannerPresenter = { aLoadedPinnedMessagesBannerState() },
|
||||
resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() },
|
||||
networkMonitor = FakeNetworkMonitor(),
|
||||
snackbarDispatcher = SnackbarDispatcher(),
|
||||
navigator = navigator,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ 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.ResolveVerifiedUserSendFailureEvents
|
||||
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.aChangedIdentitySendFailure
|
||||
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.aResolveVerifiedUserSendFailureState
|
||||
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 +333,7 @@ class MessagesViewTest {
|
|||
event = timelineItem,
|
||||
displayEmojiReactions = true,
|
||||
actions = persistentListOf(TimelineItemAction.Edit),
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
|
@ -399,6 +404,7 @@ class MessagesViewTest {
|
|||
target = ActionListState.Target.Success(
|
||||
event = timelineItem,
|
||||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = persistentListOf(TimelineItemAction.Edit),
|
||||
),
|
||||
),
|
||||
|
|
@ -416,6 +422,34 @@ 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<ResolveVerifiedUserSendFailureEvents>()
|
||||
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(),
|
||||
),
|
||||
),
|
||||
resolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(
|
||||
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(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(timelineItem))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on a custom emoji emits the expected Events`() {
|
||||
val aUnicode = "🙈"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -37,6 +37,7 @@ import io.element.android.libraries.designsystem.theme.temporaryColorBgSpecial
|
|||
* @param resourceId the resource id of the icon to display, exclusive with [imageVector]
|
||||
* @param imageVector the image vector of the icon to display, exclusive with [resourceId]
|
||||
* @param tint the tint to apply to the icon
|
||||
* @param backgroundTint the tint to apply to the icon background
|
||||
*/
|
||||
@Composable
|
||||
fun RoundedIconAtom(
|
||||
|
|
@ -44,13 +45,14 @@ fun RoundedIconAtom(
|
|||
size: RoundedIconAtomSize = RoundedIconAtomSize.Large,
|
||||
resourceId: Int? = null,
|
||||
imageVector: ImageVector? = null,
|
||||
tint: Color = MaterialTheme.colorScheme.secondary
|
||||
tint: Color = MaterialTheme.colorScheme.secondary,
|
||||
backgroundTint: Color = ElementTheme.colors.temporaryColorBgSpecial,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(size.toContainerSize())
|
||||
.background(
|
||||
color = ElementTheme.colors.temporaryColorBgSpecial,
|
||||
color = backgroundTint,
|
||||
shape = RoundedCornerShape(size.toCornerSize())
|
||||
)
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import io.element.android.libraries.designsystem.icons.CompoundDrawables
|
|||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.temporaryColorBgSpecial
|
||||
|
||||
/**
|
||||
* IconTitleSubtitleMolecule is a molecule which displays an icon, a title and a subtitle.
|
||||
|
|
@ -37,6 +38,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
|
|||
* @param iconResourceId the resource id of the icon to display, exclusive with [iconImageVector]
|
||||
* @param iconImageVector the image vector of the icon to display, exclusive with [iconResourceId]
|
||||
* @param iconTint the tint to apply to the icon
|
||||
* @param iconBackgroundTint the tint to apply to the icon background
|
||||
*/
|
||||
@Composable
|
||||
fun IconTitleSubtitleMolecule(
|
||||
|
|
@ -46,6 +48,7 @@ fun IconTitleSubtitleMolecule(
|
|||
iconResourceId: Int? = null,
|
||||
iconImageVector: ImageVector? = null,
|
||||
iconTint: Color = MaterialTheme.colorScheme.primary,
|
||||
iconBackgroundTint: Color = ElementTheme.colors.temporaryColorBgSpecial,
|
||||
) {
|
||||
Column(modifier) {
|
||||
RoundedIconAtom(
|
||||
|
|
@ -55,6 +58,7 @@ fun IconTitleSubtitleMolecule(
|
|||
resourceId = iconResourceId,
|
||||
imageVector = iconImageVector,
|
||||
tint = iconTint,
|
||||
backgroundTint = iconBackgroundTint,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
|
|
|
|||
|
|
@ -80,11 +80,11 @@ fun ListItem(
|
|||
/**
|
||||
* A List Item component to be used in lists and menus with simple layouts, matching the Material 3 guidelines.
|
||||
* @param headlineContent The main content of the list item, usually a text.
|
||||
* @param colors The colors to use for the list item. You can use [ListItemDefaults.colors] to create this.
|
||||
* @param modifier The modifier to be applied to the list item.
|
||||
* @param supportingContent The content to be displayed below the headline content.
|
||||
* @param leadingContent The content to be displayed before the headline content.
|
||||
* @param trailingContent The content to be displayed after the headline content.
|
||||
* @param colors The colors to use for the list item.
|
||||
* @param enabled Whether the list item is enabled. When disabled, will change the color of the headline content and the leading content to use disabled tokens.
|
||||
* @param onClick The callback to be called when the list item is clicked.
|
||||
*/
|
||||
|
|
@ -412,8 +412,8 @@ private object PreviewItems {
|
|||
) {
|
||||
ElementThemedPreview {
|
||||
ListItem(
|
||||
headlineContent = PreviewItems.headline(),
|
||||
supportingContent = PreviewItems.text(),
|
||||
headlineContent = headline(),
|
||||
supportingContent = text(),
|
||||
leadingContent = leadingContent,
|
||||
trailingContent = trailingContent,
|
||||
style = style,
|
||||
|
|
@ -431,8 +431,8 @@ private object PreviewItems {
|
|||
) {
|
||||
ElementThemedPreview {
|
||||
ListItem(
|
||||
headlineContent = PreviewItems.headline(),
|
||||
supportingContent = PreviewItems.textSingleLine(),
|
||||
headlineContent = headline(),
|
||||
supportingContent = textSingleLine(),
|
||||
leadingContent = leadingContent,
|
||||
trailingContent = trailingContent,
|
||||
style = style,
|
||||
|
|
@ -451,7 +451,7 @@ private object PreviewItems {
|
|||
) {
|
||||
ElementThemedPreview {
|
||||
ListItem(
|
||||
headlineContent = PreviewItems.headline(),
|
||||
headlineContent = headline(),
|
||||
leadingContent = leadingContent,
|
||||
trailingContent = trailingContent,
|
||||
enabled = enabled,
|
||||
|
|
|
|||
|
|
@ -354,5 +354,4 @@ interface MatrixRoom : Closeable {
|
|||
suspend fun withdrawVerificationAndResend(userIds: List<UserId>, transactionId: TransactionId): Result<Unit>
|
||||
|
||||
override fun close() = destroy()
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -647,10 +647,9 @@ class RustMatrixRoom(
|
|||
|
||||
override suspend fun ignoreDeviceTrustAndResend(devices: Map<UserId, List<DeviceId>>, transactionId: TransactionId) = runCatching {
|
||||
innerRoom.ignoreDeviceTrustAndResend(
|
||||
devices = devices
|
||||
.entries.associate { entry ->
|
||||
entry.key.value to entry.value.map { it.value }
|
||||
},
|
||||
devices = devices.entries.associate { entry ->
|
||||
entry.key.value to entry.value.map { it.value }
|
||||
},
|
||||
transactionId = transactionId.value
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ class FakeMatrixRoom(
|
|||
private var roomPermalinkResult: () -> Result<String> = { lambdaError() },
|
||||
private var eventPermalinkResult: (EventId) -> Result<String> = { lambdaError() },
|
||||
private val sendCallNotificationIfNeededResult: () -> Result<Unit> = { lambdaError() },
|
||||
private val userDisplayNameResult: () -> Result<String?> = { lambdaError() },
|
||||
private val userDisplayNameResult: (UserId) -> Result<String?> = { lambdaError() },
|
||||
private val userAvatarUrlResult: () -> Result<String?> = { lambdaError() },
|
||||
private val userRoleResult: () -> Result<RoomMember.Role> = { lambdaError() },
|
||||
private val getUpdatedMemberResult: (UserId) -> Result<RoomMember> = { lambdaError() },
|
||||
|
|
@ -137,7 +137,6 @@ class FakeMatrixRoom(
|
|||
private val subscribeToSyncLambda: () -> Unit = { lambdaError() },
|
||||
private val ignoreDeviceTrustAndResendResult: (Map<UserId, List<DeviceId>>, TransactionId) -> Result<Unit> = { _, _ -> lambdaError() },
|
||||
private val withdrawVerificationAndResendResult: (List<UserId>, TransactionId) -> Result<Unit> = { _, _ -> lambdaError() },
|
||||
|
||||
) : MatrixRoom {
|
||||
private val _roomInfoFlow: MutableSharedFlow<MatrixRoomInfo> = MutableSharedFlow(replay = 1)
|
||||
override val roomInfoFlow: Flow<MatrixRoomInfo> = _roomInfoFlow
|
||||
|
|
@ -203,7 +202,7 @@ class FakeMatrixRoom(
|
|||
override fun destroy() = Unit
|
||||
|
||||
override suspend fun userDisplayName(userId: UserId): Result<String?> = simulateLongTask {
|
||||
userDisplayNameResult()
|
||||
userDisplayNameResult(userId)
|
||||
}
|
||||
|
||||
override suspend fun userAvatarUrl(userId: UserId): Result<String?> = simulateLongTask {
|
||||
|
|
@ -230,7 +229,7 @@ class FakeMatrixRoom(
|
|||
return toggleReactionResult(emoji, uniqueId)
|
||||
}
|
||||
|
||||
override suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit> {
|
||||
override suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit> = simulateLongTask {
|
||||
return retrySendMessageResult(transactionId)
|
||||
}
|
||||
|
||||
|
|
@ -496,11 +495,11 @@ class FakeMatrixRoom(
|
|||
return getWidgetDriverResult(widgetSettings)
|
||||
}
|
||||
|
||||
override suspend fun ignoreDeviceTrustAndResend(devices: Map<UserId, List<DeviceId>>, transactionId: TransactionId): Result<Unit> {
|
||||
override suspend fun ignoreDeviceTrustAndResend(devices: Map<UserId, List<DeviceId>>, transactionId: TransactionId): Result<Unit> = simulateLongTask {
|
||||
return ignoreDeviceTrustAndResendResult(devices, transactionId)
|
||||
}
|
||||
|
||||
override suspend fun withdrawVerificationAndResend(userIds: List<UserId>, transactionId: TransactionId): Result<Unit> {
|
||||
override suspend fun withdrawVerificationAndResend(userIds: List<UserId>, transactionId: TransactionId): Result<Unit> = simulateLongTask {
|
||||
return withdrawVerificationAndResendResult(userIds, transactionId)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue