From 5d5744e180e1d50c8465adb33bdd9f615f901b0d Mon Sep 17 00:00:00 2001 From: Infi Date: Sat, 29 Mar 2025 03:44:57 +0100 Subject: [PATCH] feat: finish swipe-to-reply impl Signed-off-by: Infi --- .../screens/chat/atoms/RegularMessage.kt | 286 ++++++++++++++++++ .../chat/views/channel/ChannelScreen.kt | 209 ++----------- app/src/main/res/values/strings.xml | 3 + 3 files changed, 316 insertions(+), 182 deletions(-) create mode 100644 app/src/main/java/chat/revolt/components/screens/chat/atoms/RegularMessage.kt diff --git a/app/src/main/java/chat/revolt/components/screens/chat/atoms/RegularMessage.kt b/app/src/main/java/chat/revolt/components/screens/chat/atoms/RegularMessage.kt new file mode 100644 index 00000000..6d95036e --- /dev/null +++ b/app/src/main/java/chat/revolt/components/screens/chat/atoms/RegularMessage.kt @@ -0,0 +1,286 @@ +package chat.revolt.components.screens.chat.atoms + +import android.annotation.SuppressLint +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeightIn +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.layout.onSizeChanged +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.unit.dp +import androidx.compose.ui.unit.min +import chat.revolt.R +import chat.revolt.api.RevoltAPI +import chat.revolt.api.schemas.Channel +import chat.revolt.api.schemas.Message +import chat.revolt.api.settings.LoadedSettings +import chat.revolt.api.settings.MessageReplyStyle +import chat.revolt.callbacks.Action +import chat.revolt.callbacks.ActionChannel +import chat.revolt.components.chat.Message +import chat.revolt.internals.extensions.supportSwipeReply +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlin.math.abs + +const val SWIPE_TO_REPLY_THRESHOLD = -450f + +// Display a regular message in the LazyColumn of the chat screen. +@SuppressLint("UnusedBoxWithConstraintsScope") // we do use it, but the IDE is stupid +@Composable +fun RegularMessage( + message: Message, + channel: Channel?, + setDrawerGestureEnabled: (Boolean) -> Unit, + setDisableScroll: (Boolean) -> Unit, + showMessageBottomSheet: (String) -> Unit, + showReactBottomSheet: () -> Unit, + putTextAtCursorPosition: (String) -> Unit, + replyToMessage: suspend (String) -> Unit, + scope: CoroutineScope = rememberCoroutineScope() +) { + val haptic = LocalHapticFeedback.current + + var offsetX by remember { mutableFloatStateOf(0f) } + val animOffsetX by animateFloatAsState( + when { + offsetX > -20f -> 0f + else -> offsetX + }, + label = "X offset of message for Swipe to Reply" + ) + var markGestureInvalid by remember { mutableStateOf(false) } + var hapticFeedbackPerformed by remember { mutableStateOf(false) } + var messageHeight by remember { mutableIntStateOf(0) } + + val canReleaseToSend = remember(offsetX) { offsetX <= SWIPE_TO_REPLY_THRESHOLD } + val indicatorBackground by animateColorAsState( + when { + canReleaseToSend -> MaterialTheme.colorScheme.inversePrimary + else -> MaterialTheme.colorScheme.primaryContainer + }, + label = "Swipe to Reply indicator background" + ) + val indicatorForeground by animateColorAsState( + when { + canReleaseToSend -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onPrimaryContainer + }, + label = "Swipe to Reply indicator foreground" + ) + + var onFingerMoveHandler: (List) -> Unit = + { changeList: List -> + changeList.firstOrNull() + ?.let { + val deltaX = it.position.x - it.previousPosition.x + val deltaY = it.position.y - it.previousPosition.y + + val couldBeTopDownScroll = + deltaX > -30f && abs(deltaY) > 30f && offsetX >= -100f + + if (couldBeTopDownScroll) { + offsetX = 0f + markGestureInvalid = true + return@let + } + + val goesTowardsLeft = it.position.x < it.previousPosition.x + if (goesTowardsLeft || offsetX <= -20f) { + if (markGestureInvalid) return@let + + offsetX += deltaX + setDrawerGestureEnabled(false) + } + + if (goesTowardsLeft && offsetX <= -30f) { + setDisableScroll(true) + } + + if (goesTowardsLeft && offsetX <= SWIPE_TO_REPLY_THRESHOLD && !hapticFeedbackPerformed) { + hapticFeedbackPerformed = true + haptic.performHapticFeedback( + HapticFeedbackType.GestureThresholdActivate + ) + } else if (hapticFeedbackPerformed && offsetX >= -100f) { + hapticFeedbackPerformed = false + } + } + } + + Box { + Message( + message = message, + onMessageContextMenu = { + message.id?.let { messageId -> + showMessageBottomSheet(messageId) + } + }, + onAvatarClick = { + if (message.webhook != null) { + scope.launch { + ActionChannel.send(Action.OpenWebhookSheet) + } + } else { + message.author?.let { author -> + scope.launch { + ActionChannel.send(Action.OpenUserSheet(author, channel?.server)) + } + } + } + }, + onNameClick = { + val author = message.author?.let { RevoltAPI.userCache[it] } ?: return@Message + putTextAtCursorPosition("@${author.username}#${author.discriminator}") + }, + canReply = true, + onReply = { + message.id?.let { messageId -> + scope.launch { + replyToMessage(messageId) + } + } + }, + onAddReaction = { + message.id?.let { messageId -> + showReactBottomSheet() + } + }, + fromWebhook = message.webhook != null, + webhookName = message.webhook?.name, + modifier = Modifier + .offset( + x = with(LocalDensity.current) { animOffsetX.toDp() } + ) + .then( + if (LoadedSettings.messageReplyStyle == MessageReplyStyle.SwipeFromEnd) + Modifier.supportSwipeReply( + onDown = {}, + onMove = onFingerMoveHandler, + onUp = { + if (offsetX <= SWIPE_TO_REPLY_THRESHOLD) { + scope.launch { + message.id?.let { + replyToMessage(it) + } + } + } + + setDrawerGestureEnabled(true) + markGestureInvalid = false + setDisableScroll(false) + hapticFeedbackPerformed = false + offsetX = 0f + } + ) + else Modifier + ) + .onSizeChanged { + // FIXME: + // This whole onSizeChanged pattern is, technically a workaround. LazyColumn + // doesn't support the usual idiomatic ways to make an item as tall as the + // tallest item in the row (intrinsic sizing; fill parent etc.) + // This workaround may bite us performance-wise! + if (messageHeight != it.height) messageHeight = it.height + } + ) + with(LocalDensity.current) { + val msgHeightAsDp = messageHeight.toDp() + BoxWithConstraints(Modifier.height(msgHeightAsDp)) { + AnimatedContent( + targetState = canReleaseToSend, + transitionSpec = { + if (canReleaseToSend) { + slideInVertically { -it } togetherWith slideOutVertically { it } + } else { + slideInVertically { it } togetherWith slideOutVertically { -it } + } + }, + label = "Swipe to Reply indicator content slide" + ) { + Row( + Modifier + .height(msgHeightAsDp) + .requiredHeightIn(max = msgHeightAsDp) // must not cause message to be taller + .offset( + x = with(LocalDensity.current) { + maxWidth - abs( + animOffsetX + ).toDp() + } + ) + .background( + // indicatorBackground + if (it) MaterialTheme.colorScheme.inversePrimary + else MaterialTheme.colorScheme.primaryContainer + ) + .fillMaxWidth() + .padding(start = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy( + 8.dp, + Alignment.Start + ) + ) { + Icon( + painter = painterResource(R.drawable.ic_reply_24dp), + contentDescription = null, + modifier = Modifier.size( + min( + msgHeightAsDp - 4.dp, + 24.dp + ) + ), + tint = // indicatorForeground + if (it) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + when (it) { + true -> stringResource( + R.string.swipe_to_reply_release + ) + + else -> stringResource( + R.string.swipe_to_reply_keep_swiping + ) + }, + color = indicatorForeground + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt index cc572e1e..003e0ec6 100644 --- a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt @@ -17,6 +17,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade +import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateIntAsState @@ -36,7 +37,6 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -49,6 +49,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.requiredHeightIn import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.lazy.LazyColumn @@ -88,6 +89,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -99,6 +101,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -115,6 +118,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.min import androidx.compose.ui.unit.sp import androidx.documentfile.provider.DocumentFile import androidx.hilt.navigation.compose.hiltViewModel @@ -150,6 +154,7 @@ import chat.revolt.components.screens.chat.AttachmentManager import chat.revolt.components.screens.chat.ChannelIcon import chat.revolt.components.screens.chat.ReplyManager import chat.revolt.components.screens.chat.TypingIndicator +import chat.revolt.components.screens.chat.atoms.RegularMessage import chat.revolt.components.skeletons.MessageSkeleton import chat.revolt.components.skeletons.MessageSkeletonVariant import chat.revolt.internals.extensions.rememberChannelPermissions @@ -716,189 +721,29 @@ fun ChannelScreen( ) { index -> when (val item = viewModel.items[index]) { is ChannelScreenItem.RegularMessage -> { - var offsetX by remember { mutableFloatStateOf(0f) } - val animOffsetX by animateFloatAsState( - when { - offsetX > -20f -> 0f - else -> offsetX + RegularMessage( + item.message, + viewModel.channel, + setDrawerGestureEnabled = { + setDrawerGestureEnabled(it) }, - label = "X offset of message for replies" - ) - var markGestureInvalid by remember { mutableStateOf(false) } - var hapticFeedbackPerformed by remember { - mutableStateOf( - false - ) - } - - var onMoveHandler: (List) -> Unit = - { changeList: List -> - changeList - .firstOrNull() - ?.let { - val deltaX = - it.position.x - it.previousPosition.x - val deltaY = - it.position.y - it.previousPosition.y - - val couldBeTopDownScroll = - deltaX > -30f - && abs(deltaY) > 30f - // too far in to consider it an accident - && offsetX >= -100f - if (couldBeTopDownScroll) { - offsetX = 0f - markGestureInvalid = true - return@let - } - - val goesTowardsLeft = - it.position.x < it.previousPosition.x - if (goesTowardsLeft || offsetX <= -20f) { - if (markGestureInvalid) { - return@let - } - offsetX += deltaX - setDrawerGestureEnabled( - false - ) - } - - if (goesTowardsLeft && offsetX <= -30f) { - disableScroll = true - } - - if ( - goesTowardsLeft - && offsetX <= -300f - && !hapticFeedbackPerformed - ) { - hapticFeedbackPerformed = true - haptic.performHapticFeedback( - HapticFeedbackType.GestureThresholdActivate - ) - } else if ( - hapticFeedbackPerformed - && offsetX >= -100f - ) { - hapticFeedbackPerformed = false - } - } - } - - Box { - Message( - message = item.message, - onMessageContextMenu = { - item.message.id?.let { messageId -> - messageContextSheetTarget = messageId - messageContextSheetShown = true - } - }, - onAvatarClick = { - if (item.message.webhook != null) { - scope.launch { - ActionChannel.send(Action.OpenWebhookSheet) - } - } else { - item.message.author?.let { author -> - scope.launch { - ActionChannel.send( - Action.OpenUserSheet( - author, - viewModel.channel?.server - ) - ) - } - } - } - }, - onNameClick = { - val author = - item.message.author?.let { RevoltAPI.userCache[it] } - ?: return@Message - viewModel.putAtCursorPosition("@${author.username}#${author.discriminator}") - }, - canReply = true, - onReply = { - item.message.id?.let { messageId -> - scope.launch { - viewModel.addReplyTo( - messageId - ) - } - } - }, - onAddReaction = { - item.message.id?.let { messageId -> - reactSheetTarget = messageId - reactSheetShown = true - } - }, - fromWebhook = item.message.webhook != null, - webhookName = item.message.webhook?.name, - modifier = Modifier - .offset( - x = with(LocalDensity.current) { animOffsetX.toDp() } - ) - .then( - if (LoadedSettings.messageReplyStyle == MessageReplyStyle.SwipeFromEnd) - Modifier.supportSwipeReply( - onDown = {}, - onMove = onMoveHandler, - onUp = { - if (offsetX <= -300f) { - scope.launch { - item.message.id?.let { - viewModel.addReplyTo( - it - ) - } - } - } - - setDrawerGestureEnabled(true) - markGestureInvalid = false - disableScroll = false - hapticFeedbackPerformed = false - offsetX = 0f - } - ) - else Modifier - ) - ) - BoxWithConstraints(Modifier.fillMaxHeight()) { - Row( - Modifier - .fillMaxHeight() - .offset( - x = with(LocalDensity.current) { - maxWidth - abs( - animOffsetX - ).toDp() - } - ) - .background( - MaterialTheme.colorScheme.primary.copy( - alpha = 0.1f - ) - ) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy( - 8.dp, - Alignment.Start - ) - ) { - Text( - when { - offsetX <= -300f -> "stop" - else -> "keep" - } - ) + setDisableScroll = { + disableScroll = it + }, + showMessageBottomSheet = { + messageContextSheetTarget = it + messageContextSheetShown = true + }, + showReactBottomSheet = { + item.message.id?.let { + reactSheetTarget = it + reactSheetShown = true } - } - } + }, + putTextAtCursorPosition = viewModel::putAtCursorPosition, + replyToMessage = viewModel::addReplyTo, + scope = scope + ) } is ChannelScreenItem.ProspectiveMessage -> { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 53939ba1..ff243172 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -268,6 +268,9 @@ %1$d days ago (edited) + Reply + Release to reply + Disconnected Tap to reconnect Reconnecting…