feat: swipe to reply

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2025-03-27 03:03:52 +01:00
parent 922ca6b874
commit df3a1ab26f
4 changed files with 247 additions and 44 deletions

View File

@ -184,7 +184,8 @@ fun Message(
onReply: () -> Unit = {}, onReply: () -> Unit = {},
onAddReaction: () -> Unit = {}, onAddReaction: () -> Unit = {},
fromWebhook: Boolean = false, fromWebhook: Boolean = false,
webhookName: String? = null webhookName: String? = null,
modifier: Modifier = Modifier
) { ) {
val author = RevoltAPI.userCache[message.author] ?: return CircularProgressIndicator() val author = RevoltAPI.userCache[message.author] ?: return CircularProgressIndicator()
val context = LocalContext.current val context = LocalContext.current
@ -200,7 +201,7 @@ fun Message(
val authorIsBlocked = remember(author) { author.relationship == "Blocked" } val authorIsBlocked = remember(author) { author.relationship == "Blocked" }
Column(Modifier.animateContentSize()) { Column(modifier.animateContentSize()) {
if (message.tail == false) { if (message.tail == false) {
Spacer(modifier = Modifier.height(10.dp)) Spacer(modifier = Modifier.height(10.dp))
} }

View File

@ -0,0 +1,32 @@
package chat.revolt.internals.extensions
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.pointerInput
fun Modifier.supportSwipeReply(
pass: PointerEventPass = PointerEventPass.Main,
onDown: (pointer: PointerInputChange) -> Unit,
onMove: (changes: List<PointerInputChange>) -> Unit,
onUp: () -> Unit,
) = this.then(
Modifier.pointerInput(pass) {
awaitEachGesture {
val down = awaitFirstDown(pass = pass, requireUnconsumed = false)
onDown(down)
do {
val event: PointerEvent = awaitPointerEvent(
pass = pass
)
onMove(event.changes)
} while (event.changes.any { it.pressed })
onUp()
}
}
)

View File

@ -804,8 +804,10 @@ fun ChatRouterScreen(
) )
} }
} else { } else {
var useSidebarGesture by remember { mutableStateOf(true) }
DismissibleNavigationDrawer( DismissibleNavigationDrawer(
drawerState = drawerState, drawerState = drawerState,
gesturesEnabled = useSidebarGesture,
drawerContent = { drawerContent = {
DismissibleDrawerSheet( DismissibleDrawerSheet(
drawerContainerColor = Color.Transparent, drawerContainerColor = Color.Transparent,
@ -842,7 +844,11 @@ fun ChatRouterScreen(
toggleDrawer = { toggleDrawer = {
toggleDrawerLambda() toggleDrawerLambda()
}, },
drawerState = drawerState drawerState = drawerState,
drawerGestureEnabled = useSidebarGesture,
setDrawerGestureEnabled = {
useSidebarGesture = it
}
) )
} }
} }
@ -884,7 +890,9 @@ fun ChannelNavigator(
topNav: NavController, topNav: NavController,
useDrawer: Boolean, useDrawer: Boolean,
toggleDrawer: () -> Unit, toggleDrawer: () -> Unit,
drawerState: DrawerState? = null drawerState: DrawerState? = null,
drawerGestureEnabled: Boolean = true,
setDrawerGestureEnabled: (Boolean) -> Unit = {},
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -922,7 +930,10 @@ fun ChannelNavigator(
} }
} }
}, },
useDrawer = useDrawer useDrawer = useDrawer,
drawerGestureEnabled = drawerGestureEnabled,
setDrawerGestureEnabled = setDrawerGestureEnabled,
drawerState = drawerState,
) )
} }

View File

