From d59d01d4b7f61c47c51e7f34f0bb25ab88f75bc8 Mon Sep 17 00:00:00 2001 From: Vitor Hugo Schwaab Date: Thu, 2 May 2024 16:29:01 +0200 Subject: [PATCH] feat: allow swipe to reply (WPB-982) (#2955) --- .../home/conversations/ConversationScreen.kt | 5 + .../conversations/media/FileAssetsContent.kt | 3 +- .../messages/item/MessageContainerItem.kt | 2 + .../messages/item/MessageItemTemplate.kt | 3 +- .../messages/item/RegularMessageItem.kt | 122 +++++++++++++++++- .../ui/home/conversations/model/UIMessage.kt | 30 ++++- ...SearchConversationMessagesResultsScreen.kt | 1 + 7 files changed, 159 insertions(+), 7 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt index 06e54bde0e1..6e75593d2eb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt @@ -799,6 +799,7 @@ private fun ConversationScreen( onOpenProfile = onOpenProfile, onUpdateConversationReadDate = onUpdateConversationReadDate, onShowEditingOptions = conversationScreenState::showEditContextMenu, + onSwipedToReply = messageComposerStateHolder::toReply, onSelfDeletingMessageRead = onSelfDeletingMessageRead, onFailedMessageCancelClicked = remember { { onDeleteMessage(it, false) } }, onFailedMessageRetryClicked = onFailedMessageRetryClicked, @@ -847,6 +848,7 @@ private fun ConversationScreenContent( onOpenProfile: (String) -> Unit, onUpdateConversationReadDate: (String) -> Unit, onShowEditingOptions: (UIMessage.Regular) -> Unit, + onSwipedToReply: (UIMessage.Regular) -> Unit, onSelfDeletingMessageRead: (UIMessage) -> Unit, conversationDetailsData: ConversationDetailsData, onFailedMessageRetryClicked: (String, ConversationId) -> Unit, @@ -888,6 +890,7 @@ private fun ConversationScreenContent( onResetSessionClicked = onResetSessionClicked, onSelfDeletingMessageRead = onSelfDeletingMessageRead, onShowEditingOption = onShowEditingOptions, + onSwipedToReply = onSwipedToReply, conversationDetailsData = conversationDetailsData, onFailedMessageCancelClicked = onFailedMessageCancelClicked, onFailedMessageRetryClicked = onFailedMessageRetryClicked, @@ -961,6 +964,7 @@ fun MessageList( onReactionClicked: (String, String) -> Unit, onResetSessionClicked: (senderUserId: UserId, clientId: String?) -> Unit, onShowEditingOption: (UIMessage.Regular) -> Unit, + onSwipedToReply: (UIMessage.Regular) -> Unit, onSelfDeletingMessageRead: (UIMessage) -> Unit, conversationDetailsData: ConversationDetailsData, onFailedMessageRetryClicked: (String, ConversationId) -> Unit, @@ -1054,6 +1058,7 @@ fun MessageList( onAudioClick = onAudioItemClicked, onChangeAudioPosition = onChangeAudioPosition, onLongClicked = onShowEditingOption, + onSwipedToReply = onSwipedToReply, onAssetMessageClicked = onAssetItemClicked, onImageMessageClicked = onImageFullScreenMode, onOpenProfile = onOpenProfile, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/FileAssetsContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/FileAssetsContent.kt index 8eebbdd90f7..17be9d60ed9 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/FileAssetsContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/FileAssetsContent.kt @@ -134,7 +134,8 @@ private fun AssetMessagesListContent( defaultBackgroundColor = colorsScheme().backgroundVariant, shouldDisplayMessageStatus = false, shouldDisplayFooter = false, - onReplyClickable = null + onReplyClickable = null, + onSwipedToReply = { } ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContainerItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContainerItem.kt index b23a89dbd5e..f8cb5cc70d2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContainerItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContainerItem.kt @@ -60,6 +60,7 @@ fun MessageContainerItem( audioMessagesState: PersistentMap, assetStatus: AssetTransferStatus? = null, onLongClicked: (UIMessage.Regular) -> Unit, + onSwipedToReply: (UIMessage.Regular) -> Unit, onAssetMessageClicked: (String) -> Unit, onAudioClick: (String) -> Unit, onChangeAudioPosition: (String, Int) -> Unit, @@ -142,6 +143,7 @@ fun MessageContainerItem( onAudioClick = onAudioClick, onChangeAudioPosition = onChangeAudioPosition, onLongClicked = onLongClicked, + onSwipedToReply = onSwipedToReply, onAssetMessageClicked = onAssetMessageClicked, onImageMessageClicked = onImageMessageClicked, onOpenProfile = onOpenProfile, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageItemTemplate.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageItemTemplate.kt index fea61a7533a..cf33b0cda93 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageItemTemplate.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageItemTemplate.kt @@ -34,11 +34,12 @@ fun MessageItemTemplate( showAuthor: Boolean = true, useSmallBottomPadding: Boolean = false, fullAvatarOuterPadding: Dp, + modifier: Modifier = Modifier, leading: @Composable () -> Unit, content: @Composable () -> Unit ) { Row( - Modifier + modifier .fillMaxWidth() .padding( end = dimensions().messageItemHorizontalPadding, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt index e6d2fed4e62..5758e4601b0 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt @@ -18,32 +18,53 @@ package com.wire.android.ui.home.conversations.messages.item +import androidx.compose.animation.core.FastOutLinearInEasing import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxState +import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset import com.wire.android.R import com.wire.android.media.audiomessage.AudioState import com.wire.android.model.Clickable import com.wire.android.ui.common.LegalHoldIndicator import com.wire.android.ui.common.StatusBox import com.wire.android.ui.common.UserBadge +import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.spacers.HorizontalSpace import com.wire.android.ui.common.spacers.VerticalSpace @@ -84,6 +105,8 @@ import com.wire.kalium.logic.data.user.UserId import kotlinx.collections.immutable.PersistentMap import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow +import kotlin.math.absoluteValue +import kotlin.math.min // TODO: a definite candidate for a refactor and cleanup @Suppress("ComplexMethod") @@ -96,6 +119,7 @@ fun RegularMessageItem( audioMessagesState: PersistentMap, assetStatus: AssetTransferStatus? = null, onLongClicked: (UIMessage.Regular) -> Unit, + onSwipedToReply: (UIMessage.Regular) -> Unit = {}, onAssetMessageClicked: (String) -> Unit, onAudioClick: (String) -> Unit, onChangeAudioPosition: (String, Int) -> Unit, @@ -114,8 +138,12 @@ fun RegularMessageItem( useSmallBottomPadding: Boolean = false, currentTimeInMillisFlow: Flow = flow { }, selfDeletionTimerState: SelfDeletionTimerHelper.SelfDeletionTimerState = SelfDeletionTimerHelper.SelfDeletionTimerState.NotExpirable -) { - with(message) { +): Unit = with(message) { + val onSwipe = remember(message) { { onSwipedToReply(message) } } + SwipableToReplyBox( + isSwipable = isReplyable, + onSwipedToReply = onSwipe + ) { MessageItemTemplate( showAuthor, useSmallBottomPadding = useSmallBottomPadding, @@ -240,6 +268,96 @@ fun RegularMessageItem( } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SwipableToReplyBox( + isSwipable: Boolean, + modifier: Modifier = Modifier, + onSwipedToReply: () -> Unit = {}, + content: @Composable RowScope.() -> Unit +) { + val density = LocalDensity.current + val haptic = LocalHapticFeedback.current + var didVibrateOnCurrentDrag by remember { mutableStateOf(false) } + + // Finish the animation in the first 25% of the drag + val progressUntilAnimationCompletion = 0.25f + val dismissState = remember { + SwipeToDismissBoxState( + SwipeToDismissBoxValue.Settled, + density, + positionalThreshold = { distance: Float -> distance * progressUntilAnimationCompletion }, + confirmValueChange = { changedValue -> + if (changedValue == SwipeToDismissBoxValue.StartToEnd) { + // Attempt to finish dismiss, notify reply intention + onSwipedToReply() + } + if (changedValue == SwipeToDismissBoxValue.Settled) { + // Reset the haptic feedback when drag is stopped + didVibrateOnCurrentDrag = false + } + // Reject state change, only allow returning back to rest position + changedValue == SwipeToDismissBoxValue.Settled + } + ) + } + val primaryColor = colorsScheme().primary + // TODO: RTL is currently broken https://issuetracker.google.com/issues/321600474 + // Maybe addressed in compose3 1.3.0 (currently in alpha) + SwipeToDismissBox( + state = dismissState, + modifier = modifier, + enableDismissFromStartToEnd = isSwipable, + content = content, + enableDismissFromEndToStart = false, + backgroundContent = { + Row( + modifier = Modifier.fillMaxSize() + .drawBehind { + // TODO(RTL): Might need adjusting once RTL is supported (also lacking in SwipeToDismissBox) + drawRect( + color = primaryColor, + topLeft = Offset(0f, 0f), + size = Size(dismissState.requireOffset().absoluteValue, size.height), + ) + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + if (dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd + // Sometimes this is called with progress 1f when the user stops the interaction, causing a blink. + // Ignore these cases as it doesn't make any difference + && dismissState.progress < 1f + ) { + val adjustedProgress = min(1f, (dismissState.progress / progressUntilAnimationCompletion)) + val iconSize = dimensions().fabIconSize + val spacing = dimensions().spacing16x + val progress = FastOutLinearInEasing.transform(adjustedProgress) + val xOffset = with(density) { + val offsetBeforeScreenStart = iconSize.toPx() + val offsetAfterScreenStart = spacing.toPx() + val totalTravelDistance = offsetBeforeScreenStart + offsetAfterScreenStart + -offsetBeforeScreenStart + (totalTravelDistance * progress) + } + // Got to the end, user can release to + if (progress == 1f && !didVibrateOnCurrentDrag) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + didVibrateOnCurrentDrag = true + } + Icon( + painter = painterResource(id = R.drawable.ic_reply), + contentDescription = "", + modifier = Modifier + .size(iconSize) + .offset { IntOffset(xOffset.toInt(), 0) }, + tint = colorsScheme().onPrimary + ) + } + } + } + ) +} + @Composable fun EphemeralMessageExpiredLabel(isSelfMessage: Boolean, conversationDetailsData: ConversationDetailsData) { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMessage.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMessage.kt index ea03e70aaa1..c17fd3d4e1a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMessage.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMessage.kt @@ -74,10 +74,34 @@ sealed interface UIMessage { val isAvailable: Boolean = !isDeleted && !sendingFailed && !decryptionFailed override val isPending: Boolean = header.messageStatus.flowStatus == MessageFlowStatus.Sending val isMyMessage = source == MessageSource.Self + val isAssetMessage = messageContent is UIMessageContent.AssetMessage || messageContent is UIMessageContent.ImageMessage || messageContent is UIMessageContent.AudioAssetMessage + + private val isReplyableContent: Boolean + get() = messageContent is UIMessageContent.TextMessage || + messageContent is UIMessageContent.AssetMessage || + messageContent is UIMessageContent.AudioAssetMessage || + messageContent is UIMessageContent.Location || + messageContent is UIMessageContent.Regular + + /** + * The message was sent from the sender (either self or others), and is available for other users + * to retrieve from the backend, or is already retrieved. + */ + private val isTheMessageAvailableToOtherUsers: Boolean + get() = header.messageStatus.flowStatus is MessageFlowStatus.Delivered || + header.messageStatus.flowStatus is MessageFlowStatus.Sent || + header.messageStatus.flowStatus is MessageFlowStatus.Read + + val isReplyable: Boolean + get() = isReplyableContent && + isTheMessageAvailableToOtherUsers && + header.messageStatus.expirationStatus is ExpirationStatus.NotExpirable + val isTextContentWithoutQuote = messageContent is UIMessageContent.TextMessage && messageContent.messageBody.quotedMessage == null + val isLocation: Boolean = messageContent is UIMessageContent.Location } @@ -133,8 +157,8 @@ sealed class MessageEditStatus { sealed class MessageFlowStatus { - object Sending : MessageFlowStatus() - object Sent : MessageFlowStatus() + data object Sending : MessageFlowStatus() + data object Sent : MessageFlowStatus() sealed class Failure(val errorText: UIText) : MessageFlowStatus() { sealed class Send(errorText: UIText) : Failure(errorText) { data class Locally(val isEdited: Boolean) : Send( @@ -165,7 +189,7 @@ sealed class MessageFlowStatus { ) } - object Delivered : MessageFlowStatus() + data object Delivered : MessageFlowStatus() data class Read(val count: Long) : MessageFlowStatus() } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesResultsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesResultsScreen.kt index 86481b84862..4851ef9db54 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesResultsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesResultsScreen.kt @@ -71,6 +71,7 @@ fun SearchConversationMessagesResultsScreen( shouldDisplayMessageStatus = false, shouldDisplayFooter = false, onReplyClickable = null, + onSwipedToReply = {} ) }