From c8fa16e2ec4b4120b6a7d726b758bb00075e7c55 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 16:16:43 -0400 Subject: [PATCH] fix(chat): arm the reply swipe only where the bubble is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The avatar column was already inert, but the rule was keyed off that column being reserved, which only happens on an incoming row of a group. Every outgoing row still armed from its leading edge, where the bubble is aligned to the far side and there is nothing under the finger — in a DM and in a group alike. `replyGutterFor` derives the strip from where the bubble sits instead: inert unless the bubble reaches the leading edge, which is true for exactly one kind of row, an incoming message in a DM. A drag that starts on a bubble still always replies. The strip keeps the avatar column's 30dp and cannot reach an outgoing bubble, which stops at 0.78 of the row width; the only content that fills the row edge to edge is a system notice, and `resolveCapabilities` returns an empty set for those. --- .../internal/screens/components/MessageRow.kt | 16 +++++----- .../screens/components/SwipeToReply.kt | 30 +++++++++++++++---- .../messenger/internal/SwipeToReplyTest.kt | 28 +++++++++++++++++ 3 files changed, 60 insertions(+), 14 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt index bd305c6eab..4028409619 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt @@ -158,14 +158,14 @@ internal fun MessageRow( enabled = bubble != null && !selecting && MessageCapability.Reply in bubble.capabilities, - // The avatar column is the target for the person, not for the message: a drag that starts - // on someone's picture is reaching for them, and the picture is small enough that arming a - // reply from it would mostly catch fingers that missed the tap. - senderGutter = if (showsSenderGutter && bubble?.isFromSelf == false) { - CodeTheme.dimens.staticGrid.x6 - } else { - 0.dp - }, + // A drag arms on the message, not beside it: on someone's avatar it is reaching for them, + // and on the empty side of an outgoing row it is reaching for nothing. The one row that + // leads with the bubble itself — an incoming message in a DM — keeps its leading edge live. + leadingGutter = replyGutterFor( + width = CodeTheme.dimens.staticGrid.x6, + isFromSelf = bubble?.isFromSelf == true, + showsSenderGutter = showsSenderGutter, + ), onReply = { bubble?.let { onAction(ChatAction.ReplyTo(it)) } }, ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SwipeToReply.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SwipeToReply.kt index 9ba8ed09fc..37f941fac2 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SwipeToReply.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SwipeToReply.kt @@ -36,9 +36,9 @@ import kotlin.math.roundToInt * it holds at the end of its own travel, because it marks where the gesture fires and there is * nothing further along to mark. * - * [senderGutter] is the width of the avatar column at the row's leading edge, if the row reserves - * one. A drag that starts inside it is inert — that column is the target for the person, not for - * the message, and a row that opened there would promise a reply it is not going to send. + * [leadingGutter] is the strip at the row's leading edge that the bubble does not reach — see + * [replyGutterFor]. A drag that starts inside it is inert: a row that opened from a point that is + * not the message would promise a reply it is not going to send. * * The distances are iOS's, in absolute units rather than the fractions of screen width this was * ported with. They have to be: [SwipeToReplyState.progress] is the fraction of the trigger @@ -49,7 +49,7 @@ import kotlin.math.roundToInt @Composable internal fun rememberSwipeToReply( enabled: Boolean, - senderGutter: Dp, + leadingGutter: Dp, onReply: () -> Unit, ): SwipeToReplyState { val density = LocalDensity.current @@ -58,7 +58,7 @@ internal fun rememberSwipeToReply( val maxPx = with(density) { MAX_TRANSLATION.toPx() } val triggerPx = with(density) { TRIGGER_THRESHOLD.toPx() } - val gutterPx = with(density) { senderGutter.toPx() } + val gutterPx = with(density) { leadingGutter.toPx() } val reply by rememberUpdatedState(onReply) @@ -128,6 +128,24 @@ internal fun rememberSwipeToReply( } } +/** + * The strip at a row's leading edge that a reply drag may not start in: everything ahead of the + * bubble, and nothing of the bubble itself. + * + * Only one kind of row has its bubble hard against the leading edge — an incoming message in a DM, + * which reserves no avatar column and is not pushed inward by one. Every other row leads with + * something that is not the message: the avatar column on an incoming row of a group, which is the + * target for the person rather than for what they said, and empty transcript on an outgoing row, + * where the bubble is aligned to the far side. + * + * [width] is that one avatar column's width, reused as the outgoing strip so both read the same. It + * cannot eat into an outgoing bubble: a bubble stops at 0.78 of the row, which leaves far more than + * this to its leading side at every width the app runs at. The one content that fills the row is a + * system notice, and those carry no capabilities at all, so no drag on one was ever going to reply. + */ +internal fun replyGutterFor(width: Dp, isFromSelf: Boolean, showsSenderGutter: Boolean): Dp = + if (!isFromSelf && !showsSenderGutter) 0.dp else width + /** * What a row needs from the gesture: the modifier that carries it, and how far it has travelled, so * the row can draw the affordance the drag is uncovering. @@ -161,7 +179,7 @@ internal class SwipeToReplyState( * for no visible change. */ private class DragLatch { - /** Whether this drag may reply at all — false for one that started in the sender gutter. */ + /** Whether this drag may reply at all — false for one that started in the leading gutter. */ var armed = false /** Whether the threshold haptic has fired. Latched for the rest of the drag, so a finger held diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/SwipeToReplyTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/SwipeToReplyTest.kt index 90ab107036..abbbdd60b8 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/SwipeToReplyTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/SwipeToReplyTest.kt @@ -1,7 +1,9 @@ package com.flipcash.app.messenger.internal import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import com.flipcash.app.messenger.internal.screens.components.SwipeToReplyState +import com.flipcash.app.messenger.internal.screens.components.replyGutterFor import com.flipcash.app.messenger.internal.screens.components.resist import org.junit.Test import kotlin.test.assertEquals @@ -13,11 +15,15 @@ import kotlin.test.assertTrue * * Mirrors iOS's `ChatSwipeToReplyTests`. The two implementations share these numbers, so a change * here is a change to the gesture on both platforms. + * + * Also where the row arms from, which is a layout question rather than a shared one: the gesture + * starts on the bubble and nowhere else. */ class SwipeToReplyTest { private val max = 64f private val trigger = 48f + private val gutter = 30.dp private val tolerance = 0.01f @Test @@ -63,6 +69,28 @@ class SwipeToReplyTest { assertEquals(1f, stateAt(max).progress(), tolerance) } + @Test + fun `a DM's incoming row arms from its own leading edge`() { + assertEquals( + 0.dp, + replyGutterFor(gutter, isFromSelf = false, showsSenderGutter = false), + ) + } + + @Test + fun `a group's incoming row does not arm from the avatar column`() { + assertEquals( + gutter, + replyGutterFor(gutter, isFromSelf = false, showsSenderGutter = true), + ) + } + + @Test + fun `an outgoing row does not arm from the empty side it is aligned away from`() { + assertEquals(gutter, replyGutterFor(gutter, isFromSelf = true, showsSenderGutter = false)) + assertEquals(gutter, replyGutterFor(gutter, isFromSelf = true, showsSenderGutter = true)) + } + private fun stateAt(offset: Float) = SwipeToReplyState( modifier = Modifier, offsetPx = { offset },