@ -1,5 +1,6 @@
package chat.revolt.screens.chat.views.channel package chat.revolt.screens.chat.views.channel
import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.content.ContentValues import android.content.ContentValues
import android.content.res.Configuration import android.content.res.Configuration
@ -17,6 +18,7 @@ import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.animateIntAsState import androidx.compose.animation.core.animateIntAsState
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@ -27,12 +29,14 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@ -42,6 +46,7 @@ import androidx.compose.foundation.layout.imeAnimationTarget
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@ -60,6 +65,7 @@ import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DrawerState
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -81,6 +87,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@ -90,8 +97,12 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.Placeholder
@ -120,6 +131,8 @@ import chat.revolt.api.routes.channel.react
import chat.revolt.api.routes.microservices.autumn.FileArgs import chat.revolt.api.routes.microservices.autumn.FileArgs
import chat.revolt.api.schemas.ChannelType import chat.revolt.api.schemas.ChannelType
import chat.revolt.api.schemas.Message 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.Action
import chat.revolt.callbacks.ActionChannel import chat.revolt.callbacks.ActionChannel
import chat.revolt.components.chat.DateDivider import chat.revolt.components.chat.DateDivider
@ -140,6 +153,7 @@ import chat.revolt.components.screens.chat.TypingIndicator
import chat.revolt.components.skeletons.MessageSkeleton import chat.revolt.components.skeletons.MessageSkeleton
import chat.revolt.components.skeletons.MessageSkeletonVariant import chat.revolt.components.skeletons.MessageSkeletonVariant
import chat.revolt.internals.extensions.rememberChannelPermissions import chat.revolt.internals.extensions.rememberChannelPermissions
import chat.revolt.internals.extensions.supportSwipeReply
import chat.revolt.internals.extensions.zero import chat.revolt.internals.extensions.zero
import chat.revolt.sheets.ChannelInfoSheet import chat.revolt.sheets.ChannelInfoSheet
import chat.revolt.sheets.MessageContextSheet import chat.revolt.sheets.MessageContextSheet
@ -151,6 +165,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import java.io.File import java.io.File
import kotlin.math.abs
import kotlin.math.max import kotlin.math.max
sealed class ChannelScreenItem { sealed class ChannelScreenItem {
@ -182,6 +197,7 @@ private fun pxAsDp(px: Int): Dp {
private const val NOT_ENOUGH_SPACE_FOR_PANES_THRESHOLD = 500 private const val NOT_ENOUGH_SPACE_FOR_PANES_THRESHOLD = 500
@SuppressLint("UnusedBoxWithConstraintsScope")
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun ChannelScreen( fun ChannelScreen(
@ -189,6 +205,9 @@ fun ChannelScreen(
onToggleDrawer: () -> Unit, onToggleDrawer: () -> Unit,
useDrawer: Boolean, useDrawer: Boolean,
useBackButton: Boolean = false, useBackButton: Boolean = false,
drawerGestureEnabled: Boolean = true,
setDrawerGestureEnabled: (Boolean) -> Unit = {},
drawerState: DrawerState? = null,
backButtonAction: (() -> Unit)? = null, backButtonAction: (() -> Unit)? = null,
useChatUI: Boolean = false, useChatUI: Boolean = false,
viewModel: ChannelScreenViewModel = hiltViewModel() viewModel: ChannelScreenViewModel = hiltViewModel()
@ -196,6 +215,8 @@ fun ChannelScreen(
// <editor-fold desc="State and effects"> // <editor-fold desc="State and effects">
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
val haptic = LocalHapticFeedback.current
val config = LocalConfiguration.current
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.listenToWsEvents() viewModel.listenToWsEvents()
@ -381,6 +402,7 @@ fun ChannelScreen(
// </editor-fold> // </editor-fold>
// <editor-fold desc="UI elements"> // <editor-fold desc="UI elements">
val lazyListState = rememberLazyListState() val lazyListState = rememberLazyListState()
var disableScroll by remember { mutableStateOf(false) }
val isScrolledToBottom = remember(lazyListState) { val isScrolledToBottom = remember(lazyListState) {
derivedStateOf { derivedStateOf {
@ -654,6 +676,7 @@ fun ChannelScreen(
) { ) {
LazyColumn( LazyColumn(
state = lazyListState, state = lazyListState,
userScrollEnabled = !disableScroll,
reverseLayout = true, reverseLayout = true,
contentPadding = PaddingValues(top = 16.dp, bottom = 32.dp) contentPadding = PaddingValues(top = 16.dp, bottom = 32.dp)
) { ) {
@ -693,53 +716,189 @@ fun ChannelScreen(
) { index -> ) { index ->
when (val item = viewModel.items[index]) { when (val item = viewModel.items[index]) {
is ChannelScreenItem.RegularMessage -> { is ChannelScreenItem.RegularMessage -> {
Message( var offsetX by remember { mutableFloatStateOf(0f) }
message = item.message, val animOffsetX by animateFloatAsState(
onMessageContextMenu = { when {
item.message.id?.let { messageId -> offsetX > -20f -> 0f
messageContextSheetTarget = messageId else -> offsetX
messageContextSheetShown = true
}
}, },
onAvatarClick = { label = "X offset of message for replies"
if (item.message.webhook != null) { )
scope.launch { var markGestureInvalid by remember { mutableStateOf(false) }
ActionChannel.send(Action.OpenWebhookSheet) 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
}
} }
} else { }
item.message.author?.let { author ->
Box {
Message(
message = item.message,
onMessageContextMenu = {
item.message.id?.let { messageId ->
messageContextSheetTarget = messageId
messageContextSheetShown = true
}
},
onAvatarClick = {
if (item.message.webhook != null) {
scope.launch { scope.launch {
ActionChannel.send( ActionChannel.send(Action.OpenWebhookSheet)
Action.OpenUserSheet( }
author, } else {
viewModel.channel?.server 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"
}
)
} }
}, }
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
)
} }
is ChannelScreenItem.ProspectiveMessage -> { is ChannelScreenItem.ProspectiveMessage -> {