From 8a60716e959bf18f642b2f0a4c5afd0b4bc2db72 Mon Sep 17 00:00:00 2001 From: Damian Kaczmarek <76782439+damian-kaczmarek@users.noreply.github.com> Date: Thu, 28 Nov 2024 08:08:33 +0100 Subject: [PATCH] fix: big padding around reaction pills #WPB-12094 #WPB-14269 (#3643) --- .../wire/android/ui/edit/ReactionOption.kt | 5 +- .../MessageDetailsEmptyScreenText.kt | 4 +- .../conversations/messages/ReactionPill.kt | 17 +- .../messages/item/MessageContentAndStatus.kt | 276 +++++++++++ .../messages/item/RegularMessageItem.kt | 442 ++---------------- .../messages/item/SwipableToReplyBox.kt | 173 +++++++ 6 files changed, 495 insertions(+), 422 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SwipableToReplyBox.kt diff --git a/app/src/main/kotlin/com/wire/android/ui/edit/ReactionOption.kt b/app/src/main/kotlin/com/wire/android/ui/edit/ReactionOption.kt index d6058a82706..f2ca12b41d1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/edit/ReactionOption.kt +++ b/app/src/main/kotlin/com/wire/android/ui/edit/ReactionOption.kt @@ -31,7 +31,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.LocalMinimumInteractiveComponentEnforcement +import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -45,6 +45,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -80,7 +81,7 @@ fun ReactionOption( ) { listOf("❤ī¸", "👍", "😁", "🙂", "☚ī¸", "👎").forEach { emoji -> CompositionLocalProvider( - LocalMinimumInteractiveComponentEnforcement provides false + LocalMinimumInteractiveComponentSize provides Dp.Unspecified, ) { Button( onClick = { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messagedetails/MessageDetailsEmptyScreenText.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messagedetails/MessageDetailsEmptyScreenText.kt index cbbda3b81f8..b9da133ddf9 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messagedetails/MessageDetailsEmptyScreenText.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messagedetails/MessageDetailsEmptyScreenText.kt @@ -22,11 +22,11 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ReactionPill.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ReactionPill.kt index db7a3477f41..0c0089b03d3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ReactionPill.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ReactionPill.kt @@ -26,7 +26,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.LocalMinimumInteractiveComponentEnforcement +import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text @@ -34,11 +34,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.wire.android.ui.common.dimensions import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.ui.PreviewMultipleThemes @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -69,7 +71,7 @@ fun ReactionPill( } CompositionLocalProvider( - LocalMinimumInteractiveComponentEnforcement provides false + LocalMinimumInteractiveComponentSize provides Dp.Unspecified ) { OutlinedButton( onClick = onTap, @@ -93,6 +95,17 @@ fun ReactionPill( } } +@PreviewMultipleThemes +@Composable +fun ReactionPillPreview() { + ReactionPill( + emoji = "👍", + count = 5, + isOwn = false, + onTap = {} + ) +} + private val minDimension = 1.dp private val borderRadius = 12.dp private val borderStrokeWidth = 1.dp diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt new file mode 100644 index 00000000000..b38cbd0ff91 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt @@ -0,0 +1,276 @@ +package com.wire.android.ui.home.conversations.messages.item + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.wire.android.R +import com.wire.android.media.audiomessage.AudioState +import com.wire.android.model.Clickable +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.spacers.HorizontalSpace +import com.wire.android.ui.common.spacers.VerticalSpace +import com.wire.android.ui.home.conversations.info.ConversationDetailsData +import com.wire.android.ui.home.conversations.messages.QuotedMessage +import com.wire.android.ui.home.conversations.messages.QuotedMessageStyle +import com.wire.android.ui.home.conversations.messages.QuotedUnavailable +import com.wire.android.ui.home.conversations.model.DeliveryStatusContent +import com.wire.android.ui.home.conversations.model.MessageBody +import com.wire.android.ui.home.conversations.model.MessageImage +import com.wire.android.ui.home.conversations.model.MessageSource +import com.wire.android.ui.home.conversations.model.UIMessage +import com.wire.android.ui.home.conversations.model.UIMessageContent +import com.wire.android.ui.home.conversations.model.UIQuotedMessage +import com.wire.android.ui.home.conversations.model.messagetypes.asset.MessageAsset +import com.wire.android.ui.home.conversations.model.messagetypes.asset.RestrictedAssetMessage +import com.wire.android.ui.home.conversations.model.messagetypes.asset.RestrictedGenericFileMessage +import com.wire.android.ui.home.conversations.model.messagetypes.audio.AudioMessage +import com.wire.android.ui.home.conversations.model.messagetypes.image.ImageMessageParams +import com.wire.android.ui.home.conversations.model.messagetypes.location.LocationMessageContent +import com.wire.android.util.launchGeoIntent +import com.wire.kalium.logic.data.asset.AssetTransferStatus + +@Composable +internal fun UIMessage.Regular.MessageContentAndStatus( + message: UIMessage.Regular, + assetStatus: AssetTransferStatus?, + searchQuery: String, + audioState: AudioState?, + onAssetClicked: (String) -> Unit, + onImageClicked: (UIMessage.Regular, Boolean) -> Unit, + onAudioClicked: (String) -> Unit, + onAudioPositionChanged: (String, Int) -> Unit, + onProfileClicked: (String) -> Unit, + onLinkClicked: (String) -> Unit, + onReplyClicked: (UIMessage.Regular) -> Unit, + shouldDisplayMessageStatus: Boolean, + conversationDetailsData: ConversationDetailsData, +) { + val onAssetClickable = remember(message) { + Clickable(enabled = isAvailable, onClick = { + onAssetClicked(header.messageId) + }) + } + val onImageClickable = remember(message) { + Clickable(enabled = isAvailable, onClick = { + onImageClicked(message, source == MessageSource.Self) + }) + } + val onReplyClickable = remember(message) { + Clickable { + onReplyClicked(message) + } + } + Row { + Box(modifier = Modifier.weight(1F)) { + MessageContent( + message = message, + messageContent = messageContent, + searchQuery = searchQuery, + audioState = audioState, + assetStatus = assetStatus, + onAudioClick = onAudioClicked, + onChangeAudioPosition = onAudioPositionChanged, + onAssetClick = onAssetClickable, + onImageClick = onImageClickable, + onOpenProfile = onProfileClicked, + onLinkClick = onLinkClicked, + onReplyClick = onReplyClickable, + ) + } + if (isMyMessage && shouldDisplayMessageStatus) { + MessageStatusIndicator( + status = message.header.messageStatus.flowStatus, + isGroupConversation = conversationDetailsData is ConversationDetailsData.Group, + modifier = Modifier.padding( + top = if (message.isTextContentWithoutQuote) dimensions().spacing2x else dimensions().spacing4x, + start = dimensions().spacing8x + ) + ) + } else { + HorizontalSpace.x24() + } + } +} + +@Suppress("ComplexMethod") +@Composable +private fun MessageContent( + message: UIMessage.Regular, + messageContent: UIMessageContent.Regular?, + searchQuery: String, + audioState: AudioState?, + assetStatus: AssetTransferStatus?, + onAssetClick: Clickable, + onImageClick: Clickable, + onAudioClick: (String) -> Unit, + onChangeAudioPosition: (String, Int) -> Unit, + onOpenProfile: (String) -> Unit, + onLinkClick: (String) -> Unit, + onReplyClick: Clickable, +) { + when (messageContent) { + is UIMessageContent.ImageMessage -> { + Column { + MessageImage( + asset = messageContent.asset, + imgParams = ImageMessageParams(messageContent.width, messageContent.height), + transferStatus = assetStatus ?: AssetTransferStatus.NOT_DOWNLOADED, + onImageClick = onImageClick + ) + PartialDeliveryInformation(messageContent.deliveryStatus) + } + } + + is UIMessageContent.TextMessage -> { + Column { + messageContent.messageBody.quotedMessage?.let { + VerticalSpace.x4() + when (it) { + is UIQuotedMessage.UIQuotedData -> QuotedMessage( + messageData = it, + clickable = onReplyClick + ) + + UIQuotedMessage.UnavailableData -> QuotedUnavailable(style = QuotedMessageStyle.COMPLETE) + } + VerticalSpace.x4() + } + MessageBody( + messageBody = messageContent.messageBody, + searchQuery = searchQuery, + isAvailable = !message.isPending && message.isAvailable, + onOpenProfile = onOpenProfile, + buttonList = null, + messageId = message.header.messageId, + onLinkClick = onLinkClick, + ) + PartialDeliveryInformation(messageContent.deliveryStatus) + } + } + + is UIMessageContent.Composite -> { + Column { + messageContent.messageBody?.quotedMessage?.let { + VerticalSpace.x4() + when (it) { + is UIQuotedMessage.UIQuotedData -> QuotedMessage( + messageData = it, + clickable = onReplyClick + ) + + UIQuotedMessage.UnavailableData -> QuotedUnavailable(style = QuotedMessageStyle.COMPLETE) + } + VerticalSpace.x4() + } + MessageBody( + messageBody = messageContent.messageBody, + isAvailable = !message.isPending && message.isAvailable, + onOpenProfile = onOpenProfile, + buttonList = messageContent.buttonList, + messageId = message.header.messageId, + onLinkClick = onLinkClick + ) + } + } + + is UIMessageContent.AssetMessage -> { + Column { + MessageAsset( + assetName = messageContent.assetName, + assetExtension = messageContent.assetExtension, + assetSizeInBytes = messageContent.assetSizeInBytes, + assetTransferStatus = assetStatus ?: AssetTransferStatus.NOT_DOWNLOADED, + onAssetClick = onAssetClick + ) + PartialDeliveryInformation(messageContent.deliveryStatus) + } + } + + is UIMessageContent.RestrictedAsset -> { + Column { + when { + messageContent.mimeType.contains("image/") -> { + RestrictedAssetMessage( + R.drawable.ic_gallery, + stringResource(id = R.string.prohibited_images_message) + ) + } + + messageContent.mimeType.contains("video/") -> { + RestrictedAssetMessage(R.drawable.ic_video, stringResource(id = R.string.prohibited_videos_message)) + } + + messageContent.mimeType.contains("audio/") -> { + RestrictedAssetMessage( + R.drawable.ic_speaker_on, + stringResource(id = R.string.prohibited_audio_message) + ) + } + + else -> { + RestrictedGenericFileMessage(messageContent.assetName, messageContent.assetSizeInBytes) + } + } + PartialDeliveryInformation(messageContent.deliveryStatus) + } + } + + is UIMessageContent.AudioAssetMessage -> { + Column { + val audioMessageState: AudioState = audioState ?: AudioState.DEFAULT + + val totalTimeInMs = remember(audioMessageState.totalTimeInMs) { + audioMessageState.sanitizeTotalTime(messageContent.audioMessageDurationInMs.toInt()) + } + + AudioMessage( + audioMediaPlayingState = audioMessageState.audioMediaPlayingState, + totalTimeInMs = totalTimeInMs, + currentPositionInMs = audioMessageState.currentPositionInMs, + onPlayButtonClick = { onAudioClick(message.header.messageId) }, + onSliderPositionChange = { position -> + onChangeAudioPosition(message.header.messageId, position.toInt()) + }, + ) + PartialDeliveryInformation(messageContent.deliveryStatus) + } + } + + is UIMessageContent.Location -> with(messageContent) { + val context = LocalContext.current + val locationUrl = stringResource(urlCoordinates, zoom, latitude, longitude) + Column { + LocationMessageContent( + locationName = name, + locationUrl = locationUrl, + onLocationClick = Clickable( + enabled = message.isAvailable, + onClick = { launchGeoIntent(latitude, longitude, name, locationUrl, context) }, + ) + ) + PartialDeliveryInformation(deliveryStatus) + } + } + + UIMessageContent.Deleted -> {} + null -> { + throw NullPointerException("messageContent is null") + } + } +} + +@Composable +private fun PartialDeliveryInformation(deliveryStatus: DeliveryStatusContent) { + (deliveryStatus as? DeliveryStatusContent.PartialDelivery)?.let { partialDelivery -> + if (partialDelivery.hasFailures) { + VerticalSpace.x4() + MessageSentPartialDeliveryFailures(partialDelivery) + } + } +} 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 d548d2a7b0b..b573aa57c02 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,15 +18,7 @@ package com.wire.android.ui.home.conversations.messages.item -import androidx.compose.animation.core.FastOutLinearInEasing -import androidx.compose.animation.core.tween -import androidx.compose.animation.splineBasedDecay -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.LocalIndication -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.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -35,82 +27,45 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row 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.Icon +import androidx.compose.foundation.layout.width import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.Stable -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.LocalConfiguration import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.platform.LocalViewConfiguration -import androidx.compose.ui.platform.ViewConfiguration -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp 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 import com.wire.android.ui.common.typography import com.wire.android.ui.home.conversations.SelfDeletionTimerHelper import com.wire.android.ui.home.conversations.info.ConversationDetailsData -import com.wire.android.ui.home.conversations.messages.QuotedMessage -import com.wire.android.ui.home.conversations.messages.QuotedMessageStyle -import com.wire.android.ui.home.conversations.messages.QuotedUnavailable import com.wire.android.ui.home.conversations.messages.ReactionPill import com.wire.android.ui.home.conversations.model.DeliveryStatusContent -import com.wire.android.ui.home.conversations.model.MessageBody import com.wire.android.ui.home.conversations.model.MessageFlowStatus import com.wire.android.ui.home.conversations.model.MessageFooter import com.wire.android.ui.home.conversations.model.MessageHeader -import com.wire.android.ui.home.conversations.model.MessageImage -import com.wire.android.ui.home.conversations.model.MessageSource import com.wire.android.ui.home.conversations.model.MessageStatus import com.wire.android.ui.home.conversations.model.UIMessage import com.wire.android.ui.home.conversations.model.UIMessageContent -import com.wire.android.ui.home.conversations.model.UIQuotedMessage -import com.wire.android.ui.home.conversations.model.messagetypes.asset.MessageAsset -import com.wire.android.ui.home.conversations.model.messagetypes.asset.RestrictedAssetMessage -import com.wire.android.ui.home.conversations.model.messagetypes.asset.RestrictedGenericFileMessage -import com.wire.android.ui.home.conversations.model.messagetypes.audio.AudioMessage -import com.wire.android.ui.home.conversations.model.messagetypes.image.ImageMessageParams -import com.wire.android.ui.home.conversations.model.messagetypes.location.LocationMessageContent import com.wire.android.ui.theme.Accent +import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography -import com.wire.android.util.launchGeoIntent +import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.data.asset.AssetTransferStatus -import kotlin.math.absoluteValue -import kotlin.math.min -// TODO: a definite candidate for a refactor and cleanup +// TODO: a definite candidate for a refactor and cleanup WPB-14390 @Suppress("ComplexMethod") @Composable fun RegularMessageItem( @@ -245,197 +200,6 @@ fun RegularMessageItem( } } -@Stable -sealed interface SwipableMessageConfiguration { - data object NotSwipable : SwipableMessageConfiguration - class SwipableToReply(val onSwipedToReply: (uiMessage: UIMessage.Regular) -> Unit) : SwipableMessageConfiguration -} - -enum class SwipeAnchor { - CENTERED, - START_TO_END -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun SwipableToReplyBox( - modifier: Modifier = Modifier, - onSwipedToReply: () -> Unit = {}, - content: @Composable () -> Unit -) { - val density = LocalDensity.current - val haptic = LocalHapticFeedback.current - val configuration = LocalConfiguration.current - val screenWidth = with(density) { configuration.screenWidthDp.dp.toPx() } - var didVibrateOnCurrentDrag by remember { mutableStateOf(false) } - - // Finish the animation in the first 25% of the drag - val progressUntilAnimationCompletion = 0.25f - val dragWidth = screenWidth * progressUntilAnimationCompletion - - val currentViewConfiguration = LocalViewConfiguration.current - val scopedViewConfiguration = object : ViewConfiguration by currentViewConfiguration { - // Make it easier to scroll by giving the user a bit more length to identify the gesture as vertical - override val touchSlop: Float - get() = currentViewConfiguration.touchSlop * 3f - } - CompositionLocalProvider(LocalViewConfiguration provides scopedViewConfiguration) { - val dragState = remember { - AnchoredDraggableState( - initialValue = SwipeAnchor.CENTERED, - positionalThreshold = { dragWidth }, - 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 - } - ) - } - val primaryColor = colorsScheme().primary - - Box( - modifier = modifier.fillMaxSize(), - ) { - // 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), - ) - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - 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 - } - - ReplySwipeIcon(dragWidth, density, progress) - } - } - // Message content, which is draggable - Box( - modifier = Modifier - .fillMaxSize() - .anchoredDraggable(dragState, Orientation.Horizontal, startDragImmediately = false) - .offset { - val x = dragState - .requireOffset() - .toInt() - IntOffset(x, 0) - }, - ) { content() } - } - } -} - -@Composable -private fun ReplySwipeIcon(dragWidth: Float, density: Density, progress: Float) { - 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) - } - Icon( - painter = painterResource(id = R.drawable.ic_reply), - contentDescription = "", - modifier = Modifier - .size(iconSize) - .offset { IntOffset(xOffset.toInt(), 0) }, - tint = colorsScheme().onPrimary - ) -} - -@Composable -private fun UIMessage.Regular.MessageContentAndStatus( - message: UIMessage.Regular, - assetStatus: AssetTransferStatus?, - searchQuery: String, - audioState: AudioState?, - onAssetClicked: (String) -> Unit, - onImageClicked: (UIMessage.Regular, Boolean) -> Unit, - onAudioClicked: (String) -> Unit, - onAudioPositionChanged: (String, Int) -> Unit, - onProfileClicked: (String) -> Unit, - onLinkClicked: (String) -> Unit, - onReplyClicked: (UIMessage.Regular) -> Unit, - shouldDisplayMessageStatus: Boolean, - conversationDetailsData: ConversationDetailsData, -) { - val onAssetClickable = remember(message) { - Clickable(enabled = isAvailable, onClick = { - onAssetClicked(header.messageId) - }) - } - val onImageClickable = remember(message) { - Clickable(enabled = isAvailable, onClick = { - onImageClicked(message, source == MessageSource.Self) - }) - } - val onReplyClickable = remember(message) { - Clickable { - onReplyClicked(message) - } - } - Row { - Box(modifier = Modifier.weight(1F)) { - MessageContent( - message = message, - messageContent = messageContent, - searchQuery = searchQuery, - audioState = audioState, - assetStatus = assetStatus, - onAudioClick = onAudioClicked, - onChangeAudioPosition = onAudioPositionChanged, - onAssetClick = onAssetClickable, - onImageClick = onImageClickable, - onOpenProfile = onProfileClicked, - onLinkClick = onLinkClicked, - onReplyClick = onReplyClickable, - ) - } - if (isMyMessage && shouldDisplayMessageStatus) { - MessageStatusIndicator( - status = message.header.messageStatus.flowStatus, - isGroupConversation = conversationDetailsData is ConversationDetailsData.Group, - modifier = Modifier.padding( - top = if (message.isTextContentWithoutQuote) dimensions().spacing2x else dimensions().spacing4x, - start = dimensions().spacing8x - ) - ) - } else { - HorizontalSpace.x24() - } - } -} - @Composable fun EphemeralMessageExpiredLabel( isSelfMessage: Boolean, @@ -579,187 +343,33 @@ private fun Username(username: String, accent: Accent, modifier: Modifier = Modi ) } -@Suppress("ComplexMethod") @Composable -private fun MessageContent( - message: UIMessage.Regular, - messageContent: UIMessageContent.Regular?, - searchQuery: String, - audioState: AudioState?, - assetStatus: AssetTransferStatus?, - onAssetClick: Clickable, - onImageClick: Clickable, - onAudioClick: (String) -> Unit, - onChangeAudioPosition: (String, Int) -> Unit, - onOpenProfile: (String) -> Unit, - onLinkClick: (String) -> Unit, - onReplyClick: Clickable, -) { - when (messageContent) { - is UIMessageContent.ImageMessage -> { - Column { - MessageImage( - asset = messageContent.asset, - imgParams = ImageMessageParams(messageContent.width, messageContent.height), - transferStatus = assetStatus ?: AssetTransferStatus.NOT_DOWNLOADED, - onImageClick = onImageClick - ) - PartialDeliveryInformation(messageContent.deliveryStatus) - } - } - - is UIMessageContent.TextMessage -> { - Column { - messageContent.messageBody.quotedMessage?.let { - VerticalSpace.x4() - when (it) { - is UIQuotedMessage.UIQuotedData -> QuotedMessage( - messageData = it, - clickable = onReplyClick - ) - - UIQuotedMessage.UnavailableData -> QuotedUnavailable(style = QuotedMessageStyle.COMPLETE) - } - VerticalSpace.x4() - } - MessageBody( - messageBody = messageContent.messageBody, - searchQuery = searchQuery, - isAvailable = !message.isPending && message.isAvailable, - onOpenProfile = onOpenProfile, - buttonList = null, - messageId = message.header.messageId, - onLinkClick = onLinkClick, - ) - PartialDeliveryInformation(messageContent.deliveryStatus) - } - } - - is UIMessageContent.Composite -> { - Column { - messageContent.messageBody?.quotedMessage?.let { - VerticalSpace.x4() - when (it) { - is UIQuotedMessage.UIQuotedData -> QuotedMessage( - messageData = it, - clickable = onReplyClick - ) - - UIQuotedMessage.UnavailableData -> QuotedUnavailable(style = QuotedMessageStyle.COMPLETE) - } - VerticalSpace.x4() - } - MessageBody( - messageBody = messageContent.messageBody, - isAvailable = !message.isPending && message.isAvailable, - onOpenProfile = onOpenProfile, - buttonList = messageContent.buttonList, - messageId = message.header.messageId, - onLinkClick = onLinkClick - ) - } - } - - is UIMessageContent.AssetMessage -> { - Column { - MessageAsset( - assetName = messageContent.assetName, - assetExtension = messageContent.assetExtension, - assetSizeInBytes = messageContent.assetSizeInBytes, - assetTransferStatus = assetStatus ?: AssetTransferStatus.NOT_DOWNLOADED, - onAssetClick = onAssetClick - ) - PartialDeliveryInformation(messageContent.deliveryStatus) - } - } - - is UIMessageContent.RestrictedAsset -> { - Column { - when { - messageContent.mimeType.contains("image/") -> { - RestrictedAssetMessage( - R.drawable.ic_gallery, - stringResource(id = R.string.prohibited_images_message) - ) - } - - messageContent.mimeType.contains("video/") -> { - RestrictedAssetMessage(R.drawable.ic_video, stringResource(id = R.string.prohibited_videos_message)) - } - - messageContent.mimeType.contains("audio/") -> { - RestrictedAssetMessage( - R.drawable.ic_speaker_on, - stringResource(id = R.string.prohibited_audio_message) - ) - } - - else -> { - RestrictedGenericFileMessage(messageContent.assetName, messageContent.assetSizeInBytes) - } - } - PartialDeliveryInformation(messageContent.deliveryStatus) - } - } - - is UIMessageContent.AudioAssetMessage -> { - Column { - val audioMessageState: AudioState = audioState ?: AudioState.DEFAULT - - val totalTimeInMs = remember(audioMessageState.totalTimeInMs) { - audioMessageState.sanitizeTotalTime(messageContent.audioMessageDurationInMs.toInt()) - } - - AudioMessage( - audioMediaPlayingState = audioMessageState.audioMediaPlayingState, - totalTimeInMs = totalTimeInMs, - currentPositionInMs = audioMessageState.currentPositionInMs, - onPlayButtonClick = { onAudioClick(message.header.messageId) }, - onSliderPositionChange = { position -> - onChangeAudioPosition(message.header.messageId, position.toInt()) - }, - ) - PartialDeliveryInformation(messageContent.deliveryStatus) - } - } - - is UIMessageContent.Location -> with(messageContent) { - val context = LocalContext.current - val locationUrl = stringResource(urlCoordinates, zoom, latitude, longitude) - Column { - LocationMessageContent( - locationName = name, - locationUrl = locationUrl, - onLocationClick = Clickable( - enabled = message.isAvailable, - onClick = { launchGeoIntent(latitude, longitude, name, locationUrl, context) }, - ) - ) - PartialDeliveryInformation(deliveryStatus) - } - } - - UIMessageContent.Deleted -> {} - null -> { - throw NullPointerException("messageContent is null") - } - } -} - -@Composable -private fun PartialDeliveryInformation(deliveryStatus: DeliveryStatusContent) { - (deliveryStatus as? DeliveryStatusContent.PartialDelivery)?.let { partialDelivery -> - if (partialDelivery.hasFailures) { - VerticalSpace.x4() - MessageSentPartialDeliveryFailures(partialDelivery) - } +private fun MessageStatusLabel(messageStatus: MessageStatus) { + messageStatus.badgeText?.let { + StatusBox(it.asString()) } } +@PreviewMultipleThemes @Composable -private fun MessageStatusLabel(messageStatus: MessageStatus) { - messageStatus.badgeText?.let { - StatusBox(it.asString()) +fun LongMessageFooterPreview() = WireTheme { + Box(modifier = Modifier.width(200.dp)) { + MessageFooter( + messageFooter = MessageFooter( + messageId = "messageId", + reactions = mapOf( + "👍" to 1, + "👎" to 2, + "👏" to 3, + "🤔" to 4, + "🤷" to 5, + "đŸ¤Ļ" to 6, + "đŸ¤ĸ" to 7 + ), + ownReactions = setOf("👍"), + ), + onReactionClicked = { _, _ -> } + ) } } 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/SwipableToReplyBox.kt new file mode 100644 index 00000000000..1cef5be095e --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SwipableToReplyBox.kt @@ -0,0 +1,173 @@ +package com.wire.android.ui.home.conversations.messages.item + +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.splineBasedDecay +import androidx.compose.foundation.ExperimentalFoundationApi +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.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +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.Stable +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.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.platform.ViewConfiguration +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntOffset +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 +} + +enum class SwipeAnchor { + CENTERED, + START_TO_END +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun SwipableToReplyBox( + modifier: Modifier = Modifier, + onSwipedToReply: () -> Unit = {}, + content: @Composable () -> Unit +) { + val density = LocalDensity.current + val haptic = LocalHapticFeedback.current + val configuration = LocalConfiguration.current + val screenWidth = with(density) { configuration.screenWidthDp.dp.toPx() } + var didVibrateOnCurrentDrag by remember { mutableStateOf(false) } + + // Finish the animation in the first 25% of the drag + val progressUntilAnimationCompletion = 0.25f + val dragWidth = screenWidth * progressUntilAnimationCompletion + + val currentViewConfiguration = LocalViewConfiguration.current + val scopedViewConfiguration = object : ViewConfiguration by currentViewConfiguration { + // Make it easier to scroll by giving the user a bit more length to identify the gesture as vertical + override val touchSlop: Float + get() = currentViewConfiguration.touchSlop * 3f + } + CompositionLocalProvider(LocalViewConfiguration provides scopedViewConfiguration) { + val dragState = remember { + AnchoredDraggableState( + initialValue = SwipeAnchor.CENTERED, + positionalThreshold = { dragWidth }, + 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 + } + ) + } + val primaryColor = colorsScheme().primary + + Box( + modifier = modifier.fillMaxSize(), + ) { + // 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), + ) + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + 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 + } + + ReplySwipeIcon(dragWidth, density, progress) + } + } + // Message content, which is draggable + Box( + modifier = Modifier + .fillMaxSize() + .anchoredDraggable(dragState, Orientation.Horizontal, startDragImmediately = false) + .offset { + val x = dragState + .requireOffset() + .toInt() + IntOffset(x, 0) + }, + ) { content() } + } + } +} + +@Composable +private fun ReplySwipeIcon(dragWidth: Float, density: Density, progress: Float) { + 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) + } + Icon( + painter = painterResource(id = R.drawable.ic_reply), + contentDescription = "", + modifier = Modifier + .size(iconSize) + .offset { IntOffset(xOffset.toInt(), 0) }, + tint = colorsScheme().onPrimary + ) +}