Skip to content

feat: swipe to react (#WPB-16545) #4008

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
May 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ import com.wire.android.ui.destinations.MediaGalleryScreenDestination
import com.wire.android.ui.destinations.MessageDetailsScreenDestination
import com.wire.android.ui.destinations.OtherUserProfileScreenDestination
import com.wire.android.ui.destinations.SelfUserProfileScreenDestination
import com.wire.android.ui.emoji.EmojiPickerBottomSheet
import com.wire.android.ui.home.conversations.AuthorHeaderHelper.rememberShouldHaveSmallBottomPadding
import com.wire.android.ui.home.conversations.AuthorHeaderHelper.rememberShouldShowHeader
import com.wire.android.ui.home.conversations.ConversationSnackbarMessages.OnFileDownloaded
Expand All @@ -150,7 +151,7 @@ import com.wire.android.ui.home.conversations.messages.ConversationMessagesViewS
import com.wire.android.ui.home.conversations.messages.draft.MessageDraftViewModel
import com.wire.android.ui.home.conversations.messages.item.MessageClickActions
import com.wire.android.ui.home.conversations.messages.item.MessageContainerItem
import com.wire.android.ui.home.conversations.messages.item.SwipableMessageConfiguration
import com.wire.android.ui.home.conversations.messages.item.SwipeableMessageConfiguration
import com.wire.android.ui.home.conversations.migration.ConversationMigrationViewModel
import com.wire.android.ui.home.conversations.model.ExpirationStatus
import com.wire.android.ui.home.conversations.model.UIMessage
Expand Down Expand Up @@ -1088,6 +1089,8 @@ private fun ConversationScreenContent(
LazyListState(unreadEventCount)
}

var showEmojiPickerForMessage by remember { mutableStateOf<String?>(null) }

MessageComposer(
conversationId = conversationId,
bottomSheetVisible = bottomSheetVisible,
Expand Down Expand Up @@ -1115,6 +1118,9 @@ private fun ConversationScreenContent(
),
onSelfDeletingMessageRead = onSelfDeletingMessageRead,
onSwipedToReply = onSwipedToReply,
onSwipedToReact = { message ->
showEmojiPickerForMessage = message.header.messageId
},
conversationDetailsData = conversationDetailsData,
selectedMessageId = selectedMessageId,
interactionAvailability = messageComposerStateHolder.messageComposerViewState.value.interactionAvailability,
Expand All @@ -1136,6 +1142,19 @@ private fun ConversationScreenContent(
onAttachmentPicked = onAttachmentPicked,
onAudioRecorded = onAudioRecorded,
)

showEmojiPickerForMessage?.let { messageId ->
EmojiPickerBottomSheet(
isVisible = true,
onEmojiSelected = { emoji ->
onReactionClicked(messageId, emoji)
showEmojiPickerForMessage = null
},
onDismiss = {
showEmojiPickerForMessage = null
},
)
}
}

@Composable
Expand Down Expand Up @@ -1182,6 +1201,7 @@ fun MessageList(
assetStatuses: PersistentMap<String, MessageAssetStatus>,
onUpdateConversationReadDate: (String) -> Unit,
onSwipedToReply: (UIMessage.Regular) -> Unit,
onSwipedToReact: (UIMessage.Regular) -> Unit,
onSelfDeletingMessageRead: (UIMessage) -> Unit,
conversationDetailsData: ConversationDetailsData,
selectedMessageId: String?,
Expand Down Expand Up @@ -1281,9 +1301,15 @@ fun MessageList(
}
}
}
val swipableConfiguration = remember(message) {
SwipableMessageConfiguration.SwipableToReply {
onSwipedToReply(it)

val swipeableConfiguration = remember(message) {
if (message is UIMessage.Regular && message.isSwipeable) {
SwipeableMessageConfiguration.Swipeable(
onSwipedRight = { onSwipedToReply(message) }.takeIf { message.isReplyable },
onSwipedLeft = { onSwipedToReact(message) }.takeIf { message.isReactionAllowed },
)
} else {
SwipeableMessageConfiguration.NotSwipeable
}
}

Expand All @@ -1294,7 +1320,7 @@ fun MessageList(
useSmallBottomPadding = useSmallBottomPadding,
assetStatus = assetStatuses[message.header.messageId]?.transferStatus,
clickActions = clickActions,
swipableMessageConfiguration = swipableConfiguration,
swipeableMessageConfiguration = swipeableConfiguration,
onSelfDeletingMessageRead = onSelfDeletingMessageRead,
isSelectedMessage = (message.header.messageId == selectedMessageId),
failureInteractionAvailable = interactionAvailability == InteractionAvailability.ENABLED
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import com.wire.android.ui.common.progress.WireCircularProgressIndicator
import com.wire.android.ui.home.conversations.info.ConversationDetailsData
import com.wire.android.ui.home.conversations.messages.item.MessageClickActions
import com.wire.android.ui.home.conversations.messages.item.MessageContainerItem
import com.wire.android.ui.home.conversations.messages.item.SwipableMessageConfiguration
import com.wire.android.ui.home.conversations.messages.item.SwipeableMessageConfiguration
import com.wire.android.ui.home.conversations.mock.mockAssetAudioMessage
import com.wire.android.ui.home.conversations.mock.mockAssetMessage
import com.wire.android.ui.home.conversations.model.UIMessage
Expand Down Expand Up @@ -134,7 +134,7 @@ private fun AssetMessagesListContent(
onSelfDeletingMessageRead = { },
shouldDisplayMessageStatus = false,
shouldDisplayFooter = false,
swipableMessageConfiguration = SwipableMessageConfiguration.NotSwipable,
swipeableMessageConfiguration = SwipeableMessageConfiguration.NotSwipeable,
failureInteractionAvailable = false,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ fun MessageContainerItem(
message: UIMessage,
conversationDetailsData: ConversationDetailsData,
clickActions: MessageClickActions,
swipableMessageConfiguration: SwipableMessageConfiguration,
swipeableMessageConfiguration: SwipeableMessageConfiguration,
onSelfDeletingMessageRead: (UIMessage) -> Unit,
modifier: Modifier = Modifier,
searchQuery: String = "",
Expand Down Expand Up @@ -83,7 +83,7 @@ fun MessageContainerItem(
clickActions = clickActions,
showAuthor = showAuthor,
assetStatus = assetStatus,
swipableMessageConfiguration = swipableMessageConfiguration,
swipeableMessageConfiguration = swipeableMessageConfiguration,
failureInteractionAvailable = failureInteractionAvailable,
searchQuery = searchQuery,
shouldDisplayMessageStatus = shouldDisplayMessageStatus,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ fun RegularMessageItem(
searchQuery: String = "",
showAuthor: Boolean = true,
assetStatus: AssetTransferStatus? = null,
swipableMessageConfiguration: SwipableMessageConfiguration = SwipableMessageConfiguration.NotSwipable,
swipeableMessageConfiguration: SwipeableMessageConfiguration = SwipeableMessageConfiguration.NotSwipeable,
shouldDisplayMessageStatus: Boolean = true,
shouldDisplayFooter: Boolean = true,
failureInteractionAvailable: Boolean = true,
Expand Down Expand Up @@ -190,14 +190,15 @@ fun RegularMessageItem(
}
)
}
if (swipableMessageConfiguration is SwipableMessageConfiguration.SwipableToReply && isReplyable) {
val onSwipe =
remember(message) { { swipableMessageConfiguration.onSwipedToReply(message) } }
SwipableToReplyBox(onSwipedToReply = onSwipe) {
messageContent()

when (swipeableMessageConfiguration) {
is SwipeableMessageConfiguration.Swipeable -> {
SwipeableMessageBox(swipeableMessageConfiguration) {
messageContent()
}
}
} else {
messageContent()

SwipeableMessageConfiguration.NotSwipeable -> messageContent()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.anchoredDraggable
import androidx.compose.foundation.gestures.animateTo
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
Expand All @@ -17,6 +18,7 @@
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
Expand All @@ -40,26 +42,60 @@
import com.wire.android.R
import com.wire.android.ui.common.colorsScheme
import com.wire.android.ui.common.dimensions
import com.wire.android.ui.home.conversations.model.UIMessage
import kotlin.math.absoluteValue
import kotlin.math.min

@Stable
sealed interface SwipableMessageConfiguration {
data object NotSwipable : SwipableMessageConfiguration
class SwipableToReply(val onSwipedToReply: (uiMessage: UIMessage.Regular) -> Unit) : SwipableMessageConfiguration
sealed interface SwipeableMessageConfiguration {
data object NotSwipeable : SwipeableMessageConfiguration

Check warning on line 50 in app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SwipeableMessageBox.kt

View check run for this annotation

Codecov / codecov/patch

app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SwipeableMessageBox.kt#L50

Added line #L50 was not covered by tests
class Swipeable(
val onSwipedRight: (() -> Unit)? = null,
val onSwipedLeft: (() -> Unit)? = null,

Check warning on line 53 in app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SwipeableMessageBox.kt

View check run for this annotation

Codecov / codecov/patch

app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SwipeableMessageBox.kt#L52-L53

Added lines #L52 - L53 were not covered by tests
) : SwipeableMessageConfiguration
}

enum class SwipeAnchor {
CENTERED,
START_TO_END
START_TO_END,
END_TO_START,

Check warning on line 60 in app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SwipeableMessageBox.kt

View check run for this annotation

Codecov / codecov/patch

app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SwipeableMessageBox.kt#L59-L60

Added lines #L59 - L60 were not covered by tests
}

data class SwipeAction(
val icon: Int,
val action: () -> Unit,

Check warning on line 65 in app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SwipeableMessageBox.kt

View check run for this annotation

Codecov / codecov/patch

app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SwipeableMessageBox.kt#L63-L65

Added lines #L63 - L65 were not covered by tests
)

@Composable
internal fun SwipeableMessageBox(
configuration: SwipeableMessageConfiguration.Swipeable,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
SwipeableBox(
modifier = modifier,
onSwipeRight = configuration.onSwipedRight?.let {
SwipeAction(
icon = R.drawable.ic_reply,
action = it,
)
},
onSwipeLeft = configuration.onSwipedLeft?.let {
SwipeAction(
icon = R.drawable.ic_react,
action = it,
)
},
content = content,
)
}

@Suppress("CyclomaticComplexMethod")
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun SwipableToReplyBox(
private fun SwipeableBox(
modifier: Modifier = Modifier,
onSwipedToReply: () -> Unit = {},
onSwipeRight: SwipeAction? = null,
onSwipeLeft: SwipeAction? = null,
content: @Composable () -> Unit
) {
val density = LocalDensity.current
Expand All @@ -86,55 +122,83 @@
velocityThreshold = { screenWidth },
snapAnimationSpec = tween(),
decayAnimationSpec = splineBasedDecay(density),
confirmValueChange = { changedValue ->
if (changedValue == SwipeAnchor.START_TO_END) {
// Attempt to finish dismiss, notify reply intention
onSwipedToReply()
}
if (changedValue == SwipeAnchor.CENTERED) {
// Reset the haptic feedback when drag is stopped
didVibrateOnCurrentDrag = false
}
// Reject state change, only allow returning back to rest position
changedValue == SwipeAnchor.CENTERED
},
anchors = DraggableAnchors {

SwipeAnchor.CENTERED at 0f
SwipeAnchor.START_TO_END at screenWidth

if (onSwipeRight != null) {
SwipeAnchor.START_TO_END at dragWidth
}

if (onSwipeLeft != null) {
SwipeAnchor.END_TO_START at -dragWidth
}
}
)
}

LaunchedEffect(dragState.settledValue) {
when (dragState.settledValue) {
SwipeAnchor.START_TO_END -> {
onSwipeRight?.action?.invoke()
dragState.animateTo(SwipeAnchor.CENTERED)
}

SwipeAnchor.END_TO_START -> {
onSwipeLeft?.action?.invoke()
dragState.animateTo(SwipeAnchor.CENTERED)
}

SwipeAnchor.CENTERED -> {}
}
didVibrateOnCurrentDrag = false
}

val primaryColor = colorsScheme().primary

Box(
modifier = modifier.fillMaxSize(),
) {

val dragOffset = dragState.requireOffset()

// Drag indication
Row(
modifier = Modifier
.matchParentSize()
.drawBehind {
// TODO(RTL): Might need adjusting once RTL is supported
drawRect(
color = primaryColor,
topLeft = Offset(0f, 0f),
size = Size(dragState.requireOffset().absoluteValue, size.height),
topLeft = if (dragOffset >= 0f) {
Offset(0f, 0f)
} else {
Offset(size.width - dragOffset.absoluteValue, 0f)
},
size = Size(dragOffset.absoluteValue, size.height),
)
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {

val dragProgress = dragState.offset.absoluteValue / dragWidth
val adjustedProgress = min(1f, dragProgress)
val progress = FastOutLinearInEasing.transform(adjustedProgress)

// Got to the end, user can release to perform action, so we vibrate to show it
if (progress == 1f && !didVibrateOnCurrentDrag) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
didVibrateOnCurrentDrag = true
}

if (dragState.offset > 0f) {
val dragProgress = dragState.offset / dragWidth
val adjustedProgress = min(1f, dragProgress)
val progress = FastOutLinearInEasing.transform(adjustedProgress)
// Got to the end, user can release to perform action, so we vibrate to show it
if (progress == 1f && !didVibrateOnCurrentDrag) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
didVibrateOnCurrentDrag = true
onSwipeRight?.let { action ->
SwipeActionIcon(action.icon, screenWidth, dragWidth, density, progress)
}
} else if (dragState.offset < 0f) {
onSwipeLeft?.let {
SwipeActionIcon(it.icon, screenWidth, dragWidth, density, progress, false)
}

ReplySwipeIcon(dragWidth, density, progress)
}
}
// Message content, which is draggable
Expand All @@ -154,20 +218,37 @@
}

@Composable
private fun ReplySwipeIcon(dragWidth: Float, density: Density, progress: Float) {
private fun SwipeActionIcon(
resourceId: Int,
screenWidth: Float,
dragWidth: Float,
density: Density,
progress: Float,
swipeRight: Boolean = true
) {
val midPointBetweenStartAndGestureEnd = dragWidth / 2
val iconSize = dimensions().fabIconSize
val targetIconAnchorPosition = midPointBetweenStartAndGestureEnd - with(density) { iconSize.toPx() / 2 }
val xOffset = with(density) {
val totalTravelDistance = iconSize.toPx() + targetIconAnchorPosition
-iconSize.toPx() + (totalTravelDistance * progress)
if (swipeRight) {
(totalTravelDistance * progress) - iconSize.toPx()
} else {
(totalTravelDistance * progress) - iconSize.toPx() / 2
}
}
Icon(
painter = painterResource(id = R.drawable.ic_reply),
painter = painterResource(id = resourceId),
contentDescription = "",
modifier = Modifier
.size(iconSize)
.offset { IntOffset(xOffset.toInt(), 0) },
.offset {
if (swipeRight) {
IntOffset(xOffset.toInt(), 0)
} else {
IntOffset(screenWidth.toInt() - xOffset.toInt(), 0)
}
},
tint = colorsScheme().onPrimary
)
}
Loading
Loading