Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)) } },
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 },
Expand Down
Loading