From 0284c8c86ad276e364498d7f17dbe8b7724965af Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 14:58:08 +0000 Subject: [PATCH] fix: keep swipe-to-reply from stranding the chat bubble off-screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways the bubble could get stuck shifted sideways (dragOffset frozen at a non-zero value, reply icon left visible), both fixed: 1. Cancelling the settle-back animation on awaitFirstDown — which fires for every touch, including one that turns out to be a vertical scroll — while settleBack() only ran on the horizontal-drag path. A scroll started during an in-flight settle froze dragOffset. Now the settle is cancelled only once a horizontal drag is actually committed, so every cancel is paired with a settleBack(). 2. Keying pointerInput on the onSwipeReply lambda. The caller allocates a fresh lambda every recomposition (it captures the note), so the detector was torn down and restarted on every row update. A restart mid-drag cancelled horizontalDrag before settleBack() ran, stranding the bubble; idle restarts also churned the gesture coroutine across all visible bubbles. Now keyed on the stable isLoggedInUser, with the callback read through rememberUpdatedState and the drag wrapped in try/finally so an interrupted drag always settles. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016f7dTu8RoJRXjnHM7FKAE2 --- .../chats/feed/layouts/ChatBubbleLayout.kt | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt index 84fdc03ccf..1ee06c0232 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt @@ -56,6 +56,7 @@ import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -180,6 +181,12 @@ fun ChatBubbleLayout( var dragOffset by remember { mutableFloatStateOf(0f) } var settleJob by remember { mutableStateOf(null) } val swipeScope = rememberCoroutineScope() + // The caller passes a fresh onSwipeReply lambda every recomposition (it captures + // the note), so read it through updated-state and key pointerInput on the stable + // isLoggedInUser instead. Keying on the lambda would tear down and restart the + // detector on every recomposition — stranding an in-flight drag and churning the + // gesture coroutine on every idle row update. + val latestOnSwipeReply by rememberUpdatedState(onSwipeReply) val density = LocalDensity.current val swipeThresholdPx = remember(density) { with(density) { SwipeReplyThreshold.toPx() } } val swipeMaxPx = remember(density) { with(density) { SwipeReplyMaxDrag.toPx() } } @@ -219,7 +226,7 @@ fun ChatBubbleLayout( if (onSwipeReply != null) { Modifier .graphicsLayer { translationX = dragOffset } - .pointerInput(onSwipeReply) { + .pointerInput(isLoggedInUser) { // Drag toward the screen center only; a haptic tick marks the // commit point, releasing past it fires the reply. var crossedThreshold = false @@ -247,8 +254,6 @@ fun ChatBubbleLayout( awaitEachGesture { val down = awaitFirstDown(requireUnconsumed = false) - settleJob?.cancel() - crossedThreshold = false // Claim the pointer only once the motion is clearly more // horizontal than vertical; a mostly-vertical drag leaves the @@ -264,16 +269,30 @@ fun ChatBubbleLayout( } if (drag != null) { + // Take over from any in-flight settle only now that we've + // committed to a horizontal drag. Cancelling earlier (on + // the down) would strand dragOffset when the gesture turns + // out to be a vertical scroll, since settleBack() below + // only runs on this path. + settleJob?.cancel() + crossedThreshold = false applyDrag(overSlop.x) - val completed = - horizontalDrag(drag.id) { change -> - applyDrag(change.positionChange().x) - change.consume() + try { + val completed = + horizontalDrag(drag.id) { change -> + applyDrag(change.positionChange().x) + change.consume() + } + if (completed && abs(dragOffset) >= swipeThresholdPx) { + latestOnSwipeReply?.invoke() } - if (completed && abs(dragOffset) >= swipeThresholdPx) { - onSwipeReply() + } finally { + // Settle even if the drag coroutine is cancelled (e.g. + // the bubble leaves composition mid-swipe); settleBack + // launches on swipeScope, not this pointer coroutine, + // so it still runs while that scope is alive. + settleBack() } - settleBack() } } }