diff --git a/app/src/main/java/chat/revolt/components/chat/Message.kt b/app/src/main/java/chat/revolt/components/chat/Message.kt index b5e14f97..d366abc9 100644 --- a/app/src/main/java/chat/revolt/components/chat/Message.kt +++ b/app/src/main/java/chat/revolt/components/chat/Message.kt @@ -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)) } diff --git a/app/src/main/java/chat/revolt/internals/extensions/Input.kt b/app/src/main/java/chat/revolt/internals/extensions/Input.kt new file mode 100644 index 00000000..f63c1ea9 --- /dev/null +++ b/app/src/main/java/chat/revolt/internals/extensions/Input.kt @@ -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) -> 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() + } + } +) \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt index d2c784a0..ad1a50bd 100644 --- a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt @@ -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, ) } 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 d5dd14e2..cc572e1e 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 @@ -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( // 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( // // 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) -> 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 + } } - } 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 -> {