From c451dcaba6afdaaa00ab077c32724c49b13a871b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 13:09:14 -0400 Subject: [PATCH 1/3] fix(chat): size text bubbles to iOS's max width Text, reply, deleted, and encrypted bubbles capped at 0.78 of the row after the transcript inset, while iOS and our own link cards take 0.78 of the transcript's full width. The bubbles came out about 31dp narrower than iOS's on a normal-width phone. transcriptCardWidth becomes transcriptBubbleMaxWidth and sizes both. The reaction pill row under a bubble calls it too, in place of its own copy of the fraction. Cash and media bubbles keep their 0.64 of the inset row. --- .../screens/components/MessageList.kt | 4 ++-- .../internal/screens/components/MessageRow.kt | 18 ++++++++------- .../screens/components/SwipeToReply.kt | 2 +- .../flipcash/shared/chat/ui/MessageBubble.kt | 23 ++++++++++--------- 4 files changed, 25 insertions(+), 22 deletions(-) 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 ada715fae6..34a1796938 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 @@ -65,7 +65,7 @@ import com.flipcash.shared.chat.ui.ChatAnimations import com.flipcash.shared.chat.ui.GroupInviteCardDefaults import com.flipcash.shared.chat.ui.GroupInviteLinkCard import com.flipcash.shared.chat.ui.QuickReactionStripPlacement -import com.flipcash.shared.chat.ui.transcriptCardWidth +import com.flipcash.shared.chat.ui.transcriptBubbleMaxWidth import com.getcode.theme.CodeTheme import com.getcode.ui.utils.rememberKeyboardController import com.getcode.util.vibration.LocalVibrator @@ -478,7 +478,7 @@ internal fun MessageList( ) { // The width a link card gets in a DM transcript, so this reads as the // same card sent there (iOS sizes both the same way). - val cardWidth = transcriptCardWidth( + val cardWidth = transcriptBubbleMaxWidth( rowWidth = maxWidth, rowInset = CodeTheme.dimens.inset, ) 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 47a49d4eed..13614a7e91 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 @@ -70,6 +70,7 @@ import com.flipcash.shared.chat.ui.QuickReactionStripPopup import com.flipcash.shared.chat.ui.ReactionPillRow import com.flipcash.shared.chat.ui.bubblePositionOf import com.flipcash.shared.chat.ui.rendersBare +import com.flipcash.shared.chat.ui.transcriptBubbleMaxWidth import com.flipcash.shared.common.ui.ContactAvatar import com.getcode.theme.CodeTheme import com.getcode.ui.core.addIf @@ -399,9 +400,9 @@ internal fun MessageRow( ) } } - // Same width as the bubble above it (decision 2) — matched here - // against the same fraction MessageBubble sizes a text/reply/deleted - // bubble to, since the row doesn't expose its resolved width outward. + // Same width as the bubble above it (decision 2) — sized by the same + // rule MessageBubble caps a text/reply/deleted bubble with, since the + // row doesn't expose its resolved width outward. // Kept on screen while selecting, as iOS keeps them under its // backdrop, but inert like every other target on the row. // @@ -433,7 +434,12 @@ internal fun MessageRow( }, modifier = Modifier .align(if (item.isFromSelf) Alignment.TopEnd else Alignment.TopStart) - .width(maxWidth * BUBBLE_ROW_WIDTH_FRACTION), + .width( + transcriptBubbleMaxWidth( + rowWidth = maxWidth, + rowInset = CodeTheme.dimens.inset, + ), + ), alignEnd = item.isFromSelf, animateInitialPills = !pillsAtFirstComposition, ) @@ -554,10 +560,6 @@ private fun senderNameInset(showsGutter: Boolean): Dp = CodeTheme.dimens.grid.x1 } -// Matches MessageBubble's own BUBBLE_MAX_WIDTH_FRACTION for a text/reply/deleted bubble — the row -// doesn't expose its resolved width outward, so the pill row underneath it re-derives the same -// fraction of the shared row width instead. -private const val BUBBLE_ROW_WIDTH_FRACTION = 0.78f private val STRIP_BAR_GAP = 8.dp private val AFFORDANCE_SIZE = 32.dp diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SwipeToReply.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SwipeToReply.kt index 37f941fac2..381acda0a8 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SwipeToReply.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SwipeToReply.kt @@ -139,7 +139,7 @@ internal fun rememberSwipeToReply( * where the bubble is aligned to the far side. * * [width] is that one avatar column's width, reused as the outgoing strip so both read the same. It - * cannot eat into an outgoing bubble: a bubble stops at 0.78 of the row, which leaves far more than + * cannot eat into an outgoing bubble: a bubble stops at 0.78 of the transcript, which leaves more than * this to its leading side at every width the app runs at. The one content that fills the row is a * system notice, and those carry no capabilities at all, so no drag on one was ever going to reply. */ 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 75b11d1e18..d0ab6d5e9d 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 @@ -84,14 +84,14 @@ enum class BubblePosition { Solo, First, Middle, Last } private const val BUBBLE_MAX_WIDTH_FRACTION = 0.78f /** - * How wide a link card is drawn in a transcript whose rows are inset by [rowInset] on each side. + * The widest a text bubble or link card grows in a transcript whose rows are inset by [rowInset] + * on each side. * - * The same fraction as a text bubble, but of the transcript's full width rather than of the inset - * row, which is how iOS sizes its link cards and its group's head card - * (`ChatViewController.maxBubbleWidthFraction`). Taken of the inset row, the cards came out about - * 30dp narrower than iOS's on the same screen. + * A fraction of the transcript's full width rather than of the inset row, which is how iOS sizes + * its bubbles, link cards, and group head card (`ChatViewController.maxBubbleWidthFraction`). Taken + * of the inset row, they came out about 30dp narrower than iOS's on the same screen. */ -fun transcriptCardWidth(rowWidth: Dp, rowInset: Dp): Dp = +fun transcriptBubbleMaxWidth(rowWidth: Dp, rowInset: Dp): Dp = (rowWidth + rowInset * 2) * BUBBLE_MAX_WIDTH_FRACTION private val EDITED_MARKER_GAP = 6.dp @@ -119,16 +119,17 @@ fun ContentBubble( val jumbo = remember(item) { item.rendersBareEmoji() } BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { val isCardRow = item.linkCard != null && item.part == MessagePart.Card + val fullWidth = transcriptBubbleMaxWidth(rowWidth = maxWidth, rowInset = CodeTheme.dimens.inset) val bubbleMaxWidth = if (isCardRow) { - transcriptCardWidth(rowWidth = maxWidth, rowInset = CodeTheme.dimens.inset) + fullWidth } else when (item.content) { - is MessageContent.Text -> maxWidth * BUBBLE_MAX_WIDTH_FRACTION + is MessageContent.Text -> fullWidth is MessageContent.Cash -> maxWidth * CASH_BUBBLE_MAX_WIDTH_FRACTION - is MessageContent.Deleted -> maxWidth * BUBBLE_MAX_WIDTH_FRACTION + is MessageContent.Deleted -> fullWidth is MessageContent.Media -> maxWidth * CASH_BUBBLE_MAX_WIDTH_FRACTION - is MessageContent.Reply -> maxWidth * BUBBLE_MAX_WIDTH_FRACTION + is MessageContent.Reply -> fullWidth is MessageContent.System -> maxWidth - is MessageContent.Encrypted -> maxWidth * BUBBLE_MAX_WIDTH_FRACTION + is MessageContent.Encrypted -> fullWidth } // A split message's citation goes on its first row and its marker on its last, so the From 61eb5406b4b8c132f34cf117c151badef639bc1d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 13:13:17 -0400 Subject: [PATCH 2/3] fix(chat): size the cash card to the shared bubble width The cash card took 0.64 of the inset row. It now takes the same 0.78 of the transcript width as every other bubble, which is the rule iOS moves its fixed 232pt card to as well. The reaction pill row under a cash card already used that width, so it now lines up with the card. --- .../internal/screens/components/MessageRow.kt | 4 ++-- .../flipcash/shared/chat/ui/MessageBubble.kt | 22 ++++++------------- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt index 13614a7e91..6cf42586a5 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 @@ -401,8 +401,8 @@ internal fun MessageRow( } } // Same width as the bubble above it (decision 2) — sized by the same - // rule MessageBubble caps a text/reply/deleted bubble with, since the - // row doesn't expose its resolved width outward. + // rule MessageBubble caps every bubble with, since the row doesn't + // expose its resolved width outward. // Kept on screen while selecting, as iOS keeps them under its // backdrop, but inert like every other target on the row. // 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 d0ab6d5e9d..36e43fd902 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 @@ -84,8 +84,8 @@ enum class BubblePosition { Solo, First, Middle, Last } private const val BUBBLE_MAX_WIDTH_FRACTION = 0.78f /** - * The widest a text bubble or link card grows in a transcript whose rows are inset by [rowInset] - * on each side. + * The widest a bubble, cash card, or link card grows in a transcript whose rows are inset by + * [rowInset] on each side. * * A fraction of the transcript's full width rather than of the inset row, which is how iOS sizes * its bubbles, link cards, and group head card (`ChatViewController.maxBubbleWidthFraction`). Taken @@ -95,7 +95,6 @@ fun transcriptBubbleMaxWidth(rowWidth: Dp, rowInset: Dp): Dp = (rowWidth + rowInset * 2) * BUBBLE_MAX_WIDTH_FRACTION private val EDITED_MARKER_GAP = 6.dp -private const val CASH_BUBBLE_MAX_WIDTH_FRACTION = 0.64f /** * @param onLongClick what a long-press on this bubble reports, or `null` where the row behind it @@ -118,18 +117,11 @@ fun ContentBubble( val actionHandler = LocalChatActionHandler.current val jumbo = remember(item) { item.rendersBareEmoji() } BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { - val isCardRow = item.linkCard != null && item.part == MessagePart.Card - val fullWidth = transcriptBubbleMaxWidth(rowWidth = maxWidth, rowInset = CodeTheme.dimens.inset) - val bubbleMaxWidth = if (isCardRow) { - fullWidth - } else when (item.content) { - is MessageContent.Text -> fullWidth - is MessageContent.Cash -> maxWidth * CASH_BUBBLE_MAX_WIDTH_FRACTION - is MessageContent.Deleted -> fullWidth - is MessageContent.Media -> maxWidth * CASH_BUBBLE_MAX_WIDTH_FRACTION - is MessageContent.Reply -> fullWidth - is MessageContent.System -> maxWidth - is MessageContent.Encrypted -> fullWidth + // Every bubble shares one width but a system notice, which spans the row. + val bubbleMaxWidth = if (item.content is MessageContent.System) { + maxWidth + } else { + transcriptBubbleMaxWidth(rowWidth = maxWidth, rowInset = CodeTheme.dimens.inset) } // A split message's citation goes on its first row and its marker on its last, so the From 9536bf272ad0792916c285602ed503b485f88859 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 14:24:02 -0400 Subject: [PATCH 3/3] fix(chat): keep the cash card at 0.64 of the inset row The cash card goes back to the width it had before this branch: 0.64 of the row after the transcript inset, not the shared 0.78 bubble width. The reaction pill row under it sizes by the same rule, so it stays the card's width. --- .../internal/screens/components/MessageRow.kt | 5 ++-- .../flipcash/shared/chat/ui/MessageBubble.kt | 29 ++++++++++++++----- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt index 6cf42586a5..78d731440d 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 @@ -68,9 +68,9 @@ import com.flipcash.shared.chat.reactions.ReactionStrip import com.flipcash.shared.chat.ui.ContentBubble import com.flipcash.shared.chat.ui.QuickReactionStripPopup import com.flipcash.shared.chat.ui.ReactionPillRow +import com.flipcash.shared.chat.ui.bubbleMaxWidth import com.flipcash.shared.chat.ui.bubblePositionOf import com.flipcash.shared.chat.ui.rendersBare -import com.flipcash.shared.chat.ui.transcriptBubbleMaxWidth import com.flipcash.shared.common.ui.ContactAvatar import com.getcode.theme.CodeTheme import com.getcode.ui.core.addIf @@ -435,7 +435,8 @@ internal fun MessageRow( modifier = Modifier .align(if (item.isFromSelf) Alignment.TopEnd else Alignment.TopStart) .width( - transcriptBubbleMaxWidth( + bubbleMaxWidth( + content = item.content, rowWidth = maxWidth, rowInset = CodeTheme.dimens.inset, ), 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 36e43fd902..d0ab027db5 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 @@ -83,9 +83,11 @@ enum class BubblePosition { Solo, First, Middle, Last } private const val BUBBLE_MAX_WIDTH_FRACTION = 0.78f +private const val CASH_CARD_WIDTH_FRACTION = 0.64f + /** - * The widest a bubble, cash card, or link card grows in a transcript whose rows are inset by - * [rowInset] on each side. + * The widest a bubble or link card grows in a transcript whose rows are inset by [rowInset] on each + * side. * * A fraction of the transcript's full width rather than of the inset row, which is how iOS sizes * its bubbles, link cards, and group head card (`ChatViewController.maxBubbleWidthFraction`). Taken @@ -94,6 +96,18 @@ private const val BUBBLE_MAX_WIDTH_FRACTION = 0.78f fun transcriptBubbleMaxWidth(rowWidth: Dp, rowInset: Dp): Dp = (rowWidth + rowInset * 2) * BUBBLE_MAX_WIDTH_FRACTION +/** + * The widest a bubble carrying [content] grows in a row [rowWidth] wide, inset by [rowInset] on each + * side. A system notice spans the row, and a cash card keeps its own narrower fraction of the inset + * row (iOS's `ChatViewController.cashCardWidthFraction` matches it); everything else takes + * [transcriptBubbleMaxWidth]. + */ +fun bubbleMaxWidth(content: MessageContent, rowWidth: Dp, rowInset: Dp): Dp = when (content) { + is MessageContent.System -> rowWidth + is MessageContent.Cash -> rowWidth * CASH_CARD_WIDTH_FRACTION + else -> transcriptBubbleMaxWidth(rowWidth = rowWidth, rowInset = rowInset) +} + private val EDITED_MARKER_GAP = 6.dp /** @@ -117,12 +131,11 @@ fun ContentBubble( val actionHandler = LocalChatActionHandler.current val jumbo = remember(item) { item.rendersBareEmoji() } BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { - // Every bubble shares one width but a system notice, which spans the row. - val bubbleMaxWidth = if (item.content is MessageContent.System) { - maxWidth - } else { - transcriptBubbleMaxWidth(rowWidth = maxWidth, rowInset = CodeTheme.dimens.inset) - } + val bubbleMaxWidth = bubbleMaxWidth( + content = item.content, + rowWidth = maxWidth, + rowInset = CodeTheme.dimens.inset, + ) // A split message's citation goes on its first row and its marker on its last, so the // pieces read as one message stacked rather than as three.