11package com.getcode.ui.components.chat
22
33import androidx.compose.animation.AnimatedVisibility
4+ import androidx.compose.animation.core.Spring
5+ import androidx.compose.animation.core.spring
46import androidx.compose.animation.core.tween
57import androidx.compose.animation.fadeIn
68import androidx.compose.animation.fadeOut
@@ -14,6 +16,8 @@ import androidx.compose.foundation.gestures.AnchoredDraggableState
1416import androidx.compose.foundation.gestures.DraggableAnchors
1517import androidx.compose.foundation.gestures.Orientation
1618import androidx.compose.foundation.gestures.anchoredDraggable
19+ import androidx.compose.foundation.gestures.detectDragGestures
20+ import androidx.compose.foundation.gestures.detectHorizontalDragGestures
1721import androidx.compose.foundation.gestures.snapTo
1822import androidx.compose.foundation.layout.Arrangement
1923import androidx.compose.foundation.layout.Box
@@ -30,6 +34,8 @@ import androidx.compose.foundation.layout.widthIn
3034import androidx.compose.foundation.shape.CircleShape
3135import androidx.compose.foundation.shape.CornerBasedShape
3236import androidx.compose.foundation.shape.CornerSize
37+ import androidx.compose.material.ExperimentalMaterialApi
38+ import androidx.compose.material.FractionalThreshold
3339import androidx.compose.material.Icon
3440import androidx.compose.material.Text
3541import androidx.compose.material.icons.Icons
@@ -40,27 +46,37 @@ import androidx.compose.runtime.LaunchedEffect
4046import androidx.compose.runtime.getValue
4147import androidx.compose.runtime.mutableStateOf
4248import androidx.compose.runtime.remember
49+ import androidx.compose.runtime.rememberCoroutineScope
4350import androidx.compose.runtime.setValue
51+ import androidx.compose.runtime.snapshotFlow
4452import androidx.compose.ui.Alignment
4553import androidx.compose.ui.Modifier
4654import androidx.compose.ui.draw.clip
55+ import androidx.compose.ui.geometry.Offset
4756import androidx.compose.ui.graphics.Color
4857import androidx.compose.ui.graphics.ColorFilter
4958import androidx.compose.ui.graphics.CompositingStrategy
5059import androidx.compose.ui.graphics.Shape
5160import androidx.compose.ui.graphics.graphicsLayer
61+ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
62+ import androidx.compose.ui.input.nestedscroll.NestedScrollSource
63+ import androidx.compose.ui.input.nestedscroll.nestedScroll
64+ import androidx.compose.ui.input.pointer.pointerInput
5265import androidx.compose.ui.platform.LocalDensity
5366import androidx.compose.ui.res.painterResource
5467import androidx.compose.ui.text.TextStyle
5568import androidx.compose.ui.text.font.FontWeight
5669import androidx.compose.ui.unit.IntOffset
5770import androidx.compose.ui.unit.dp
71+ import androidx.compose.ui.unit.times
5872import com.getcode.model.ID
5973import com.getcode.model.chat.MessageContent
6074import com.getcode.model.chat.MessageStatus
6175import com.getcode.model.chat.Sender
6276import com.getcode.theme.CodeTheme
77+ import com.getcode.ui.components.Anchor
6378import com.getcode.ui.components.R
79+ import com.getcode.ui.components.SlideToConfirmDefaults
6480import com.getcode.ui.components.chat.messagecontents.AnnouncementMessage
6581import com.getcode.ui.components.chat.messagecontents.DeletedMessage
6682import com.getcode.ui.components.chat.messagecontents.EncryptedContent
@@ -70,10 +86,13 @@ import com.getcode.ui.components.chat.messagecontents.MessageText
7086import com.getcode.ui.components.chat.utils.ReplyMessageAnchor
7187import com.getcode.ui.components.chat.utils.localizedText
7288import com.getcode.ui.components.text.markup.Markup
89+ import com.getcode.ui.utils.toPx
7390import com.getcode.util.vibration.LocalVibrator
7491import kotlinx.coroutines.delay
92+ import kotlinx.coroutines.launch
7593import kotlinx.datetime.Instant
7694import kotlin.coroutines.cancellation.CancellationException
95+ import kotlin.math.max
7796import kotlin.math.roundToInt
7897import kotlin.reflect.KClass
7998
@@ -86,7 +105,10 @@ object MessageNodeDefaults {
86105 @Composable get() = DefaultShape .copy(topStart = CornerSize (3 .dp))
87106
88107 private val NextSameShapeIncoming : CornerBasedShape
89- @Composable get() = DefaultShape .copy(bottomStart = CornerSize (3 .dp), topStart = CornerSize (3 .dp))
108+ @Composable get() = DefaultShape .copy(
109+ bottomStart = CornerSize (3 .dp),
110+ topStart = CornerSize (3 .dp)
111+ )
90112
91113 private val MiddleSameShapeIncoming : CornerBasedShape
92114 @Composable get() = DefaultShape .copy(
@@ -98,7 +120,10 @@ object MessageNodeDefaults {
98120 @Composable get() = DefaultShape .copy(topEnd = CornerSize (3 .dp))
99121
100122 private val NextSameShapeOutgoing : CornerBasedShape
101- @Composable get() = DefaultShape .copy(bottomEnd = CornerSize (3 .dp), topEnd = CornerSize (3 .dp))
123+ @Composable get() = DefaultShape .copy(
124+ bottomEnd = CornerSize (3 .dp),
125+ topEnd = CornerSize (3 .dp)
126+ )
102127
103128 private val MiddleSameShapeOutgoing : CornerBasedShape
104129 @Composable get() = DefaultShape .copy(
@@ -189,7 +214,7 @@ private enum class MessageNodeDragAnchors {
189214 DEFAULT , REPLY
190215}
191216
192- @OptIn(ExperimentalFoundationApi ::class )
217+ @OptIn(ExperimentalFoundationApi ::class , ExperimentalMaterialApi :: class )
193218@Composable
194219fun MessageNode (
195220 contents : MessageContent ,
@@ -219,9 +244,8 @@ fun MessageNode(
219244
220245 BoxWithConstraints (modifier = Modifier .fillMaxWidth()) {
221246 val maxWidth = maxWidth
222- val swipeThreshold = with (density) { maxWidth.toPx() } * 0.20f
223- var hasTriggeredTick by remember { mutableStateOf(false ) }
224- var hasFiredReply by remember { mutableStateOf(false ) }
247+ val swipeThreshold = with (density) { maxWidth.toPx() } * 0.40f
248+ var hasReachedThreshold by remember { mutableStateOf(false ) }
225249
226250 val anchors = remember(maxWidth) {
227251 DraggableAnchors {
@@ -234,46 +258,46 @@ fun MessageNode(
234258 AnchoredDraggableState (
235259 initialValue = MessageNodeDragAnchors .DEFAULT ,
236260 anchors = anchors,
237- positionalThreshold = { it * 0.3f },
238- velocityThreshold = { with (density) { 200 .dp.toPx() } },
261+ positionalThreshold = { it * 0.9f },
262+ velocityThreshold = { Float . POSITIVE_INFINITY },
239263 confirmValueChange = { targetValue ->
240- if (targetValue == MessageNodeDragAnchors .REPLY && ! hasFiredReply ) {
241- hasFiredReply = true
242- onReply ()
264+ if (targetValue == MessageNodeDragAnchors .REPLY && ! hasReachedThreshold ) {
265+ hasReachedThreshold = true
266+ vibrator.tick ()
243267 }
244- true
268+ false
245269 },
246- snapAnimationSpec = tween(durationMillis = 400 ),
270+ snapAnimationSpec = spring(
271+ dampingRatio = Spring .DampingRatioNoBouncy ,
272+ stiffness = Spring .StiffnessMediumLow ,
273+ ),
247274 decayAnimationSpec = splineBasedDecay(density)
248275 )
249276 }
250277
251- LaunchedEffect (replyDragState.currentValue) {
252- if (replyDragState.currentValue == MessageNodeDragAnchors .REPLY ) {
253- // Reset drag state to allow future replies
254- delay(200 )
255- try {
256- replyDragState.snapTo(MessageNodeDragAnchors .DEFAULT )
257- println (" Animation completed" )
258- } catch (e: CancellationException ) {
259- println (" Animation canceled: ${e.message} " )
260- }
261-
262- hasFiredReply = false
263- hasTriggeredTick = false
278+ LaunchedEffect (hasReachedThreshold, replyDragState.targetValue) {
279+ if (hasReachedThreshold && replyDragState.targetValue == MessageNodeDragAnchors .DEFAULT && replyDragState.isAnimationRunning) {
280+ onReply()
281+ hasReachedThreshold = false
264282 }
265283 }
266284
267- LaunchedEffect (replyDragState.offset) {
268- if (replyDragState.offset >= swipeThreshold && ! hasTriggeredTick) {
269- hasTriggeredTick = true
270- vibrator.tick()
285+ val nestedScrollConnection = remember {
286+ object : NestedScrollConnection {
287+ override fun onPreScroll (available : Offset , source : NestedScrollSource ): Offset {
288+ // Block vertical scrolling only when horizontal drag is active
289+ return if (replyDragState.offset != 0f ) Offset .Zero else super .onPreScroll(
290+ available,
291+ source
292+ )
293+ }
271294 }
272295 }
273296
274297 Box (
275298 modifier = modifier
276299 .graphicsLayer { compositingStrategy = CompositingStrategy .Offscreen }
300+ .nestedScroll(nestedScrollConnection)
277301 .anchoredDraggable(
278302 state = replyDragState,
279303 enabled = enableReply,
@@ -284,7 +308,13 @@ fun MessageNode(
284308 Box (
285309 modifier = Modifier
286310 .padding(horizontal = CodeTheme .dimens.inset)
287- .offset { IntOffset (x = replyDragState.offset.coerceAtMost(swipeThreshold).roundToInt(), y = 0 ) }
311+ .offset {
312+ IntOffset (
313+ x = replyDragState.offset.coerceAtMost(maxWidth.toPx() * 0.3f )
314+ .roundToInt(),
315+ y = 0
316+ )
317+ }
288318 ) {
289319 val scope = rememberMessageNodeScope(
290320 contents = contents,
@@ -308,8 +338,12 @@ fun MessageNode(
308338
309339 when (contents) {
310340 is MessageContent .Exchange -> {
311- val alignment = if (sender.isSelf) Alignment .CenterEnd else Alignment .CenterStart
312- Box (modifier = modifier.fillMaxWidth(), contentAlignment = alignment) {
341+ val alignment =
342+ if (sender.isSelf) Alignment .CenterEnd else Alignment .CenterStart
343+ Box (
344+ modifier = modifier.fillMaxWidth(),
345+ contentAlignment = alignment
346+ ) {
313347 MessagePayment (
314348 modifier = Modifier
315349 .sizeableWidth()
@@ -340,8 +374,12 @@ fun MessageNode(
340374 }
341375
342376 is MessageContent .SodiumBox -> {
343- val alignment = if (sender.isSelf) Alignment .CenterEnd else Alignment .CenterStart
344- Box (modifier = modifier.fillMaxWidth(), contentAlignment = alignment) {
377+ val alignment =
378+ if (sender.isSelf) Alignment .CenterEnd else Alignment .CenterStart
379+ Box (
380+ modifier = modifier.fillMaxWidth(),
381+ contentAlignment = alignment
382+ ) {
345383 EncryptedContent (
346384 modifier = Modifier
347385 .sizeableWidth()
@@ -438,14 +476,22 @@ fun MessageNode(
438476 }
439477
440478 AnimatedVisibility (
441- visible = replyDragState.offset > 0f ,
479+ visible = replyDragState.offset > with (density) { 5 .dp.toPx() } ,
442480 enter = fadeIn() + scaleIn(),
443481 exit = scaleOut() + fadeOut(),
444- modifier = Modifier .align(Alignment .CenterStart )
445- .offset { IntOffset (x = replyDragState.offset.coerceAtMost(swipeThreshold).roundToInt() - 20 .dp.roundToPx(), y = 0 ) }
482+ modifier = Modifier
483+ .align(Alignment .CenterStart )
484+ .offset {
485+ IntOffset (
486+ x = replyDragState.offset.times(0.2f ).coerceAtMost(20 .dp.toPx())
487+ .roundToInt(), y = 0
488+ )
489+ }
446490 ) {
447491 Icon (
448- modifier = Modifier ,
492+ modifier = Modifier .graphicsLayer {
493+ alpha = (replyDragState.offset.times(0.2f ) / 20 .dp.toPx()).coerceIn(0f , 1f )
494+ },
449495 imageVector = Icons .AutoMirrored .Default .Reply ,
450496 contentDescription = " Swipe to Reply" ,
451497 )
0 commit comments