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 42f8e9d8e84..90ea20bd31b 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 @@ -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 @@ -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 @@ -1088,6 +1089,8 @@ private fun ConversationScreenContent( LazyListState(unreadEventCount) } + var showEmojiPickerForMessage by remember { mutableStateOf(null) } + MessageComposer( conversationId = conversationId, bottomSheetVisible = bottomSheetVisible, @@ -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, @@ -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 @@ -1182,6 +1201,7 @@ fun MessageList( assetStatuses: PersistentMap, onUpdateConversationReadDate: (String) -> Unit, onSwipedToReply: (UIMessage.Regular) -> Unit, + onSwipedToReact: (UIMessage.Regular) -> Unit, onSelfDeletingMessageRead: (UIMessage) -> Unit, conversationDetailsData: ConversationDetailsData, selectedMessageId: String?, @@ -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 } } @@ -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 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 0d551c6547e..105e88fb06f 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 @@ -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 @@ -134,7 +134,7 @@ private fun AssetMessagesListContent( onSelfDeletingMessageRead = { }, shouldDisplayMessageStatus = false, shouldDisplayFooter = false, - swipableMessageConfiguration = SwipableMessageConfiguration.NotSwipable, + swipeableMessageConfiguration = SwipeableMessageConfiguration.NotSwipeable, failureInteractionAvailable = false, ) } 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 c49a77a1540..21c8839e554 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 @@ -34,7 +34,7 @@ fun MessageContainerItem( message: UIMessage, conversationDetailsData: ConversationDetailsData, clickActions: MessageClickActions, - swipableMessageConfiguration: SwipableMessageConfiguration, + swipeableMessageConfiguration: SwipeableMessageConfiguration, onSelfDeletingMessageRead: (UIMessage) -> Unit, modifier: Modifier = Modifier, searchQuery: String = "", @@ -83,7 +83,7 @@ fun MessageContainerItem( clickActions = clickActions, showAuthor = showAuthor, assetStatus = assetStatus, - swipableMessageConfiguration = swipableMessageConfiguration, + swipeableMessageConfiguration = swipeableMessageConfiguration, failureInteractionAvailable = failureInteractionAvailable, searchQuery = searchQuery, shouldDisplayMessageStatus = shouldDisplayMessageStatus, 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 3079fc4f46d..96ab0d03432 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 @@ -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, @@ -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() } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SwipableToReplyBox.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SwipeableMessageBox.kt similarity index 57% rename from app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SwipableToReplyBox.kt rename to app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SwipeableMessageBox.kt index 1cef5be095e..32ccf80a903 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SwipableToReplyBox.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SwipeableMessageBox.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.gestures.AnchoredDraggableState 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 @@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.size 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 @@ -40,26 +42,60 @@ import androidx.compose.ui.unit.dp 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 + class Swipeable( + val onSwipedRight: (() -> Unit)? = null, + val onSwipedLeft: (() -> Unit)? = null, + ) : SwipeableMessageConfiguration } enum class SwipeAnchor { CENTERED, - START_TO_END + START_TO_END, + END_TO_START, } +data class SwipeAction( + val icon: Int, + val action: () -> Unit, +) + +@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 @@ -86,55 +122,83 @@ internal fun SwipableToReplyBox( 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 @@ -154,20 +218,37 @@ internal fun SwipableToReplyBox( } @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 ) } 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 4a92d15ac0d..9d3ab687a74 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 @@ -103,11 +103,22 @@ sealed interface UIMessage { get() = isReplyableContent && isTheMessageAvailableToOtherUsers && !isDeleted && - header.messageStatus.expirationStatus is ExpirationStatus.NotExpirable + header.messageStatus.expirationStatus is ExpirationStatus.NotExpirable && + !isMultipart + + val isReactionAllowed: Boolean + get() = !isDeleted && + !isPending && + messageContent !is UIMessageContent.Composite && + header.messageStatus.expirationStatus !is ExpirationStatus.Expirable + + val isSwipeable: Boolean + get() = isReplyable || isReactionAllowed val isTextContentWithoutQuote = messageContent is UIMessageContent.TextMessage && messageContent.messageBody.quotedMessage == null val isLocation: Boolean = messageContent is UIMessageContent.Location + val isMultipart: Boolean = messageContent is UIMessageContent.Multipart } @Serializable 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 063b1777abb..1fba1d6f1d8 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 @@ -30,7 +30,7 @@ import androidx.paging.compose.itemKey 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.mockMessageWithText import com.wire.android.ui.home.conversations.model.UIMessage import com.wire.android.ui.theme.WireTheme @@ -67,7 +67,7 @@ fun SearchConversationMessagesResultsScreen( onSelfDeletingMessageRead = { }, shouldDisplayMessageStatus = false, shouldDisplayFooter = false, - swipableMessageConfiguration = SwipableMessageConfiguration.NotSwipable, + swipeableMessageConfiguration = SwipeableMessageConfiguration.NotSwipeable, failureInteractionAvailable = false, ) } diff --git a/app/src/main/res/drawable/ic_react.xml b/app/src/main/res/drawable/ic_react.xml new file mode 100644 index 00000000000..36d45e672b5 --- /dev/null +++ b/app/src/main/res/drawable/ic_react.xml @@ -0,0 +1,40 @@ + + + + + + + + diff --git a/kalium b/kalium index 614ae930972..4d8b4132504 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 614ae930972e69aa306dd20a3dd00a38f0981840 +Subproject commit 4d8b413250432b7935c4b87394ad0ca773182799