feat: finish swipe-to-reply impl

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2025-03-29 03:44:57 +01:00
parent df3a1ab26f
commit 5d5744e180
3 changed files with 316 additions and 182 deletions

View File

@ -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<PointerInputChange>) -> Unit =
{ changeList: List<PointerInputChange> ->
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
)
}
}
}
}
}
}

View File

@ -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<PointerInputChange>) -> Unit =
{ changeList: List<PointerInputChange> ->
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 -> {

View File

@ -268,6 +268,9 @@
<string name="x_days_ago">%1$d days ago</string>
<string name="edited">(edited)</string>
<string name="swipe_to_reply_keep_swiping">Reply</string>
<string name="swipe_to_reply_release">Release to reply</string>
<string name="disconnected">Disconnected</string>
<string name="tap_to_reconnect">Tap to reconnect</string>
<string name="reconnecting">Reconnecting…</string>