diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 8eabf156af..05329c3fea 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -241,6 +241,9 @@ fun MessagesView( state.customReactionState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event)) }, onEmojiReactionClick = ::onEmojiReactionClick, + onVerifiedUserSendFailureClick = { event -> + + } ) CustomReactionBottomSheet( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt index 840e12583f..802fdbf9ed 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt @@ -36,6 +36,7 @@ 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 @@ -115,12 +116,14 @@ class DefaultActionListPresenter @AssistedInject constructor( isEventPinned = pinnedEventIds.contains(timelineItem.eventId), ) - val displayEmojiReactions = usersEventPermissions.canSendReaction && - timelineItem.content.canReact() - if (actions.isNotEmpty() || displayEmojiReactions) { + val verifiedUserSendFailure = buildVerifiedUserSendFailure(timelineItem) + val displayEmojiReactions = usersEventPermissions.canSendReaction && timelineItem.content.canReact() + + if (actions.isNotEmpty() || displayEmojiReactions || verifiedUserSendFailure != ActionListState.VerifiedUserSendFailure.None) { target.value = ActionListState.Target.Success( event = timelineItem, displayEmojiReactions = displayEmojiReactions, + verifiedUserSendFailure = verifiedUserSendFailure, actions = actions.toImmutableList() ) } else { @@ -128,6 +131,32 @@ 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, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListState.kt index bb3bd92fbe..614dcd3776 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListState.kt @@ -7,9 +7,12 @@ 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.timeline.model.TimelineItem +import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList @Immutable @@ -17,13 +20,31 @@ data class ActionListState( val target: Target, val eventSink: (ActionListEvents) -> Unit, ) { + @Immutable sealed interface Target { data object None : Target data class Loading(val event: TimelineItem.Event) : Target data class Success( val event: TimelineItem.Event, val displayEmojiReactions: Boolean, + val verifiedUserSendFailure: VerifiedUserSendFailure, val actions: ImmutableList, ) : 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) + } + } + } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt index 722d8af11e..e53f3f11b2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt @@ -18,6 +18,8 @@ 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 @@ -35,6 +37,7 @@ open class ActionListStateProvider : PreviewParameterProvider { reactionsState = reactionsState ), displayEmojiReactions = true, + verifiedUserSendFailure = ActionListState.VerifiedUserSendFailure.None, actions = aTimelineItemActionList(), ) ), @@ -47,6 +50,7 @@ open class ActionListStateProvider : PreviewParameterProvider { reactionsState = reactionsState, ), displayEmojiReactions = true, + verifiedUserSendFailure = ActionListState.VerifiedUserSendFailure.None, actions = aTimelineItemActionList(), ) ), @@ -56,6 +60,7 @@ open class ActionListStateProvider : PreviewParameterProvider { reactionsState = reactionsState ), displayEmojiReactions = true, + verifiedUserSendFailure = ActionListState.VerifiedUserSendFailure.None, actions = aTimelineItemActionList(), ) ), @@ -65,6 +70,7 @@ open class ActionListStateProvider : PreviewParameterProvider { reactionsState = reactionsState ), displayEmojiReactions = true, + verifiedUserSendFailure = ActionListState.VerifiedUserSendFailure.None, actions = aTimelineItemActionList(), ) ), @@ -74,6 +80,7 @@ open class ActionListStateProvider : PreviewParameterProvider { reactionsState = reactionsState ), displayEmojiReactions = true, + verifiedUserSendFailure = ActionListState.VerifiedUserSendFailure.None, actions = aTimelineItemActionList(), ) ), @@ -83,6 +90,7 @@ open class ActionListStateProvider : PreviewParameterProvider { reactionsState = reactionsState ), displayEmojiReactions = true, + verifiedUserSendFailure = ActionListState.VerifiedUserSendFailure.None, actions = aTimelineItemActionList(), ) ), @@ -92,6 +100,7 @@ open class ActionListStateProvider : PreviewParameterProvider { reactionsState = reactionsState ), displayEmojiReactions = true, + verifiedUserSendFailure = ActionListState.VerifiedUserSendFailure.None, actions = aTimelineItemActionList(), ) ), @@ -101,6 +110,7 @@ open class ActionListStateProvider : PreviewParameterProvider { reactionsState = reactionsState ), displayEmojiReactions = false, + verifiedUserSendFailure = ActionListState.VerifiedUserSendFailure.None, actions = aTimelineItemActionList(), ), ), @@ -110,6 +120,7 @@ open class ActionListStateProvider : PreviewParameterProvider { reactionsState = reactionsState ), displayEmojiReactions = false, + verifiedUserSendFailure = ActionListState.VerifiedUserSendFailure.None, actions = aTimelineItemPollActionList(), ), ), @@ -120,6 +131,15 @@ open class ActionListStateProvider : PreviewParameterProvider { messageShield = MessageShield.UnknownDevice(isCritical = true) ), displayEmojiReactions = true, + verifiedUserSendFailure = ActionListState.VerifiedUserSendFailure.None, + actions = aTimelineItemActionList(), + ) + ), + anActionListState().copy( + target = ActionListState.Target.Success( + event = aTimelineItemEvent(), + displayEmojiReactions = true, + verifiedUserSendFailure = ActionListState.VerifiedUserSendFailure.UnsignedDevice(displayName = "Alice"), actions = aTimelineItemActionList(), ) ), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt index 8316bddb32..e56a1e63f5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -90,6 +91,7 @@ fun ActionListView( onSelectAction: (action: TimelineItemAction, TimelineItem.Event) -> Unit, onEmojiReactionClick: (String, TimelineItem.Event) -> Unit, onCustomReactionClick: (TimelineItem.Event) -> Unit, + onVerifiedUserSendFailureClick: (TimelineItem.Event) -> Unit, modifier: Modifier = Modifier, ) { val sheetState = rememberModalBottomSheetState() @@ -126,6 +128,14 @@ fun ActionListView( state.eventSink(ActionListEvents.Clear) } + fun onVerifiedUserSendFailureClick() { + if (targetItem == null) return + sheetState.hide(coroutineScope) { + state.eventSink(ActionListEvents.Clear) + onVerifiedUserSendFailureClick(targetItem) + } + } + if (targetItem != null) { ModalBottomSheet( sheetState = sheetState, @@ -137,6 +147,7 @@ fun ActionListView( onActionClick = ::onItemActionClick, onEmojiReactionClick = ::onEmojiReactionClick, onCustomReactionClick = ::onCustomReactionClick, + onVerifiedUserSendFailureClick = ::onVerifiedUserSendFailureClick, modifier = Modifier .navigationBarsPadding() .imePadding() @@ -151,6 +162,7 @@ private fun SheetContent( onActionClick: (TimelineItemAction) -> Unit, onEmojiReactionClick: (String) -> Unit, onCustomReactionClick: () -> Unit, + onVerifiedUserSendFailureClick: () -> Unit, modifier: Modifier = Modifier, ) { when (val target = state.target) { @@ -184,6 +196,16 @@ private fun SheetContent( HorizontalDivider() } } + if (target.verifiedUserSendFailure != ActionListState.VerifiedUserSendFailure.None) { + item { + VerifiedUserSendFailureView( + sendFailure = target.verifiedUserSendFailure, + modifier = Modifier.fillMaxWidth(), + onClick = onVerifiedUserSendFailureClick + ) + HorizontalDivider() + } + } if (target.displayEmojiReactions) { item { EmojiReactionsRow( @@ -338,6 +360,33 @@ private fun EmojiReactionsRow( } } +@Composable +private fun VerifiedUserSendFailureView( + sendFailure: ActionListState.VerifiedUserSendFailure, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ListItem( + modifier = modifier + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 8.dp), + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Error())), + trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChevronRight())), + headlineContent = { + Text( + text = sendFailure.formatted(), + style = ElementTheme.typography.fontBodySmMedium, + ) + }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + leadingIconColor = ElementTheme.colors.iconCriticalPrimary, + trailingIconColor = ElementTheme.colors.iconPrimary, + headlineColor = ElementTheme.colors.textCriticalPrimary, + ), + ) +} + @Composable private fun EmojiButton( emoji: String, @@ -387,5 +436,6 @@ internal fun SheetContentPreview( onActionClick = {}, onEmojiReactionClick = {}, onCustomReactionClick = {}, + onVerifiedUserSendFailureClick = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt index 8a03ed857c..f853b7c971 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt @@ -181,6 +181,7 @@ private fun PinnedMessagesListLoaded( onSelectAction = ::onActionSelected, onCustomReactionClick = {}, onEmojiReactionClick = { _, _ -> }, + onVerifiedUserSendFailureClick = {} ) LazyColumn( modifier = modifier.fillMaxSize(), diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index f7c3213ba4..b4fec91a0c 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -36,6 +36,7 @@ "Back" "Call" "Cancel" + "Cancel for now" "Choose photo" "Clear" "Close" @@ -283,6 +284,12 @@ Reason: %1$s." "Pinned messages" "You\'re about to go to your %1$s account to reset your identity. Afterwards you\'ll be taken back to the app." "Can\'t confirm? Go to your account to reset your identity." + "Withdraw verification and send" + "You can withdraw your verification and send this message anyway, or you can cancel for now and try again later after reverifying %1$s." + "Your message was not sent because %1$s’s verified identity has changed" + "Send message anyway" + "%1$s is using one or more unverified devices. You can send the message anyway, or you can cancel for now and try again later after %2$s has verified all their devices." + "Your message was not sent because %1$s has not verified one or more devices" "Pinned messages" "Failed processing media to upload, please try again." "Could not retrieve user details" @@ -304,6 +311,8 @@ Reason: %1$s." "Open in Google Maps" "Open in OpenStreetMap" "Share this location" + "Message not sent because %1$s’s verified identity has changed." + "Message not sent because %1$s has not verified one or more devices." "Location" "Version: %1$s (%2$s)" "en"