Skip to content

Commit 656a66e

Browse files
authored
fix(chat): polish the quick reaction strip and add double-tap tapback (#1605)
* fix(chat): polish the quick reaction strip and add double-tap tapback The strip's fades were fixed gradients, its emoji sat off-center, and it opened below bubbles near the top of the list. This matches the iOS spec: - Fades follow the scroll position: 20dp on the leading edge, and a trailing fade that ends at the "+", mirrored in RTL. - Emoji line height equals the font size, trimmed and without font padding, so glyphs center in their circles. "+" is 40dp. - End padding is Inset + AddSize + ItemSpacing (52dp). - The strip always opens above the bubble, except for a bubble taller than the viewport. Focusing a reactable message scrolls an extra 55 + 16 + 8dp to make room. - Double-tap presents the strip alone, with no selection bar or actions, on any bubble that can take reactions. Cash bubbles, link cards and reply quotes take double-tap too, so their single tap now waits out the double-tap timeout. Buttons inside cards stay instant. While a message is raised, the composer, the $ button and the items above the oldest message (info/invite card, date) now dim and blur with the transcript; before, they drew over the backdrop. Half sheets now keep the expanded detent once their content has overflowed. The reactors sheet overflowed at half height but fit when expanded, so it lost the detent on expanding and snapped back to half. * fix(chat): play one haptic on a message long-press combinedClickable plays its own long-press haptic by default, and the row's select also calls vibrator.tick(), so each long-press buzzed twice. Turn off the platform haptic on every combinedClickable that reports select: the row, cash bubbles, reply quotes and link cards. * fix(chat): frost the strip's "+" over the emoji scrolling under it The emoji now scroll on under the "+", which blurs them with Haze instead of cutting them off at its edge. The trailing fade runs from the middle of the "+" to 14dp past it, where the 40dp circle is last as tall as a 28dp emoji, and hides everything from there to the strip's end; ending it later let the emoji poke out around the circle's curve. Long-presses that do nothing (the strip's emoji and "+", and the pill row's "N more" and "+") no longer play the platform haptic. A pill's own long-press opens the reactors sheet and keeps it.
1 parent dd25a5b commit 656a66e

17 files changed

Lines changed: 480 additions & 79 deletions

File tree

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt‎

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,11 @@ internal class ChatViewModel @Inject constructor(
281281
* its actions disabled.
282282
*/
283283
val selection: ChatListItem.ContentBubble? = null,
284+
/**
285+
* Whether [selection] was made by a double tap, which presents only the quick reaction strip:
286+
* the lift and backdrop without the selection bar. Only meaningful while [selection] is set.
287+
*/
288+
val reactionStripOnly: Boolean = false,
284289
/** The message the composer is editing, or `null` when it is composing a new one. */
285290
val editing: EditingMessage? = null,
286291
/**
@@ -603,6 +608,12 @@ internal class ChatViewModel @Inject constructor(
603608

604609
/** Selects [bubble], or leaves selection mode if it is already the selected one. */
605610
data class ToggleMessageSelection(val bubble: ChatListItem.ContentBubble) : Event
611+
612+
/**
613+
* Selects [bubble] for the quick reaction strip alone, as a double tap does; nothing when it
614+
* can't take a reaction once narrowed to what is open now.
615+
*/
616+
data class PresentReactionStrip(val bubble: ChatListItem.ContentBubble) : Event
606617
data object ClearMessageSelection : Event
607618

608619
// The message actions carry what they act on rather than reading it back off the selection:
@@ -2657,19 +2668,25 @@ internal class ChatViewModel @Inject constructor(
26572668
// By message rather than by row: a message split around its card is one
26582669
// selection whichever of its rows was pressed.
26592670
val alreadySelected = state.selection?.messageKey == event.bubble.messageKey
2660-
// The transcript resolved this bubble when it was mapped, which may have been
2661-
// well inside a window that has since closed. Narrow it again here so the bar
2662-
// offers what is open now rather than what was open when the row was built.
2663-
// The same goes for membership: a viewer who has left since keeps only what a
2664-
// reader outside the group may do.
2665-
val selected = event.bubble.takeUnless { alreadySelected }?.let { bubble ->
2666-
val open = bubble.capabilities
2667-
.withinWindows(bubble.timestamp, state.messagePolicy)
2668-
bubble.copy(
2669-
capabilities = if (state.isOutsideGroup) open.readOnly() else open,
2670-
)
2671+
// Narrowed again so the bar offers what is open now, not when the row was built.
2672+
val selected = event.bubble.takeUnless { alreadySelected }?.narrowedFor(state)
2673+
state.copy(
2674+
selection = selected,
2675+
reactionStripOnly = false,
2676+
confirmingDelete = false,
2677+
).withQuickReactionStrip()
2678+
}
2679+
is Event.PresentReactionStrip -> { state ->
2680+
val selected = event.bubble.narrowedFor(state).takeIf { it.canReact }
2681+
if (selected == null) {
2682+
state
2683+
} else {
2684+
state.copy(
2685+
selection = selected,
2686+
reactionStripOnly = true,
2687+
confirmingDelete = false,
2688+
).withQuickReactionStrip()
26712689
}
2672-
state.copy(selection = selected, confirmingDelete = false).withQuickReactionStrip()
26732690
}
26742691
Event.ClearMessageSelection -> { state ->
26752692
state.copy(selection = null, confirmingDelete = false).withQuickReactionStrip()
@@ -2753,6 +2770,17 @@ internal class ChatViewModel @Inject constructor(
27532770
}
27542771
}
27552772

2773+
/**
2774+
* This bubble with its capabilities narrowed to what is open now. The transcript resolved it when it
2775+
* was mapped, which may have been well inside a window that has since closed, so a selection offers
2776+
* what is open at the moment it is made. The same goes for membership: a viewer who has left since
2777+
* keeps only what a reader outside the group may do.
2778+
*/
2779+
private fun ChatListItem.ContentBubble.narrowedFor(state: ChatViewModel.State): ChatListItem.ContentBubble {
2780+
val open = capabilities.withinWindows(timestamp, state.messagePolicy)
2781+
return copy(capabilities = if (state.isOutsideGroup) open.readOnly() else open)
2782+
}
2783+
27562784
/**
27572785
* [ChatViewModel.State.quickReactionStrip] rebuilt for the current selection: empty while nothing
27582786
* is selected, while the selected message can't be reacted to, or before the inputs have loaded.

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt‎

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package com.flipcash.app.messenger.internal.screens
22

33
import androidx.activity.compose.BackHandler
4+
import androidx.compose.animation.core.animateDpAsState
5+
import androidx.compose.animation.core.animateFloatAsState
6+
import androidx.compose.foundation.gestures.detectTapGestures
7+
import androidx.compose.foundation.layout.Box
48
import androidx.compose.foundation.layout.fillMaxSize
59
import androidx.compose.foundation.layout.imePadding
610
import androidx.compose.runtime.Composable
@@ -10,6 +14,10 @@ import androidx.compose.runtime.mutableStateOf
1014
import androidx.compose.runtime.remember
1115
import androidx.compose.runtime.setValue
1216
import androidx.compose.ui.Modifier
17+
import androidx.compose.ui.draw.BlurredEdgeTreatment
18+
import androidx.compose.ui.draw.blur
19+
import androidx.compose.ui.graphics.graphicsLayer
20+
import androidx.compose.ui.input.pointer.pointerInput
1321
import androidx.compose.ui.platform.LocalContext
1422
import androidx.compose.ui.platform.LocalUriHandler
1523
import androidx.compose.ui.platform.testTag
@@ -154,6 +162,10 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
154162
viewModel.dispatchEvent(ChatViewModel.Event.ToggleMessageSelection(action.bubble))
155163
}
156164

165+
is ChatAction.PresentReactionStrip -> {
166+
viewModel.dispatchEvent(ChatViewModel.Event.PresentReactionStrip(action.bubble))
167+
}
168+
157169
ChatAction.ClearSelection -> {
158170
viewModel.dispatchEvent(ChatViewModel.Event.ClearMessageSelection)
159171
}
@@ -273,12 +285,43 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
273285
)
274286
},
275287
bottomBar = {
276-
UserControlBottomBar(
277-
state = state,
278-
hazeState = hazeState,
279-
onAction = chatActionHandler,
280-
dispatch = viewModel::dispatchEvent,
288+
// Behind the backdrop with the transcript while a message is raised, dimmed and blurred
289+
// as its rows are, and a tap on it dismisses the selection as a tap on them does. An
290+
// edit raises a message too, but it is the composer doing the editing, so it stays sharp.
291+
val behindBackdrop = state.selection != null && state.editing == null
292+
val dimAlpha by animateFloatAsState(
293+
targetValue = if (behindBackdrop) 0.4f else 1f,
294+
label = "composerDim",
295+
)
296+
val dimBlur by animateDpAsState(
297+
targetValue = if (behindBackdrop) 8.dp else 0.dp,
298+
label = "composerBlur",
281299
)
300+
Box {
301+
Box(
302+
modifier = Modifier
303+
.blur(dimBlur, BlurredEdgeTreatment.Unbounded)
304+
.graphicsLayer { alpha = dimAlpha },
305+
) {
306+
UserControlBottomBar(
307+
state = state,
308+
hazeState = hazeState,
309+
onAction = chatActionHandler,
310+
dispatch = viewModel::dispatchEvent,
311+
)
312+
}
313+
if (behindBackdrop) {
314+
Box(
315+
modifier = Modifier
316+
.matchParentSize()
317+
.pointerInput(Unit) {
318+
detectTapGestures {
319+
viewModel.dispatchEvent(ChatViewModel.Event.ClearMessageSelection)
320+
}
321+
},
322+
)
323+
}
324+
}
282325
},
283326
) { overlapPadding ->
284327
// The transcript and the info card go behind a blur together, because they are one surface

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,8 @@ internal fun ChatTopBar(
9090
// would offer the profile and leave back as the only way out.
9191
val mode: TopBarMode = when {
9292
state.editing != null -> TopBarMode.Editing
93-
state.selection != null -> TopBarMode.Selecting(state.selection)
93+
// A double tap presents the strip alone, over the ordinary title bar.
94+
state.selection != null && !state.reactionStripOnly -> TopBarMode.Selecting(state.selection)
9495
else -> TopBarMode.Conversation
9596
}
9697
AnimatedContent(

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt‎

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.flipcash.app.messenger.internal.screens.components
22

33
import androidx.compose.animation.core.Animatable
4+
import androidx.compose.animation.core.animateDpAsState
5+
import androidx.compose.animation.core.animateFloatAsState
46
import androidx.compose.foundation.gestures.animateScrollBy
57
import androidx.compose.foundation.gestures.detectTapGestures
68
import androidx.compose.foundation.layout.Arrangement
@@ -40,6 +42,9 @@ import androidx.compose.ui.res.stringResource
4042
import androidx.compose.ui.unit.Dp
4143
import androidx.compose.ui.unit.IntRect
4244
import androidx.compose.ui.unit.dp
45+
import androidx.compose.ui.draw.BlurredEdgeTreatment
46+
import androidx.compose.ui.draw.blur
47+
import androidx.compose.ui.graphics.graphicsLayer
4348
import androidx.lifecycle.Lifecycle
4449
import androidx.lifecycle.compose.LocalLifecycleOwner
4550
import androidx.lifecycle.repeatOnLifecycle
@@ -149,16 +154,38 @@ internal fun MessageList(
149154
// The backdrop, read once for everything it covers: the rows, the bubbles' own targets,
150155
// and the contact card at the start of history all stop taking taps together.
151156
val selecting = state.selection != null || state.editing != null
157+
// The items past the oldest message (its date, the info or invite card) are not rows, so
158+
// they take the rows' backdrop dim and blur here; nothing among them can be the focus.
159+
val trailingDimAlpha by animateFloatAsState(
160+
targetValue = if (selecting) 0.4f else 1f,
161+
label = "trailingDim",
162+
)
163+
val trailingDimBlur by animateDpAsState(
164+
targetValue = if (selecting) 8.dp else 0.dp,
165+
label = "trailingBlur",
166+
)
167+
val behindBackdrop = Modifier
168+
.blur(trailingDimBlur, BlurredEdgeTreatment.Unbounded)
169+
.graphicsLayer { alpha = trailingDimAlpha }
152170

153171
// A message can be long-pressed while it is running under the top bar, which the fade
154172
// there makes easy to do — and the bar then swaps to the actions for it, so the row the
155173
// backdrop leaves sharp sits behind the buttons acting on it. Bring it down level with the
156174
// bar's lower edge before that happens. Selecting and then editing is one focus, not two,
157175
// so the key doesn't change across that step and the row isn't scrolled twice.
176+
//
177+
// A message that takes reactions comes down further, far enough for the quick strip to sit
178+
// above it: the strip is always above, and there's always room to make, since the info card
179+
// at the start of history sits over the oldest message.
158180
val focusedMessageId = state.editing?.messageId ?: state.selection?.messageId
181+
val stripRoom = with(LocalDensity.current) {
182+
(QuickReactionStripPlacement.StripHeight + QuickReactionStripPlacement.Gap + STRIP_BAR_GAP)
183+
.roundToPx()
184+
}
159185
LaunchedEffect(focusedMessageId) {
160186
if (focusedMessageId == null) return@LaunchedEffect
161-
val buried = -listState.layoutInfo.headroomAbove(messages, focusedMessageId)
187+
val room = if (state.editing == null && state.selection?.canReact == true) stripRoom else 0
188+
val buried = room - listState.layoutInfo.headroomAbove(messages, focusedMessageId)
162189
if (buried > 0) listState.animateScrollBy(buried.toFloat())
163190
}
164191

@@ -430,7 +457,9 @@ internal fun MessageList(
430457
// rather than fetched.
431458
if (messages.itemCount == 0 && state.obscuresTranscript) {
432459
item(key = "gated-transcript-placeholder") {
433-
GatedTranscriptPlaceholder(modifier = Modifier.fillParentMaxWidth())
460+
GatedTranscriptPlaceholder(
461+
modifier = Modifier.fillParentMaxWidth().then(behindBackdrop),
462+
)
434463
}
435464
}
436465

@@ -449,7 +478,9 @@ internal fun MessageList(
449478
if (oldestTimestamp != null) {
450479
item(key = "trailing-date-${oldestTimestamp.epochSeconds}") {
451480
Box(
452-
modifier = Modifier.padding(bottom = CodeTheme.dimens.grid.x2),
481+
modifier = Modifier
482+
.padding(bottom = CodeTheme.dimens.grid.x2)
483+
.then(behindBackdrop),
453484
) {
454485
DateSeparatorRow(oldestTimestamp)
455486
}
@@ -473,7 +504,7 @@ internal fun MessageList(
473504
)
474505
}
475506
BoxWithConstraints(
476-
modifier = Modifier.fillParentMaxWidth(),
507+
modifier = Modifier.fillParentMaxWidth().then(behindBackdrop),
477508
contentAlignment = Alignment.Center,
478509
) {
479510
// The width a link card gets in a DM transcript, so this reads as the
@@ -498,7 +529,8 @@ internal fun MessageList(
498529
item {
499530
Box(
500531
modifier = Modifier
501-
.fillParentMaxWidth(),
532+
.fillParentMaxWidth()
533+
.then(behindBackdrop),
502534
contentAlignment = Alignment.Center,
503535
) {
504536
ChatInfoCard(

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,16 @@ internal fun MessageRow(
163163
onAction(ChatAction.ToggleSelection(target))
164164
}
165165
}
166+
// A double tap presents the strip alone, as iMessage's tapback does. Hoisted like select: a
167+
// bubble with a tap target of its own -- a cash bubble, a link card, a quote -- reports it too,
168+
// and holds its single tap for the double-tap timeout to tell the two apart. A button inside a
169+
// card is not given it, so it stays instant.
170+
val presentStrip = bubble?.takeIf { it.canReact }?.let { target ->
171+
{
172+
vibrator.tick()
173+
onAction(ChatAction.PresentReactionStrip(target))
174+
}
175+
}
166176

167177
val dimAlpha by animateFloatAsState(
168178
targetValue = if (focused) 1f else 0.4f,
@@ -241,6 +251,10 @@ internal fun MessageRow(
241251
interactionSource = interactionSource,
242252
indication = null,
243253
onLongClick = select,
254+
onDoubleClick = presentStrip,
255+
// select plays the tick; the platform's own long-press haptic on top of
256+
// it is felt as a second buzz.
257+
hapticFeedbackEnabled = false,
244258
// Only reachable with the backdrop down, so the tap has nothing to
245259
// dismiss but the keyboard.
246260
onClick = { keyboard.hide() },
@@ -357,6 +371,7 @@ internal fun MessageRow(
357371
// so the row's long-press never reaches it. Handing it the
358372
// same gesture is what makes a cash bubble selectable.
359373
onLongClick = select,
374+
onDoubleClick = presentStrip,
360375
position = bubblePositionOf(
361376
index,
362377
item,
@@ -561,7 +576,8 @@ private fun senderNameInset(showsGutter: Boolean): Dp =
561576
CodeTheme.dimens.grid.x1
562577
}
563578

564-
private val STRIP_BAR_GAP = 8.dp
579+
/** Between the top bar and the highest the quick strip goes. */
580+
internal val STRIP_BAR_GAP = 8.dp
565581

566582
private val AFFORDANCE_SIZE = 32.dp
567583
private val AFFORDANCE_INSET = 20.dp

‎apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReactionsReducerTest.kt‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import com.flipcash.shared.chat.reactions.ReactionStrip
77
import com.flipcash.shared.chat.reactions.SelfReaction
88
import kotlin.test.Test
99
import kotlin.test.assertEquals
10+
import kotlin.test.assertFalse
1011
import kotlin.test.assertTrue
1112
import kotlin.time.Instant
1213

@@ -114,4 +115,39 @@ class ChatReactionsReducerTest {
114115

115116
assertEquals(selected, state)
116117
}
118+
119+
@Test
120+
fun `a double tap selects the bubble for the strip alone`() {
121+
val state = reduce(
122+
ChatViewModel.State(messagePolicy = unbounded, quickReactionInputs = inputs),
123+
ChatViewModel.Event.PresentReactionStrip(bubble(1)),
124+
)
125+
126+
assertEquals(1L, state.selection?.messageId)
127+
assertTrue(state.reactionStripOnly)
128+
assertTrue(state.quickReactionStrip.isNotEmpty())
129+
}
130+
131+
@Test
132+
fun `a double tap on a message that can't be reacted to does nothing`() {
133+
val before = ChatViewModel.State(messagePolicy = unbounded, quickReactionInputs = inputs)
134+
135+
val state = reduce(before, ChatViewModel.Event.PresentReactionStrip(bubble(1, canReact = false)))
136+
137+
assertEquals(before, state)
138+
}
139+
140+
@Test
141+
fun `a long-press after a double tap brings the selection bar back`() {
142+
val stripOnly = reduce(
143+
ChatViewModel.State(messagePolicy = unbounded, quickReactionInputs = inputs),
144+
ChatViewModel.Event.PresentReactionStrip(bubble(1)),
145+
)
146+
val cleared = reduce(stripOnly, ChatViewModel.Event.ClearMessageSelection)
147+
148+
val selected = reduce(cleared, ChatViewModel.Event.ToggleMessageSelection(bubble(1)))
149+
150+
assertEquals(1L, selected.selection?.messageId)
151+
assertFalse(selected.reactionStripOnly)
152+
}
117153
}

‎apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ sealed interface ChatAction {
6868
*/
6969
data class ToggleSelection(val bubble: ChatListItem.ContentBubble) : ChatAction
7070

71+
/**
72+
* Presents only the quick reaction strip over [bubble], as a double tap does: the lift and
73+
* backdrop a long-press gives, with no selection bar or actions. Dismissed like a selection.
74+
*/
75+
data class PresentReactionStrip(val bubble: ChatListItem.ContentBubble) : ChatAction
76+
7177
/** Leaves selection mode without acting on anything. */
7278
data object ClearSelection : ChatAction
7379

‎apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ fun ChatQuotePanel(
4747
modifier: Modifier = Modifier,
4848
onClick: (() -> Unit)? = null,
4949
onLongClick: (() -> Unit)? = null,
50+
onDoubleClick: (() -> Unit)? = null,
5051
) {
5152
val accent = quote.accent ?: CodeTheme.colors.tertiary
5253
val name = quote.nameAccent ?: accent
@@ -64,7 +65,10 @@ fun ChatQuotePanel(
6465
.addIf(onClick != null || onLongClick != null) {
6566
Modifier.combinedClickable(
6667
onLongClick = onLongClick,
68+
onDoubleClick = onDoubleClick,
6769
onClick = { onClick?.invoke() },
70+
// The row's select plays the tick; a second, platform haptic doubles it.
71+
hapticFeedbackEnabled = false,
6872
)
6973
}
7074
.height(IntrinsicSize.Min),

0 commit comments

Comments
 (0)