diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index 572bc6944..572c4adb9 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -241,12 +241,18 @@ fun ChatLayout( modifier = Modifier.navigationBarsWithImePadding(), floatingActionButton = floatingButton.value, ) { contentPadding -> - BoxWithConstraints(Modifier.padding(contentPadding)) { - ChatItemsList( - user, chat, unreadCount, composeState, chatItems, - useLinkPreviews, openDirectChat, loadPrevMessages, deleteMessage, - receiveFile, joinGroup, acceptCall, markRead, floatingButton - ) + CompositionLocalProvider( + // Makes horizontal and vertical scrolling to coexist nicely. + // With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view + LocalViewConfiguration provides LocalViewConfiguration.current.bigTouchSlop() + ) { + BoxWithConstraints(Modifier.padding(contentPadding)) { + ChatItemsList( + user, chat, unreadCount, composeState, chatItems, + useLinkPreviews, openDirectChat, loadPrevMessages, deleteMessage, + receiveFile, joinGroup, acceptCall, markRead, floatingButton + ) + } } } } @@ -390,12 +396,33 @@ fun BoxWithConstraintsScope.ChatItemsList( val reversedChatItems by remember { derivedStateOf { chatItems.reversed() } } LazyColumn(state = listState, reverseLayout = true) { itemsIndexed(reversedChatItems) { i, cItem -> + val dismissState = rememberDismissState(initialValue = DismissValue.Default) { false } + val directions = setOf(DismissDirection.EndToStart) + val swipeableModifier = SwipeToDismissModifier( + state = dismissState, + directions = directions, + swipeDistance = with(LocalDensity.current) { 30.dp.toPx() }, + ) + val swipedToEnd = (dismissState.overflow.value > 0f && directions.contains(DismissDirection.StartToEnd)) + val swipedToStart = (dismissState.overflow.value < 0f && directions.contains(DismissDirection.EndToStart)) + if (dismissState.isAnimationRunning && (swipedToStart || swipedToEnd)) { + LaunchedEffect(Unit) { + scope.launch { + if (composeState.value.editing) { + composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) + } else { + composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) + } + } + } + } + if (chat.chatInfo is ChatInfo.Group) { if (cItem.chatDir is CIDirection.GroupRcv) { val prevItem = if (i < reversedChatItems.lastIndex) reversedChatItems[i + 1] else null val member = cItem.chatDir.groupMember val showMember = showMemberImage(member, prevItem) - Row(Modifier.padding(start = 8.dp, end = 66.dp)) { + Row(Modifier.padding(start = 8.dp, end = 66.dp).then(swipeableModifier)) { if (showMember) { val contactId = member.memberContactId if (contactId == null) { @@ -420,7 +447,7 @@ fun BoxWithConstraintsScope.ChatItemsList( ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, showMember = showMember, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall) } } else { - Box(Modifier.padding(start = 86.dp, end = 12.dp)) { + Box(Modifier.padding(start = 86.dp, end = 12.dp).then(swipeableModifier)) { ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall) } } @@ -430,7 +457,7 @@ fun BoxWithConstraintsScope.ChatItemsList( Modifier.padding( start = if (sent) 76.dp else 12.dp, end = if (sent) 12.dp else 76.dp, - ) + ).then(swipeableModifier) ) { ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall) } @@ -634,6 +661,19 @@ private fun bottomEndFloatingButton( } } +private fun ViewConfiguration.bigTouchSlop(slop: Float = 80f) = object: ViewConfiguration { + override val longPressTimeoutMillis + get() = + this@bigTouchSlop.longPressTimeoutMillis + override val doubleTapTimeoutMillis + get() = + this@bigTouchSlop.doubleTapTimeoutMillis + override val doubleTapMinTimeMillis + get() = + this@bigTouchSlop.doubleTapMinTimeMillis + override val touchSlop: Float get() = slop +} + @Preview(showBackground = true) @Preview( uiMode = Configuration.UI_MODE_NIGHT_YES, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt index b88f5230c..e456f0576 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt @@ -12,11 +12,13 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.outlined.ArrowUpward import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier +import androidx.compose.ui.* import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.KeyboardCapitalization @@ -26,7 +28,9 @@ import chat.simplex.app.R import chat.simplex.app.model.ChatItem import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme +import kotlinx.coroutines.delay +@OptIn(ExperimentalComposeUiApi::class) @Composable fun SendMsgView( composeState: MutableState, @@ -35,6 +39,16 @@ fun SendMsgView( textStyle: MutableState ) { val cs = composeState.value + val focusRequester = remember { FocusRequester() } + val keyboard = LocalSoftwareKeyboardController.current + LaunchedEffect(cs.contextItem) { + if (cs.contextItem !is ComposeContextItem.QuotedItem) return@LaunchedEffect + // In replying state + focusRequester.requestFocus() + delay(50) + keyboard?.show() + } + BasicTextField( value = cs.message, onValueChange = onMessageChange, @@ -44,7 +58,7 @@ fun SendMsgView( capitalization = KeyboardCapitalization.Sentences, autoCorrect = true ), - modifier = Modifier.padding(vertical = 8.dp), + modifier = Modifier.padding(vertical = 8.dp).focusRequester(focusRequester), cursorBrush = SolidColor(HighOrLowlight), decorationBox = { innerTextField -> Surface( diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Modifiers.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Modifiers.kt index a8d9f0e43..f60d644a8 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Modifiers.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Modifiers.kt @@ -1,7 +1,15 @@ package chat.simplex.app.views.helpers +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.layout.offset +import androidx.compose.material.* +import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.LayoutDirection +import kotlin.math.roundToInt fun Modifier.badgeLayout() = layout { measurable, constraints -> @@ -15,3 +23,22 @@ fun Modifier.badgeLayout() = placeable.place((width - placeable.width) / 2, 0) } } + +@Composable +fun SwipeToDismissModifier( + state: DismissState, + directions: Set = setOf(DismissDirection.EndToStart, DismissDirection.StartToEnd), + swipeDistance: Float, +): Modifier { + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl + val anchors = mutableMapOf(0f to DismissValue.Default) + if (DismissDirection.StartToEnd in directions) anchors += swipeDistance to DismissValue.DismissedToEnd + if (DismissDirection.EndToStart in directions) anchors += -swipeDistance to DismissValue.DismissedToStart + return Modifier.swipeable( + state = state, + anchors = anchors, + thresholds = { _, _ -> FractionalThreshold(0.5f) }, + orientation = Orientation.Horizontal, + reverseDirection = isRtl, + ).offset { IntOffset(state.offset.value.roundToInt(), 0) } +}