mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-10-31 08:15:02 +00:00
Merge pull request #7490 from element-hq/feature/bma/sendingMessageFailedReason
Show dialog with reason when user clicks on a message with a failure
This commit is contained in:
@@ -39,6 +39,7 @@ PRs must meet these rules.
|
||||
- Use `@PreviewsDayNight` for consistency.
|
||||
- Use `PreviewParameterProvider` (e.g., `FooStateProvider`) to provide states.
|
||||
- Wrap previews in `ElementPreview { ... }`.
|
||||
- When writing tests, never try to record the screenshots, the CI will do it.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+14
@@ -400,9 +400,23 @@ class MessagesPresenter(
|
||||
TimelineItemAction.Pin -> handlePinAction(targetEvent)
|
||||
TimelineItemAction.Unpin -> handleUnpinAction(targetEvent)
|
||||
TimelineItemAction.ViewInTimeline -> Unit
|
||||
TimelineItemAction.RetrySending -> handleRetrySending(targetEvent)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleRetrySending(targetEvent: TimelineItem.Event) {
|
||||
val sendHandle = targetEvent.sendhandle ?: return Unit.also {
|
||||
Timber.w("No send handle for event ${targetEvent.eventOrTransactionId}")
|
||||
}
|
||||
sendHandle.retry()
|
||||
.onSuccess {
|
||||
Timber.d("Succeed to add the message back to the send queue")
|
||||
}
|
||||
.onFailure {
|
||||
Timber.e(it, "Failed to add the message back to the send queue")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleRemoveCaption(targetEvent: TimelineItem.Event) {
|
||||
timelineController.invokeOnCurrentTimeline {
|
||||
editCaption(
|
||||
|
||||
+34
-2
@@ -93,6 +93,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogView
|
||||
import io.element.android.features.messages.impl.topbars.MessagesViewTopBar
|
||||
import io.element.android.features.messages.impl.topbars.ThreadTopBar
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog
|
||||
@@ -429,6 +430,31 @@ fun MessagesView(
|
||||
},
|
||||
state = state.linkState,
|
||||
)
|
||||
|
||||
SendFailureDialogView(
|
||||
sendFailureDialogState = state.timelineState.sendFailureDialogState,
|
||||
onDismiss = {
|
||||
state.timelineState.eventSink(TimelineEvent.HideSendFailureDialog)
|
||||
},
|
||||
onRetry = { event ->
|
||||
state.eventSink(
|
||||
MessagesEvent.HandleAction(
|
||||
action = TimelineItemAction.RetrySending,
|
||||
event = event,
|
||||
)
|
||||
)
|
||||
state.timelineState.eventSink(TimelineEvent.HideSendFailureDialog)
|
||||
},
|
||||
onRemoveMessage = { event ->
|
||||
state.eventSink(
|
||||
MessagesEvent.HandleAction(
|
||||
action = TimelineItemAction.Redact,
|
||||
event = event,
|
||||
)
|
||||
)
|
||||
state.timelineState.eventSink(TimelineEvent.HideSendFailureDialog)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -590,13 +616,19 @@ private fun MessagesViewComposerBottomSheetContents(
|
||||
when {
|
||||
state.successorRoom != null -> {
|
||||
SuccessorRoomBanner(
|
||||
modifier = Modifier.fillMaxWidth().padding(contentPadding),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(contentPadding),
|
||||
roomSuccessor = state.successorRoom,
|
||||
onRoomSuccessorClick = onRoomSuccessorClick
|
||||
)
|
||||
}
|
||||
state.userEventPermissions.canSendMessage -> {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(contentPadding)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(contentPadding)
|
||||
) {
|
||||
// Do not show the identity change if user is composing a Rich message or is seeing suggestion(s).
|
||||
if (state.composerState.suggestions.isEmpty() &&
|
||||
state.composerState.textEditorState is TextEditorState.Markdown) {
|
||||
|
||||
+3
@@ -36,4 +36,7 @@ enum class TimelineItemAction(
|
||||
EndPoll(CommonStrings.action_end_poll, CompoundDrawables.ic_compound_polls_end),
|
||||
Pin(CommonStrings.action_pin, CompoundDrawables.ic_compound_pin),
|
||||
Unpin(CommonStrings.action_unpin, CompoundDrawables.ic_compound_unpin),
|
||||
|
||||
// Note: this action is not used in UI for now
|
||||
RetrySending(CommonStrings.action_retry, CompoundDrawables.ic_compound_send_solid),
|
||||
}
|
||||
|
||||
+2
@@ -32,6 +32,8 @@ class TimelineItemActionComparator : Comparator<TimelineItemAction> {
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.ReportContent,
|
||||
TimelineItemAction.Redact,
|
||||
// Not rendered in the action list, but has to be listed here for exhaustiveness.
|
||||
TimelineItemAction.RetrySending,
|
||||
)
|
||||
|
||||
override fun compare(o1: TimelineItemAction, o2: TimelineItemAction): Int {
|
||||
|
||||
+3
@@ -34,6 +34,9 @@ sealed interface TimelineEvent {
|
||||
*/
|
||||
sealed interface TimelineItemEvent : TimelineEvent
|
||||
|
||||
data class ShowSendFailureDialog(val event: TimelineItem.Event) : TimelineItemEvent
|
||||
data object HideSendFailureDialog : TimelineEvent
|
||||
|
||||
data class ComputeVerifiedUserSendFailure(val event: TimelineItem.Event) : TimelineItemEvent
|
||||
data class ShowShieldDialog(val messageShieldData: MessageShieldData) : TimelineItemEvent
|
||||
data class LoadMore(val direction: Timeline.PaginationDirection) : TimelineItemEvent
|
||||
|
||||
+45
@@ -37,6 +37,7 @@ import io.element.android.features.messages.impl.timeline.model.virtual.Timeline
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogState
|
||||
import io.element.android.features.messages.impl.typing.TypingNotificationState
|
||||
import io.element.android.features.messages.impl.userEventPermissions
|
||||
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
|
||||
@@ -56,6 +57,7 @@ import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsSta
|
||||
import io.element.android.libraries.matrix.api.room.roomMembers
|
||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
|
||||
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
|
||||
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.DisplayFirstTimelineItems
|
||||
@@ -142,6 +144,7 @@ class TimelinePresenter(
|
||||
|
||||
val newEventState = remember { mutableStateOf<NewEventState>(NewEventState.None) }
|
||||
val messageShieldDialogData: MutableState<MessageShieldData?> = remember { mutableStateOf(null) }
|
||||
var sendFailureDialogState by remember { mutableStateOf<SendFailureDialogState>(SendFailureDialogState.Hidden) }
|
||||
|
||||
// Forces [JumpToUnreadState.Hidden] until the next RoomInfo push. Set after a
|
||||
// [TimelineEvent.MarkAllAsRead] await completes so the FAB hides without waiting for
|
||||
@@ -244,6 +247,47 @@ class TimelinePresenter(
|
||||
suppressJumpToUnread.value = true
|
||||
}
|
||||
is TimelineEvent.ShowShieldDialog -> messageShieldDialogData.value = event.messageShieldData
|
||||
is TimelineEvent.ShowSendFailureDialog -> {
|
||||
val sendStateFailure = event.event.localSendState as? LocalEventSendState.Failed ?: return
|
||||
when (sendStateFailure) {
|
||||
is LocalEventSendState.Failed.VerifiedUser -> {
|
||||
// defer to the resolveVerifiedUserSendFailureState to handle this case,
|
||||
// as it will show a dialog with the right information
|
||||
resolveVerifiedUserSendFailureState.eventSink(ResolveVerifiedUserSendFailureEvent.ComputeForMessage(event.event))
|
||||
}
|
||||
is LocalEventSendState.Failed.InvalidMimeType -> {
|
||||
sendFailureDialogState = SendFailureDialogState.Show(
|
||||
event = event.event,
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.InvalidMimeType(sendStateFailure.mimeType),
|
||||
)
|
||||
}
|
||||
LocalEventSendState.Failed.MissingMediaContent -> {
|
||||
sendFailureDialogState = SendFailureDialogState.Show(
|
||||
event = event.event,
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.MissingMediaContent,
|
||||
)
|
||||
}
|
||||
LocalEventSendState.Failed.SendingFromUnverifiedDevice -> {
|
||||
sendFailureDialogState = SendFailureDialogState.Show(
|
||||
event = event.event,
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.SendingFromUnverifiedDevice,
|
||||
)
|
||||
}
|
||||
is LocalEventSendState.Failed.Unknown -> {
|
||||
sendFailureDialogState = SendFailureDialogState.Show(
|
||||
event = event.event,
|
||||
sendFailureType = if (sendStateFailure.error.isNotBlank()) {
|
||||
SendFailureDialogState.SendFailureType.Error(sendStateFailure.error)
|
||||
} else {
|
||||
SendFailureDialogState.SendFailureType.Unknown
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is TimelineEvent.HideSendFailureDialog -> {
|
||||
sendFailureDialogState = SendFailureDialogState.Hidden
|
||||
}
|
||||
is TimelineEvent.ComputeVerifiedUserSendFailure -> {
|
||||
resolveVerifiedUserSendFailureState.eventSink(ResolveVerifiedUserSendFailureEvent.ComputeForMessage(event.event))
|
||||
}
|
||||
@@ -408,6 +452,7 @@ class TimelinePresenter(
|
||||
focusRequestState = focusRequestState.value,
|
||||
messageShieldDialogData = messageShieldDialogData.value,
|
||||
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
|
||||
sendFailureDialogState = sendFailureDialogState,
|
||||
displayThreadSummaries = displayThreadSummaries,
|
||||
displayJumpToUnread = displayJumpToUnread,
|
||||
jumpToUnread = jumpToUnread.value,
|
||||
|
||||
+2
@@ -13,6 +13,7 @@ import io.element.android.features.messages.impl.crypto.sendfailure.resolve.Reso
|
||||
import io.element.android.features.messages.impl.timeline.components.MessageShieldData
|
||||
import io.element.android.features.messages.impl.timeline.model.NewEventState
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogState
|
||||
import io.element.android.features.messages.impl.typing.TypingNotificationState
|
||||
import io.element.android.features.roomcall.api.RoomCallState
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
@@ -32,6 +33,7 @@ data class TimelineState(
|
||||
// If not null, info will be rendered in a dialog
|
||||
val messageShieldDialogData: MessageShieldData?,
|
||||
val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState,
|
||||
val sendFailureDialogState: SendFailureDialogState,
|
||||
val displayThreadSummaries: Boolean,
|
||||
val displayJumpToUnread: Boolean,
|
||||
val jumpToUnread: JumpToUnreadState,
|
||||
|
||||
+3
@@ -25,6 +25,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
|
||||
import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogState
|
||||
import io.element.android.features.messages.impl.typing.TypingNotificationState
|
||||
import io.element.android.features.messages.impl.typing.aTypingNotificationState
|
||||
import io.element.android.features.roomcall.api.aStandByCallState
|
||||
@@ -57,6 +58,7 @@ fun aTimelineState(
|
||||
isLive: Boolean = true,
|
||||
messageShield: MessageShield? = null,
|
||||
resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(),
|
||||
sendFailureDialogState: SendFailureDialogState = SendFailureDialogState.Hidden,
|
||||
displayThreadSummaries: Boolean = false,
|
||||
displayJumpToUnread: Boolean = false,
|
||||
jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden,
|
||||
@@ -78,6 +80,7 @@ fun aTimelineState(
|
||||
focusRequestState = focusRequestState,
|
||||
messageShieldDialogData = messageShield?.let { MessageShieldData(it) },
|
||||
resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState,
|
||||
sendFailureDialogState = sendFailureDialogState,
|
||||
displayThreadSummaries = displayThreadSummaries,
|
||||
displayJumpToUnread = displayJumpToUnread,
|
||||
jumpToUnread = jumpToUnread,
|
||||
|
||||
+4
-4
@@ -56,10 +56,11 @@ fun TimelineEventTimestampView(
|
||||
val isVerifiedUserSendFailure = event.localSendState is LocalEventSendState.Failed.VerifiedUser
|
||||
val onClickLabel = when {
|
||||
shield != null -> stringResource(CommonStrings.a11y_view_details)
|
||||
hasError && isVerifiedUserSendFailure -> stringResource(CommonStrings.action_open_context_menu)
|
||||
isVerifiedUserSendFailure -> stringResource(CommonStrings.action_open_context_menu)
|
||||
hasError -> stringResource(CommonStrings.a11y_view_details)
|
||||
else -> null
|
||||
}
|
||||
val clickableModifier = remember(shield, hasError) {
|
||||
val clickableModifier = remember(event) {
|
||||
when {
|
||||
shield != null -> {
|
||||
Modifier.clickable(
|
||||
@@ -70,10 +71,9 @@ fun TimelineEventTimestampView(
|
||||
}
|
||||
hasError -> Modifier
|
||||
.clickable(
|
||||
enabled = isVerifiedUserSendFailure,
|
||||
onClickLabel = onClickLabel,
|
||||
) {
|
||||
eventSink(TimelineEvent.ComputeVerifiedUserSendFailure(event))
|
||||
eventSink(TimelineEvent.ShowSendFailureDialog(event))
|
||||
}
|
||||
else -> Modifier
|
||||
}
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.sendfailure
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
|
||||
@Immutable
|
||||
sealed interface SendFailureDialogState {
|
||||
data object Hidden : SendFailureDialogState
|
||||
|
||||
data class Show(
|
||||
val event: TimelineItem.Event,
|
||||
val sendFailureType: SendFailureType,
|
||||
) : SendFailureDialogState
|
||||
|
||||
@Immutable
|
||||
sealed interface SendFailureType {
|
||||
data class InvalidMimeType(val mimeType: String) : SendFailureType
|
||||
data object MissingMediaContent : SendFailureType
|
||||
data object SendingFromUnverifiedDevice : SendFailureType
|
||||
data class Error(val message: String) : SendFailureType
|
||||
data object Unknown : SendFailureType
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.sendfailure
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
|
||||
class SendFailureDialogStateProvider : PreviewParameterProvider<SendFailureDialogState> {
|
||||
override val values: Sequence<SendFailureDialogState>
|
||||
get() = sequenceOf(
|
||||
SendFailureDialogState.Hidden,
|
||||
SendFailureDialogState.Show(
|
||||
event = aTimelineItemEvent(),
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.InvalidMimeType("image/invalid")
|
||||
),
|
||||
SendFailureDialogState.Show(
|
||||
event = aTimelineItemEvent(),
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.MissingMediaContent,
|
||||
),
|
||||
SendFailureDialogState.Show(
|
||||
event = aTimelineItemEvent(),
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.SendingFromUnverifiedDevice,
|
||||
),
|
||||
SendFailureDialogState.Show(
|
||||
event = aTimelineItemEvent(),
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.Error("Message error from server"),
|
||||
),
|
||||
SendFailureDialogState.Show(
|
||||
event = aTimelineItemEvent(),
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.Unknown,
|
||||
)
|
||||
)
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.sendfailure
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import io.element.android.features.messages.impl.R
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun SendFailureDialogView(
|
||||
sendFailureDialogState: SendFailureDialogState,
|
||||
onDismiss: () -> Unit,
|
||||
onRetry: (TimelineItem.Event) -> Unit,
|
||||
onRemoveMessage: (TimelineItem.Event) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (sendFailureDialogState) {
|
||||
is SendFailureDialogState.Hidden -> Unit
|
||||
is SendFailureDialogState.Show -> {
|
||||
// Show the dialog with the message and actions
|
||||
val content = when (sendFailureDialogState.sendFailureType) {
|
||||
is SendFailureDialogState.SendFailureType.InvalidMimeType -> stringResource(
|
||||
id = R.string.error_sending_failure_invalid_mime_type,
|
||||
sendFailureDialogState.sendFailureType.mimeType,
|
||||
)
|
||||
is SendFailureDialogState.SendFailureType.MissingMediaContent -> stringResource(
|
||||
id = R.string.error_sending_failure_missing_media_content,
|
||||
)
|
||||
is SendFailureDialogState.SendFailureType.SendingFromUnverifiedDevice -> stringResource(
|
||||
id = R.string.error_sending_failure_sending_from_unverified_device,
|
||||
)
|
||||
is SendFailureDialogState.SendFailureType.Error -> stringResource(
|
||||
id = R.string.error_sending_failure_other,
|
||||
sendFailureDialogState.sendFailureType.message
|
||||
)
|
||||
is SendFailureDialogState.SendFailureType.Unknown -> stringResource(
|
||||
id = R.string.error_sending_failure_unknown,
|
||||
)
|
||||
}
|
||||
|
||||
ConfirmationDialog(
|
||||
modifier = modifier,
|
||||
title = stringResource(id = CommonStrings.common_sending_failed),
|
||||
content = content,
|
||||
onDismiss = onDismiss,
|
||||
submitText = stringResource(id = CommonStrings.action_retry),
|
||||
onSubmitClick = { onRetry(sendFailureDialogState.event) },
|
||||
onCancelClick = onDismiss,
|
||||
thirdButtonText = stringResource(id = CommonStrings.action_remove_message),
|
||||
onThirdButtonClick = { onRemoveMessage(sendFailureDialogState.event) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun SendFailureDialogViewPreview(
|
||||
@PreviewParameter(SendFailureDialogStateProvider::class) sendFailureDialogState: SendFailureDialogState,
|
||||
) = ElementPreview {
|
||||
SendFailureDialogView(
|
||||
sendFailureDialogState = sendFailureDialogState,
|
||||
onDismiss = {},
|
||||
onRetry = {},
|
||||
onRemoveMessage = {},
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,11 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Encrypted by an unknown or deleted device."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Encrypted by a device not verified by its owner."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Encrypted by an unverified user."</string>
|
||||
<string name="error_sending_failure_invalid_mime_type">"Invalid mime type: %s"</string>
|
||||
<string name="error_sending_failure_missing_media_content">"Missing media content"</string>
|
||||
<string name="error_sending_failure_other">"Error details: %s"</string>
|
||||
<string name="error_sending_failure_sending_from_unverified_device">"Sending from unverified device"</string>
|
||||
<string name="error_sending_failure_unknown">"Unknown error"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally">"Flip image horizontally"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Flipped horizontally"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Original"</string>
|
||||
|
||||
+52
@@ -72,6 +72,7 @@ import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
|
||||
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
@@ -83,6 +84,7 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_THREAD_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.core.FakeSendHandle
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||
@@ -1043,6 +1045,56 @@ class MessagesPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - handle action retry sending`() = runTest {
|
||||
val retryLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val messageEvent = aMessageEvent(
|
||||
sendState = LocalEventSendState.Failed.Unknown("Error"),
|
||||
sendHandleProvider = { FakeSendHandle(retryLambda = retryLambda) },
|
||||
)
|
||||
val presenter = createMessagesPresenter()
|
||||
presenter.testWithLifecycleOwner {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, messageEvent))
|
||||
advanceUntilIdle()
|
||||
retryLambda.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - handle action retry sending - failure is ignored`() = runTest {
|
||||
val retryLambda = lambdaRecorder<Result<Unit>> { Result.failure(AN_EXCEPTION) }
|
||||
val messageEvent = aMessageEvent(
|
||||
sendState = LocalEventSendState.Failed.Unknown("Error"),
|
||||
sendHandleProvider = { FakeSendHandle(retryLambda = retryLambda) },
|
||||
)
|
||||
val presenter = createMessagesPresenter()
|
||||
presenter.testWithLifecycleOwner {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, messageEvent))
|
||||
advanceUntilIdle()
|
||||
retryLambda.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - handle action retry sending - no send handle, it should have no effect`() = runTest {
|
||||
val messageEvent = aMessageEvent(
|
||||
sendState = LocalEventSendState.Failed.Unknown("Error"),
|
||||
sendHandleProvider = { null },
|
||||
)
|
||||
val presenter = createMessagesPresenter()
|
||||
presenter.testWithLifecycleOwner {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, messageEvent))
|
||||
advanceUntilIdle()
|
||||
// No op!
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - handle action view in timeline, it should have no effect`() = runTest {
|
||||
val messageEvent = aMessageEvent(
|
||||
|
||||
+77
@@ -55,12 +55,14 @@ import io.element.android.features.messages.impl.timeline.components.receipt.aRe
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvent
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogState
|
||||
import io.element.android.features.roomcall.api.aStandByCallState
|
||||
import io.element.android.libraries.emoji.impl.picker.DefaultEmojiPickerRenderer
|
||||
import io.element.android.libraries.emoji.impl.picker.anEmojiPickerState
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
@@ -88,6 +90,7 @@ import org.junit.Test
|
||||
import org.robolectric.annotation.Config
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Suppress("LargeClass")
|
||||
class MessagesViewTest : RobolectricTest() {
|
||||
@Test
|
||||
fun `clicking on back invoke expected callback`() = runAndroidComposeUiTest {
|
||||
@@ -558,6 +561,80 @@ class MessagesViewTest : RobolectricTest() {
|
||||
eventsRecorder.assertSingle(TimelineEvent.ComputeVerifiedUserSendFailure(timelineItem))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on retry in the send failure dialog emits the expected Events`() = runAndroidComposeUiTest {
|
||||
val messagesEventsRecorder = EventsRecorder<MessagesEvent>()
|
||||
val timelineEventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
val timelineItem = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown("Error"))
|
||||
setMessagesView(
|
||||
state = aMessagesState(
|
||||
timelineState = aTimelineState(
|
||||
sendFailureDialogState = SendFailureDialogState.Show(
|
||||
event = timelineItem,
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.Error("Error"),
|
||||
),
|
||||
eventSink = timelineEventsRecorder,
|
||||
),
|
||||
eventSink = messagesEventsRecorder,
|
||||
),
|
||||
)
|
||||
// Clear initial 'LoadMore' event emitted when setting the state
|
||||
timelineEventsRecorder.clear()
|
||||
|
||||
clickOn(CommonStrings.action_retry)
|
||||
messagesEventsRecorder.assertSingle(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, timelineItem))
|
||||
timelineEventsRecorder.assertSingle(TimelineEvent.HideSendFailureDialog)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on remove message in the send failure dialog emits the expected Events`() = runAndroidComposeUiTest {
|
||||
val messagesEventsRecorder = EventsRecorder<MessagesEvent>()
|
||||
val timelineEventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
val timelineItem = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown("Error"))
|
||||
setMessagesView(
|
||||
state = aMessagesState(
|
||||
timelineState = aTimelineState(
|
||||
sendFailureDialogState = SendFailureDialogState.Show(
|
||||
event = timelineItem,
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.Error("Error"),
|
||||
),
|
||||
eventSink = timelineEventsRecorder,
|
||||
),
|
||||
eventSink = messagesEventsRecorder,
|
||||
),
|
||||
)
|
||||
// Clear initial 'LoadMore' event emitted when setting the state
|
||||
timelineEventsRecorder.clear()
|
||||
|
||||
clickOn(CommonStrings.action_remove_message)
|
||||
messagesEventsRecorder.assertSingle(MessagesEvent.HandleAction(TimelineItemAction.Redact, timelineItem))
|
||||
timelineEventsRecorder.assertSingle(TimelineEvent.HideSendFailureDialog)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancelling the send failure dialog emits the expected Event`() = runAndroidComposeUiTest {
|
||||
val messagesEventsRecorder = EventsRecorder<MessagesEvent>(expectEvents = false)
|
||||
val timelineEventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
val timelineItem = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown("Error"))
|
||||
setMessagesView(
|
||||
state = aMessagesState(
|
||||
timelineState = aTimelineState(
|
||||
sendFailureDialogState = SendFailureDialogState.Show(
|
||||
event = timelineItem,
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.Error("Error"),
|
||||
),
|
||||
eventSink = timelineEventsRecorder,
|
||||
),
|
||||
eventSink = messagesEventsRecorder,
|
||||
),
|
||||
)
|
||||
// Clear initial 'LoadMore' event emitted when setting the state
|
||||
timelineEventsRecorder.clear()
|
||||
|
||||
clickOn(CommonStrings.action_cancel)
|
||||
timelineEventsRecorder.assertSingle(TimelineEvent.HideSendFailureDialog)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on a custom emoji emits the expected Events`() = runAndroidComposeUiTest {
|
||||
val aUnicode = "🙈"
|
||||
|
||||
+112
-1
@@ -12,6 +12,8 @@ import app.cash.turbine.ReceiveTurbine
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
|
||||
import io.element.android.features.messages.impl.FakeMessagesNavigator
|
||||
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureEvent
|
||||
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.fixtures.aMessageEvent
|
||||
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator
|
||||
@@ -21,6 +23,7 @@ import io.element.android.features.messages.impl.timeline.model.NewEventState
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
|
||||
import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogState
|
||||
import io.element.android.features.messages.impl.typing.aTypingNotificationState
|
||||
import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager
|
||||
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
|
||||
@@ -45,6 +48,7 @@ import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
|
||||
@@ -1390,6 +1394,112 @@ class TimelinePresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - show and hide send failure dialog`() = runTest {
|
||||
val presenter = createTimelinePresenter()
|
||||
val event = aMessageEvent(sendState = LocalEventSendState.Failed.Unknown("An error"))
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.sendFailureDialogState).isEqualTo(SendFailureDialogState.Hidden)
|
||||
initialState.eventSink(TimelineEvent.ShowSendFailureDialog(event))
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.sendFailureDialogState).isEqualTo(
|
||||
SendFailureDialogState.Show(
|
||||
event = event,
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.Error("An error"),
|
||||
)
|
||||
)
|
||||
state.eventSink(TimelineEvent.HideSendFailureDialog)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.sendFailureDialogState).isEqualTo(SendFailureDialogState.Hidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - show send failure dialog - unknown error with a blank reason`() {
|
||||
assertSendFailureType(
|
||||
sendState = LocalEventSendState.Failed.Unknown(error = ""),
|
||||
expectedSendFailureType = SendFailureDialogState.SendFailureType.Unknown,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - show send failure dialog - invalid mime type`() {
|
||||
assertSendFailureType(
|
||||
sendState = LocalEventSendState.Failed.InvalidMimeType(mimeType = "invalid/mimeType"),
|
||||
expectedSendFailureType = SendFailureDialogState.SendFailureType.InvalidMimeType("invalid/mimeType"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - show send failure dialog - missing media content`() {
|
||||
assertSendFailureType(
|
||||
sendState = LocalEventSendState.Failed.MissingMediaContent,
|
||||
expectedSendFailureType = SendFailureDialogState.SendFailureType.MissingMediaContent,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - show send failure dialog - sending from unverified device`() {
|
||||
assertSendFailureType(
|
||||
sendState = LocalEventSendState.Failed.SendingFromUnverifiedDevice,
|
||||
expectedSendFailureType = SendFailureDialogState.SendFailureType.SendingFromUnverifiedDevice,
|
||||
)
|
||||
}
|
||||
|
||||
private fun assertSendFailureType(
|
||||
sendState: LocalEventSendState.Failed,
|
||||
expectedSendFailureType: SendFailureDialogState.SendFailureType,
|
||||
) = runTest {
|
||||
val presenter = createTimelinePresenter()
|
||||
val event = aMessageEvent(sendState = sendState)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink(TimelineEvent.ShowSendFailureDialog(event))
|
||||
assertThat(awaitItem().sendFailureDialogState).isEqualTo(
|
||||
SendFailureDialogState.Show(event = event, sendFailureType = expectedSendFailureType)
|
||||
)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - show send failure dialog - verified user failure is delegated to the resolve presenter`() = runTest {
|
||||
val resolveEventSinkRecorder = lambdaRecorder<ResolveVerifiedUserSendFailureEvent, Unit> { }
|
||||
val presenter = createTimelinePresenter(
|
||||
resolveVerifiedUserSendFailurePresenter = {
|
||||
aResolveVerifiedUserSendFailureState(eventSink = resolveEventSinkRecorder)
|
||||
},
|
||||
)
|
||||
val event = aMessageEvent(
|
||||
sendState = LocalEventSendState.Failed.VerifiedUserChangedIdentity(users = listOf(A_USER_ID)),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink(TimelineEvent.ShowSendFailureDialog(event))
|
||||
// The generic dialog is not displayed, the dedicated one is.
|
||||
assertThat(consumeItemsUntilTimeout()).isEmpty()
|
||||
assertThat(initialState.sendFailureDialogState).isEqualTo(SendFailureDialogState.Hidden)
|
||||
resolveEventSinkRecorder.assertions()
|
||||
.isCalledOnce()
|
||||
.with(value(ResolveVerifiedUserSendFailureEvent.ComputeForMessage(event)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - show send failure dialog - no effect if the event did not fail to send`() = runTest {
|
||||
val presenter = createTimelinePresenter()
|
||||
val event = aMessageEvent(sendState = LocalEventSendState.Sending.Event)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink(TimelineEvent.ShowSendFailureDialog(event))
|
||||
assertThat(consumeItemsUntilTimeout()).isEmpty()
|
||||
assertThat(initialState.sendFailureDialogState).isEqualTo(SendFailureDialogState.Hidden)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - when room member info is loaded, read receipts info should be updated`() = runTest {
|
||||
val timeline = FakeTimeline(
|
||||
@@ -1589,6 +1699,7 @@ class TimelinePresenterTest {
|
||||
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
|
||||
markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead { _, _ -> },
|
||||
timelineProtectionPresenter: Presenter<TimelineProtectionState> = { aTimelineProtectionState() },
|
||||
resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState> = { aResolveVerifiedUserSendFailureState() },
|
||||
): TimelinePresenter {
|
||||
return TimelinePresenter(
|
||||
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
|
||||
@@ -1602,7 +1713,7 @@ class TimelinePresenterTest {
|
||||
sessionPreferencesStore = sessionPreferencesStore,
|
||||
timelineItemIndexer = timelineItemIndexer,
|
||||
timelineController = TimelineController(room, timeline),
|
||||
resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() },
|
||||
resolveVerifiedUserSendFailurePresenter = resolveVerifiedUserSendFailurePresenter,
|
||||
typingNotificationPresenter = { aTypingNotificationState() },
|
||||
roomCallStatePresenter = { aStandByCallState() },
|
||||
featureFlagService = featureFlagService,
|
||||
|
||||
+41
@@ -33,6 +33,7 @@ import io.element.android.features.messages.impl.timeline.protection.aTimelinePr
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||
@@ -178,6 +179,46 @@ class TimelineViewTest : RobolectricTest() {
|
||||
eventsRecorder.assertSingle(TimelineEvent.HideShieldDialog)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on the send failure indicator emits the expected Event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
val timelineItem = aTimelineItemEvent(
|
||||
content = aTimelineItemTextContent(),
|
||||
sendState = LocalEventSendState.Failed.Unknown("Error"),
|
||||
)
|
||||
setTimelineView(
|
||||
state = aTimelineState(
|
||||
timelineItems = persistentListOf(timelineItem),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
eventsRecorder.clear()
|
||||
|
||||
val contentDescription = activity!!.getString(CommonStrings.common_sending_failed)
|
||||
onNodeWithContentDescription(contentDescription).performClick()
|
||||
eventsRecorder.assertSingle(TimelineEvent.ShowSendFailureDialog(timelineItem))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on the send failure indicator of a verified user failure emits the expected Event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
val timelineItem = aTimelineItemEvent(
|
||||
content = aTimelineItemTextContent(),
|
||||
sendState = LocalEventSendState.Failed.VerifiedUserChangedIdentity(users = listOf(A_USER_ID)),
|
||||
)
|
||||
setTimelineView(
|
||||
state = aTimelineState(
|
||||
timelineItems = persistentListOf(timelineItem),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
eventsRecorder.clear()
|
||||
|
||||
val contentDescription = activity!!.getString(CommonStrings.common_sending_failed)
|
||||
onNodeWithContentDescription(contentDescription).performClick()
|
||||
eventsRecorder.assertSingle(TimelineEvent.ShowSendFailureDialog(timelineItem))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on a reply to a loaded event emits the expected Event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<TimelineEvent>()
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalTestApi::class)
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.sendfailure
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import io.element.android.features.messages.impl.R
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
||||
import io.element.android.tests.testutils.assertNodeWithTextIsDisplayed
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import io.element.android.tests.testutils.ensureCalledOnce
|
||||
import io.element.android.tests.testutils.ensureCalledOnceWithParam
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import io.element.android.tests.testutils.setSafeContent
|
||||
import org.junit.Test
|
||||
|
||||
class SendFailureDialogViewTest : RobolectricTest() {
|
||||
@Test
|
||||
fun `hidden state renders nothing`() = runAndroidComposeUiTest {
|
||||
setSendFailureDialogView(
|
||||
sendFailureDialogState = SendFailureDialogState.Hidden,
|
||||
)
|
||||
onNodeWithText(activity!!.getString(CommonStrings.common_sending_failed)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `show state renders the error failure reason`() = runAndroidComposeUiTest {
|
||||
setSendFailureDialogView(
|
||||
sendFailureDialogState = aSendFailureDialogStateShow(
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.Error("A failure reason"),
|
||||
),
|
||||
)
|
||||
assertNodeWithTextIsDisplayed(CommonStrings.common_sending_failed)
|
||||
onNodeWithText(activity!!.getString(R.string.error_sending_failure_other, "A failure reason")).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `show state renders the invalid mime type failure reason`() = runAndroidComposeUiTest {
|
||||
setSendFailureDialogView(
|
||||
sendFailureDialogState = aSendFailureDialogStateShow(
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.InvalidMimeType("invalid/mimeType"),
|
||||
),
|
||||
)
|
||||
onNodeWithText(activity!!.getString(R.string.error_sending_failure_invalid_mime_type, "invalid/mimeType")).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `show state renders the missing media content failure reason`() = runAndroidComposeUiTest {
|
||||
setSendFailureDialogView(
|
||||
sendFailureDialogState = aSendFailureDialogStateShow(
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.MissingMediaContent,
|
||||
),
|
||||
)
|
||||
onNodeWithText(activity!!.getString(R.string.error_sending_failure_missing_media_content)).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `show state renders the sending from unverified device failure reason`() = runAndroidComposeUiTest {
|
||||
setSendFailureDialogView(
|
||||
sendFailureDialogState = aSendFailureDialogStateShow(
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.SendingFromUnverifiedDevice,
|
||||
),
|
||||
)
|
||||
onNodeWithText(activity!!.getString(R.string.error_sending_failure_sending_from_unverified_device)).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `show state renders the unknown failure reason`() = runAndroidComposeUiTest {
|
||||
setSendFailureDialogView(
|
||||
sendFailureDialogState = aSendFailureDialogStateShow(
|
||||
sendFailureType = SendFailureDialogState.SendFailureType.Unknown,
|
||||
),
|
||||
)
|
||||
onNodeWithText(activity!!.getString(R.string.error_sending_failure_unknown)).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on retry invokes the expected callback`() = runAndroidComposeUiTest {
|
||||
val state = aSendFailureDialogStateShow()
|
||||
ensureCalledOnceWithParam(state.event) { callback ->
|
||||
setSendFailureDialogView(
|
||||
sendFailureDialogState = state,
|
||||
onRetry = callback,
|
||||
)
|
||||
clickOn(CommonStrings.action_retry)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on remove message invokes the expected callback`() = runAndroidComposeUiTest {
|
||||
val state = aSendFailureDialogStateShow()
|
||||
ensureCalledOnceWithParam(state.event) { callback ->
|
||||
setSendFailureDialogView(
|
||||
sendFailureDialogState = state,
|
||||
onRemoveMessage = callback,
|
||||
)
|
||||
clickOn(CommonStrings.action_remove_message)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on cancel invokes the expected callback`() = runAndroidComposeUiTest {
|
||||
ensureCalledOnce { callback ->
|
||||
setSendFailureDialogView(
|
||||
sendFailureDialogState = aSendFailureDialogStateShow(),
|
||||
onDismiss = callback,
|
||||
)
|
||||
clickOn(CommonStrings.action_cancel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun aSendFailureDialogStateShow(
|
||||
sendFailureType: SendFailureDialogState.SendFailureType = SendFailureDialogState.SendFailureType.Unknown,
|
||||
) = SendFailureDialogState.Show(
|
||||
event = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown("")),
|
||||
sendFailureType = sendFailureType,
|
||||
)
|
||||
|
||||
private fun AndroidComposeUiTest<ComponentActivity>.setSendFailureDialogView(
|
||||
sendFailureDialogState: SendFailureDialogState,
|
||||
onDismiss: () -> Unit = EnsureNeverCalled(),
|
||||
onRetry: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onRemoveMessage: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),
|
||||
) {
|
||||
setSafeContent {
|
||||
SendFailureDialogView(
|
||||
sendFailureDialogState = sendFailureDialogState,
|
||||
onDismiss = onDismiss,
|
||||
onRetry = onRetry,
|
||||
onRemoveMessage = onRemoveMessage,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -272,6 +272,7 @@
|
||||
"screen_room_retry.*",
|
||||
"screen_room_timeline.*",
|
||||
"screen\\.room\\.timeline.*",
|
||||
"error\\.sending_failure\\..*",
|
||||
"screen\\.room_timeline.*",
|
||||
"screen_room_typing.*",
|
||||
"screen\\.image_edition\\..*",
|
||||
|
||||
Reference in New Issue
Block a user