From ad5474d3880d4ed045147a5cfa6330598e4c2868 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 16:35:48 -0400 Subject: [PATCH 1/3] 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. --- .../app/messenger/internal/ChatViewModel.kt | 52 +++++-- .../internal/screens/MessengerScreen.kt | 53 ++++++- .../internal/screens/components/ChatTopBar.kt | 3 +- .../screens/components/MessageList.kt | 42 +++++- .../internal/screens/components/MessageRow.kt | 15 +- .../internal/ChatReactionsReducerTest.kt | 36 +++++ .../flipcash/shared/chat/models/ChatAction.kt | 6 + .../flipcash/shared/chat/ui/ChatQuotePanel.kt | 2 + .../flipcash/shared/chat/ui/LinkCardView.kt | 12 +- .../flipcash/shared/chat/ui/MessageBubble.kt | 16 +++ .../shared/chat/ui/QuickReactionStrip.kt | 132 ++++++++++++++---- .../flipcash/shared/chat/ui/UserLinkCard.kt | 20 ++- .../ui/QuickReactionStripPlacementTest.kt | 33 ++++- .../shared/chat/ui/QuickReactionStripTest.kt | 62 ++++++++ .../scenes/ModalBottomSheetSceneStrategy.kt | 11 +- .../com/getcode/ui/utils/SheetExpansion.kt | 10 +- 16 files changed, 430 insertions(+), 75 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 12c72be038..e09642c9fa 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -281,6 +281,11 @@ internal class ChatViewModel @Inject constructor( * its actions disabled. */ val selection: ChatListItem.ContentBubble? = null, + /** + * Whether [selection] was made by a double tap, which presents only the quick reaction strip: + * the lift and backdrop without the selection bar. Only meaningful while [selection] is set. + */ + val reactionStripOnly: Boolean = false, /** The message the composer is editing, or `null` when it is composing a new one. */ val editing: EditingMessage? = null, /** @@ -603,6 +608,12 @@ internal class ChatViewModel @Inject constructor( /** Selects [bubble], or leaves selection mode if it is already the selected one. */ data class ToggleMessageSelection(val bubble: ChatListItem.ContentBubble) : Event + + /** + * Selects [bubble] for the quick reaction strip alone, as a double tap does; nothing when it + * can't take a reaction once narrowed to what is open now. + */ + data class PresentReactionStrip(val bubble: ChatListItem.ContentBubble) : Event data object ClearMessageSelection : Event // 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( // By message rather than by row: a message split around its card is one // selection whichever of its rows was pressed. val alreadySelected = state.selection?.messageKey == event.bubble.messageKey - // The transcript resolved this bubble when it was mapped, which may have been - // well inside a window that has since closed. Narrow it again here so the bar - // offers what is open now rather than what was open when the row was built. - // The same goes for membership: a viewer who has left since keeps only what a - // reader outside the group may do. - val selected = event.bubble.takeUnless { alreadySelected }?.let { bubble -> - val open = bubble.capabilities - .withinWindows(bubble.timestamp, state.messagePolicy) - bubble.copy( - capabilities = if (state.isOutsideGroup) open.readOnly() else open, - ) + // Narrowed again so the bar offers what is open now, not when the row was built. + val selected = event.bubble.takeUnless { alreadySelected }?.narrowedFor(state) + state.copy( + selection = selected, + reactionStripOnly = false, + confirmingDelete = false, + ).withQuickReactionStrip() + } + is Event.PresentReactionStrip -> { state -> + val selected = event.bubble.narrowedFor(state).takeIf { it.canReact } + if (selected == null) { + state + } else { + state.copy( + selection = selected, + reactionStripOnly = true, + confirmingDelete = false, + ).withQuickReactionStrip() } - state.copy(selection = selected, confirmingDelete = false).withQuickReactionStrip() } Event.ClearMessageSelection -> { state -> state.copy(selection = null, confirmingDelete = false).withQuickReactionStrip() @@ -2753,6 +2770,17 @@ internal class ChatViewModel @Inject constructor( } } +/** + * This bubble with its capabilities narrowed to what is open now. The transcript resolved it when it + * was mapped, which may have been well inside a window that has since closed, so a selection offers + * what is open at the moment it is made. The same goes for membership: a viewer who has left since + * keeps only what a reader outside the group may do. + */ +private fun ChatListItem.ContentBubble.narrowedFor(state: ChatViewModel.State): ChatListItem.ContentBubble { + val open = capabilities.withinWindows(timestamp, state.messagePolicy) + return copy(capabilities = if (state.isOutsideGroup) open.readOnly() else open) +} + /** * [ChatViewModel.State.quickReactionStrip] rebuilt for the current selection: empty while nothing * is selected, while the selected message can't be reacted to, or before the inputs have loaded. diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 756d7cccd3..a567961c54 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -1,6 +1,10 @@ package com.flipcash.app.messenger.internal.screens import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding import androidx.compose.runtime.Composable @@ -10,6 +14,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.testTag @@ -154,6 +162,10 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { viewModel.dispatchEvent(ChatViewModel.Event.ToggleMessageSelection(action.bubble)) } + is ChatAction.PresentReactionStrip -> { + viewModel.dispatchEvent(ChatViewModel.Event.PresentReactionStrip(action.bubble)) + } + ChatAction.ClearSelection -> { viewModel.dispatchEvent(ChatViewModel.Event.ClearMessageSelection) } @@ -273,12 +285,43 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { ) }, bottomBar = { - UserControlBottomBar( - state = state, - hazeState = hazeState, - onAction = chatActionHandler, - dispatch = viewModel::dispatchEvent, + // Behind the backdrop with the transcript while a message is raised, dimmed and blurred + // as its rows are, and a tap on it dismisses the selection as a tap on them does. An + // edit raises a message too, but it is the composer doing the editing, so it stays sharp. + val behindBackdrop = state.selection != null && state.editing == null + val dimAlpha by animateFloatAsState( + targetValue = if (behindBackdrop) 0.4f else 1f, + label = "composerDim", + ) + val dimBlur by animateDpAsState( + targetValue = if (behindBackdrop) 8.dp else 0.dp, + label = "composerBlur", ) + Box { + Box( + modifier = Modifier + .blur(dimBlur, BlurredEdgeTreatment.Unbounded) + .graphicsLayer { alpha = dimAlpha }, + ) { + UserControlBottomBar( + state = state, + hazeState = hazeState, + onAction = chatActionHandler, + dispatch = viewModel::dispatchEvent, + ) + } + if (behindBackdrop) { + Box( + modifier = Modifier + .matchParentSize() + .pointerInput(Unit) { + detectTapGestures { + viewModel.dispatchEvent(ChatViewModel.Event.ClearMessageSelection) + } + }, + ) + } + } }, ) { overlapPadding -> // The transcript and the info card go behind a blur together, because they are one surface diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt index e892c621e3..f3ccfe8928 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt @@ -89,7 +89,8 @@ internal fun ChatTopBar( // would offer the profile and leave back as the only way out. val mode: TopBarMode = when { state.editing != null -> TopBarMode.Editing - state.selection != null -> TopBarMode.Selecting(state.selection) + // A double tap presents the strip alone, over the ordinary title bar. + state.selection != null && !state.reactionStripOnly -> TopBarMode.Selecting(state.selection) else -> TopBarMode.Conversation } AnimatedContent( diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index 34a1796938..0527d2def6 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -1,6 +1,8 @@ package com.flipcash.app.messenger.internal.screens.components import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement @@ -40,6 +42,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.dp +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +import androidx.compose.ui.graphics.graphicsLayer import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.repeatOnLifecycle @@ -149,16 +154,38 @@ internal fun MessageList( // The backdrop, read once for everything it covers: the rows, the bubbles' own targets, // and the contact card at the start of history all stop taking taps together. val selecting = state.selection != null || state.editing != null + // The items past the oldest message (its date, the info or invite card) are not rows, so + // they take the rows' backdrop dim and blur here; nothing among them can be the focus. + val trailingDimAlpha by animateFloatAsState( + targetValue = if (selecting) 0.4f else 1f, + label = "trailingDim", + ) + val trailingDimBlur by animateDpAsState( + targetValue = if (selecting) 8.dp else 0.dp, + label = "trailingBlur", + ) + val behindBackdrop = Modifier + .blur(trailingDimBlur, BlurredEdgeTreatment.Unbounded) + .graphicsLayer { alpha = trailingDimAlpha } // A message can be long-pressed while it is running under the top bar, which the fade // there makes easy to do — and the bar then swaps to the actions for it, so the row the // backdrop leaves sharp sits behind the buttons acting on it. Bring it down level with the // bar's lower edge before that happens. Selecting and then editing is one focus, not two, // so the key doesn't change across that step and the row isn't scrolled twice. + // + // A message that takes reactions comes down further, far enough for the quick strip to sit + // above it: the strip is always above, and there's always room to make, since the info card + // at the start of history sits over the oldest message. val focusedMessageId = state.editing?.messageId ?: state.selection?.messageId + val stripRoom = with(LocalDensity.current) { + (QuickReactionStripPlacement.StripHeight + QuickReactionStripPlacement.Gap + STRIP_BAR_GAP) + .roundToPx() + } LaunchedEffect(focusedMessageId) { if (focusedMessageId == null) return@LaunchedEffect - val buried = -listState.layoutInfo.headroomAbove(messages, focusedMessageId) + val room = if (state.editing == null && state.selection?.canReact == true) stripRoom else 0 + val buried = room - listState.layoutInfo.headroomAbove(messages, focusedMessageId) if (buried > 0) listState.animateScrollBy(buried.toFloat()) } @@ -430,7 +457,9 @@ internal fun MessageList( // rather than fetched. if (messages.itemCount == 0 && state.obscuresTranscript) { item(key = "gated-transcript-placeholder") { - GatedTranscriptPlaceholder(modifier = Modifier.fillParentMaxWidth()) + GatedTranscriptPlaceholder( + modifier = Modifier.fillParentMaxWidth().then(behindBackdrop), + ) } } @@ -449,7 +478,9 @@ internal fun MessageList( if (oldestTimestamp != null) { item(key = "trailing-date-${oldestTimestamp.epochSeconds}") { Box( - modifier = Modifier.padding(bottom = CodeTheme.dimens.grid.x2), + modifier = Modifier + .padding(bottom = CodeTheme.dimens.grid.x2) + .then(behindBackdrop), ) { DateSeparatorRow(oldestTimestamp) } @@ -473,7 +504,7 @@ internal fun MessageList( ) } BoxWithConstraints( - modifier = Modifier.fillParentMaxWidth(), + modifier = Modifier.fillParentMaxWidth().then(behindBackdrop), contentAlignment = Alignment.Center, ) { // The width a link card gets in a DM transcript, so this reads as the @@ -498,7 +529,8 @@ internal fun MessageList( item { Box( modifier = Modifier - .fillParentMaxWidth(), + .fillParentMaxWidth() + .then(behindBackdrop), contentAlignment = Alignment.Center, ) { ChatInfoCard( 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 78d731440d..096e3a898d 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 @@ -163,6 +163,16 @@ internal fun MessageRow( onAction(ChatAction.ToggleSelection(target)) } } + // A double tap presents the strip alone, as iMessage's tapback does. Hoisted like select: a + // bubble with a tap target of its own -- a cash bubble, a link card, a quote -- reports it too, + // and holds its single tap for the double-tap timeout to tell the two apart. A button inside a + // card is not given it, so it stays instant. + val presentStrip = bubble?.takeIf { it.canReact }?.let { target -> + { + vibrator.tick() + onAction(ChatAction.PresentReactionStrip(target)) + } + } val dimAlpha by animateFloatAsState( targetValue = if (focused) 1f else 0.4f, @@ -241,6 +251,7 @@ internal fun MessageRow( interactionSource = interactionSource, indication = null, onLongClick = select, + onDoubleClick = presentStrip, // Only reachable with the backdrop down, so the tap has nothing to // dismiss but the keyboard. onClick = { keyboard.hide() }, @@ -357,6 +368,7 @@ internal fun MessageRow( // so the row's long-press never reaches it. Handing it the // same gesture is what makes a cash bubble selectable. onLongClick = select, + onDoubleClick = presentStrip, position = bubblePositionOf( index, item, @@ -561,7 +573,8 @@ private fun senderNameInset(showsGutter: Boolean): Dp = CodeTheme.dimens.grid.x1 } -private val STRIP_BAR_GAP = 8.dp +/** Between the top bar and the highest the quick strip goes. */ +internal val STRIP_BAR_GAP = 8.dp private val AFFORDANCE_SIZE = 32.dp private val AFFORDANCE_INSET = 20.dp diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReactionsReducerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReactionsReducerTest.kt index 7a84afc789..e1ab6feaf3 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReactionsReducerTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReactionsReducerTest.kt @@ -7,6 +7,7 @@ import com.flipcash.shared.chat.reactions.ReactionStrip import com.flipcash.shared.chat.reactions.SelfReaction import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.time.Instant @@ -114,4 +115,39 @@ class ChatReactionsReducerTest { assertEquals(selected, state) } + + @Test + fun `a double tap selects the bubble for the strip alone`() { + val state = reduce( + ChatViewModel.State(messagePolicy = unbounded, quickReactionInputs = inputs), + ChatViewModel.Event.PresentReactionStrip(bubble(1)), + ) + + assertEquals(1L, state.selection?.messageId) + assertTrue(state.reactionStripOnly) + assertTrue(state.quickReactionStrip.isNotEmpty()) + } + + @Test + fun `a double tap on a message that can't be reacted to does nothing`() { + val before = ChatViewModel.State(messagePolicy = unbounded, quickReactionInputs = inputs) + + val state = reduce(before, ChatViewModel.Event.PresentReactionStrip(bubble(1, canReact = false))) + + assertEquals(before, state) + } + + @Test + fun `a long-press after a double tap brings the selection bar back`() { + val stripOnly = reduce( + ChatViewModel.State(messagePolicy = unbounded, quickReactionInputs = inputs), + ChatViewModel.Event.PresentReactionStrip(bubble(1)), + ) + val cleared = reduce(stripOnly, ChatViewModel.Event.ClearMessageSelection) + + val selected = reduce(cleared, ChatViewModel.Event.ToggleMessageSelection(bubble(1))) + + assertEquals(1L, selected.selection?.messageId) + assertFalse(selected.reactionStripOnly) + } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt index a461f7dc55..c54da580f9 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt @@ -68,6 +68,12 @@ sealed interface ChatAction { */ data class ToggleSelection(val bubble: ChatListItem.ContentBubble) : ChatAction + /** + * Presents only the quick reaction strip over [bubble], as a double tap does: the lift and + * backdrop a long-press gives, with no selection bar or actions. Dismissed like a selection. + */ + data class PresentReactionStrip(val bubble: ChatListItem.ContentBubble) : ChatAction + /** Leaves selection mode without acting on anything. */ data object ClearSelection : ChatAction diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt index 9d114931db..ec7430c0d3 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt @@ -47,6 +47,7 @@ fun ChatQuotePanel( modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null, + onDoubleClick: (() -> Unit)? = null, ) { val accent = quote.accent ?: CodeTheme.colors.tertiary val name = quote.nameAccent ?: accent @@ -64,6 +65,7 @@ fun ChatQuotePanel( .addIf(onClick != null || onLongClick != null) { Modifier.combinedClickable( onLongClick = onLongClick, + onDoubleClick = onDoubleClick, onClick = { onClick?.invoke() }, ) } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt index 4a36cfacff..69c814bebb 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt @@ -100,6 +100,7 @@ internal fun LinkCardView( onClick: ((LinkCard) -> Unit)?, modifier: Modifier = Modifier, onLongClick: (() -> Unit)? = null, + onDoubleClick: (() -> Unit)? = null, ) { val live = rememberResolvedCard(card) @@ -111,6 +112,7 @@ internal fun LinkCardView( modifier = modifier.semantics { linkCardShape = shape }, shape = shape, onLongClick = onLongClick, + onDoubleClick = onDoubleClick, ) return } @@ -122,9 +124,12 @@ internal fun LinkCardView( modifier = modifier .fillMaxWidth() .semantics { linkCardShape = shape } - .addIf(onLongClick != null) { - Modifier.pointerInput(onLongClick) { - detectTapGestures(onLongPress = { onLongClick?.invoke() }) + .addIf(onLongClick != null || onDoubleClick != null) { + Modifier.pointerInput(onLongClick, onDoubleClick) { + detectTapGestures( + onLongPress = { onLongClick?.invoke() }, + onDoubleTap = onDoubleClick?.let { double -> { double() } }, + ) } }, ) { @@ -155,6 +160,7 @@ internal fun LinkCardView( .clip(shape) .combinedClickable( onLongClick = onLongClick, + onDoubleClick = onDoubleClick, onClick = { onClick?.invoke(live) }, ) }, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index d0ab027db5..cd890ce93b 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -126,6 +126,7 @@ fun ContentBubble( modifier: Modifier = Modifier, interactive: Boolean = true, onLongClick: (() -> Unit)? = null, + onDoubleClick: (() -> Unit)? = null, attention: () -> Float = { 0f }, ) { val actionHandler = LocalChatActionHandler.current @@ -163,6 +164,7 @@ fun ContentBubble( onQuoteClick = onQuoteClick, onQuoteLongClick = onLongClick?.takeIf { interactive }, onLongClick = onLongClick?.takeIf { interactive }, + onDoubleClick = onDoubleClick?.takeIf { interactive }, interactive = interactive, attention = attention, ) @@ -219,6 +221,7 @@ fun ContentBubble( // Gated with the tap, and for the same reason: behind the backdrop the bar is // already acting on a message, and the row drops its own gestures there too. onLongClick = onLongClick?.takeIf { interactive }, + onDoubleClick = onDoubleClick?.takeIf { interactive }, attention = attention, ) @@ -237,6 +240,7 @@ fun ContentBubble( // should dismiss the backdrop, not jump the transcript out from under it. onQuoteClick = onQuoteClick, onQuoteLongClick = onLongClick?.takeIf { interactive }, + onQuoteDoubleClick = onDoubleClick?.takeIf { interactive }, jumbo = jumbo, attention = attention, ) @@ -354,6 +358,7 @@ private fun TextBubble( quote: ChatQuote? = null, onQuoteClick: (() -> Unit)? = null, onQuoteLongClick: (() -> Unit)? = null, + onQuoteDoubleClick: (() -> Unit)? = null, jumbo: Boolean = false, attention: () -> Float = { 0f }, ) { @@ -468,6 +473,7 @@ private fun TextBubble( quote = quote, onClick = onQuoteClick, onLongClick = onQuoteLongClick, + onDoubleClick = onQuoteDoubleClick, // Tagged because the citation repeats the quoted message's own text, so a // UI test matching on that text cannot tell the two apart. modifier = Modifier.testTag(REPLY_QUOTE_TAG), @@ -567,7 +573,9 @@ private fun BareLinkCard( quote: ChatQuote? = null, onQuoteClick: (() -> Unit)? = null, onQuoteLongClick: (() -> Unit)? = null, + onQuoteDoubleClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null, + onDoubleClick: (() -> Unit)? = null, interactive: Boolean = true, attention: () -> Float = { 0f }, ) { @@ -600,6 +608,7 @@ private fun BareLinkCard( quote = quote, onClick = onQuoteClick, onLongClick = onQuoteLongClick, + onDoubleClick = onDoubleClick, modifier = Modifier.testTag(REPLY_QUOTE_TAG), ) } @@ -612,6 +621,7 @@ private fun BareLinkCard( // The card takes the press for its own tap, so it has to hand the transcript's // selection gesture back or a long press on it would select nothing. onLongClick = onLongClick, + onDoubleClick = onDoubleClick, ) } } @@ -662,6 +672,7 @@ private fun CashBubble( action: MessageContent.Cash.Action = MessageContent.Cash.Action.SENT, onClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null, + onDoubleClick: (() -> Unit)? = null, modifier: Modifier = Modifier, attention: () -> Float = { 0f }, ) { @@ -672,6 +683,7 @@ private fun CashBubble( maxWidth = maxWidth, onClick = onClick, onLongClick = onLongClick, + onDoubleClick = onDoubleClick, modifier = modifier, attention = attention, ) { @@ -801,6 +813,7 @@ private fun Bubble( minWidth: Dp = 0.dp, onClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null, + onDoubleClick: (() -> Unit)? = null, horizontalPadding: Dp = BubbleDefaults.paddingHorizontal, verticalPadding: Dp = BubbleDefaults.paddingVertical, bare: Boolean = false, @@ -840,8 +853,11 @@ private fun Bubble( .addIf(onClick != null || onLongClick != null) { // combinedClickable rather than two modifiers: a bubble that takes the tap takes // the long press with it, so both gestures are reported from the same target. + // A double tap here waits out the timeout before a single tap acts: that delay is + // the price of the tapback on a bubble whose tap already opens something. Modifier.clip(shape).combinedClickable( onLongClick = onLongClick, + onDoubleClick = onDoubleClick, onClick = { onClick?.invoke() }, ) } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt index e1ef1a3414..c10ce2d560 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt @@ -51,7 +51,10 @@ import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupProperties @@ -62,15 +65,18 @@ import androidx.compose.ui.res.stringResource import com.flipcash.core.R // Sizes from the iOS strip (node 9779:105563). -private val StripHeight = 55.dp +private val StripHeight = QuickReactionStripPlacement.StripHeight private val StripMaxWidth = 313.dp private val ItemSize = 40.dp private val ItemSpacing = 4.dp private val Inset = 8.dp -private val AddSize = 38.dp +private val AddSize = ItemSize -/** How far the fade reaches ahead of the "+" before the row draws fully. */ -private val FadeLead = 28.dp +/** + * How far each edge fade reaches: in from the leading edge while the row can scroll back, and up to + * the "+" while it can scroll on. Either side draws solid when there's nothing more that way. + */ +private val EdgeFade = 20.dp /** Gap between one emoji starting to pop in and the next, as in the iOS strip's entrance. */ private const val ItemStaggerMillis = 18L @@ -89,8 +95,9 @@ private val StripSurface = Color(0xFF303030) * A tap on an entry both toggles the reaction and clears the selection — [onToggle] is the one * signal for both. * - * The capsule grows to fit its entries up to 313dp; past that the emoji scroll under the "+", - * fading out ahead of it. + * The capsule grows to fit its entries up to 313dp; past that the emoji scroll under the "+". Each + * end fades only while there's more to scroll that way, growing in over the first [EdgeFade] of + * travel so the fade doesn't snap on at the first pixel. * * It enters as iOS's does: the capsule widens out of the side it hugs ([growsFromEnd] for the * trailing side), the "+" riding its growing edge, and the emoji pop in one after another from @@ -142,31 +149,40 @@ fun QuickReactionStrip( contentAlignment = Alignment.CenterEnd, ) { val density = LocalDensity.current + val scroll = rememberScrollState() Row( modifier = Modifier .fillMaxHeight() .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } .drawWithContent { drawContent() - val width = size.width - val inset = Inset.toPx() - val solidUntil = width - inset - AddSize.toPx() - FadeLead.toPx() - val clearFrom = width - inset - AddSize.toPx() / 2 - drawRect( - brush = Brush.horizontalGradient( - 0f to Color.Transparent, - (inset / width) to Color.Black, - (solidUntil / width).coerceAtLeast(inset / width) to Color.Black, - (clearFrom / width) to Color.Transparent, - 1f to Color.Transparent, - ), - blendMode = BlendMode.DstIn, + val fade = EdgeFade.toPx() + val stops = edgeFadeStops( + width = size.width, + fade = fade, + plusStart = size.width - Inset.toPx() - AddSize.toPx(), + leading = if (scroll.canScrollBackward) (scroll.value / fade).coerceIn(0f, 1f) else 0f, + trailing = if (scroll.canScrollForward) { + ((scroll.maxValue - scroll.value) / fade).coerceIn(0f, 1f) + } else { + 0f + }, + rtl = layoutDirection == LayoutDirection.Rtl, ) + if (stops.isNotEmpty()) { + drawRect( + brush = Brush.horizontalGradient( + *stops.map { (at, alpha) -> at to Color.Black.copy(alpha = alpha) } + .toTypedArray(), + ), + blendMode = BlendMode.DstIn, + ) + } } .testTag("quick_reaction_scroll") - .horizontalScroll(rememberScrollState()) - // Room to bring the last emoji out from under the "+" and its fade. - .padding(start = Inset, end = Inset + AddSize + FadeLead), + .horizontalScroll(scroll) + // Room to bring the last emoji out from under the "+", one spacing short of it. + .padding(start = Inset, end = Inset + AddSize + ItemSpacing), horizontalArrangement = Arrangement.spacedBy(ItemSpacing), verticalAlignment = Alignment.CenterVertically, ) { @@ -187,7 +203,9 @@ fun QuickReactionStrip( .combinedClickable(onClick = { onToggle(entry.emoji) }, onLongClick = {}), contentAlignment = Alignment.Center, ) { - Text(text = entry.emoji, fontSize = with(density) { 28.dp.toSp() }) + // The line box trimmed to the glyph, so the circle centers the emoji rather than + // the font's ascent and descent around it. + Text(text = entry.emoji, style = with(density) { emojiStyle(28.dp.toSp()) }) } } } @@ -222,9 +240,9 @@ fun QuickReactionStrip( /** * [QuickReactionStrip] in a popup placed against [bubbleBounds], the bubble as drawn in the window - * (lift included), falling back to the layout it sits in until that's measured. Placed the way - * iOS places it: above the bubble with a 16dp gap (below when there's no room under [minTop]), - * hugging the sender's side, and never within 16dp of the window's edges. + * (lift included), falling back to the layout it sits in until that's measured. Placed above the + * bubble with a 16dp gap, hugging the sender's side, and never within 16dp of the window's edges. + * The transcript scrolls the bubble down to make that room — see [QuickReactionStripPlacement]. */ @Composable fun QuickReactionStripPopup( @@ -252,7 +270,7 @@ fun QuickReactionStripPopup( strip = popupContentSize, hugsTrailing = hugsTrailing, margin = 16.dp.roundToPx(), - gap = 16.dp.roundToPx(), + gap = QuickReactionStripPlacement.Gap.roundToPx(), minTop = minTop.roundToPx(), ) } @@ -288,8 +306,60 @@ private class RevealCapsule( } } +/** + * The mask [QuickReactionStrip] draws over its scrolling row, as (fraction of [width], alpha) stops + * left to right, or empty when neither end fades. [leading] and [trailing] are each end's strength, + * 0 to 1. The leading fade runs [fade] in from the edge; the trailing one runs [fade] up to + * [plusStart], and everything past it is hidden under the "+" to the same strength. Mirrored for + * [rtl], where the leading edge is the right one. + */ +internal fun edgeFadeStops( + width: Float, + fade: Float, + plusStart: Float, + leading: Float, + trailing: Float, + rtl: Boolean, +): List> { + if (width <= 0f || (leading <= 0f && trailing <= 0f)) return emptyList() + val clear = (plusStart / width).coerceIn(0f, 1f) + val solidFrom = (fade / width).coerceIn(0f, clear) + val solidUntil = ((plusStart - fade) / width).coerceIn(solidFrom, clear) + val stops = listOf( + 0f to 1f - leading, + solidFrom to 1f, + solidUntil to 1f, + clear to 1f - trailing, + 1f to 1f - trailing, + ) + return if (rtl) stops.reversed().map { (at, alpha) -> (1f - at) to alpha } else stops +} + +/** An emoji's text style with the line box trimmed to [size], which centers the glyph in its box. */ +private fun emojiStyle(size: TextUnit) = TextStyle( + fontSize = size, + lineHeight = size, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.Both, + ), + platformStyle = PlatformTextStyle(includeFontPadding = false), +) + /** Where [QuickReactionStripPopup] puts the strip, in window pixels. */ object QuickReactionStripPlacement { + /** The strip's height, which the transcript makes room for above a bubble it lifts. */ + val StripHeight = 55.dp + + /** Between the strip and the bubble under it. */ + val Gap = 16.dp + + /** + * Above the bubble, [gap] clear of it. A bubble that sits too high for that is scrolled down by + * the transcript, and until it gets there the strip waits at [minTop] rather than flipping + * below and back. Only a bubble too tall to leave room for the strip on screen at all gets the + * strip under it. + */ fun position( anchor: IntRect, window: IntSize, @@ -306,13 +376,15 @@ object QuickReactionStripPlacement { } val x = hugged.coerceIn(margin, maxOf(margin, window.width - margin - strip.width)) val above = anchor.top - gap - strip.height - val y = if (above >= minTop) above else anchor.bottom + gap + val tooTall = anchor.height > window.height - minTop - gap - strip.height + val y = if (tooTall && above < minTop) anchor.bottom + gap else above return IntOffset(x, y.coerceIn(minTop, maxOf(minTop, window.height - strip.height))) } /** * The anchor for a message drawn as several rows -- one split around its link card -- so the - * strip sits above the top row, or below the bottom one, rather than between two of them. + * strip sits above the top row, or below the bottom one of a message too tall for that, rather + * than between two of them. * `null` until a row has been measured. */ fun messageBounds(rows: Collection): IntRect? = rows.reduceOrNull { union, row -> diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/UserLinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/UserLinkCard.kt index 318fdef210..0ef9275703 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/UserLinkCard.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/UserLinkCard.kt @@ -67,6 +67,7 @@ internal fun UserLinkCard( modifier: Modifier = Modifier, shape: Shape = GroupInviteCardDefaults.SHAPE, onLongClick: (() -> Unit)? = null, + onDoubleClick: (() -> Unit)? = null, ) { val state = card.state @@ -97,11 +98,20 @@ internal fun UserLinkCard( .border(CodeTheme.dimens.border, GroupInviteCardDefaults.STROKE, shape) .then( when { - tap != null -> Modifier.combinedClickable(onLongClick = onLongClick, onClick = tap) - // Nothing to open, but a long press still has to reach the transcript. - onLongClick != null -> Modifier.pointerInput(onLongClick) { - detectTapGestures(onLongPress = { onLongClick() }) - } + tap != null -> Modifier.combinedClickable( + onLongClick = onLongClick, + onDoubleClick = onDoubleClick, + onClick = tap, + ) + // Nothing to open, but a long press and a double tap still have to reach the + // transcript. + onLongClick != null || onDoubleClick != null -> + Modifier.pointerInput(onLongClick, onDoubleClick) { + detectTapGestures( + onLongPress = { onLongClick?.invoke() }, + onDoubleTap = onDoubleClick?.let { double -> { double() } }, + ) + } else -> Modifier }, ) diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/QuickReactionStripPlacementTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/QuickReactionStripPlacementTest.kt index d075accbe1..00323a29c7 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/QuickReactionStripPlacementTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/QuickReactionStripPlacementTest.kt @@ -6,7 +6,7 @@ import androidx.compose.ui.unit.IntSize import org.junit.Test import kotlin.test.assertEquals -/** [QuickReactionStripPlacement]: iOS's strip placement, beside the long-pressed bubble. */ +/** [QuickReactionStripPlacement]: the strip above the lifted bubble, hugging the sender's side. */ class QuickReactionStripPlacementTest { private val window = IntSize(1000, 2000) @@ -60,13 +60,32 @@ class QuickReactionStripPlacementTest { } @Test - fun `goes below the bubble when there is no room above`() { + fun `stays above a bubble that has no room yet, held at the top`() { + // The transcript scrolls the bubble down to make room; until then the strip waits at minTop + // rather than flipping below and back. assertEquals( - IntOffset(120, 600 + 40), + IntOffset(120, 300), place(IntRect(left = 120, top = 350, right = 500, bottom = 600), hugsTrailing = false), ) } + @Test + fun `goes below a bubble too tall to leave room for it above`() { + // 2000 - 300 (minTop) - 40 (gap) - 100 (strip) = 1560 is the tallest bubble it can sit over. + assertEquals( + IntOffset(120, 1700 + 40), + place(IntRect(left = 120, top = 100, right = 500, bottom = 1700), hugsTrailing = false), + ) + } + + @Test + fun `a bubble exactly as tall as the room left keeps the strip above`() { + assertEquals( + IntOffset(120, 300), + place(IntRect(left = 120, top = 440, right = 500, bottom = 2000), hugsTrailing = false), + ) + } + @Test fun `a message split around its card is one anchor spanning its rows`() { // "Join my group ": a text bubble over a wider card, both outgoing. @@ -81,13 +100,13 @@ class QuickReactionStripPlacementTest { } @Test - fun `a split message with no room above puts the strip below its last row`() { - val text = IntRect(left = 120, top = 320, right = 400, bottom = 420) - val card = IntRect(left = 120, top = 440, right = 680, bottom = 900) + fun `a split message too tall for the strip above puts it below its last row`() { + val text = IntRect(left = 120, top = 200, right = 400, bottom = 300) + val card = IntRect(left = 120, top = 320, right = 680, bottom = 1800) val anchor = QuickReactionStripPlacement.messageBounds(listOf(text, card))!! - assertEquals(IntOffset(120, 900 + 40), place(anchor, hugsTrailing = false)) + assertEquals(IntOffset(120, 1800 + 40), place(anchor, hugsTrailing = false)) } @Test diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/QuickReactionStripTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/QuickReactionStripTest.kt index dffbc1f1e3..d499f89882 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/QuickReactionStripTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/QuickReactionStripTest.kt @@ -3,14 +3,19 @@ package com.flipcash.shared.chat.ui import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.width import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertHeightIsEqualTo import androidx.compose.ui.test.assertIsNotSelected import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.assertWidthIsEqualTo +import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeLeft import androidx.compose.ui.unit.dp import com.flipcash.shared.chat.reactions.ReactionStrip import com.getcode.theme.DesignSystem @@ -90,6 +95,63 @@ class QuickReactionStripTest { composeTestRule.runOnIdle { assertEquals(1, opened) } } + @Test + fun `plus is the size of an emoji circle`() { + setStrip(entries = listOf(entry("❤️"))) + + composeTestRule.onNodeWithTag("quick_reaction_plus") + .assertWidthIsEqualTo(40.dp) + .assertHeightIsEqualTo(40.dp) + } + + @Test + fun `scrolled to the end, the last entry sits one spacing short of the plus`() { + val entries = List(11) { entry("😀") } + entry("🔥") + setStrip(entries = entries, width = 313.dp) + + composeTestRule.onNodeWithTag("quick_reaction_scroll").performTouchInput { swipeLeft() } + composeTestRule.onNodeWithTag("quick_reaction_scroll").performTouchInput { swipeLeft() } + composeTestRule.waitForIdle() + + val last = composeTestRule.onNodeWithTag("quick_reaction_🔥").getUnclippedBoundsInRoot() + val plus = composeTestRule.onNodeWithTag("quick_reaction_plus").getUnclippedBoundsInRoot() + assertEquals(4f, (plus.left - last.right).value, absoluteTolerance = 0.5f) + } + + @Test + fun `no fade at either end with nowhere to scroll`() { + assertEquals( + emptyList(), + edgeFadeStops(width = 300f, fade = 20f, plusStart = 250f, leading = 0f, trailing = 0f, rtl = false), + ) + } + + @Test + fun `the trailing fade ends at the plus and hides what is under it`() { + assertEquals( + listOf(0f to 1f, 20f / 300 to 1f, 230f / 300 to 1f, 250f / 300 to 0f, 1f to 0f), + edgeFadeStops(width = 300f, fade = 20f, plusStart = 250f, leading = 0f, trailing = 1f, rtl = false), + ) + } + + @Test + fun `the leading fade grows in with the scroll`() { + assertEquals( + listOf(0f to 0.5f, 20f / 300 to 1f, 230f / 300 to 1f, 250f / 300 to 1f, 1f to 1f), + edgeFadeStops(width = 300f, fade = 20f, plusStart = 250f, leading = 0.5f, trailing = 0f, rtl = false), + ) + } + + @Test + fun `both fades mirror in RTL`() { + val ltr = edgeFadeStops(width = 300f, fade = 20f, plusStart = 250f, leading = 1f, trailing = 1f, rtl = false) + val rtl = edgeFadeStops(width = 300f, fade = 20f, plusStart = 250f, leading = 1f, trailing = 1f, rtl = true) + + assertEquals(ltr.reversed().map { (at, alpha) -> (1f - at) to alpha }, rtl) + // The leading edge is the right one, and it fades. + assertEquals(1f to 0f, rtl.last()) + } + @Test fun `overflowing entries are reachable by horizontal scroll`() { // The strip doesn't dedupe entries, so 29 identical filler emoji plus one distinguishable diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt index a8ebd3c7a9..5f04955bb2 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt @@ -87,8 +87,8 @@ private val WrapContent = SheetDetent("wrap-content") { containerHeight, sheetHe * * The sheet always fills this detent, so its content is measured against it and does not need to * restate the fraction. [Expanded] joins the detent list only once the content reports that it - * overruns this one, so a sheet with nothing below the fold can't be dragged up into dead space — - * see [com.getcode.ui.utils.LocalSheetExpansionState]. + * overruns this one, so a sheet with nothing below the fold can't be dragged up into dead space, and + * stays in it from then on — see [com.getcode.ui.utils.LocalSheetExpansionState]. */ private val Half = SheetDetent("half") { containerHeight, _ -> containerHeight * 0.5f @@ -182,8 +182,13 @@ internal class ModalBottomSheetScene constructor( var contentOverflows by remember { mutableStateOf(false) } // Kept stable: this backs a static local, so a fresh lambda each pass would // needlessly invalidate the whole sheet subtree. + // + // Latched: once the content overruns [restingDetent], the sheet keeps [Expanded]. A list + // that overflows at Half but fits at Expanded stops reporting overflow the moment the + // sheet grows to hold it, and withdrawing the detent then drops the sheet back to Half, + // where the list overflows again -- the sheet never stays expanded. val setContentOverflows = remember { { overflows: Boolean -> - contentOverflows = overflows + if (overflows) contentOverflows = true } } val sheetState = rememberBottomSheetState( diff --git a/ui/navigation/src/main/kotlin/com/getcode/ui/utils/SheetExpansion.kt b/ui/navigation/src/main/kotlin/com/getcode/ui/utils/SheetExpansion.kt index 29830823bd..c0cb958922 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/ui/utils/SheetExpansion.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/ui/utils/SheetExpansion.kt @@ -13,15 +13,19 @@ import androidx.compose.runtime.staticCompositionLocalOf * * A [com.getcode.navigation.HalfSheet] rests at half the screen, so content that fits in that half * is already showing everything it has; dragging up would only reveal empty space. The host - * therefore withholds the expanded detent until content reports `true` here. + * therefore withholds the expanded detent until content reports `true` here, and keeps it from then + * on: content that fits once the sheet has grown reports `false`, and dropping the detent at that + * point would send the sheet back to the half where the content overflows again. */ val LocalSheetExpansionState = staticCompositionLocalOf<(Boolean) -> Unit> { { } } /** * Reports the host sheet as expandable for as long as [scrollState] has content out of view. * - * A list that can't scroll at the resting detent has nothing to gain from expanding. The report is - * reset when the content leaves composition so the next sheet starts from its own measurement. + * A list that can't scroll at the resting detent has nothing to gain from expanding. Only a `true` + * report changes anything: the host keeps the expanded detent for the rest of the sheet's life once + * it has been offered, so the `false` this sends when the list fits, or leaves composition, is + * ignored. */ @Composable fun AllowSheetExpansionWhenScrollable(scrollState: LazyListState) { From 13bc95592ae7d26604bac8044a9248ce75ee8220 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 16:42:33 -0400 Subject: [PATCH 2/3] 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. --- .../app/messenger/internal/screens/components/MessageRow.kt | 3 +++ .../main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt | 2 ++ .../main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt | 2 ++ .../main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt | 2 ++ .../main/kotlin/com/flipcash/shared/chat/ui/UserLinkCard.kt | 2 ++ 5 files changed, 11 insertions(+) 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 096e3a898d..9753ae75d5 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 @@ -252,6 +252,9 @@ internal fun MessageRow( indication = null, onLongClick = select, onDoubleClick = presentStrip, + // select plays the tick; the platform's own long-press haptic on top of + // it is felt as a second buzz. + hapticFeedbackEnabled = false, // Only reachable with the backdrop down, so the tap has nothing to // dismiss but the keyboard. onClick = { keyboard.hide() }, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt index ec7430c0d3..6f6c5c1bce 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt @@ -67,6 +67,8 @@ fun ChatQuotePanel( onLongClick = onLongClick, onDoubleClick = onDoubleClick, onClick = { onClick?.invoke() }, + // The row's select plays the tick; a second, platform haptic doubles it. + hapticFeedbackEnabled = false, ) } .height(IntrinsicSize.Min), diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt index 69c814bebb..734cc3a1ac 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt @@ -162,6 +162,8 @@ internal fun LinkCardView( onLongClick = onLongClick, onDoubleClick = onDoubleClick, onClick = { onClick?.invoke(live) }, + // The row's select plays the tick; a second, platform haptic doubles it. + hapticFeedbackEnabled = false, ) }, ) { diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index cd890ce93b..13176d171a 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -859,6 +859,8 @@ private fun Bubble( onLongClick = onLongClick, onDoubleClick = onDoubleClick, onClick = { onClick?.invoke() }, + // The row's select plays the tick; a second, platform haptic doubles it. + hapticFeedbackEnabled = false, ) } .padding( diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/UserLinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/UserLinkCard.kt index 0ef9275703..d6c8bd6fde 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/UserLinkCard.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/UserLinkCard.kt @@ -102,6 +102,8 @@ internal fun UserLinkCard( onLongClick = onLongClick, onDoubleClick = onDoubleClick, onClick = tap, + // The row's select plays the tick; a second, platform haptic doubles it. + hapticFeedbackEnabled = false, ) // Nothing to open, but a long press and a double tap still have to reach the // transcript. From 8a365e380d82d9e0d9b1d23cfd11bf1556afaa40 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 16:59:02 -0400 Subject: [PATCH 3/3] 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. --- .../shared/chat/ui/QuickReactionStrip.kt | 66 ++++++++++++++----- .../shared/chat/ui/ReactionPillRow.kt | 4 +- .../shared/chat/ui/QuickReactionStripTest.kt | 17 ++--- 3 files changed, 61 insertions(+), 26 deletions(-) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt index c10ce2d560..d322191f84 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/QuickReactionStrip.kt @@ -63,6 +63,12 @@ import kotlinx.coroutines.launch import com.flipcash.shared.chat.reactions.ReactionStrip import androidx.compose.ui.res.stringResource import com.flipcash.core.R +import dev.chrisbanes.haze.HazeInput +import dev.chrisbanes.haze.blur.HazeBlurStyle +import dev.chrisbanes.haze.blur.HazeColorEffect +import dev.chrisbanes.haze.blur.hazeBlur +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState // Sizes from the iOS strip (node 9779:105563). private val StripHeight = QuickReactionStripPlacement.StripHeight @@ -73,11 +79,19 @@ private val Inset = 8.dp private val AddSize = ItemSize /** - * How far each edge fade reaches: in from the leading edge while the row can scroll back, and up to - * the "+" while it can scroll on. Either side draws solid when there's nothing more that way. + * How far the leading fade reaches in from the strip's edge while the row can scroll back, and how + * much scroll either fade takes to grow in. Either side draws solid when there's nothing more that + * way. */ private val EdgeFade = 20.dp +/** + * The trailing fade, while the row can scroll on: from the middle of the "+" to where its circle is + * last as tall as an emoji. Past that the circle curves in, and an emoji still showing there would + * poke out above and below it, so everything from there to the strip's end is hidden. + */ +private val TrailingFade = 14.dp + /** Gap between one emoji starting to pop in and the next, as in the iOS strip's entrance. */ private const val ItemStaggerMillis = 18L @@ -88,6 +102,13 @@ private val PopSpring = spring(dampingRatio = 0.65f, stiffness = 1200f) /** iOS's pre-glass strip surface. */ private val StripSurface = Color(0xFF303030) +/** The "+"'s frost over the emoji passing under it: a blur tinted back toward the strip. */ +private val PlusGlass = HazeBlurStyle { + blurRadius(12.dp) + backgroundColor(StripSurface) + colorEffects(listOf(HazeColorEffect.tint(StripSurface.copy(alpha = 0.6f)))) +} + /** * The strip a long-press on a reactable, selected bubble shows beside it: up to the 12 entries * [ReactionStripComposer][com.flipcash.shared.chat.reactions.ReactionStripComposer] composed, @@ -95,9 +116,10 @@ private val StripSurface = Color(0xFF303030) * A tap on an entry both toggles the reaction and clears the selection — [onToggle] is the one * signal for both. * - * The capsule grows to fit its entries up to 313dp; past that the emoji scroll under the "+". Each - * end fades only while there's more to scroll that way, growing in over the first [EdgeFade] of - * travel so the fade doesn't snap on at the first pixel. + * The capsule grows to fit its entries up to 313dp; past that the emoji scroll under the "+", which + * frosts them with a blur of what passes beneath it. Each end of the strip fades only while there's + * more to scroll that way, growing in over the first [EdgeFade] of travel so the fade doesn't snap on + * at the first pixel. * * It enters as iOS's does: the capsule widens out of the side it hugs ([growsFromEnd] for the * trailing side), the "+" riding its growing edge, and the emoji pop in one after another from @@ -150,17 +172,20 @@ fun QuickReactionStrip( ) { val density = LocalDensity.current val scroll = rememberScrollState() + val haze = rememberHazeState() Row( modifier = Modifier .fillMaxHeight() + .hazeSource(haze) .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } .drawWithContent { drawContent() val fade = EdgeFade.toPx() val stops = edgeFadeStops( width = size.width, - fade = fade, - plusStart = size.width - Inset.toPx() - AddSize.toPx(), + leadingFade = fade, + trailingFade = TrailingFade.toPx(), + trailingInset = (Inset + AddSize / 2 - TrailingFade).toPx(), leading = if (scroll.canScrollBackward) (scroll.value / fade).coerceIn(0f, 1f) else 0f, trailing = if (scroll.canScrollForward) { ((scroll.maxValue - scroll.value) / fade).coerceIn(0f, 1f) @@ -200,7 +225,12 @@ fun QuickReactionStrip( .size(ItemSize) .clip(CircleShape) .background(if (entry.highlighted) Color.White.copy(alpha = 0.18f) else Color.Transparent) - .combinedClickable(onClick = { onToggle(entry.emoji) }, onLongClick = {}), + // A long-press does nothing here, so it plays no haptic either. + .combinedClickable( + onClick = { onToggle(entry.emoji) }, + onLongClick = {}, + hapticFeedbackEnabled = false, + ), contentAlignment = Alignment.Center, ) { // The line box trimmed to the glyph, so the circle centers the emoji rather than @@ -224,8 +254,11 @@ fun QuickReactionStrip( .semantics { contentDescription = "More reactions" } .size(AddSize) .clip(CircleShape) + // The emoji scroll on under the "+" and show through it frosted, rather than being + // cut off at its edge. + .hazeBlur(HazeInput.Sources(haze), PlusGlass) .background(Color.White.copy(alpha = 0.18f)) - .combinedClickable(onClick = onOpenPicker, onLongClick = {}), + .combinedClickable(onClick = onOpenPicker, onLongClick = {}, hapticFeedbackEnabled = false), contentAlignment = Alignment.Center, ) { Icon( @@ -309,22 +342,23 @@ private class RevealCapsule( /** * The mask [QuickReactionStrip] draws over its scrolling row, as (fraction of [width], alpha) stops * left to right, or empty when neither end fades. [leading] and [trailing] are each end's strength, - * 0 to 1. The leading fade runs [fade] in from the edge; the trailing one runs [fade] up to - * [plusStart], and everything past it is hidden under the "+" to the same strength. Mirrored for + * 0 to 1. The leading fade runs [leadingFade] in from its edge. The trailing one runs [trailingFade] + * and ends [trailingInset] short of the edge, which stays hidden to the same strength. Mirrored for * [rtl], where the leading edge is the right one. */ internal fun edgeFadeStops( width: Float, - fade: Float, - plusStart: Float, + leadingFade: Float, + trailingFade: Float, + trailingInset: Float, leading: Float, trailing: Float, rtl: Boolean, ): List> { if (width <= 0f || (leading <= 0f && trailing <= 0f)) return emptyList() - val clear = (plusStart / width).coerceIn(0f, 1f) - val solidFrom = (fade / width).coerceIn(0f, clear) - val solidUntil = ((plusStart - fade) / width).coerceIn(solidFrom, clear) + val solidFrom = (leadingFade / width).coerceIn(0f, 1f) + val clear = (1f - trailingInset / width).coerceIn(solidFrom, 1f) + val solidUntil = (clear - trailingFade / width).coerceIn(solidFrom, clear) val stops = listOf( 0f to 1f - leading, solidFrom to 1f, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt index ed00c35b9b..fc2687913f 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionPillRow.kt @@ -574,7 +574,7 @@ private fun MorePillChip( .let { base -> if (onClick != null) base.testTag("reaction_pill_more") else base } .clip(shape) .background(color = Color.White.copy(alpha = 0.06f), shape = shape) - .let { base -> onClick?.let { base.combinedClickable(onClick = it, onLongClick = {}) } ?: base } + .let { base -> onClick?.let { base.combinedClickable(onClick = it, onLongClick = {}, hapticFeedbackEnabled = false) } ?: base } .height(height) .padding(horizontal = horizontalPadding), contentAlignment = Alignment.Center, @@ -596,7 +596,7 @@ private fun PlusChip(size: Dp, onClick: () -> Unit, modifier: Modifier = Modifie .size(size) .clip(CircleShape) .background(color = Color.White.copy(alpha = 0.18f), shape = CircleShape) - .combinedClickable(onClick = onClick, onLongClick = {}), + .combinedClickable(onClick = onClick, onLongClick = {}, hapticFeedbackEnabled = false), contentAlignment = Alignment.Center, ) { // The strip's add-reaction icon, as iOS reuses its strip asset here. diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/QuickReactionStripTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/QuickReactionStripTest.kt index d499f89882..20540a9dda 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/QuickReactionStripTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/QuickReactionStripTest.kt @@ -122,30 +122,31 @@ class QuickReactionStripTest { fun `no fade at either end with nowhere to scroll`() { assertEquals( emptyList(), - edgeFadeStops(width = 300f, fade = 20f, plusStart = 250f, leading = 0f, trailing = 0f, rtl = false), + edgeFadeStops(width = 300f, leadingFade = 20f, trailingFade = 14f, trailingInset = 14f, leading = 0f, trailing = 0f, rtl = false), ) } @Test - fun `the trailing fade ends at the plus and hides what is under it`() { + fun `the trailing fade ends inside the plus and hides everything past it`() { + val clear = 1f - 14f / 300 assertEquals( - listOf(0f to 1f, 20f / 300 to 1f, 230f / 300 to 1f, 250f / 300 to 0f, 1f to 0f), - edgeFadeStops(width = 300f, fade = 20f, plusStart = 250f, leading = 0f, trailing = 1f, rtl = false), + listOf(0f to 1f, 20f / 300 to 1f, clear - 14f / 300 to 1f, clear to 0f, 1f to 0f), + edgeFadeStops(width = 300f, leadingFade = 20f, trailingFade = 14f, trailingInset = 14f, leading = 0f, trailing = 1f, rtl = false), ) } @Test fun `the leading fade grows in with the scroll`() { assertEquals( - listOf(0f to 0.5f, 20f / 300 to 1f, 230f / 300 to 1f, 250f / 300 to 1f, 1f to 1f), - edgeFadeStops(width = 300f, fade = 20f, plusStart = 250f, leading = 0.5f, trailing = 0f, rtl = false), + listOf(0f to 0.5f, 20f / 300 to 1f, (1f - 14f / 300) - 14f / 300 to 1f, 1f - 14f / 300 to 1f, 1f to 1f), + edgeFadeStops(width = 300f, leadingFade = 20f, trailingFade = 14f, trailingInset = 14f, leading = 0.5f, trailing = 0f, rtl = false), ) } @Test fun `both fades mirror in RTL`() { - val ltr = edgeFadeStops(width = 300f, fade = 20f, plusStart = 250f, leading = 1f, trailing = 1f, rtl = false) - val rtl = edgeFadeStops(width = 300f, fade = 20f, plusStart = 250f, leading = 1f, trailing = 1f, rtl = true) + val ltr = edgeFadeStops(width = 300f, leadingFade = 20f, trailingFade = 14f, trailingInset = 14f, leading = 1f, trailing = 1f, rtl = false) + val rtl = edgeFadeStops(width = 300f, leadingFade = 20f, trailingFade = 14f, trailingInset = 14f, leading = 1f, trailing = 1f, rtl = true) assertEquals(ltr.reversed().map { (at, alpha) -> (1f - at) to alpha }, rtl) // The leading edge is the right one, and it fades.