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 = {},
onAddReaction: () -> Unit = {},
fromWebhook: Boolean = false,
webhookName: String? = null
webhookName: String? = null,
modifier: Modifier = Modifier
) {
val author = RevoltAPI.userCache[message.author] ?: return CircularProgressIndicator()
val context = LocalContext.current
@ -200,7 +201,7 @@ fun Message(
val authorIsBlocked = remember(author) { author.relationship == "Blocked" }
Column(Modifier.animateContentSize()) {
Column(modifier.animateContentSize()) {
if (message.tail == false) {
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 {
var useSidebarGesture by remember { mutableStateOf(true) }
DismissibleNavigationDrawer(
drawerState = drawerState,
gesturesEnabled = useSidebarGesture,
drawerContent = {
DismissibleDrawerSheet(
drawerContainerColor = Color.Transparent,
@ -842,7 +844,11 @@ fun ChatRouterScreen(
toggleDrawer = {
toggleDrawerLambda()
},
drawerState = drawerState
drawerState = drawerState,
drawerGestureEnabled = useSidebarGesture,
setDrawerGestureEnabled = {
useSidebarGesture = it
}
)
}
}
@ -884,7 +890,9 @@ fun ChannelNavigator(
topNav: NavController,
useDrawer: Boolean,
toggleDrawer: () -> Unit,
drawerState: DrawerState? = null
drawerState: DrawerState? = null,
drawerGestureEnabled: Boolean = true,
setDrawerGestureEnabled: (Boolean) -> Unit = {},
) {
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
import android.annotation.SuppressLint
import android.app.Activity
import android.content.ContentValues
import android.content.res.Configuration
@ -17,6 +18,7 @@ import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.animateIntAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@ -27,12 +29,14 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
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
@ -42,6 +46,7 @@ import androidx.compose.foundation.layout.imeAnimationTarget
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars
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.size
@ -60,6 +65,7 @@ import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DrawerState
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
@ -81,6 +87,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -90,8 +97,12 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
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.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
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.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.schemas.ChannelType
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.DateDivider
@ -140,6 +153,7 @@ import chat.revolt.components.screens.chat.TypingIndicator
import chat.revolt.components.skeletons.MessageSkeleton
import chat.revolt.components.skeletons.MessageSkeletonVariant
import chat.revolt.internals.extensions.rememberChannelPermissions
import chat.revolt.internals.extensions.supportSwipeReply
import chat.revolt.internals.extensions.zero
import chat.revolt.sheets.ChannelInfoSheet
import chat.revolt.sheets.MessageContextSheet
@ -151,6 +165,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlinx.datetime.Instant
import java.io.File
import kotlin.math.abs
import kotlin.math.max
sealed class ChannelScreenItem {
@ -182,6 +197,7 @@ private fun pxAsDp(px: Int): Dp {
private const val NOT_ENOUGH_SPACE_FOR_PANES_THRESHOLD = 500
@SuppressLint("UnusedBoxWithConstraintsScope")
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun ChannelScreen(
@ -189,6 +205,9 @@ fun ChannelScreen(
onToggleDrawer: () -> Unit,
useDrawer: Boolean,
useBackButton: Boolean = false,
drawerGestureEnabled: Boolean = true,
setDrawerGestureEnabled: (Boolean) -> Unit = {},
drawerState: DrawerState? = null,
backButtonAction: (() -> Unit)? = null,
useChatUI: Boolean = false,
viewModel: ChannelScreenViewModel = hiltViewModel()
@ -196,6 +215,8 @@ fun ChannelScreen(
// <editor-fold desc="State and effects">
val scope = rememberCoroutineScope()
val context = LocalContext.current
val haptic = LocalHapticFeedback.current
val config = LocalConfiguration.current
LaunchedEffect(Unit) {
viewModel.listenToWsEvents()
@ -381,6 +402,7 @@ fun ChannelScreen(
// </editor-fold>
// <editor-fold desc="UI elements">
val lazyListState = rememberLazyListState()
var disableScroll by remember { mutableStateOf(false) }
val isScrolledToBottom = remember(lazyListState) {
derivedStateOf {
@ -654,6 +676,7 @@ fun ChannelScreen(
) {
LazyColumn(
state = lazyListState,
userScrollEnabled = !disableScroll,
reverseLayout = true,
contentPadding = PaddingValues(top = 16.dp, bottom = 32.dp)
) {
@ -693,53 +716,189 @@ fun ChannelScreen(
) { index ->
when (val item = viewModel.items[index]) {
is ChannelScreenItem.RegularMessage -> {
Message(
message = item.message,
onMessageContextMenu = {
item.message.id?.let { messageId ->
messageContextSheetTarget = messageId
messageContextSheetShown = true
}
var offsetX by remember { mutableFloatStateOf(0f) }
val animOffsetX by animateFloatAsState(
when {
offsetX > -20f -> 0f
else -> offsetX
},
onAvatarClick = {
if (item.message.webhook != null) {
scope.launch {
ActionChannel.send(Action.OpenWebhookSheet)
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
}
}
} 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 {
ActionChannel.send(
Action.OpenUserSheet(
author,
viewModel.channel?.server
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"
}
)
}
},
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 -> {