From 1a14ff29a46803d322822149d7c3b91f33af7ff7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 8 Sep 2026 15:10:33 -0400 Subject: [PATCH] fix(chat): nest the reply quote concentrically in its bubble The citation panel inside a reply drew an 8dp corner inside the bubble's 12, with a 16dp gap down its sides, 10 at the top and 6 under it. Four numbers, none derived from the others, so neither arc ran parallel to the other and there were three different gaps to read the panel against. Derive the panel from the bubble instead. `BubbleDefaults` names the bubble's own geometry, and `surroundInset` is one gap used on the panel's top, leading and trailing edges and again between the panel and the body beneath it, so the corner has a single surround to be concentric with: the bubble's radius less that gap. The bubble hands a reply the surround as its horizontal padding and the body puts the difference back, which is how the panel reaches past the text on both sides. A bubble with no quote passes the bubble's own padding, so the difference is zero and nothing about it moves. The panel also stretches to the bubble's width when it is the narrower of the two. A `Column` left a short quote hanging inside a wider reply, and the panel is a filled surface, so the slack read as a notch cut out of the bubble. `fillMaxWidth` fills the incoming maximum rather than the sibling's width, so `QuotedBody` measures the body first and makes its width the panel's floor. A quote longer than the reply still keeps its own width and carries it out to the bubble. Every measurement that has a token now reads one. `CodeTheme.shapes.medium` and `.tiny` supply the bubble's two radii through a new `CornerBasedShape.cornerRadius()`, and the paddings come off `CodeTheme.dimens.staticGrid`. That grid is 5pt and has no 16dp step, so the bubble's horizontal padding moves to 15. The panel's accent width, flag size and alphas stay as literals; they came from iOS and have no token to come from. iOS landed the same surround in code-payments/code-ios-app#735. One difference is deliberate: the radius is floored at the bubble's flattened corner, because iOS's unfloored 3pt is a continuous curve and the same number as a circular arc draws a hard rectangle inside a rounded one. --- .../flipcash/shared/chat/ui/ChatQuotePanel.kt | 48 ++++- .../flipcash/shared/chat/ui/MessageBubble.kt | 188 ++++++++++++++++-- .../main/kotlin/com/getcode/theme/Shape.kt | 14 ++ 3 files changed, 222 insertions(+), 28 deletions(-) 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 2c1d82aa13..9d114931db 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 @@ -18,9 +18,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.max import com.flipcash.shared.chat.models.ChatQuote import com.flipcash.shared.chat.models.ChatQuoteSnippet import com.getcode.opencode.compose.LocalExchange @@ -142,14 +145,47 @@ fun ChatQuotePanel( } } -/** Every measurement the panel makes, carried over from iOS. */ +/** + * The panel's measurements. Its typography and its accent come from iOS; its shape and its padding + * come from the bubble it sits in, because those are what read as wrong against the bubble's own + * edge rather than against the iOS panel. The rest are iOS's, and have no token to come from. + */ private object QuotePanelDefaults { - val shape = RoundedCornerShape(8.dp) - val gap = 8.dp - val trailingPadding = 8.dp - val verticalPadding = 6.dp + /** + * Concentric with the bubble: an inner corner whose arc is the outer one less the gap between + * them holds that gap constant all the way round the turn. That only means anything because + * [BubbleDefaults.surroundInset] is the same gap on all three sides the panel touches — a panel + * inset by one number down the sides and another at the top has no single arc to parallel. + * + * Floored at the bubble's own flattened corner. iOS takes the arithmetic straight, but its 3pt + * result is a continuous curve; the same number as a circular arc draws a hard rectangle inside + * a rounded one, which is the thing being fixed rather than a tighter version of it. + */ + val shape: Shape + @Composable get() = RoundedCornerShape( + max( + BubbleDefaults.cornerLarge - BubbleDefaults.surroundInset, + BubbleDefaults.cornerSmall, + ) + ) + + /** + * One step in from the bubble's own padding on each axis. Matching the bubble exactly is what + * makes the panel read as nested, but at the panel's size it also leaves the two short lines + * swimming — a citation is glanced at, so it wants to be denser than the body it sits above. + * A step down the same grid keeps the relationship without the slack. + */ + val gap: Dp + @Composable get() = CodeTheme.dimens.staticGrid.x2 + val trailingPadding: Dp + @Composable get() = CodeTheme.dimens.staticGrid.x2 + val verticalPadding: Dp + @Composable get() = CodeTheme.dimens.staticGrid.x1 + + val cashGap: Dp + @Composable get() = CodeTheme.dimens.staticGrid.x1 + val nameGap = 1.dp - val cashGap = 5.dp val accentWidth = 3.dp val flagSize = 14.dp const val groundAlpha = 0.14f 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 f205559c30..3e89a7f8f4 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 @@ -33,6 +33,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalInspectionMode @@ -58,6 +59,7 @@ import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.services.models.chat.MessageContent import com.flipcash.shared.chat.models.ChatAction import com.flipcash.shared.chat.models.ChatQuote +import com.flipcash.shared.chat.models.ChatQuoteSnippet import com.flipcash.shared.chat.models.ChatListItem import com.flipcash.shared.chat.models.LocalChatActionHandler import com.flipcash.shared.chat.models.SeparatorConfig @@ -65,6 +67,8 @@ import com.getcode.opencode.compose.ExchangeStub import com.getcode.opencode.compose.LocalExchange import com.getcode.opencode.model.financial.Fiat import com.getcode.theme.CodeTheme +import com.getcode.theme.cornerRadius +import com.getcode.theme.tiny import com.getcode.ui.components.PriceWithFlag import com.getcode.ui.core.addIf @@ -193,8 +197,43 @@ private const val EDITED_MARKER_SLOT = "edited-marker" /** How white a bubble goes at the peak of the flash a jump leaves on it. */ private const val ATTENTION_SCRIM_ALPHA = 0.14f -/** Space between a reply's citation and its body. */ -private val QUOTE_GAP = 6.dp +/** + * The citation over the body, with the narrower of the two stretched to the width of the wider. + * + * A [Column] would leave a short quote hanging inside a wider reply, and the panel is a filled + * surface, so the slack reads as a notch cut out of the bubble rather than as a quote that happens + * to be short. Filling the width outright is no better: it is the incoming maximum, not the + * sibling's width, so every reply would square off against the widest bubble the transcript allows. + * + * So the body is measured at its own width first and that width becomes the panel's floor. A quote + * longer than the reply keeps its own width and carries it out to the bubble, which is what makes + * this a floor rather than a fixed width. + */ +@Composable +private fun QuotedBody( + gap: Dp, + quote: @Composable () -> Unit, + body: @Composable () -> Unit, +) { + Layout(contents = listOf(quote, body)) { (quoteMeasurables, bodyMeasurables), constraints -> + val gapPx = gap.roundToPx() + val bodyPlaceable = bodyMeasurables.first().measure( + constraints.copy(minWidth = 0, minHeight = 0), + ) + val quotePlaceable = quoteMeasurables.first().measure( + constraints.copy( + minWidth = bodyPlaceable.width.coerceAtMost(constraints.maxWidth), + minHeight = 0, + ), + ) + val width = maxOf(quotePlaceable.width, bodyPlaceable.width) + val height = quotePlaceable.height + gapPx + bodyPlaceable.height + layout(width, height) { + quotePlaceable.place(0, 0) + bodyPlaceable.place(0, quotePlaceable.height + gapPx) + } + } +} @Composable private fun TextBubble( @@ -210,7 +249,19 @@ private fun TextBubble( onQuoteLongClick: (() -> Unit)? = null, attention: () -> Float = { 0f }, ) { - Bubble(isFromSelf, position, maxWidth, modifier, attention = attention) { + // A reply hands the bubble the narrower surround, so the citation clears the body's own inset + // on both sides; the body then puts the difference back and keeps the inset it has without a + // quote. A bubble with no quote never widens, because the two are equal there. + val surround = if (quote != null) BubbleDefaults.surroundInset else BubbleDefaults.paddingHorizontal + val bodyInset = BubbleDefaults.paddingHorizontal - surround + Bubble( + isFromSelf, + position, + maxWidth, + modifier, + horizontalPadding = surround, + attention = attention, + ) { val linkStyle = SpanStyle( color = CodeTheme.colors.textMain, textDecoration = TextDecoration.Underline, @@ -274,22 +325,10 @@ private fun TextBubble( // selection handle inside the bubble would consume it before the row ever sees it. Copying // a message is the selection bar's Copy action instead — the same trade WhatsApp makes. // The citation sits inside the bubble, above the body, so the two move together and the - // reply reads as one message rather than as a quote with a message under it. The panel - // wraps its content rather than filling the bubble: a one-word reply to a long message - // should not stretch to the full bubble width. - Column(verticalArrangement = Arrangement.spacedBy(QUOTE_GAP)) { - if (quote != null) { - ChatQuotePanel( - quote = quote, - onClick = onQuoteClick, - onLongClick = onQuoteLongClick, - // 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("bubble_reply_quote"), - ) - } - + // reply reads as one message rather than as a quote with a message under it. + val bodyText = @Composable { Text( + modifier = Modifier.padding(horizontal = bodyInset), text = laidOut, inlineContent = inlineContent, style = bodyStyle, @@ -297,9 +336,30 @@ private fun TextBubble( ) } + if (quote == null) { + bodyText() + } else { + QuotedBody( + gap = BubbleDefaults.surroundInset, + quote = { + ChatQuotePanel( + quote = quote, + onClick = onQuoteClick, + onLongClick = onQuoteLongClick, + // 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("bubble_reply_quote"), + ) + }, + body = bodyText, + ) + } + if (isEdited) { Text( - modifier = Modifier.align(Alignment.BottomEnd), + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = bodyInset), text = markerLabel, style = markerStyle, color = CodeTheme.colors.textSecondary, @@ -416,6 +476,39 @@ private fun CashBubble( } } +/** + * The bubble's own geometry. Named rather than inlined because the citation panel inside a reply + * has to be laid out against it: a panel that picks its own corner radius and its own padding stops + * matching the bubble the moment either of these moves. + */ +internal object BubbleDefaults { + /** The radius of a corner on the outside of a same-sender run. */ + val cornerLarge: Dp + @Composable get() = CodeTheme.shapes.medium.cornerRadius() + + /** The flattened radius of a corner facing the rest of the run. */ + val cornerSmall: Dp + @Composable get() = CodeTheme.shapes.tiny.cornerRadius() + + val paddingHorizontal: Dp + @Composable get() = CodeTheme.dimens.staticGrid.x3 + + val paddingVertical: Dp + @Composable get() = CodeTheme.dimens.staticGrid.x2 + + /** + * The gap between the citation panel and the bubble, the same on the top, leading and trailing + * sides and again between the panel and the body beneath it. One number rather than three, so + * the panel's corner has a single surround to be concentric with and the quote, the body and + * the bubble's edges sit on one rhythm. + * + * The bubble's vertical margin, which is the narrower of its two insets: the body keeps the + * wider horizontal one, so the panel reaches past the text on both sides the way it does on iOS. + */ + val surroundInset: Dp + @Composable get() = paddingVertical +} + @Composable private fun Bubble( isFromSelf: Boolean, @@ -425,6 +518,7 @@ private fun Bubble( minWidth: Dp = 0.dp, onClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null, + horizontalPadding: Dp = BubbleDefaults.paddingHorizontal, attention: () -> Float = { 0f }, content: @Composable BoxScope.() -> Unit, ) { @@ -464,7 +558,10 @@ private fun Bubble( onClick = { onClick?.invoke() }, ) } - .padding(horizontal = 16.dp, vertical = 10.dp), + .padding( + horizontal = horizontalPadding, + vertical = BubbleDefaults.paddingVertical, + ), ) { content() } @@ -472,8 +569,8 @@ private fun Bubble( @Composable fun bubbleShape(position: BubblePosition, isFromSelf: Boolean): Shape { - val l = 12.dp - val s = 4.dp + val l = BubbleDefaults.cornerLarge + val s = BubbleDefaults.cornerSmall val cornerSpec = spring(dampingRatio = 0.68f, stiffness = 500f) @@ -776,4 +873,51 @@ private fun Preview_Conversation() { } } +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_TextBubble_Reply() { + TextBubble( + text = "on my way", + isFromSelf = true, + position = BubblePosition.Solo, + maxWidth = 300.dp, + quote = ChatQuote( + messageId = 1L, + authorName = "Alice", + snippet = ChatQuoteSnippet.Text("are you still coming tonight?"), + accent = Color(0xFF5B8DEF), + nameAccent = Color(0xFF8FB4F5), + ), + ) +} + +/** + * The citation against the bubble corners it is set into: a run's middle bubble flattens the + * corners the panel sits nearest, which is where an inner radius of its own would show. + */ +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_GroupedBubbles_Reply() { + val quote = ChatQuote( + messageId = 1L, + authorName = "Alice", + snippet = ChatQuoteSnippet.Text("are you still coming tonight?"), + accent = Color(0xFF5B8DEF), + nameAccent = Color(0xFF8FB4F5), + ) + Column(verticalArrangement = Arrangement.spacedBy(2.dp), horizontalAlignment = Alignment.End) { + TextBubble("First message", true, BubblePosition.First, 300.dp) + TextBubble( + text = "on my way", + isFromSelf = true, + position = BubblePosition.Middle, + maxWidth = 300.dp, + quote = quote, + ) + TextBubble("Third message", true, BubblePosition.Last, 300.dp) + } +} + // endregion diff --git a/ui/theme/src/main/kotlin/com/getcode/theme/Shape.kt b/ui/theme/src/main/kotlin/com/getcode/theme/Shape.kt index 0a4168cb04..70f93bbcad 100644 --- a/ui/theme/src/main/kotlin/com/getcode/theme/Shape.kt +++ b/ui/theme/src/main/kotlin/com/getcode/theme/Shape.kt @@ -33,6 +33,20 @@ val Shapes.xxl: CornerBasedShape @Composable fun Shapes.receipt(step: Dp = CodeTheme.dimens.grid.x2) = TriangleCutShape(step) +/** + * This shape's corner radius as a [Dp], for a caller that has to recombine or animate the corners + * itself rather than hand the shape straight to a modifier. + * + * Reads [topStart]: a shape whose corners differ has no single radius to report, so this is for the + * uniform tokens in [shapes] and the extensions beside them. + */ +@Composable +fun CornerBasedShape.cornerRadius(): Dp { + val density = LocalDensity.current + val ref = Size(10_000f, 10_000f) + return with(density) { topStart.toPx(ref, this).toDp() } +} + /** * Returns a new [RoundedCornerShape] whose corners are concentric with this shape, * inset by [inset]. Useful for nested rounded rectangles (e.g. a thumb inside a track)