Skip to content

Commit

Permalink
feat: allow swipe to reply (WPB-982) (#2955)
Browse files Browse the repository at this point in the history
  • Loading branch information
vitorhugods committed May 3, 2024
1 parent 8a6eed1 commit d59d01d
Show file tree
Hide file tree
Showing 7 changed files with 159 additions and 7 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -888,6 +890,7 @@ private fun ConversationScreenContent(
onResetSessionClicked = onResetSessionClicked,
onSelfDeletingMessageRead = onSelfDeletingMessageRead,
onShowEditingOption = onShowEditingOptions,
onSwipedToReply = onSwipedToReply,
conversationDetailsData = conversationDetailsData,
onFailedMessageCancelClicked = onFailedMessageCancelClicked,
onFailedMessageRetryClicked = onFailedMessageRetryClicked,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1054,6 +1058,7 @@ fun MessageList(
onAudioClick = onAudioItemClicked,
onChangeAudioPosition = onChangeAudioPosition,
onLongClicked = onShowEditingOption,
onSwipedToReply = onSwipedToReply,
onAssetMessageClicked = onAssetItemClicked,
onImageMessageClicked = onImageFullScreenMode,
onOpenProfile = onOpenProfile,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,8 @@ private fun AssetMessagesListContent(
defaultBackgroundColor = colorsScheme().backgroundVariant,
shouldDisplayMessageStatus = false,
shouldDisplayFooter = false,
onReplyClickable = null
onReplyClickable = null,
onSwipedToReply = { }
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ fun MessageContainerItem(
audioMessagesState: PersistentMap<String, AudioState>,
assetStatus: AssetTransferStatus? = null,
onLongClicked: (UIMessage.Regular) -> Unit,
onSwipedToReply: (UIMessage.Regular) -> Unit,
onAssetMessageClicked: (String) -> Unit,
onAudioClick: (String) -> Unit,
onChangeAudioPosition: (String, Int) -> Unit,
Expand Down Expand Up @@ -142,6 +143,7 @@ fun MessageContainerItem(
onAudioClick = onAudioClick,
onChangeAudioPosition = onChangeAudioPosition,
onLongClicked = onLongClicked,
onSwipedToReply = onSwipedToReply,
onAssetMessageClicked = onAssetMessageClicked,
onImageMessageClicked = onImageMessageClicked,
onOpenProfile = onOpenProfile,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -96,6 +119,7 @@ fun RegularMessageItem(
audioMessagesState: PersistentMap<String, AudioState>,
assetStatus: AssetTransferStatus? = null,
onLongClicked: (UIMessage.Regular) -> Unit,
onSwipedToReply: (UIMessage.Regular) -> Unit = {},
onAssetMessageClicked: (String) -> Unit,
onAudioClick: (String) -> Unit,
onChangeAudioPosition: (String, Int) -> Unit,
Expand All @@ -114,8 +138,12 @@ fun RegularMessageItem(
useSmallBottomPadding: Boolean = false,
currentTimeInMillisFlow: Flow<Long> = 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,
Expand Down Expand Up @@ -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) {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -165,7 +189,7 @@ sealed class MessageFlowStatus {
)
}

object Delivered : MessageFlowStatus()
data object Delivered : MessageFlowStatus()

data class Read(val count: Long) : MessageFlowStatus()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ fun SearchConversationMessagesResultsScreen(
shouldDisplayMessageStatus = false,
shouldDisplayFooter = false,
onReplyClickable = null,
onSwipedToReply = {}
)
}

Expand Down

0 comments on commit d59d01d

Please sign in to comment.